From cb2979c28ac725e06011d9afe647033aec778a8a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:40:54 -0700 Subject: [PATCH 1/2] feat: Keep override-affected evaluations out of individual events Adds the analytics event behavior of the OVERRIDE specification. An evaluation marked as override-affected produces no individual feature event and no debug event, even when the flag requests them. It is counted in summary events like any other evaluation, in a separate counter that carries the overrideAffected marker. The marker is part of the summary aggregation key, so override-affected and other evaluations of the same flag, variation, and version accumulate into separate counters. The marker is present on a counter only when true, like the unknown marker. The client hands the marking to the event processor as a scalar on the input event, taken from the evaluation result. Event generation does not read the reason. The record of a prerequisite evaluation carries the prerequisite's own marking, so an unaffected prerequisite inside a marked evaluation still produces its individual event. The OVERRIDE test vector runner now also asserts the marking of the summary counter that each evaluation contributes to. --- .../evaluation/EventFactoryBase.test.ts | 35 ++++ .../internal/events/EventProcessor.test.ts | 48 +++++ .../internal/events/EventSummarizer.test.ts | 35 ++++ .../internal/evaluation/EventFactoryBase.ts | 2 + .../src/internal/events/EventProcessor.ts | 9 +- .../src/internal/events/EventSummarizer.ts | 11 +- .../src/internal/events/InputEvalEvent.ts | 12 ++ .../src/internal/events/LDEventSummarizer.ts | 4 + .../src/internal/events/SummaryCounter.ts | 1 + .../LDClientImpl.overrideEvents.test.ts | 164 ++++++++++++++++++ .../LDClientImpl.overrideVectors.test.ts | 27 ++- .../overrides/overridesTestSupport.ts | 23 ++- .../shared/sdk-server/src/LDClientImpl.ts | 11 +- .../sdk-server/src/evaluation/Evaluator.ts | 10 +- .../sdk-server/src/events/EventFactory.ts | 2 + 15 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 packages/shared/common/__tests__/internal/evaluation/EventFactoryBase.test.ts create mode 100644 packages/shared/sdk-server/__tests__/LDClientImpl.overrideEvents.test.ts diff --git a/packages/shared/common/__tests__/internal/evaluation/EventFactoryBase.test.ts b/packages/shared/common/__tests__/internal/evaluation/EventFactoryBase.test.ts new file mode 100644 index 0000000000..bdcb1fb9af --- /dev/null +++ b/packages/shared/common/__tests__/internal/evaluation/EventFactoryBase.test.ts @@ -0,0 +1,35 @@ +import Context from '../../../src/Context'; +import EventFactoryBase from '../../../src/internal/evaluation/EventFactoryBase'; + +const context = Context.fromLDContext({ key: 'user-key' }); + +function evalEvent(factory: EventFactoryBase, overrideAffected?: boolean) { + return factory.evalEvent({ + context, + defaultVal: 'default', + flagKey: 'flag', + trackEvents: true, + value: 'value', + variation: 1, + version: 3, + reason: { kind: 'FALLTHROUGH' }, + overrideAffected, + }); +} + +it('carries the override marking on the evaluation event', () => { + const event = evalEvent(new EventFactoryBase(true), true); + expect(event.overrideAffected).toBe(true); + expect(event.key).toEqual('flag'); + expect(event.trackEvents).toBe(true); +}); + +it('omits the marking when the evaluation was not affected', () => { + expect(evalEvent(new EventFactoryBase(true), false).overrideAffected).toBe(false); + expect(evalEvent(new EventFactoryBase(true))).not.toHaveProperty('overrideAffected'); +}); + +it('does not mark an unknown flag event', () => { + const event = new EventFactoryBase(false).unknownFlagEvent('flag', 'default', context); + expect(event).not.toHaveProperty('overrideAffected'); +}); diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index f0e6579f3b..c54328d080 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -243,6 +243,54 @@ describe('given an event processor', () => { ]); }); + it('produces no feature or debug event for an override-affected evaluation and marks its counter', async () => { + Date.now = jest.fn(() => 1000); + // Both flags request individual feature events and debug events. Only the override-affected + // evaluation is kept out of the individual event stream. + const base = { + kind: 'feature' as const, + creationDate: 1000, + context: Context.fromLDContext(user), + version: 11, + variation: 1, + value: 'value', + trackEvents: true, + debugEventsUntilDate: 2000, + default: 'default', + samplingRatio: 1, + withReasons: true, + }; + eventProcessor.sendEvent({ ...base, key: 'flagkey', overrideAffected: true }); + eventProcessor.sendEvent({ ...base, key: 'plain' }); + + await eventProcessor.flush(); + + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + testIndexEvent, + makeFeatureEvent(1000, 11, false, 'plain'), + makeFeatureEvent(1000, 11, true, 'plain'), + { + endDate: 1000, + features: { + flagkey: { + contextKinds: ['user'], + counters: [ + { count: 1, value: 'value', variation: 1, version: 11, overrideAffected: true }, + ], + default: 'default', + }, + plain: { + contextKinds: ['user'], + counters: [{ count: 1, value: 'value', variation: 1, version: 11 }], + default: 'default', + }, + }, + kind: 'summary', + startDate: 1000, + }, + ]); + }); + it('uses sampling ratio for feature events', async () => { Date.now = jest.fn(() => 1000); eventProcessor.sendEvent({ diff --git a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts index fcf50fff00..9ded78751c 100644 --- a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts @@ -318,3 +318,38 @@ describe('given an event summarizer', () => { expect(secondSummary.endDate).toBe(0); }); }); + +it('keeps override-affected evaluations in a separate counter that carries the marker', () => { + const summarizer = new EventSummarizer(); + const context = Context.fromLDContext({ key: 'key' }); + const base = { + kind: 'feature', + creationDate: 1000, + key: 'key1', + version: 11, + context, + variation: 1, + value: 100, + default: 111, + }; + + summarizer.summarizeEvent(base as any); + summarizer.summarizeEvent({ ...base, overrideAffected: true } as any); + summarizer.summarizeEvent({ ...base, overrideAffected: true } as any); + + const data = summarizer.getSummary(); + const { counters } = data.features.key1; + expect(counters).toHaveLength(2); + + const plain = counters.find((counter) => !counter.overrideAffected); + const marked = counters.find((counter) => counter.overrideAffected); + expect(plain).toEqual({ value: 100, count: 1, variation: 1, version: 11 }); + expect(plain).not.toHaveProperty('overrideAffected'); + expect(marked).toEqual({ + value: 100, + count: 2, + variation: 1, + version: 11, + overrideAffected: true, + }); +}); diff --git a/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts index fc0605dfef..0ee9af9198 100644 --- a/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts +++ b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts @@ -9,6 +9,7 @@ export type EvalEventArgs = { defaultVal: any; excludeFromSummaries?: boolean; flagKey: string; + overrideAffected?: boolean; prereqOfFlagKey?: string; reason?: LDEvaluationReason; samplingRatio?: number; @@ -37,6 +38,7 @@ export default class EventFactoryBase { e.debugEventsUntilDate, e.excludeFromSummaries, e.samplingRatio, + e.overrideAffected, ); } diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 18b4dd07d4..8cffadfbaa 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -287,9 +287,14 @@ export default class EventProcessor implements LDEventProcessor { const isFeatureEvent = isFeature(inputEvent); - const addFullEvent = (isFeatureEvent && inputEvent.trackEvents) || !isFeatureEvent; + // An override-affected evaluation appears only in the summary counters. It produces no + // individual feature event and no debug event, even when the flag requests them. + const overrideAffected = isFeature(inputEvent) && !!inputEvent.overrideAffected; - const addDebugEvent = this._shouldDebugEvent(inputEvent); + const addFullEvent = + (isFeatureEvent && inputEvent.trackEvents && !overrideAffected) || !isFeatureEvent; + + const addDebugEvent = !overrideAffected && this._shouldDebugEvent(inputEvent); const isIdentifyEvent = isIdentify(inputEvent); const shouldNotDeduplicate = this._contextDeduplicator?.processContext(inputEvent.context); diff --git a/packages/shared/common/src/internal/events/EventSummarizer.ts b/packages/shared/common/src/internal/events/EventSummarizer.ts index 9bd68a21ea..66145e4fe4 100644 --- a/packages/shared/common/src/internal/events/EventSummarizer.ts +++ b/packages/shared/common/src/internal/events/EventSummarizer.ts @@ -10,10 +10,14 @@ import LDEventSummarizer, { } from './LDEventSummarizer'; import SummaryCounter from './SummaryCounter'; +// The override marker is part of the key, so override-affected and other evaluations of the same +// flag, variation, and version accumulate into separate counters. function counterKey(event: InputEvalEvent) { return `${event.key}:${ event.variation !== null && event.variation !== undefined ? event.variation : '' - }:${event.version !== null && event.version !== undefined ? event.version : ''}`; + }:${event.version !== null && event.version !== undefined ? event.version : ''}:${ + event.overrideAffected ? 'overrideAffected' : '' + }`; } /** @@ -59,6 +63,7 @@ export default class EventSummarizer implements LDEventSummarizer { event.default, event.version, event.variation, + event.overrideAffected, ); } @@ -96,6 +101,10 @@ export default class EventSummarizer implements LDEventSummarizer { } else { counterOut.unknown = true; } + if (counter.overrideAffected) { + // The marker is present only when true, like the unknown marker. + counterOut.overrideAffected = true; + } flagSummary.counters.push(counterOut); return acc; diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index 5c9b9377bf..4bf4b880cc 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -24,6 +24,13 @@ export default class InputEvalEvent { public readonly excludeFromSummaries?: boolean; + /** + * True when an override affected the evaluation, directly or transitively. The SDK sets it + * from the evaluation result, not from the reason. A marked evaluation produces no individual + * feature or debug event and is counted in a summary counter that carries the marker. + */ + public readonly overrideAffected?: boolean; + constructor( public readonly withReasons: boolean, public readonly context: Context, @@ -38,6 +45,7 @@ export default class InputEvalEvent { debugEventsUntilDate?: number, excludeFromSummaries?: boolean, public readonly samplingRatio: number = 1, + overrideAffected?: boolean, ) { this.creationDate = Date.now(); this.value = value; @@ -70,5 +78,9 @@ export default class InputEvalEvent { if (excludeFromSummaries !== undefined) { this.excludeFromSummaries = excludeFromSummaries; } + + if (overrideAffected !== undefined) { + this.overrideAffected = overrideAffected; + } } } diff --git a/packages/shared/common/src/internal/events/LDEventSummarizer.ts b/packages/shared/common/src/internal/events/LDEventSummarizer.ts index 53ada4d5b4..a7678def15 100644 --- a/packages/shared/common/src/internal/events/LDEventSummarizer.ts +++ b/packages/shared/common/src/internal/events/LDEventSummarizer.ts @@ -9,6 +9,10 @@ export interface FlagCounter { variation?: number; version?: number; unknown?: boolean; + /** + * Present, and true, only for a counter that aggregates override-affected evaluations. + */ + overrideAffected?: boolean; } /** diff --git a/packages/shared/common/src/internal/events/SummaryCounter.ts b/packages/shared/common/src/internal/events/SummaryCounter.ts index acff9df2e3..c41d95b7bc 100644 --- a/packages/shared/common/src/internal/events/SummaryCounter.ts +++ b/packages/shared/common/src/internal/events/SummaryCounter.ts @@ -11,6 +11,7 @@ export default class SummaryCounter { defValue: any, public version?: number, public variation?: number, + public overrideAffected?: boolean, ) { this.default = defValue; } diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.overrideEvents.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.overrideEvents.test.ts new file mode 100644 index 0000000000..a1238d87c0 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.overrideEvents.test.ts @@ -0,0 +1,164 @@ +import LDClientImpl from '../src/LDClientImpl'; +import { + fdv2FullPayload, + makeFDv2Client, + makeFDv2Platform, + singleValueFlag, + summaryCountersFor, +} from './overrides/overridesTestSupport'; +import TestOverrideSource from './overrides/TestOverrideSource'; + +const user = { key: 'user-key' }; + +function eventsOfKind(events: any[], kind: string): any[] { + return events.filter((event) => event.kind === kind); +} + +/** + * A boolean flag that is on and serves variation 1 (true) by fallthrough, with optional + * prerequisites that must each serve variation 1. Every flag requests individual events. + */ +function trackedBoolFlag(key: string, on: boolean, prereqKeys: string[] = []): any { + return { + key, + version: 1, + on, + variations: [false, true], + offVariation: 0, + fallthrough: { variation: 1 }, + prerequisites: prereqKeys.map((prereqKey) => ({ key: prereqKey, variation: 1 })), + trackEvents: true, + }; +} + +describe('given an initialized client that sends events and an override source', () => { + const debugUntil = Date.now() + 100000; + // The overridden flag requests individual feature events and debug events. An ordinary flag + // with this configuration produces both for each evaluation. + const trackedOverride = { + ...singleValueFlag('flag-tracked-override', 'override-value', 300), + trackEvents: true, + debugEventsUntilDate: debugUntil, + }; + const normal = singleValueFlag('flag-normal', 'normal-value', 100); + + let capturedEvents: any[]; + let client: LDClientImpl; + + beforeEach(async () => { + capturedEvents = []; + const platform = makeFDv2Platform(fdv2FullPayload({ 'flag-normal': normal }), capturedEvents); + const source = new TestOverrideSource({ flags: [trackedOverride] }); + client = makeFDv2Client(platform, { sendEvents: true, dataSystem: { overrides: source } }); + await client.waitForInitialization({ timeout: 5 }); + }); + + afterEach(() => { + client.close(); + }); + + it('produces no individual event for an override-affected evaluation and marks its counter', async () => { + // Two evaluations of the overridden flag accumulate into one marked counter. + await client.variation('flag-tracked-override', user, 'default1'); + await client.variation('flag-tracked-override', user, 'default1'); + await client.variation('flag-normal', user, 'default2'); + + await client.flush(); + + expect(eventsOfKind(capturedEvents, 'index')).toHaveLength(1); + expect(eventsOfKind(capturedEvents, 'feature')).toHaveLength(0); + expect(eventsOfKind(capturedEvents, 'debug')).toHaveLength(0); + + const summary = eventsOfKind(capturedEvents, 'summary'); + expect(summary).toHaveLength(1); + expect(summary[0].features['flag-tracked-override'].default).toEqual('default1'); + expect(summaryCountersFor(capturedEvents, 'flag-tracked-override')).toEqual([ + { value: 'override-value', variation: 0, version: 300, count: 2, overrideAffected: true }, + ]); + expect(summary[0].features['flag-normal'].default).toEqual('default2'); + expect(summaryCountersFor(capturedEvents, 'flag-normal')).toEqual([ + { value: 'normal-value', variation: 0, version: 100, count: 1 }, + ]); + }); +}); + +describe('given a prerequisite tree with one overridden leaf', () => { + // top-flag (LaunchDarkly) --> mid-flag (LaunchDarkly) --> leaf-flag (overridden) + // --> plain-flag (LaunchDarkly) + // The LaunchDarkly copy of leaf-flag is off, so the chain passes only through the override. + let capturedEvents: any[]; + let client: LDClientImpl; + + beforeEach(async () => { + capturedEvents = []; + const platform = makeFDv2Platform( + fdv2FullPayload({ + 'top-flag': trackedBoolFlag('top-flag', true, ['mid-flag', 'plain-flag']), + 'mid-flag': trackedBoolFlag('mid-flag', true, ['leaf-flag']), + 'plain-flag': trackedBoolFlag('plain-flag', true), + 'leaf-flag': trackedBoolFlag('leaf-flag', false), + }), + capturedEvents, + ); + const source = new TestOverrideSource({ flags: [trackedBoolFlag('leaf-flag', true)] }); + client = makeFDv2Client(platform, { sendEvents: true, dataSystem: { overrides: source } }); + await client.waitForInitialization({ timeout: 5 }); + }); + + afterEach(() => { + client.close(); + }); + + it('marks each record by its own reads and keeps marked records out of the event stream', async () => { + const detail = await client.boolVariationDetail('top-flag', user, false); + expect(detail.value).toBe(true); + expect(detail.reason).toEqual({ kind: 'FALLTHROUGH', overrideAffected: true }); + + await client.flush(); + + // Only the unaffected sibling produces an individual event, even though every flag in the + // tree has event tracking on. Its record carries the prerequisite relationship. + const featureEvents = eventsOfKind(capturedEvents, 'feature'); + expect(featureEvents.map((event) => event.key)).toEqual(['plain-flag']); + expect(featureEvents[0].prereqOf).toEqual('top-flag'); + expect(featureEvents[0].reason).toEqual({ kind: 'FALLTHROUGH' }); + expect(eventsOfKind(capturedEvents, 'debug')).toHaveLength(0); + + // The top-level flag and the intermediate prerequisite came from LaunchDarkly. Their + // records are marked because a definition read during their evaluation came from the + // override store. The leaf is marked directly. The sibling read nothing from the store. + ['top-flag', 'mid-flag', 'leaf-flag'].forEach((key) => { + const counters = summaryCountersFor(capturedEvents, key); + expect(counters).toHaveLength(1); + expect(counters[0].overrideAffected).toBe(true); + expect(counters[0].value).toBe(true); + }); + const plainCounters = summaryCountersFor(capturedEvents, 'plain-flag'); + expect(plainCounters).toHaveLength(1); + expect(plainCounters[0]).not.toHaveProperty('overrideAffected'); + }); +}); + +describe('given an uninitialized client that sends events and an override source', () => { + it('marks the counter of a wrong type result of an overridden flag', async () => { + const capturedEvents: any[] = []; + const source = new TestOverrideSource({ + flags: [singleValueFlag('overridden-flag', 'not-a-bool', 7)], + }); + const client = makeFDv2Client(makeFDv2Platform(undefined, capturedEvents), { + sendEvents: true, + dataSystem: { overrides: source }, + }); + try { + expect(await client.boolVariation('overridden-flag', user, false)).toBe(false); + await client.flush(); + + expect(eventsOfKind(capturedEvents, 'feature')).toHaveLength(0); + expect(summaryCountersFor(capturedEvents, 'overridden-flag')).toEqual([ + { value: false, version: 7, count: 1, overrideAffected: true }, + ]); + } finally { + client.close(); + } + }); +}); diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.overrideVectors.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.overrideVectors.test.ts index 7cefd381ec..75c799c700 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.overrideVectors.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.overrideVectors.test.ts @@ -6,12 +6,14 @@ import { makeFDv2Client, makeFDv2Platform, singleValueFlag, + summaryCountersFor, } from './overrides/overridesTestSupport'; import TestOverrideSource from './overrides/TestOverrideSource'; // These vectors come from the OVERRIDE specification. Each vector sets up LaunchDarkly data, an // override layer, and an initialization state. The test evaluates one flag through the full -// client stack and checks the value, the variation index, and the reason. +// client stack and checks the value, the variation index, the reason, and the marking of the +// summary counter that the evaluation contributes to. const vectorsPath = path.join(__dirname, 'overrides', 'override-vectors', 'vectors.json'); // The vectors' semantics are versioned. A schema change means this runner needs review. @@ -67,12 +69,17 @@ describe.each( ], segments: Object.values(vector.overrides.segments ?? {}), }); + const capturedEvents: any[] = []; const platform = vector.launchDarklyData.initialized ? makeFDv2Platform( fdv2FullPayload(vector.launchDarklyData.flags, vector.launchDarklyData.segments), + capturedEvents, ) - : makeFDv2Platform(); - const client = makeFDv2Client(platform, { dataSystem: { overrides: source } }); + : makeFDv2Platform(undefined, capturedEvents); + const client = makeFDv2Client(platform, { + sendEvents: true, + dataSystem: { overrides: source }, + }); try { if (vector.launchDarklyData.initialized) { await client.waitForInitialization({ timeout: 5 }); @@ -101,6 +108,20 @@ describe.each( if (!('overrideAffected' in vector.expect.reason)) { expect(actualReason.overrideAffected ?? false).toBe(false); } + + // summaryOverrideAffected is the marking of the summary counter that this single + // evaluation contributes to. The event processor keys the counter on the marking the + // client hands it, not on the reason. + if (vector.expect.summaryOverrideAffected !== undefined) { + await client.flush(); + const counters = summaryCountersFor(capturedEvents, vector.evaluate.flagKey); + expect(counters).toHaveLength(1); + if (vector.expect.summaryOverrideAffected) { + expect(counters[0].overrideAffected).toBe(true); + } else { + expect(counters[0]).not.toHaveProperty('overrideAffected'); + } + } } finally { client.close(); } diff --git a/packages/shared/sdk-server/__tests__/overrides/overridesTestSupport.ts b/packages/shared/sdk-server/__tests__/overrides/overridesTestSupport.ts index b7e2208c35..0c9fe0bfc2 100644 --- a/packages/shared/sdk-server/__tests__/overrides/overridesTestSupport.ts +++ b/packages/shared/sdk-server/__tests__/overrides/overridesTestSupport.ts @@ -34,11 +34,20 @@ export function fdv2FullPayload( /** * A platform whose polling requests return the given body. With no body, requests never - * complete, so the client never initializes. + * complete, so the client never initializes. Analytics event payloads posted to the events + * endpoint are parsed and appended to capturedEvents, when it is given. */ -export function makeFDv2Platform(body?: string) { +export function makeFDv2Platform(body?: string, capturedEvents?: any[]) { const platform = createBasicPlatform(); - platform.requests.fetch = jest.fn(() => { + platform.requests.fetch = jest.fn((url: string, options?: { body?: string }) => { + if (capturedEvents && url.includes('/bulk')) { + capturedEvents.push(...JSON.parse(options?.body ?? '[]')); + return Promise.resolve({ + status: 202, + headers: new Headers(), + text: async () => '', + }); + } if (body === undefined) { return new Promise(() => {}); } @@ -51,6 +60,14 @@ export function makeFDv2Platform(body?: string) { return platform; } +/** + * The summary counter that a flush produced for a flag, from captured analytics events. + */ +export function summaryCountersFor(capturedEvents: any[], flagKey: string): any[] { + const summary = capturedEvents.find((event) => event.kind === 'summary'); + return summary?.features?.[flagKey]?.counters ?? []; +} + export function makeCallbacks( onUpdate: (key: string) => void = () => {}, hasEventListeners: boolean = false, diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index fa6a36e1c3..961813aa5d 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -1534,8 +1534,17 @@ export default class LDClientImpl implements LDClient { evalRes.events?.forEach((event) => { this._eventProcessor.sendEvent({ ...event }); }); + // The marking is handed to the event processor as a scalar. Event generation does not read + // the reason. this._eventProcessor.sendEvent( - eventFactory.evalEventServer(flag, evalContext, evalRes.detail, defaultValue, undefined), + eventFactory.evalEventServer( + flag, + evalContext, + evalRes.detail, + defaultValue, + undefined, + evalRes.overrideAffected, + ), ); } diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 5eee4e5e33..957f605793 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -299,8 +299,16 @@ export default class Evaluator { state.prerequisites.push(prereqFlag.key); } if (eventFactory) { + // The record carries the prerequisite's own marking, not the parent's. state.events.push( - eventFactory.evalEventServer(prereqFlag, context, res.detail, null, flag), + eventFactory.evalEventServer( + prereqFlag, + context, + res.detail, + null, + flag, + res.overrideAffected, + ), ); } diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index 53f9abc353..01ec97627c 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -13,6 +13,7 @@ export default class EventFactory extends internal.EventFactoryBase { detail: LDEvaluationDetail, defaultVal: any, prereqOfFlag?: Flag, + overrideAffected?: boolean, ): internal.InputEvalEvent { const addExperimentData = isExperiment(flag, detail.reason); return super.evalEvent({ @@ -22,6 +23,7 @@ export default class EventFactory extends internal.EventFactoryBase { defaultVal, excludeFromSummaries: flag.excludeFromSummaries, flagKey: flag.key, + overrideAffected, prereqOfFlagKey: prereqOfFlag?.key, reason: detail.reason, samplingRatio: flag.samplingRatio, From 3593897b52b2772ffc75b376372f558ec4b4de09 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:05:51 +0000 Subject: [PATCH 2/2] refactor: Reuse the feature event check when deciding override-affected event handling --- packages/shared/common/src/internal/events/EventProcessor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 8cffadfbaa..db016de9eb 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -289,7 +289,7 @@ export default class EventProcessor implements LDEventProcessor { // An override-affected evaluation appears only in the summary counters. It produces no // individual feature event and no debug event, even when the flag requests them. - const overrideAffected = isFeature(inputEvent) && !!inputEvent.overrideAffected; + const overrideAffected = isFeatureEvent && !!inputEvent.overrideAffected; const addFullEvent = (isFeatureEvent && inputEvent.trackEvents && !overrideAffected) || !isFeatureEvent;