Diagnostics & Support
Understand why an evaluation or tracking call returned a miss via the typed diagnose_* methods
When an evaluation or tracking call returns None (or a typed miss result) you
often want to know why without guesswork or try/except. The SDK exposes
three complementary mechanisms:
diagnose_*methods — typed diagnostic objects describing the exact outcome of any evaluation or lookup, with a closed machine-readable reason code.ConversionResult.status— goal misses surface as a typed enum value, never as an exception.- Redaction-safe stdlib logging — every diagnostic is mirrored to the
convert_sdklogger with the same closed reason code, safe to emit at any level without leaking PII or credentials.
The diagnostic surface is additive: it explains no-result outcomes without changing the behavior of
run_experience,run_feature, ortrack_conversion. Those methods keep their existing return shapes unchanged.
The diagnose_* methods
diagnose_* methodsContext exposes a paired diagnose_* method for every evaluation surface:
| Method | Returns | What it resolves against |
|---|---|---|
context.diagnose_experience(key) | ExperienceDiagnostic | experiences in the loaded config |
context.diagnose_feature(key) | FeatureDiagnostic | features in the loaded config |
context.diagnose_goal(goal_key) | GoalDiagnostic | goals in the loaded config |
context.diagnose_entity(entity_type, key) | EntityDiagnostic | any config entity by type + key |
Every diagnostic is a frozen dataclass with four members:
| Field | Type | Description |
|---|---|---|
reason | DiagnosticReason | Closed enum value — the machine-readable outcome code |
message | str | Short human-readable explanation |
details | Mapping[str, Any] | Read-only, redaction-safe operational fields |
resolved | bool (property) | True when reason is DiagnosticReason.RESOLVED |
from convert_sdk import (
Core, SDKConfig,
DiagnosticReason,
ExperienceDiagnostic,
)
core = Core(SDKConfig(data=my_config)).initialize()
context = core.create_context("visitor-001", visitor_attributes={"country": "US"})
# A bucketed visitor returns RESOLVED:
diag = context.diagnose_experience("checkout-experiment")
assert isinstance(diag, ExperienceDiagnostic)
assert diag.resolved is True
assert diag.reason is DiagnosticReason.RESOLVED
print(diag.reason.value) # "resolved"
# A key typo is immediately distinguishable:
miss = context.diagnose_experience("chekout-typo")
assert miss.resolved is False
assert miss.reason is DiagnosticReason.EXPERIENCE_NOT_FOUND
print(miss.message) # human-readable explanation
print(dict(miss.details)) # operational fields (keys, hashed visitor ref)
core.close()The closed DiagnosticReason vocabulary
DiagnosticReason vocabularyDiagnosticReason is a closed str enum — exactly eight members. The set
is frozen and shared across all Convert SDKs so a Python reason code correlates
directly with a JavaScript or PHP diagnosis:
DiagnosticReason | Wire value | When it appears |
|---|---|---|
RESOLVED | "resolved" | The request resolved to a concrete outcome |
AUDIENCE_MISMATCH | "audience_mismatch" | Visitor did not satisfy the experience's audience or location rules |
EXPERIENCE_NOT_FOUND | "experience_not_found" | No experience matched the requested key |
FEATURE_NOT_IN_SELECTED_VARIATIONS | "feature_not_in_selected_variations" | Feature is declared but the visitor's bucketed variation carries no change for it |
FEATURE_NOT_FOUND | "feature_not_found" | No feature matched the requested key |
GOAL_NOT_FOUND | "goal_not_found" | No goal matched the requested key |
ENTITY_NOT_FOUND | "entity_not_found" | Config-entity lookup found no match (unknown key/id or unsupported entity_type) |
PROJECT_MAPPING_REQUIRED | "project_mapping_required" | Loaded config lacks the project mapping required to resolve the request |
Because DiagnosticReason subclasses str you can compare against either the
enum member or its wire value:
from convert_sdk import DiagnosticReason
diag = context.diagnose_experience("checkout-experiment")
# Both forms work:
assert diag.reason is DiagnosticReason.RESOLVED
assert diag.reason == "resolved"Routing support tickets by reason value is deterministic — EXPERIENCE_NOT_FOUND
is a config/key problem, AUDIENCE_MISMATCH is a targeting question, RESOLVED
means the SDK bucketed and the issue lies elsewhere in the integration.
Goals: no GoalNotFoundError
GoalNotFoundErrorThere is no GoalNotFoundError exception. A goal key absent from the loaded
config is a typed, non-exception outcome — track_conversion always returns
a ConversionResult and sets status to ConversionStatus.GOAL_NOT_FOUND:
from convert_sdk import ConversionStatus
result = context.track_conversion("unknown-goal")
assert result.status is ConversionStatus.GOAL_NOT_FOUND
assert result.tracked is False
assert result.reason == "goal_not_found"
assert result.goal_id is None # no goal was resolvedTo confirm whether a goal key exists in the loaded config, use the diagnostic surface or the entity lookup directly:
from convert_sdk import DiagnosticReason
# Option A — diagnostic surface:
diag = context.diagnose_goal("purchase_completed")
if diag.reason is DiagnosticReason.GOAL_NOT_FOUND:
print("goal key is not in the loaded config")
# Option B — entity lookup (returns None on a miss, never raises):
goal = context.get_config_entity("goals", "purchase_completed")
if goal is None:
print("goal key is not in the loaded config")Diagnostics on features and entities
The same pattern applies to features and config entities:
from convert_sdk import DiagnosticReason
feat_diag = context.diagnose_feature("checkout-banner")
if feat_diag.reason is DiagnosticReason.FEATURE_NOT_FOUND:
print("feature key absent from config")
elif feat_diag.reason is DiagnosticReason.FEATURE_NOT_IN_SELECTED_VARIATIONS:
print("visitor bucketed but variation carries no change for this feature")
elif feat_diag.resolved:
print("feature is enabled for this visitor")
# Entity lookup — diagnose any config entity by type + key:
ent_diag = context.diagnose_entity("experiences", "checkout-experiment")
if ent_diag.reason is DiagnosticReason.ENTITY_NOT_FOUND:
print("no experience with that key")The details mapping and bucket_value
details mapping and bucket_valueThe details mapping on a diagnostic carries operational fields that are safe
to log and safe to include in a support report. Sensitive values are replaced
before details is populated:
visitor_ref— a stable SHA-256 hex prefix of the raw visitor id (never the raw id itself).bucket_value— when bucketing was attempted, the integer bucket value used for variation selection. The same(visitor_id, experience_id)pair always produces the same bucket value across all Convert SDKs.
diag = context.diagnose_experience("checkout-experiment")
# bucket_value is in details (only present when bucketing was attempted):
if "bucket_value" in diag.details:
print("bucket_value:", diag.details["bucket_value"])
# visitor_ref is a hashed reference — never the raw visitor id:
print("visitor_ref:", diag.details.get("visitor_ref"))bucket_value is not a field on ExperienceResult — it is only available
through the diagnostic surface.
To re-derive a bucket value independently for cross-SDK comparison:
from convert_sdk.evaluation.bucketing import get_bucket_value_for_visitor
bucket = get_bucket_value_for_visitor(
visitor_id="visitor-abc123",
experience_id="exp-10045",
)
print("bucket_value:", bucket) # compare to JavaScript SDK outputThe pure-Python MurmurHash3-32 implementation uses seed 9999 and hashes the
UTF-8 byte encoding of the input (value.encode("utf-8"), matching the
JavaScript SDK's new TextEncoder().encode() and the PHP SDK's UTF-8 bytes) to
maintain byte-exact parity with the JavaScript and PHP SDKs — including for
non-ASCII visitor IDs.
The redaction-safe logging seam
Every diagnose_* call emits a DEBUG-level log record through the SDK's
convert_sdk logger (or the caller-supplied SDKConfig.logger). The logged
fields are limited to the allowlisted set defined in convert_sdk.logging:
reason, environment, bucket_value, variation_key, and a hashed
visitor_ref. The raw visitor id is never logged; details contains only
allowlist-safe values.
Enable diagnostic output in your application:
import logging
# Option A — quick development setup:
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("convert_sdk").setLevel(logging.DEBUG)
# Option B — structured logging in a framework (e.g. Django):
LOGGING = {
"version": 1,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"convert_sdk": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
},
}Pass your own logger via SDKConfig.logger to route SDK output through your
application's logging infrastructure:
import logging
from convert_sdk import Core, SDKConfig
my_logger = logging.getLogger("myapp.convert")
core = Core(SDKConfig(data=my_config, logger=my_logger)).initialize()The SDK only emits through the logger — it never adds handlers, sets
levels, or calls logging.basicConfig(). Level and handler configuration is
the application's responsibility.
Privacy guarantees — no log record at any level will contain:
- Raw visitor ids (replaced with a hashed
visitor_refprefix) - SDK keys or auth secrets (omitted entirely, or masked to
first4****last4) - Full endpoint URLs with query strings (reduced to host + path)
- Raw visitor attribute mappings
Support triage workflow
"Why didn't this visitor see the experiment?"
Reproduce the visitor context and ask the diagnostic surface for the closed reason:
from convert_sdk import Core, SDKConfig, DiagnosticReason
core = Core(SDKConfig(data=my_config)).initialize()
def triage_experience(visitor_id, attributes, experience_key):
context = core.create_context(visitor_id, visitor_attributes=attributes)
diag = context.diagnose_experience(experience_key)
return diag.reason
reason = triage_experience("v-1001", {"country": "US"}, "checkout-experiment")
# Route the ticket:
# RESOLVED -> SDK bucketed; check integration (did caller read result?)
# EXPERIENCE_NOT_FOUND -> key typo or wrong environment
# AUDIENCE_MISMATCH -> check audience targeting rules
# PROJECT_MAPPING_REQUIRED -> config loaded but project mapping missing
core.close()"Why wasn't this conversion recorded?"
A "conversion not recorded" report is often a goal-key mismatch. Confirm goal existence before chasing tracking infrastructure:
from convert_sdk import DiagnosticReason
context = core.create_context("v-1001")
diag = context.diagnose_goal("purchase_completed")
if diag.reason is DiagnosticReason.RESOLVED:
# Goal exists; verify it was actually called and flushed:
result = context.track_conversion("purchase_completed")
print("tracked:", result.tracked)
core.flush()
elif diag.reason is DiagnosticReason.GOAL_NOT_FOUND:
print("goal key absent from loaded config — check key spelling and environment")To observe queue release and catch delivery failures live, subscribe to
LifecycleEvent.API_QUEUE_RELEASED:
from convert_sdk import LifecycleEvent
from convert_sdk.events import QueueReleasedPayload
def on_released(payload: QueueReleasedPayload, error=None) -> None:
if error is not None:
print(f"delivery FAILED: status={payload.status_code}")
else:
print(f"released {payload.batch_size} events (reason={payload.reason})")
core.on(LifecycleEvent.API_QUEUE_RELEASED, on_released)Minimum reproduction for a bug report
Use data= mode (no network dependency, no credential exposure) for any
reproduction:
from convert_sdk import Core, SDKConfig
config = {
"account_id": "YOUR_ACCOUNT_ID",
"project": {"id": "YOUR_PROJECT_ID", "name": "Repro"},
"experiences": [
# paste the minimal experience definition from your config
],
"features": [],
"goals": [],
}
core = Core(SDKConfig(data=config)).initialize()
context = core.create_context("repro-visitor-1", visitor_attributes={"tier": "premium"})
diag = context.diagnose_experience("your-experience-key")
print(diag.resolved, diag.reason, dict(diag.details))
core.close()Bug report checklist
Before opening an issue, collect:
- SDK version —
python -c "import convert_sdk; print(convert_sdk.__version__)" - Python version —
python --version - Initialization mode —
sdk_key(remote) ordata(direct)? - Diagnostic result — full
reason,message, anddict(details)from the relevantdiagnose_*call. - Error details — if an exception was raised, include
exc.codeanddict(exc.context). - Privacy — raw visitor ids and SDK keys are already redacted in diagnostic logs and
details; do not manually re-add them.
What to read next
- Type Hints — field reference for all
*Diagnosticand result types - Code Examples — queue-control and lifecycle event examples
- Extending — injecting a custom logger or transport
- Troubleshooting — common cross-SDK issues and fixes