Skip to content

fix(analytics): surface dropped exposure when distinct_id missing from context (SDK-84)#90

Open
tylerjroach wants to merge 2 commits into
masterfrom
fix/sdk-84-warn-on-silent-exposure-drop
Open

fix(analytics): surface dropped exposure when distinct_id missing from context (SDK-84)#90
tylerjroach wants to merge 2 commits into
masterfrom
fix/sdk-84-warn-on-silent-exposure-drop

Conversation

@tylerjroach

Copy link
Copy Markdown
Contributor

Summary

When a flag uses a non-default Variant Assignment Key (e.g. device_id) and the evaluation context doesn't include distinct_id, local eval succeeds — flag definitions are cached, bucketing only needs the configured key — but trackLocalExposure then silently returns because it can only attribute the exposure event to a distinct_id. Caller gets the right flag value with no signal that analytics were dropped.

Add a Level.WARNING log so the drop becomes visible.

Context

Linear: SDK-84. Only Java and Ruby are actually affected of the five SDKs the audit listed — Python / Go / Node already log when this happens.

This bug only affects local evaluation. Remote eval requires distinct_id at the eval step itself, so it errors out before reaching exposure tracking.

Behavior when distinct_id IS present (alongside the bucketing key) is unchanged — exposure still fires correctly attributed to the user.

Test plan

  • All 54 LocalFlagsProviderTest tests pass (mvn test)
  • New testWarnsWhenExposureDroppedDueToMissingDistinctId exercises a device_id-bucketed flag with a context that has device_id but no distinct_id: asserts the variant value is returned, no exposure event is sent, AND a WARNING log is emitted

🤖 Generated with Claude Code

…m context

When a flag uses a non-default Variant Assignment Key (e.g. device_id),
local evaluation succeeds via that key independently of whether
distinct_id is present. Exposure tracking, however, requires
distinct_id to attribute the event to the user. Previously the SDK
silently returned from track_exposure_event with no signal to the
caller — the flag value came back correctly but analytics were
silently dropped.

Surface the drop through the configured error_handler so callers can
see they need to include distinct_id in the context alongside the
bucketing key.

Behavior when distinct_id IS present is unchanged.

Linear: SDK-84

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tylerjroach tylerjroach requested review from a team and jakewski June 29, 2026 17:21
@linear-code

linear-code Bot commented Jun 29, 2026

Copy link
Copy Markdown

SDK-84

@tylerjroach

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge — one-line production change adds a warning log; no behavior change on the happy path.

The production change is a single parameterized log statement before an existing early return. The test correctly exercises the new path and properly restores JUL logger state in a finally block. No logic regressions are possible on the normal (distinct_id present) code path.

No files require special attention.

Important Files Changed

Filename Overview
src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java Adds a parameterized WARNING log before the silent return when distinct_id is missing; no logic changes to the happy path.
src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java New test covers device_id-bucketed flag without distinct_id; correctly captures originalLevel and restores it in a finally block alongside handler removal.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant LocalFlagsProvider
    participant Logger
    participant EventSender

    Caller->>LocalFlagsProvider: getVariantValue(flagKey, fallback, context)
    Note over LocalFlagsProvider: Local eval succeeds using device_id bucket key
    LocalFlagsProvider->>LocalFlagsProvider: trackLocalExposure(context, flagKey, ...)
    alt distinct_id present in context
        LocalFlagsProvider->>EventSender: trackExposure(distinctId, flagKey, ...)
    else distinct_id missing
        LocalFlagsProvider->>Logger: WARNING — exposure dropped, distinct_id missing
        LocalFlagsProvider-->>LocalFlagsProvider: return (exposure dropped)
    end
    LocalFlagsProvider-->>Caller: variant value
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant LocalFlagsProvider
    participant Logger
    participant EventSender

    Caller->>LocalFlagsProvider: getVariantValue(flagKey, fallback, context)
    Note over LocalFlagsProvider: Local eval succeeds using device_id bucket key
    LocalFlagsProvider->>LocalFlagsProvider: trackLocalExposure(context, flagKey, ...)
    alt distinct_id present in context
        LocalFlagsProvider->>EventSender: trackExposure(distinctId, flagKey, ...)
    else distinct_id missing
        LocalFlagsProvider->>Logger: WARNING — exposure dropped, distinct_id missing
        LocalFlagsProvider-->>LocalFlagsProvider: return (exposure dropped)
    end
    LocalFlagsProvider-->>Caller: variant value
Loading

Reviews (2): Last reviewed commit: "fix(flags): parameterize JUL warning + r..." | Re-trigger Greptile

Switch the dropped-exposure warning from string concatenation to a
parameterized template so the flag key is only formatted when the
WARNING level is actually enabled.

The corresponding test set the LocalFlagsProvider logger level to
Level.ALL outside the try block and never restored it in finally,
leaking that level to any test that ran afterward in the same JVM.
Capture the original level and restore it. Also switch the log
assertion to format the LogRecord via SimpleFormatter — with
parameterized logging LogRecord.getMessage() returns the raw
"{0}" template, not the interpolated string.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed 8a8ae1f addressing both Greptile threads.

P2 — parameterized JUL logging (LocalFlagsProvider.java:674): switched from "...'" + flagKey + "'..." string concatenation to logger.log(Level.WARNING, "...''{0}''...", flagKey). The template is only formatted when WARNING is actually enabled.

P2 — logger level not restored (LocalFlagsProviderTest.java:917): captured logger.getLevel() before the setLevel(Level.ALL) call and restore it alongside removeHandler in finally. Also switched the assertion to SimpleFormatter().formatMessage(record) — with parameterized logging LogRecord.getMessage() returns the raw {0} template, so the previous .contains("device-flag") check would no longer match.

All 54 LocalFlagsProviderTest tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant