Skip to content

feat(analytics): add Source discriminated union with Fallback.Reason (SDK-79)#89

Merged
tylerjroach merged 6 commits into
masterfrom
fix/sdk-79-variant-source-fallback-reason
Jul 23, 2026
Merged

feat(analytics): add Source discriminated union with Fallback.Reason (SDK-79)#89
tylerjroach merged 6 commits into
masterfrom
fix/sdk-79-variant-source-fallback-reason

Conversation

@tylerjroach

@tylerjroach tylerjroach commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New Source discriminated union (com.mixpanel.mixpanelapi.featureflags.model.Source): Source.Local | Source.Remote | Source.Fallback(Reason). Abstract class + nested static finals — Java 8 source level so no sealed keyword.
  • Source.Fallback.Reason enum: FLAG_NOT_FOUND | MISSING_CONTEXT_KEY | NO_ROLLOUT_MATCH | BACKEND_ERROR. Constants align with mixpanel-php.
  • SelectedVariant.getSource() replaces the prior variantSource string field. isFallback() now checks source instanceof Source.Fallback.
  • LocalFlagsProvider and RemoteFlagsProvider tag every returned variant: matches with Source.local() / Source.remote(), fallbacks with Source.fallback(reason) carrying the specific reason.
  • RemoteFlagsProvider now surfaces NO_ROLLOUT_MATCH (not FLAG_NOT_FOUND) when the server includes the flag key but sets variant_key to null — the flag exists, the user just wasn't assigned a variant.

Why

Three behaviorally distinct outcomes — flag-not-found, no-rollout-match, and missing-context-key — previously all returned the bare fallback. A future OpenFeature wrapper (in openfeature-provider/) can now dispatch on the reason and map each to the spec-correct error code instead of collapsing them all to FLAG_NOT_FOUND.

Discriminated union rather than two flat fields because the language is type-safe enough to model it that way; invalid states like "successful evaluation with a fallback reason" are unrepresentable.

Cross-SDK fix tracked at Linear SDK-79. Constant set matches mixpanel-php so callers across SDKs see the same vocabulary. NOT_READY is intentionally NOT in the Java enum — the OpenFeature wrapper short-circuits to PROVIDER_NOT_READY via areFlagsReady() before invoking the provider, so no producer would ever construct that reason. Same cleanup landed across the Python / Ruby / Node / Go PRs.

Follow-up PR

The openfeature-provider module needs a matching update to dispatch on Source.Fallback.Reason — that PR depends on this one shipping in a new mixpanel-java release first (the provider consumes the published Maven coordinate). It'll come right after release.

Test plan

  • 173 tests pass (mvn test -Dgpg.skip=true)
  • Existing LocalFlagsProviderTest (53) and RemoteFlagsProviderTest (9, +1 for the NO_ROLLOUT_MATCH null-variant-key path) continue passing — the isFallback() contract is preserved
  • SelectedVariant.equals() updated to include source
  • Wrapper PR pending base SDK release (Phase 2)

🤖 Generated with Claude Code

SelectedVariant now carries two source fields: `variant_source`
(local | remote | fallback) and `fallback_reason` (FLAG_NOT_FOUND |
MISSING_CONTEXT_KEY | NO_ROLLOUT_MATCH | BACKEND_ERROR | NOT_READY,
set only when source is fallback).

Three behaviorally distinct outcomes — flag-not-found, no-rollout-match,
and missing-context-key — previously all returned the bare fallback. The
OpenFeature wrapper collapsed them to FLAG_NOT_FOUND, sending callers
chasing the flag name when the real cause was usually a rule miss or
absent context.

The wrapper now dispatches on fallback_reason and maps each to the
spec-correct OpenFeature response. Most notably, NO_ROLLOUT_MATCH
becomes `reason: DEFAULT` with no error code instead of FLAG_NOT_FOUND.

Constant names align with mixpanel-php for consistency across SDKs.

Linear: SDK-79

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

linear-code Bot commented Jun 29, 2026

Copy link
Copy Markdown

SDK-79

@tylerjroach tylerjroach changed the title fix(flags): add Source discriminated union with Fallback.Reason (SDK-79) feat(analytics): add Source discriminated union with Fallback.Reason (SDK-79) Jun 29, 2026

@rahul-mixpanel rahul-mixpanel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four recommendations left as inline comments — one high, one medium, two low priority.

tylerjroach and others added 3 commits June 30, 2026 11:00
The OpenFeature wrapper short-circuits to PROVIDER_NOT_READY at the
top of resolve via the areFlagsReady check, so no producer ever
constructs a Source.Fallback with Reason.NOT_READY — the case was
dead, same pattern Swift PR #745 / Android PR #981 / Python PR #180
/ Ruby PR #153 / Node PR #277 cleaned up.

Trivial to add back the moment a real call site needs it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Source.Fallback now overrides equals/hashCode so two Fallback(REASON)
  instances compare structurally — otherwise SelectedVariant.equals()
  returns false for any two fallbacks with the same reason.
- Source subclasses get toString() (Local / Remote / Fallback(REASON))
  so SelectedVariant.toString() doesn't print Source\$Fallback@1a2b3c.
- Restore @param Javadoc on both 5-arg and 6-arg SelectedVariant
  constructors.
- Clarify isSuccess()'s source-based check vs the old variantKey-based
  check via a brief Javadoc note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Thanks for the review — pushed a58a93d. Threads addressed:

  1. HIGH — Source.Fallback equals/hashCode (line 82): Added. Two Source.fallback(REASON) instances now compare structurally so SelectedVariant.equals() works correctly. Local / Remote stay singleton, so reference equality is structural equality for them.
  2. MEDIUM — isSuccess / isFallback semantic change (SelectedVariant.java:80): Kept the source-based check — the Source is the canonical source-of-truth for whether something is a fallback, and variantKey != null was a proxy that breaks if a caller hands a key-bearing object as the fallback. Added a Javadoc note explaining the intent.
  3. LOW — NOT_READY unused (line 73): Removed entirely in 4e4d08c — the wrapper handles PROVIDER_NOT_READY via areFlagsReady() before invoking the provider, so no producer would construct it. Same cleanup landed across the other server SDKs (Python / Ruby / Node / Go).
  4. LOW — Source.toString() (line 56): Added on all three subclasses. Now prints Local / Remote / Fallback(FLAG_NOT_FOUND) instead of Source$Fallback@1a2b3c.
  5. LOW — Missing @param Javadoc (SelectedVariant.java:46): Restored on both the 5-arg and 6-arg constructors.

Mirrored on mixpanel-android (PR #981, commit 9f89b3c0) — same Java-class structure had the same equals/toString gap on Persistence and Fallback. Other SDKs aren't affected: Swift uses if case pattern matching (no Equatable needed); Python / Ruby / Node / Go use structural value types that already compare correctly.

@rahul-mixpanel rahul-mixpanel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core design is solid — the discriminated union, singleton pattern for Local/Remote, and immutable withSource() copy are all well done. Prior high-priority feedback (missing equals/hashCode on Fallback, missing toString()) has been addressed.

Four items remaining for your consideration:

  1. Medium: SelectedVariant overrides equals() but not hashCode() — pre-existing but good to fix now
  2. Medium (carried over): isSuccess()/isFallback() semantic change still needs explicit sign-off re: downstream impact
  3. Low: source documented as "never null" but not enforced; equals() defensively handles null anyway
  4. Low: Fallback(Reason) constructor doesn't null-guard reason — deferred NPE risk

Comment thread src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java Outdated
Addresses review feedback on PR #89:

- Add SelectedVariant.hashCode() to match the existing equals() (Java
  contract; the missing pair was pre-existing in v1.9.0).
- Deprecate the 5-arg SelectedVariant constructor. Retained for source
  compatibility with v1.9.0; new callers use the 6-arg overload for an
  explicit Source, or the 1-arg constructor for consumer-side fallbacks.
- 6-arg constructor: coalesce null source to Source.local() so
  hashCode() / isSuccess() / isFallback() never depend on a null field.
  Drops the corresponding defensive null-guard in equals().
- Fallback constructor: throw IllegalArgumentException on a null Reason.
  SDK-79 explicitly eliminated placeholder reasons; silently defaulting
  to FLAG_NOT_FOUND would mislabel observability data.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed d300883 addressing all four threads. Summary:

1. equals without hashCode (medium): Added. Follows the same null-safe field-hash pattern already used in equals(). Pre-existing gap in v1.9.0, worth closing while the file is open.

2. isSuccess() / isFallback() semantic change (medium, carried over): No code change needed — the concern doesn't have a real-world path.

  • Public fallback pattern is the 1-arg constructor (new SelectedVariant<>(fallbackValue)), which now stamps Source.fallback(FLAG_NOT_FOUND). isFallback() still returns true — semantically identical to v1.9.0.
  • The 5-arg constructor is the internal builder for real evaluation results with experimentation metadata. Every call site in v1.9.0 (LocalFlagsProvider.buildResult, RemoteFlagsProvider.getVariant, the OpenFeature provider, tests, demos) passes a non-null variantKey. No caller anywhere uses the 5-arg + null-key pattern the old Javadoc mentioned defensively.
  • To make the intent explicit going forward, I marked the 5-arg constructor @Deprecated with Javadoc pointing callers at the 6-arg overload (explicit Source) or the 1-arg constructor (fallback). No behavior change for v1.9.0 consumers; just a compile-time nudge toward the right choice.

3. source "never null" not enforced (low): Fixed by coalescing source == null → Source.local() in the 6-arg constructor, and dropping the defensive null-guard in equals() so the code and the contract agree. Local is the same default the (now-deprecated) 5-arg constructor uses, so behavior is consistent.

4. Fallback(Reason) doesn't null-guard (low): Added a fail-fast IllegalArgumentException. Went with throw rather than defaulting to FLAG_NOT_FOUND because the whole SDK-79 design explicitly eliminated placeholder reasons — silently coalescing nullFLAG_NOT_FOUND would mislabel observability events that a caller-side bug produced. If null slips through, the exception surfaces it at the call site instead of downstream in analytics data.

Tests: 172 base + 35 openfeature-provider, all green locally. CI will re-run.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Safe to merge with one logic fix: the broken NO_ROLLOUT_MATCH label on the "rollout matched but variant assignment failed" path in LocalFlagsProvider.

One code path in LocalFlagsProvider emits NO_ROLLOUT_MATCH when a rollout genuinely matched but variant assignment returned null — a configuration bug that should surface as BACKEND_ERROR. An OpenFeature wrapper relying on this distinction would silently treat it as a non-error DEFAULT resolution. The rest of the change is well-structured and the equals/hashCode additions are correct.

src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java — the post-loop fallback reason.

Important Files Changed

Filename Overview
src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Source.java New discriminated union; well-structured. Enum is missing NOT_READY (flagged in prior thread). Local/Remote singletons correctly rely on identity equality.
src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java Source field added, equals/hashCode updated correctly, withSource() copy pattern is clean.
src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java Most fallback reasons correctly classified, but the post-loop NO_ROLLOUT_MATCH is also returned when a rollout matched and variant assignment failed — that path should be BACKEND_ERROR.
src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java Now correctly distinguishes FLAG_NOT_FOUND from NO_ROLLOUT_MATCH (null variant_key). Source.remote() tagged on success.
src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java New tests cover the null-variantKey → NO_ROLLOUT_MATCH path and FLAG_NOT_FOUND; existing tests preserved.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[getVariant called] --> B{flag in definitions?}
    B -- No --> C[Source.fallback\nFLAG_NOT_FOUND]
    B -- Yes --> D{context key present?}
    D -- No --> E[Source.fallback\nMISSING_CONTEXT_KEY]
    D -- Yes --> F{rollout matches?}
    F -- No --> G[Source.fallback\nNO_ROLLOUT_MATCH]
    F -- Yes --> H{selectedVariant != null?}
    H -- Yes --> I[Source.local\nmatched variant]
    H -- No --> J[break → falls to\nNO_ROLLOUT_MATCH ⚠️\nshould be BACKEND_ERROR]
    A2[Exception] --> K[Source.fallback\nBACKEND_ERROR]
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"}}}%%
flowchart TD
    A[getVariant called] --> B{flag in definitions?}
    B -- No --> C[Source.fallback\nFLAG_NOT_FOUND]
    B -- Yes --> D{context key present?}
    D -- No --> E[Source.fallback\nMISSING_CONTEXT_KEY]
    D -- Yes --> F{rollout matches?}
    F -- No --> G[Source.fallback\nNO_ROLLOUT_MATCH]
    F -- Yes --> H{selectedVariant != null?}
    H -- Yes --> I[Source.local\nmatched variant]
    H -- No --> J[break → falls to\nNO_ROLLOUT_MATCH ⚠️\nshould be BACKEND_ERROR]
    A2[Exception] --> K[Source.fallback\nBACKEND_ERROR]
Loading

Reviews (2): Last reviewed commit: "fix(flags): remote null variant_key -> N..." | Re-trigger Greptile

…_FOUND

When /flags returns the flag key but sets variant_key to null, the flag
exists on the server — the user just wasn't assigned a variant (e.g.
no rollout matched). Previously the SDK conflated that with the
"flag absent from response" case and returned FLAG_NOT_FOUND,
which downstream (the upcoming OpenFeature wrapper) would surface as
the wrong error code.

The truly-missing-key path stays FLAG_NOT_FOUND — the remote SDK
still can't distinguish "flag doesn't exist" from "user in no rollout"
when the server omits the entry entirely, and the existing comment
documents that limitation.

Added a test that pins the new NO_ROLLOUT_MATCH mapping for the
null-variant-key path, and tightened the existing
testReturnFallbackWhenFlagNotFoundInSuccessfulResponse to assert
Source.Fallback.Reason.FLAG_NOT_FOUND for the missing-key path.

All 173 tests pass.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed 622d70d + updated the PR description. Summary of all five threads:

@rahul-mixpanelSelectedVariant.java source null contract: already addressed in d300883. The 6-arg constructor now coalesces nullSource.local() (line 73), and the Javadoc reflects the actual contract ("null is coalesced to Source.local()"). The equals() defensive null handling is no longer reachable since the field is guaranteed non-null after construction — kept it for belt-and-suspenders. Marking resolved.

@rahul-mixpanelSource.java:86 Fallback reason null: already addressed in d300883. Fail-fast guard added at lines 83–85: if (reason == null) throw new IllegalArgumentException("reason must not be null; pick a specific Fallback.Reason"). Marking resolved.

@rahul-mixpanelSelectedVariant.java:123 isSuccess/isFallback semantic change: audited the codebase. The only internal caller is LocalFlagsProvider.getAllVariantsByFlag (line 651), which uses isSuccess() to filter out fallbacks — behavior is identical under the new source-based check because providers always tag returned fallbacks with Source.fallback(...). No external callers rely on the old variantKey != null proxy semantics (it wasn't documented anywhere), and the new check is more correct: a real variant with a null key would now be treated as success, whereas the old proxy would misclassify it as a fallback. Marking resolved.

@greptile-appsSource.java:77 NOT_READY missing: intentional. The OpenFeature wrapper short-circuits to PROVIDER_NOT_READY via areFlagsReady() before invoking the provider, so no producer would ever construct that reason. Same cleanup landed across the Python / Ruby / Node / Go PRs — kept out of Java for parity. Updated the PR description to drop the stale NOT_READY row from the enum list. Marking resolved.

@greptile-appsRemoteFlagsProvider.java:82 variant_key null → NO_ROLLOUT_MATCH: real refinement, fixed in 622d70d. When the server includes the flag key but sets variant_key to null, the flag exists — the user just wasn't assigned a variant. Now returns Source.fallback(Source.Fallback.Reason.NO_ROLLOUT_MATCH) instead of FLAG_NOT_FOUND. The truly-missing-key path (server omits the entry) stays FLAG_NOT_FOUND — the existing comment documents why the remote SDK can't distinguish that case from "user in no rollout" without server help. New test testReturnNoRolloutMatchWhenVariantKeyIsNullInResponse pins the mapping; tightened the missing-key test to assert on FLAG_NOT_FOUND explicitly.

Full suite: 173 tests pass (mvn test -Dgpg.skip=true).

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

All five fixes look correct. Quick verification of each:

  • SelectedVariant null source: 6-arg constructor coalesces null → Source.local(), equals() on source is now plain (no null-guard needed since field is guaranteed non-null), hashCode() added and consistent with equals().
  • Source.Fallback null reason: fail-fast IllegalArgumentException guard in the constructor.
  • isSuccess/isFallback semantics: correctly delegates to source instanceof Source.Fallback — more precise than the old variantKey != null proxy.
  • NOT_READY absence: intentional and documented; consistent with the other SDK-79 PRs.
  • variant_key null → NO_ROLLOUT_MATCH: two-level check — missing key stays FLAG_NOT_FOUND, present key with null variant_key maps to NO_ROLLOUT_MATCH. Comment on the missing-key ambiguity limitation is clear.

One small nit: the 1-arg constructor hardcodes Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND) as the initial source for caller-supplied fallbacks. That's fine since the SDK always overwrites it via withSource() before returning — every return path in RemoteFlagsProvider (and presumably LocalFlagsProvider) calls withSource(), so the initial label is never observable. Just worth keeping in mind if a future code path ever returns the caller's fallback object directly without going through a provider.

Everything else is in good shape. LGTM. ✅

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@tylerjroach

Copy link
Copy Markdown
Contributor Author

Ack on the SelectedVariant(T variantValue) placeholder nit — leaving it as-is intentionally.

Every current provider path (RemoteFlagsProvider, LocalFlagsProvider) calls .withSource(...) before returning, so the hardcoded FLAG_NOT_FOUND label is never observable, as you noted. More importantly, this matches the shape every other SDK-79 PR shipped (Python / Ruby / Node all bake a placeholder reason into the caller's fallback and rely on the provider to stamp the real one). Diverging here would put Java out of step with the rest of the SDK-79 rollout for no observable benefit today.

Worth revisiting only if a future code path ever returns the caller's fallback object without routing through a provider — at that point the fix belongs across all SDKs at once, not just Java.

@tylerjroach
tylerjroach merged commit 34412fd into master Jul 23, 2026
14 checks passed
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.

2 participants