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:

  1. diagnose_* methods — typed diagnostic objects describing the exact outcome of any evaluation or lookup, with a closed machine-readable reason code.
  2. ConversionResult.status — goal misses surface as a typed enum value, never as an exception.
  3. Redaction-safe stdlib logging — every diagnostic is mirrored to the convert_sdk logger 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, or track_conversion. Those methods keep their existing return shapes unchanged.


The diagnose_* methods

Context exposes a paired diagnose_* method for every evaluation surface:

MethodReturnsWhat it resolves against
context.diagnose_experience(key)ExperienceDiagnosticexperiences in the loaded config
context.diagnose_feature(key)FeatureDiagnosticfeatures in the loaded config
context.diagnose_goal(goal_key)GoalDiagnosticgoals in the loaded config
context.diagnose_entity(entity_type, key)EntityDiagnosticany config entity by type + key

Every diagnostic is a frozen dataclass with four members:

FieldTypeDescription
reasonDiagnosticReasonClosed enum value — the machine-readable outcome code
messagestrShort human-readable explanation
detailsMapping[str, Any]Read-only, redaction-safe operational fields
resolvedbool (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 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:

DiagnosticReasonWire valueWhen 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

There is no GoalNotFoundError exception. A goal key absent from the loaded config is a typed, non-exception outcometrack_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 resolved

To 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

The 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 output

The 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_ref prefix)
  • 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:

  1. SDK versionpython -c "import convert_sdk; print(convert_sdk.__version__)"
  2. Python versionpython --version
  3. Initialization modesdk_key (remote) or data (direct)?
  4. Diagnostic result — full reason, message, and dict(details) from the relevant diagnose_* call.
  5. Error details — if an exception was raised, include exc.code and dict(exc.context).
  6. 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 *Diagnostic and result types
  • Code Examples — queue-control and lifecycle event examples
  • Extending — injecting a custom logger or transport
  • Troubleshooting — common cross-SDK issues and fixes