From a770a61e91273969d212c1b11210e7a4f09f6a40 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Thu, 29 Oct 2026 22:58:43 +0000 Subject: [PATCH] feat(react-native): add LaunchDarkly context keys to all telemetry Store the current context keys on every identify and stamp them as `context.contextKeys.` on all logs, errors, spans and metrics, matching the web SDK. Keys are replaced (not merged) on each identify and are set regardless of identify status, as on web. An identify made before the SDK initializes is buffered and applied in order. The bare `user`/`device` fields on the `LD.identify` log are kept. Co-Authored-By: Claude Opus 5.5 Generated-by: termag --- .../src/client/ContextKeys.test.ts | 300 ++++++++++++++++++ .../src/client/InstrumentationManager.ts | 31 +- .../src/client/ObservabilityClient.ts | 4 + .../src/plugin/observability.ts | 4 + .../src/sdk/LDObserve.ts | 5 + 5 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 sdk/@launchdarkly/observability-react-native/src/client/ContextKeys.test.ts diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ContextKeys.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/ContextKeys.test.ts new file mode 100644 index 0000000000..29cc2b05c6 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/client/ContextKeys.test.ts @@ -0,0 +1,300 @@ +import type { ReadableLogRecord } from '@opentelemetry/sdk-logs' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import { ObservabilityClient } from './ObservabilityClient' +import { InstrumentationManager } from './InstrumentationManager' +import { CustomLogExporter } from '../otel/CustomLogExporter' +import { CustomTraceExporter } from '../otel/CustomTraceExporter' +import { Observability } from '../plugin/observability' +import { _LDObserve } from '../sdk/LDObserve' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' + +// Local endpoints that fail fast (connection refused) so nothing reaches the +// network. +const localOptions = { + projectId: 'test-project-id', + serviceName: 'test-service', + backendUrl: 'http://127.0.0.1:1', + otlpEndpoint: 'http://127.0.0.1:1', + serviceVersion: '1.0.0', + resourceAttributes: {}, + customHeaders: {}, + sessionTimeout: 15 * 60 * 1000, + debug: false, + disableErrorTracking: false, + disableLogs: false, + disableTraces: false, + disableMetrics: true, + tracingOrigins: false as const, + urlBlocklist: [], + networkRecording: {}, + contextFriendlyName: () => undefined, + flagExposureDedupeWindowMillis: 0, + flagExposureDedupeMaxSize: 2000, + maxBufferSize: 1000, + uploadIntervalMillis: 10 * 60 * 1000, +} + +const USER_KEY = 'context.contextKeys.user' +const DEVICE_KEY = 'context.contextKeys.device' + +const exportedLogs: ReadableLogRecord[] = [] +const exportedSpans: ReadableSpan[] = [] + +beforeAll(() => { + vi.spyOn(CustomLogExporter.prototype, 'export').mockImplementation( + (items, resultCallback) => { + exportedLogs.push(...items) + resultCallback({ code: 0 }) + }, + ) + vi.spyOn(CustomTraceExporter.prototype, 'export').mockImplementation( + (items, resultCallback) => { + exportedSpans.push(...items) + resultCallback({ code: 0 }) + }, + ) +}) + +beforeEach(() => { + exportedLogs.length = 0 + exportedSpans.length = 0 +}) + +const findSpan = (predicate: (span: ReadableSpan) => boolean) => { + const span = exportedSpans.find(predicate) + expect(span).toBeDefined() + return span!.attributes as Record +} + +const findLog = (body: string) => { + const log = exportedLogs.find((l) => l.body === body) + expect(log).toBeDefined() + return log!.attributes as Record +} + +describe('InstrumentationManager context keys', () => { + describe('store', () => { + it('prefixes each context kind', () => { + const manager = new InstrumentationManager(localOptions) + expect(manager.getContextKeyAttributes()).toEqual({}) + + manager.setContextKeyAttributes({ user: 'u1', device: 'd1' }) + expect(manager.getContextKeyAttributes()).toEqual({ + [USER_KEY]: 'u1', + [DEVICE_KEY]: 'd1', + }) + expect(Object.isFrozen(manager.getContextKeyAttributes())).toBe( + true, + ) + }) + + it('replaces the keys on each call', () => { + const manager = new InstrumentationManager(localOptions) + manager.setContextKeyAttributes({ user: 'u1', device: 'd1' }) + manager.setContextKeyAttributes({ device: 'd2' }) + expect(manager.getContextKeyAttributes()).toEqual({ + [DEVICE_KEY]: 'd2', + }) + manager.setContextKeyAttributes({}) + expect(manager.getContextKeyAttributes()).toEqual({}) + }) + + it('adds context keys to metrics alongside global attributes', () => { + const instrument = { record: vi.fn(), add: vi.fn() } + const meter = { + createGauge: vi.fn(() => instrument), + createCounter: vi.fn(() => instrument), + createHistogram: vi.fn(() => instrument), + createUpDownCounter: vi.fn(() => instrument), + } + const manager = new InstrumentationManager(localOptions) + ;(manager as any).getMeter = () => meter + manager.setAttributes({ campaign: 'spring' }) + manager.setContextKeyAttributes({ user: 'u1' }) + + const metric = { name: 'm', value: 2, attributes: { own: 'v' } } + const expected = { campaign: 'spring', [USER_KEY]: 'u1', own: 'v' } + manager.recordMetric(metric) + manager.recordHistogram(metric) + manager.recordCount(metric) + manager.recordIncr(metric) + manager.recordUpDownCounter(metric) + expect(instrument.record).toHaveBeenNthCalledWith(1, 2, expected) + expect(instrument.record).toHaveBeenNthCalledWith(2, 2, expected) + expect(instrument.add).toHaveBeenNthCalledWith(1, 2, expected) + expect(instrument.add).toHaveBeenNthCalledWith(2, 1, expected) + expect(instrument.add).toHaveBeenNthCalledWith(3, 2, expected) + }) + }) + + describe('identify through the plugin', () => { + let client: ObservabilityClient + const [hook] = new Observability(localOptions).getHooks!({ + sdk: { name: 'test-sdk', version: '1.0.0' }, + mobileKey: 'mob-key', + } as any) + + const identify = (context: any, status = 'completed') => + hook.afterIdentify!({ context, timeout: 5 } as any, {}, { + status, + } as any) + + beforeAll(async () => { + _LDObserve._resetForTesting() + // Identify before the SDK is initialized: the keys are buffered and + // applied, in order, once it loads. + identify({ kind: 'device', key: 'device-1' }) + const buffered = _LDObserve + ._getBufferStatus() + .buffer.map((e) => e.method) + expect(buffered).toEqual(['setContextKeys', 'recordLog']) + + client = new ObservabilityClient('test-project-id', localOptions) + _LDObserve._init(client) + await vi.waitFor( + () => expect(_LDObserve.isInitialized()).toBe(true), + { timeout: 2000 }, + ) + }) + + afterAll(async () => { + ;(client as any).errorInstrumentation?.destroy?.() + await client.stop() + _LDObserve._resetForTesting() + }) + + it('applies keys from an identify made before init', async () => { + await client.flush() + expect(findLog('LD.identify')).toMatchObject({ + [DEVICE_KEY]: 'device-1', + // The bare keys on the identify log are kept for back-compat. + device: 'device-1', + }) + + _LDObserve.recordLog('pre-login', 'info') + await client.flush() + const log = findLog('pre-login') + expect(log).toMatchObject({ [DEVICE_KEY]: 'device-1' }) + expect(log).not.toHaveProperty(USER_KEY) + }) + + describe('after a multi-context identify', () => { + beforeAll(() => { + identify({ + kind: 'multi', + user: { key: 'user-1' }, + device: { key: 'device-1' }, + }) + }) + + const expected = { [USER_KEY]: 'user-1', [DEVICE_KEY]: 'device-1' } + + it('adds keys to recordLog', async () => { + _LDObserve.recordLog('post-login', 'info', { own: 'v' }) + await client.flush() + expect(findLog('post-login')).toMatchObject({ + ...expected, + own: 'v', + }) + }) + + it('adds keys to recordError spans', async () => { + _LDObserve.recordError(new Error('boom-context')) + await client.flush() + expect( + findSpan( + (s) => + s.attributes['exception.message'] === + 'boom-context', + ), + ).toMatchObject(expected) + }) + + it('adds keys to console.error spans', async () => { + console.error('boom-console-context') + await client.flush() + expect( + findSpan((s) => + String(s.attributes['exception.message']).includes( + 'boom-console-context', + ), + ), + ).toMatchObject(expected) + }) + + it('adds keys to manual and track spans', async () => { + _LDObserve.startSpan('manual-context').end() + _LDObserve.track('signup-context') + await client.flush() + expect( + findSpan((s) => s.name === 'manual-context'), + ).toMatchObject(expected) + expect( + findSpan( + (s) => + s.name === 'track' && + s.attributes.key === 'signup-context', + ), + ).toMatchObject(expected) + }) + + it('adds keys to flag evaluation spans', async () => { + hook.afterEvaluation!( + { flagKey: 'context-flag', defaultValue: false } as any, + {}, + { value: true, variationIndex: 0, reason: null }, + ) + await client.flush() + expect( + findSpan( + (s) => + s.attributes['feature_flag.key'] === 'context-flag', + ), + ).toMatchObject(expected) + }) + + it('adds keys to network spans', async () => { + const url = 'http://127.0.0.1:59999/context-keys' + await new Promise((resolve) => { + const xhr = new XMLHttpRequest() + xhr.onloadend = () => resolve() + xhr.open('GET', url) + xhr.send() + }) + const isXhrSpan = (s: ReadableSpan) => + Object.values(s.attributes).some((v) => v === url) + await vi.waitFor(async () => { + await client.flush() + expect(exportedSpans.some(isXhrSpan)).toBe(true) + }) + expect(findSpan(isXhrSpan)).toMatchObject(expected) + }) + }) + + it('replaces keys on re-identify', async () => { + identify({ kind: 'user', key: 'user-2' }) + _LDObserve.recordLog('re-identified', 'info') + await client.flush() + const log = findLog('re-identified') + expect(log).toMatchObject({ [USER_KEY]: 'user-2' }) + expect(log).not.toHaveProperty(DEVICE_KEY) + }) + + it('sets keys even when identify does not complete', async () => { + identify({ kind: 'user', key: 'user-3' }, 'error') + _LDObserve.recordLog('identify-error', 'info') + await client.flush() + expect(findLog('identify-error')).toMatchObject({ + [USER_KEY]: 'user-3', + }) + }) + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts index c660c5675b..ecc2a92242 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts @@ -73,10 +73,13 @@ export type InstrumentationManagerOptions = Required & { // Span name for custom track events, matching the iOS/Android SDKs (`track`). const TRACK_SPAN_NAME = 'track' +// Prefix for LaunchDarkly context keys, matching the web SDK. +const CONTEXT_KEYS_ATTRIBUTE_PREFIX = 'context.contextKeys.' + // Attribute namespaces the SDK sets itself; `setAttributes` ignores them. const RESERVED_ATTRIBUTE_PREFIXES = [ 'highlight.', - 'context.contextKeys.', + CONTEXT_KEYS_ATTRIBUTE_PREFIX, 'launchdarkly.', 'session.', ] @@ -115,6 +118,8 @@ export class InstrumentationManager { // Frozen snapshot of `_globalAttributes`, rebuilt on change so the telemetry // hot path doesn't allocate. private _globalAttributesSnapshot: Attributes = EMPTY_ATTRIBUTES + // Keys of the current LaunchDarkly context, replaced on each identify. + private _contextKeyAttributes: Attributes = EMPTY_ATTRIBUTES constructor( private options: Required & { @@ -182,10 +187,30 @@ export class InstrumentationManager { return this._globalAttributesSnapshot } + // Replaces the context key attributes with `context.contextKeys.` + // for each kind in `contextKeys` (e.g. `{ user: 'u1', device: 'd1' }`). + public setContextKeyAttributes( + contextKeys: Record, + ): void { + const entries = Object.entries(contextKeys ?? {}).filter( + ([, key]) => key !== undefined && key !== null, + ) + this._contextKeyAttributes = entries.length + ? Object.freeze( + Object.fromEntries( + entries.map(([kind, key]) => [ + `${CONTEXT_KEYS_ATTRIBUTE_PREFIX}${kind}`, + key, + ]), + ), + ) + : EMPTY_ATTRIBUTES + } + // LaunchDarkly context key attributes (`context.contextKeys.*`) for every - // span, log and metric. None are tracked yet. + // span, log and metric. public getContextKeyAttributes(): Attributes { - return EMPTY_ATTRIBUTES + return this._contextKeyAttributes } // Attributes stamped on all telemetry by the processors and metric calls. diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts index cc96bb4d3d..f919b0769b 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts @@ -316,6 +316,10 @@ export class ObservabilityClient { this.instrumentationManager.removeAttributes(keys) } + public setContextKeys(contextKeys: Attributes): void { + this.instrumentationManager.setContextKeyAttributes(contextKeys) + } + public track( key: string, properties?: TrackProperties, diff --git a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts index 5426ed0761..1f2ad108c1 100644 --- a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts +++ b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts @@ -76,6 +76,10 @@ class TracingHook implements Hook { data: IdentifySeriesData, result: IdentifySeriesResult, ): IdentifySeriesData { + // Stamp the context keys on all telemetry from now on, matching the + // web SDK (which also sets them regardless of the identify status). + _LDObserve._setContextKeys(getContextKeys(hookContext.context)) + if (result.status === 'completed') { // The evaluation context changed, so previously recorded exposures // are no longer relevant for deduplication. Only reset on a diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index fee5f44c68..d497ca40cd 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -111,6 +111,11 @@ class LDObserveClass return this._bufferCall('removeAttributes', [keys]) } + // Internal: sets the LaunchDarkly context keys stamped on all telemetry. + _setContextKeys(contextKeys: Attributes): void { + return this._bufferCall('setContextKeys', [contextKeys]) + } + track( key: string, properties?: TrackProperties,