From bb30c8ce9a6a54412dd21f3e396d8651f6c2edfd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:33:18 -0700 Subject: [PATCH 1/3] feat(react-native): add setAttributes and custom span/log processors Co-authored-by: Cursor --- .../observability-react-native/README.md | 62 ++++ .../src/api/Observe.ts | 25 ++ .../src/api/Options.ts | 20 + .../src/api/index.ts | 10 + .../GlobalAttributes.integration.test.ts | 344 ++++++++++++++++++ .../src/client/InstrumentationManager.test.ts | 2 + .../src/client/InstrumentationManager.ts | 65 +++- .../src/client/ObservabilityClient.ts | 10 + .../src/otel/GlobalAttributes.test.ts | 72 ++++ .../src/otel/GlobalAttributes.ts | 73 ++++ .../GlobalAttributesLogRecordProcessor.ts | 28 ++ .../otel/GlobalAttributesProcessors.test.ts | 96 +++++ .../src/otel/GlobalAttributesSpanProcessor.ts | 30 ++ .../src/otel/SafeProcessors.test.ts | 109 ++++++ .../src/otel/SafeProcessors.ts | 93 +++++ .../src/sdk/LDObserve.ts | 8 + 16 files changed, 1043 insertions(+), 4 deletions(-) create mode 100644 sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.test.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesLogRecordProcessor.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesProcessors.test.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesSpanProcessor.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.test.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.ts diff --git a/sdk/@launchdarkly/observability-react-native/README.md b/sdk/@launchdarkly/observability-react-native/README.md index 8cad6e748e..dea1632683 100644 --- a/sdk/@launchdarkly/observability-react-native/README.md +++ b/sdk/@launchdarkly/observability-react-native/README.md @@ -100,6 +100,68 @@ await tracer.withSpan('LoadProducts', async (scope) => { > `LDObserve.withSpan`) for nested async work. See the > [Tracing Guide](guides/tracing.md) for details. +### Global attributes + +`LDObserve.setAttributes` attaches attributes to every span (including +automatic network and error spans), log record and metric recorded after the +call. Calls merge by key; `LDObserve.removeAttributes` removes keys. Global +attributes are kept in memory only, survive session changes, and can be set +before the SDK has finished initializing. + +```typescript +import { LDObserve } from '@launchdarkly/observability-react-native'; + +LDObserve.setAttributes({ 'app.tier': 'premium', 'user.cohort': 'beta' }); + +// Attributes passed to an individual call win over global attributes. +LDObserve.recordLog('checkout', 'info', { 'app.tier': 'trial' }); + +LDObserve.removeAttributes(['user.cohort']); +``` + +A span gets the global attributes current when it starts. Precedence is +per-call attributes, then global attributes, then resource attributes. The +SDK-managed session keys (`highlight.session_id`, `session.id`) cannot be set +this way and are ignored. + +### Custom span and log processors + +Pass OpenTelemetry processors with the `spanProcessors` and `logProcessors` +options to inspect or modify telemetry before it is exported. They run in the +given order, after global attributes are applied and before the SDK's exporting +processor, so their changes are exported. A processor that throws is isolated +and never prevents the span or log record from being exported. + +```typescript +import { + Observability, + type LogRecordProcessor, + type SpanProcessor, +} from '@launchdarkly/observability-react-native'; + +const tagSpans: SpanProcessor = { + onStart: (span) => span.setAttribute('app.build', 'release'), + onEnd: () => {}, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), +}; + +const tagLogs: LogRecordProcessor = { + onEmit: (logRecord) => logRecord.setAttribute('app.build', 'release'), + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), +}; + +new Observability({ + spanProcessors: [tagSpans], + logProcessors: [tagLogs], +}); +``` + +The `SpanProcessor`, `ReadableSpan`, `SdkSpan`, `LogRecordProcessor`, +`SdkLogRecord` and `ReadableLogRecord` types are re-exported from this package, +so you don't need to depend on matching OpenTelemetry package versions. + ## Guides - [Tracing Guide](guides/tracing.md) — a cookbook of common tracing patterns (spans, nested operations, error handling, correlated logs, and end-to-end mobile-to-backend traces). diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index 1e6eb1a4d7..18b66b2c85 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts @@ -12,6 +12,31 @@ import { SpanScope, WithSpanOptions } from './SpanScope' import { TrackProperties } from './TrackProperties' export interface Observe { + /** + * Set global attributes, merged by key into a process-wide map that is + * applied to every span (manual and auto-instrumented), log record and + * metric data point recorded after this call. A span gets the values current + * when it starts. + * + * Attributes passed to an individual call (e.g. `startSpan`, `recordLog`, + * `recordMetric`) take precedence over global attributes, which take + * precedence over resource attributes. SDK-managed session keys + * (`highlight.session_id`, `session.id`) are ignored. + * + * Global attributes are kept in memory only (not persisted), survive + * session changes, and remain until removed with {@link removeAttributes}. + * Safe to call before the SDK has finished initializing. + * + * @param attributes The attributes to set + */ + setAttributes(attributes: Attributes): void + + /** + * Remove global attributes previously set with {@link setAttributes}. + * @param keys The attribute keys to remove + */ + removeAttributes(keys: string[]): void + /** * Record an error with optional context. * @param error The error to record diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Options.ts b/sdk/@launchdarkly/observability-react-native/src/api/Options.ts index ff3811cf40..f640ab950b 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Options.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Options.ts @@ -1,4 +1,6 @@ import { Attributes } from '@opentelemetry/api' +import type { LogRecordProcessor } from '@opentelemetry/sdk-logs' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-web' import type { LDContext } from '@launchdarkly/js-sdk-common' export type NetworkRecordingOptions = { @@ -214,4 +216,22 @@ export interface ReactNativeOptions { * @default 5000 */ uploadIntervalMillis?: number + + /** + * Additional OpenTelemetry span processors, invoked in the given order after + * global attributes (see `LDObserve.setAttributes`) are applied and before + * the SDK's exporting processor, so mutations made in `onStart` are + * exported. Errors thrown by a processor are caught and never prevent + * export. + */ + spanProcessors?: SpanProcessor[] + + /** + * Additional OpenTelemetry log record processors, invoked in the given order + * after global attributes (see `LDObserve.setAttributes`) are applied and + * before the SDK's exporting processor, so mutations made in `onEmit` are + * exported. Errors thrown by a processor are caught and never prevent + * export. + */ + logProcessors?: LogRecordProcessor[] } diff --git a/sdk/@launchdarkly/observability-react-native/src/api/index.ts b/sdk/@launchdarkly/observability-react-native/src/api/index.ts index 7a98ff96e5..1672b0cf89 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/index.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/index.ts @@ -5,3 +5,13 @@ export type { LDTracer } from './LDTracer' export { wrapTracer } from './LDTracer' export type { SpanScope, WithSpanOptions } from './SpanScope' export type { TrackProperties, TrackPropertyValue } from './TrackProperties' +export type { + ReadableSpan, + Span as SdkSpan, + SpanProcessor, +} from '@opentelemetry/sdk-trace-web' +export type { + LogRecordProcessor, + ReadableLogRecord, + SdkLogRecord, +} from '@opentelemetry/sdk-logs' diff --git a/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts new file mode 100644 index 0000000000..2fd5dca973 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts @@ -0,0 +1,344 @@ +import type { ReadableLogRecord } from '@opentelemetry/sdk-logs' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { LogRecordProcessor } from '@opentelemetry/sdk-logs' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-web' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { ObservabilityClient } from './ObservabilityClient' +import { InstrumentationManager } from './InstrumentationManager' +import { CustomLogExporter } from '../otel/CustomLogExporter' +import { CustomTraceExporter } from '../otel/CustomTraceExporter' +import { globalAttributes } from '../otel/GlobalAttributes' +import { _LDObserve } from '../sdk/LDObserve' + +// FetchInstrumentation decides at module load whether it runs under Node (and +// then refuses to patch fetch), so pretend to be a non-Node runtime as React +// Native is. +const originalRelease = vi.hoisted(() => { + const release = process.release + Object.defineProperty(process, 'release', { + value: { ...release, name: 'react-native' }, + configurable: true, + }) + return release +}) + +const TEST_FETCH_URL = 'http://example.test/api' + +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: true, + disableLogs: false, + disableTraces: false, + disableMetrics: true, + tracingOrigins: false as const, + urlBlocklist: [], + networkRecording: {}, + contextFriendlyName: () => undefined, + flagExposureDedupeWindowMillis: 0, + flagExposureDedupeMaxSize: 2000, + maxBufferSize: 1000, + uploadIntervalMillis: 10 * 60 * 1000, + spanProcessors: [], + logProcessors: [], +} + +const exportedLogs: ReadableLogRecord[] = [] +const exportedSpans: ReadableSpan[] = [] + +const findSpan = (name: string) => + exportedSpans.find((span) => span.name === name) +const findLog = (body: string) => + exportedLogs.find((record) => record.body === body) + +describe('global attributes and custom processors', () => { + const seenAtSpanStart: Record = {} + const seenAtLogEmit: Record = {} + + const mutatingSpanProcessor: SpanProcessor = { + onStart: (span) => { + seenAtSpanStart[span.name] = span.attributes['app.tier'] + span.setAttribute('user.processor', 'span') + }, + onEnd: () => {}, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + } + const throwingSpanProcessor: SpanProcessor = { + onStart: () => { + throw new Error('span onStart') + }, + onEnd: () => { + throw new Error('span onEnd') + }, + forceFlush: () => Promise.reject(new Error('span forceFlush')), + shutdown: () => Promise.resolve(), + } + const mutatingLogProcessor: LogRecordProcessor = { + onEmit: (record) => { + seenAtLogEmit[String(record.body)] = record.attributes['app.tier'] + record.setAttribute('user.processor', 'log') + }, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + } + const throwingLogProcessor: LogRecordProcessor = { + onEmit: () => { + throw new Error('log onEmit') + }, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + } + + let client: ObservabilityClient + let originalFetch: typeof globalThis.fetch + + beforeAll(async () => { + 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 }) + }, + ) + originalFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).startsWith(TEST_FETCH_URL)) { + return new Response('ok', { status: 200 }) + } + throw new Error('network disabled in tests') + }) as typeof globalThis.fetch + + _LDObserve._resetForTesting() + globalAttributes._resetForTesting() + + // Called before the client exists: must be buffered and still apply. + _LDObserve.setAttributes({ + 'app.tier': 'gold', + shared: 'global', + 'highlight.session_id': 'spoofed', + 'session.id': 'spoofed', + }) + _LDObserve.recordLog('before-init', 'info') + + client = new ObservabilityClient('test-project-id', { + ...localOptions, + disableErrorTracking: false, + spanProcessors: [throwingSpanProcessor, mutatingSpanProcessor], + logProcessors: [throwingLogProcessor, mutatingLogProcessor], + }) + _LDObserve._init(client) + await vi.waitFor(() => expect(_LDObserve.isInitialized()).toBe(true), { + timeout: 2000, + }) + await client.flush() + }) + + beforeEach(() => { + // The before-init log is asserted in the first test; keep it around. + exportedSpans.length = 0 + }) + + afterAll(async () => { + await client.stop() + globalAttributes._resetForTesting() + _LDObserve._resetForTesting() + globalThis.fetch = originalFetch + Object.defineProperty(process, 'release', { + value: originalRelease, + configurable: true, + }) + vi.restoreAllMocks() + }) + + it('applies attributes set before init to telemetry recorded before init', () => { + const record = findLog('before-init') + expect(record).toBeDefined() + expect(record!.attributes['app.tier']).toBe('gold') + expect(record!.attributes['user.processor']).toBe('log') + }) + + it('never sets reserved session keys from globals', async () => { + const sessionId = _LDObserve.getSessionInfo().sessionId + _LDObserve.startSpan('reserved-span').end() + _LDObserve.recordLog('reserved-log', 'info') + await client.flush() + + const span = findSpan('reserved-span')! + expect(span.attributes['highlight.session_id']).toBeUndefined() + expect(span.attributes['session.id']).toBeUndefined() + expect(span.resource.attributes['session.id']).toBe(sessionId) + + const record = findLog('reserved-log')! + expect(record.attributes['highlight.session_id']).toBe(sessionId) + expect(record.attributes['session.id']).toBeUndefined() + }) + + it('applies globals to manual spans, with per-call attributes winning', async () => { + _LDObserve + .startSpan('manual-span', { attributes: { shared: 'call' } }) + .end() + await client.flush() + + const span = findSpan('manual-span')! + expect(span.attributes['app.tier']).toBe('gold') + expect(span.attributes.shared).toBe('call') + }) + + it('applies globals to auto-instrumented fetch spans', async () => { + const response = await fetch(TEST_FETCH_URL) + expect(response.status).toBe(200) + + await vi.waitFor( + async () => { + await client.flush() + const fetchSpan = exportedSpans.find( + (span) => + span.attributes['http.url'] === TEST_FETCH_URL || + span.attributes['url.full'] === TEST_FETCH_URL, + ) + expect(fetchSpan).toBeDefined() + expect(fetchSpan!.attributes['app.tier']).toBe('gold') + expect(fetchSpan!.attributes['user.processor']).toBe('span') + }, + { timeout: 3000, interval: 100 }, + ) + }) + + it('applies globals to error spans, with per-call attributes winning', async () => { + _LDObserve.recordError(new Error('boom'), { shared: 'call' }) + await client.flush() + + const span = exportedSpans.find( + (s) => s.attributes['exception.message'] === 'boom', + )! + expect(span).toBeDefined() + expect(span.attributes['app.tier']).toBe('gold') + expect(span.attributes.shared).toBe('call') + }) + + it('applies globals to logs, with per-call attributes winning', async () => { + _LDObserve.recordLog('log-with-globals', 'info', { shared: 'call' }) + await client.flush() + + const record = findLog('log-with-globals')! + expect(record.attributes['app.tier']).toBe('gold') + expect(record.attributes.shared).toBe('call') + }) + + it('stops applying removed attributes', async () => { + _LDObserve.setAttributes({ temporary: 'yes' }) + _LDObserve.startSpan('with-temporary').end() + _LDObserve.removeAttributes(['temporary']) + _LDObserve.startSpan('without-temporary').end() + await client.flush() + + expect(findSpan('with-temporary')!.attributes.temporary).toBe('yes') + expect( + findSpan('without-temporary')!.attributes.temporary, + ).toBeUndefined() + }) + + it('runs user processors after globals and exports their mutations despite a throwing processor', async () => { + _LDObserve.startSpan('processed-span').end() + _LDObserve.recordLog('processed-log', 'info') + await client.flush() + + expect(seenAtSpanStart['processed-span']).toBe('gold') + expect(findSpan('processed-span')!.attributes['user.processor']).toBe( + 'span', + ) + expect(seenAtLogEmit['processed-log']).toBe('gold') + expect(findLog('processed-log')!.attributes['user.processor']).toBe( + 'log', + ) + }) +}) + +describe('InstrumentationManager metrics with global attributes', () => { + beforeEach(() => { + globalAttributes._resetForTesting() + }) + + afterAll(() => { + globalAttributes._resetForTesting() + }) + + const makeManager = () => { + 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 + return { manager, instrument } + } + + it('merges globals into every metric type, with per-call attributes winning', () => { + const { manager, instrument } = makeManager() + manager.setAttributes({ 'app.tier': 'gold', shared: 'global' }) + + const metric = { name: 'm', value: 5, attributes: { shared: 'call' } } + manager.recordMetric(metric) + manager.recordHistogram(metric) + manager.recordCount(metric) + manager.recordIncr(metric) + manager.recordUpDownCounter(metric) + + const expected = { 'app.tier': 'gold', shared: 'call' } + expect(instrument.record).toHaveBeenNthCalledWith(1, 5, expected) + expect(instrument.record).toHaveBeenNthCalledWith(2, 5, expected) + expect(instrument.add).toHaveBeenNthCalledWith(1, 5, expected) + expect(instrument.add).toHaveBeenNthCalledWith(2, 1, expected) + expect(instrument.add).toHaveBeenNthCalledWith(3, 5, expected) + }) + + it('passes metric attributes through unchanged when there are no globals', () => { + const { manager, instrument } = makeManager() + + manager.recordMetric({ name: 'm', value: 1 }) + + expect(instrument.record).toHaveBeenCalledWith(1, undefined) + }) + + it('logs ignored reserved keys in debug mode', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const manager = new InstrumentationManager({ + ...localOptions, + debug: true, + }) + + manager.setAttributes({ 'session.id': 'x', ok: 1 }) + + expect(logSpy).toHaveBeenCalledWith( + '[InstrumentationManager]', + 'setAttributes ignored reserved or invalid keys:', + ['session.id'], + ) + expect(globalAttributes.getAttributes()).toEqual({ ok: 1 }) + logSpy.mockRestore() + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.test.ts index 2d4894a57e..eb5f477c26 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.test.ts @@ -38,6 +38,8 @@ const localOptions = { flagExposureDedupeMaxSize: 2000, maxBufferSize: 1000, uploadIntervalMillis: 10 * 60 * 1000, + spanProcessors: [], + logProcessors: [], } // Capture what the exporters receive instead of letting them hit the network. diff --git a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts index 217f791eb0..b9415fb86b 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts @@ -58,6 +58,13 @@ import { import { CustomBatchSpanProcessor } from '../otel/CustomBatchSpanProcessor' import { CustomTraceExporter } from '../otel/CustomTraceExporter' import { CustomLogExporter } from '../otel/CustomLogExporter' +import { globalAttributes } from '../otel/GlobalAttributes' +import { GlobalAttributesSpanProcessor } from '../otel/GlobalAttributesSpanProcessor' +import { GlobalAttributesLogRecordProcessor } from '../otel/GlobalAttributesLogRecordProcessor' +import { + SafeLogRecordProcessor, + SafeSpanProcessor, +} from '../otel/SafeProcessors' import { DEFAULT_EXPORT_TIMEOUT_MILLIS, DEFAULT_MAX_EXPORT_BATCH_SIZE, @@ -157,6 +164,13 @@ export class InstrumentationManager { ) const processors: SpanProcessor[] = [ + new GlobalAttributesSpanProcessor(globalAttributes), + ...(this.options.spanProcessors ?? []).map( + (processor) => + new SafeSpanProcessor(processor, (...data) => + this._log(...data), + ), + ), new CustomBatchSpanProcessor(exporter, { maxQueueSize: this.options.maxBufferSize, scheduledDelayMillis: this.options.uploadIntervalMillis, @@ -225,6 +239,13 @@ export class InstrumentationManager { this.loggerProvider = new LoggerProvider({ resource: this.resource, processors: [ + new GlobalAttributesLogRecordProcessor(globalAttributes), + ...(this.options.logProcessors ?? []).map( + (processor) => + new SafeLogRecordProcessor(processor, (...data) => + this._log(...data), + ), + ), new BatchLogRecordProcessor(logExporter, { maxQueueSize: this.options.maxBufferSize, scheduledDelayMillis: this.options.uploadIntervalMillis, @@ -265,6 +286,30 @@ export class InstrumentationManager { this._log('Metrics initialized') } + public setAttributes(attributes: Attributes): void { + const ignored = globalAttributes.setAttributes(attributes) + if (ignored.length) { + this._log( + 'setAttributes ignored reserved or invalid keys:', + ignored, + ) + } + } + + public removeAttributes(keys: string[]): void { + globalAttributes.removeAttributes(keys) + } + + private withGlobalAttributes( + attributes?: Attributes, + ): Attributes | undefined { + const globals = globalAttributes.getAttributes() + if (Object.keys(globals).length === 0) { + return attributes + } + return { ...globals, ...attributes } + } + public recordError( error: Error, attributes?: Attributes, @@ -313,7 +358,10 @@ export class InstrumentationManager { ) this._gauges.set(metric.name, gauge) } - gauge.record(metric.value, metric.attributes) + gauge.record( + metric.value, + this.withGlobalAttributes(metric.attributes), + ) } catch (e) { console.error('Failed to record metric:', e) } @@ -330,7 +378,10 @@ export class InstrumentationManager { ) this._counters.set(metric.name, counter) } - counter.add(metric.value, metric.attributes) + counter.add( + metric.value, + this.withGlobalAttributes(metric.attributes), + ) } catch (e) { console.error('Failed to record count:', e) } @@ -352,7 +403,10 @@ export class InstrumentationManager { ) this._histograms.set(metric.name, histogram) } - histogram.record(metric.value, metric.attributes) + histogram.record( + metric.value, + this.withGlobalAttributes(metric.attributes), + ) } catch (e) { console.error('Failed to record histogram:', e) } @@ -369,7 +423,10 @@ export class InstrumentationManager { ) this._upDownCounters.set(metric.name, upDownCounter) } - upDownCounter.add(metric.value, metric.attributes) + upDownCounter.add( + metric.value, + this.withGlobalAttributes(metric.attributes), + ) } catch (e) { console.error('Failed to record up/down counter:', e) } diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts index c261d63139..aa7760a740 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts @@ -161,6 +161,8 @@ export class ObservabilityClient { maxBufferSize: options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE, uploadIntervalMillis: options.uploadIntervalMillis ?? DEFAULT_UPLOAD_INTERVAL_MILLIS, + spanProcessors: options.spanProcessors ?? [], + logProcessors: options.logProcessors ?? [], } } @@ -258,6 +260,14 @@ export class ObservabilityClient { } } + public setAttributes(attributes: Attributes): void { + this.instrumentationManager.setAttributes(attributes) + } + + public removeAttributes(keys: string[]): void { + this.instrumentationManager.removeAttributes(keys) + } + public consumeCustomError( error: Error, attributes?: Attributes, diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.test.ts b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.test.ts new file mode 100644 index 0000000000..1fe96d4197 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { GlobalAttributes } from './GlobalAttributes' + +describe('GlobalAttributes', () => { + let store: GlobalAttributes + + beforeEach(() => { + store = new GlobalAttributes() + }) + + it('merges attributes by key', () => { + store.setAttributes({ a: 1, b: 'two' }) + store.setAttributes({ b: 'three', c: true }) + + expect(store.getAttributes()).toEqual({ a: 1, b: 'three', c: true }) + }) + + it('removes attributes by key', () => { + store.setAttributes({ a: 1, b: 2, c: 3 }) + store.removeAttributes(['a', 'c', 'missing']) + + expect(store.getAttributes()).toEqual({ b: 2 }) + }) + + it('ignores reserved session keys and reports them', () => { + const ignored = store.setAttributes({ + 'highlight.session_id': 'spoofed', + 'session.id': 'spoofed', + kept: 'yes', + }) + + expect(ignored).toEqual(['highlight.session_id', 'session.id']) + expect(store.getAttributes()).toEqual({ kept: 'yes' }) + }) + + it('ignores values that are not valid attribute values', () => { + const ignored = store.setAttributes({ + missing: undefined, + nested: { a: 1 } as any, + mixed: [1, 'two'] as any, + list: ['a', 'b'], + }) + + expect(ignored).toEqual(['missing', 'nested', 'mixed']) + expect(store.getAttributes()).toEqual({ list: ['a', 'b'] }) + }) + + it('exposes an immutable snapshot that is replaced on change', () => { + const list = ['a'] + store.setAttributes({ list }) + const snapshot = store.getAttributes() + + list.push('b') + expect(Object.isFrozen(snapshot)).toBe(true) + expect(snapshot.list).toEqual(['a']) + expect(store.getAttributes()).toBe(snapshot) + + store.setAttributes({ other: 1 }) + expect(store.getAttributes()).not.toBe(snapshot) + expect(snapshot).toEqual({ list: ['a'] }) + }) + + it('does not rebuild the snapshot when nothing changes', () => { + store.setAttributes({ a: 1 }) + const snapshot = store.getAttributes() + + store.setAttributes({ 'session.id': 'x' }) + store.removeAttributes(['missing']) + + expect(store.getAttributes()).toBe(snapshot) + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.ts b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.ts new file mode 100644 index 0000000000..f126fd8e91 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributes.ts @@ -0,0 +1,73 @@ +import { Attributes, AttributeValue } from '@opentelemetry/api' +import { isAttributeValue } from '@opentelemetry/core' + +// Session keys managed by the SDK itself; globals must never override them. +export const RESERVED_GLOBAL_ATTRIBUTE_KEYS: ReadonlySet = new Set([ + 'highlight.session_id', + 'session.id', +]) + +const EMPTY: Readonly = Object.freeze({}) + +/** + * Process-wide, in-memory attributes applied to every span, log record and + * metric data point. Readers get an immutable snapshot that is only rebuilt + * when the attributes change, so the hot path does no copying. + */ +export class GlobalAttributes { + private snapshot: Readonly = EMPTY + + /** + * Merges `attributes` by key. Returns the keys that were ignored because + * they are reserved or their value is not a valid attribute value. + */ + setAttributes(attributes: Attributes): string[] { + const ignored: string[] = [] + const next: Attributes = { ...this.snapshot } + let changed = false + for (const [key, value] of Object.entries(attributes ?? {})) { + if ( + RESERVED_GLOBAL_ATTRIBUTE_KEYS.has(key) || + value === undefined || + !isAttributeValue(value) + ) { + ignored.push(key) + continue + } + next[key] = Array.isArray(value) + ? (value.slice() as AttributeValue) + : value + changed = true + } + if (changed) { + this.snapshot = Object.freeze(next) + } + return ignored + } + + removeAttributes(keys: string[]): void { + const next: Attributes = { ...this.snapshot } + let changed = false + for (const key of keys ?? []) { + if (key in next) { + delete next[key] + changed = true + } + } + if (changed) { + this.snapshot = Object.keys(next).length + ? Object.freeze(next) + : EMPTY + } + } + + getAttributes(): Readonly { + return this.snapshot + } + + _resetForTesting(): void { + this.snapshot = EMPTY + } +} + +export const globalAttributes = new GlobalAttributes() diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesLogRecordProcessor.ts b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesLogRecordProcessor.ts new file mode 100644 index 0000000000..a8a808cec1 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesLogRecordProcessor.ts @@ -0,0 +1,28 @@ +import { Context } from '@opentelemetry/api' +import { LogRecordProcessor, SdkLogRecord } from '@opentelemetry/sdk-logs' +import { GlobalAttributes } from './GlobalAttributes' + +/** + * Fills global attributes onto each log record. Attributes already set on the + * record (e.g. passed to `recordLog`) take precedence. + */ +export class GlobalAttributesLogRecordProcessor implements LogRecordProcessor { + constructor(private readonly store: GlobalAttributes) {} + + onEmit(logRecord: SdkLogRecord, _context?: Context): void { + const attributes = this.store.getAttributes() + for (const key in attributes) { + if (logRecord.attributes[key] === undefined) { + logRecord.setAttribute(key, attributes[key]) + } + } + } + + forceFlush(): Promise { + return Promise.resolve() + } + + shutdown(): Promise { + return Promise.resolve() + } +} diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesProcessors.test.ts b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesProcessors.test.ts new file mode 100644 index 0000000000..735863e79a --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesProcessors.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' +import { + InMemoryLogRecordExporter, + LoggerProvider, + SimpleLogRecordProcessor, +} from '@opentelemetry/sdk-logs' +import { GlobalAttributes } from './GlobalAttributes' +import { GlobalAttributesSpanProcessor } from './GlobalAttributesSpanProcessor' +import { GlobalAttributesLogRecordProcessor } from './GlobalAttributesLogRecordProcessor' + +describe('GlobalAttributesSpanProcessor', () => { + let store: GlobalAttributes + let exporter: InMemorySpanExporter + let provider: WebTracerProvider + + beforeEach(() => { + store = new GlobalAttributes() + exporter = new InMemorySpanExporter() + provider = new WebTracerProvider({ + spanProcessors: [ + new GlobalAttributesSpanProcessor(store), + new SimpleSpanProcessor(exporter), + ], + }) + }) + + it('fills global attributes without overriding span attributes', () => { + store.setAttributes({ 'app.tier': 'gold', shared: 'global' }) + + provider + .getTracer('test') + .startSpan('span', { attributes: { shared: 'call' } }) + .end() + + const [span] = exporter.getFinishedSpans() + expect(span.attributes['app.tier']).toBe('gold') + expect(span.attributes.shared).toBe('call') + }) + + it('uses the values current at span start', () => { + store.setAttributes({ phase: 'start' }) + const span = provider.getTracer('test').startSpan('span') + store.setAttributes({ phase: 'later', added: true }) + span.end() + + const [finished] = exporter.getFinishedSpans() + expect(finished.attributes.phase).toBe('start') + expect(finished.attributes.added).toBeUndefined() + }) + + it('stops applying removed attributes', () => { + store.setAttributes({ a: 1, b: 2 }) + store.removeAttributes(['a']) + + provider.getTracer('test').startSpan('span').end() + + const [span] = exporter.getFinishedSpans() + expect(span.attributes.a).toBeUndefined() + expect(span.attributes.b).toBe(2) + }) +}) + +describe('GlobalAttributesLogRecordProcessor', () => { + let store: GlobalAttributes + let exporter: InMemoryLogRecordExporter + let provider: LoggerProvider + + beforeEach(() => { + store = new GlobalAttributes() + exporter = new InMemoryLogRecordExporter() + provider = new LoggerProvider({ + processors: [ + new GlobalAttributesLogRecordProcessor(store), + new SimpleLogRecordProcessor(exporter), + ], + }) + }) + + it('fills global attributes without overriding record attributes', () => { + store.setAttributes({ 'app.tier': 'gold', shared: 'global' }) + + provider.getLogger('test').emit({ + body: 'hello', + attributes: { shared: 'call' }, + }) + + const [record] = exporter.getFinishedLogRecords() + expect(record.attributes['app.tier']).toBe('gold') + expect(record.attributes.shared).toBe('call') + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesSpanProcessor.ts b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesSpanProcessor.ts new file mode 100644 index 0000000000..97add87168 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/GlobalAttributesSpanProcessor.ts @@ -0,0 +1,30 @@ +import { Context } from '@opentelemetry/api' +import { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web' +import { GlobalAttributes } from './GlobalAttributes' + +/** + * Fills global attributes onto each span at start. Attributes already set on + * the span (e.g. passed to `startSpan`) take precedence. + */ +export class GlobalAttributesSpanProcessor implements SpanProcessor { + constructor(private readonly store: GlobalAttributes) {} + + onStart(span: Span, _parentContext: Context): void { + const attributes = this.store.getAttributes() + for (const key in attributes) { + if (span.attributes[key] === undefined) { + span.setAttribute(key, attributes[key]!) + } + } + } + + onEnd(_span: ReadableSpan): void {} + + forceFlush(): Promise { + return Promise.resolve() + } + + shutdown(): Promise { + return Promise.resolve() + } +} diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.test.ts b/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.test.ts new file mode 100644 index 0000000000..21c7a88030 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { SpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web' +import { + InMemoryLogRecordExporter, + LoggerProvider, + LogRecordProcessor, + SimpleLogRecordProcessor, +} from '@opentelemetry/sdk-logs' +import { SafeLogRecordProcessor, SafeSpanProcessor } from './SafeProcessors' + +const throwingSpanProcessor = (): SpanProcessor => ({ + onStart: vi.fn(() => { + throw new Error('onStart') + }), + onEnding: vi.fn(() => { + throw new Error('onEnding') + }), + onEnd: vi.fn(() => { + throw new Error('onEnd') + }), + forceFlush: vi.fn(() => Promise.reject(new Error('forceFlush'))), + shutdown: vi.fn(() => { + throw new Error('shutdown') + }), +}) + +const throwingLogProcessor = (): LogRecordProcessor => ({ + onEmit: vi.fn(() => { + throw new Error('onEmit') + }), + forceFlush: vi.fn(() => { + throw new Error('forceFlush') + }), + shutdown: vi.fn(() => Promise.reject(new Error('shutdown'))), +}) + +describe('SafeSpanProcessor', () => { + it('keeps exporting when the wrapped processor throws', async () => { + const log = vi.fn() + const delegate = throwingSpanProcessor() + const exporter = new InMemorySpanExporter() + const provider = new WebTracerProvider({ + spanProcessors: [ + new SafeSpanProcessor(delegate, log), + new SimpleSpanProcessor(exporter), + ], + }) + + provider.getTracer('test').startSpan('span').end() + await provider.forceFlush() + + expect(exporter.getFinishedSpans().map((s) => s.name)).toEqual(['span']) + + await provider.shutdown() + + expect(delegate.onStart).toHaveBeenCalledTimes(1) + expect(delegate.onEnding).toHaveBeenCalledTimes(1) + expect(delegate.onEnd).toHaveBeenCalledTimes(1) + expect(delegate.forceFlush).toHaveBeenCalled() + expect(delegate.shutdown).toHaveBeenCalledTimes(1) + expect(log.mock.calls.map(([message]) => message)).toEqual( + expect.arrayContaining([ + 'custom processor threw in onStart:', + 'custom processor threw in onEnding:', + 'custom processor threw in onEnd:', + 'custom processor threw in forceFlush:', + 'custom processor threw in shutdown:', + ]), + ) + }) +}) + +describe('SafeLogRecordProcessor', () => { + it('keeps exporting when the wrapped processor throws', async () => { + const log = vi.fn() + const delegate = throwingLogProcessor() + const exporter = new InMemoryLogRecordExporter() + const provider = new LoggerProvider({ + processors: [ + new SafeLogRecordProcessor(delegate, log), + new SimpleLogRecordProcessor(exporter), + ], + }) + + provider.getLogger('test').emit({ body: 'hello' }) + await provider.forceFlush() + + expect(exporter.getFinishedLogRecords().map((r) => r.body)).toEqual([ + 'hello', + ]) + + await provider.shutdown() + + expect(delegate.onEmit).toHaveBeenCalledTimes(1) + expect(delegate.forceFlush).toHaveBeenCalled() + expect(delegate.shutdown).toHaveBeenCalledTimes(1) + expect(log.mock.calls.map(([message]) => message)).toEqual( + expect.arrayContaining([ + 'custom processor threw in onEmit:', + 'custom processor threw in forceFlush:', + 'custom processor threw in shutdown:', + ]), + ) + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.ts b/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.ts new file mode 100644 index 0000000000..1df2cf20c3 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/otel/SafeProcessors.ts @@ -0,0 +1,93 @@ +import { Context } from '@opentelemetry/api' +import { LogRecordProcessor, SdkLogRecord } from '@opentelemetry/sdk-logs' +import { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web' + +type Logger = (...data: any[]) => void + +function runSafely(log: Logger, name: string, fn: () => void): void { + try { + fn() + } catch (error) { + log(`custom processor threw in ${name}:`, error) + } +} + +async function runSafelyAsync( + log: Logger, + name: string, + fn: () => Promise, +): Promise { + try { + await fn() + } catch (error) { + log(`custom processor threw in ${name}:`, error) + } +} + +/** + * Wraps a user-provided span processor so that a throwing or rejecting + * processor can never break the SDK pipeline or the export of the span. + */ +export class SafeSpanProcessor implements SpanProcessor { + constructor( + private readonly delegate: SpanProcessor, + private readonly log: Logger, + ) {} + + onStart(span: Span, parentContext: Context): void { + runSafely(this.log, 'onStart', () => + this.delegate.onStart(span, parentContext), + ) + } + + onEnding(span: Span): void { + if (this.delegate.onEnding) { + runSafely(this.log, 'onEnding', () => this.delegate.onEnding!(span)) + } + } + + onEnd(span: ReadableSpan): void { + runSafely(this.log, 'onEnd', () => this.delegate.onEnd(span)) + } + + forceFlush(): Promise { + return runSafelyAsync(this.log, 'forceFlush', () => + this.delegate.forceFlush(), + ) + } + + shutdown(): Promise { + return runSafelyAsync(this.log, 'shutdown', () => + this.delegate.shutdown(), + ) + } +} + +/** + * Wraps a user-provided log record processor so that a throwing or rejecting + * processor can never break the SDK pipeline or the export of the record. + */ +export class SafeLogRecordProcessor implements LogRecordProcessor { + constructor( + private readonly delegate: LogRecordProcessor, + private readonly log: Logger, + ) {} + + onEmit(logRecord: SdkLogRecord, context?: Context): void { + runSafely(this.log, 'onEmit', () => + this.delegate.onEmit(logRecord, context), + ) + } + + forceFlush(): Promise { + return runSafelyAsync(this.log, 'forceFlush', () => + this.delegate.forceFlush(), + ) + } + + shutdown(): Promise { + return runSafelyAsync(this.log, 'shutdown', () => + this.delegate.shutdown(), + ) + } +} diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index 51b3b06004..bccc4d7743 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -60,6 +60,14 @@ class LDObserveClass super._resetForTesting() } + setAttributes(attributes: Attributes): void { + return this._bufferCall('setAttributes', [attributes]) + } + + removeAttributes(keys: string[]): void { + return this._bufferCall('removeAttributes', [keys]) + } + recordError( error: Error, attributes?: Attributes, From 5b57e7a5df0ce14aa2ef9b68efe95fe6098f26d5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:34:33 -0700 Subject: [PATCH 2/3] fix(react-native): apply pre-init setAttributes immediately Co-authored-by: Cursor --- .../src/client/GlobalAttributes.integration.test.ts | 7 ++++++- .../observability-react-native/src/sdk/LDObserve.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts index 2fd5dca973..56c3ca1a36 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/GlobalAttributes.integration.test.ts @@ -132,13 +132,18 @@ describe('global attributes and custom processors', () => { _LDObserve._resetForTesting() globalAttributes._resetForTesting() - // Called before the client exists: must be buffered and still apply. + // Called before the client exists: must apply immediately, not only + // once buffered calls are replayed after async init. _LDObserve.setAttributes({ 'app.tier': 'gold', shared: 'global', 'highlight.session_id': 'spoofed', 'session.id': 'spoofed', }) + expect(globalAttributes.getAttributes()).toEqual({ + 'app.tier': 'gold', + shared: 'global', + }) _LDObserve.recordLog('before-init', 'info') client = new ObservabilityClient('test-project-id', { diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index bccc4d7743..6f552bcbde 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -14,6 +14,7 @@ import { Observe } from '../api/Observe' import { SpanScope, WithSpanOptions } from '../api/SpanScope' import { TrackProperties } from '../api/TrackProperties' import { BufferedClass } from './BufferedClass' +import { globalAttributes } from '../otel/GlobalAttributes' import { noOpSpan } from '../utils/NoOpSpan' import { NOOP_SPAN_OPS, runInSpan, SpanOps } from './withSpan' @@ -60,12 +61,20 @@ class LDObserveClass super._resetForTesting() } + // Before load these write straight to the store rather than the call buffer, + // so spans started during async init (e.g. fetch) already carry them. setAttributes(attributes: Attributes): void { - return this._bufferCall('setAttributes', [attributes]) + if (this._isLoaded) { + return this._bufferCall('setAttributes', [attributes]) + } + globalAttributes.setAttributes(attributes) } removeAttributes(keys: string[]): void { - return this._bufferCall('removeAttributes', [keys]) + if (this._isLoaded) { + return this._bufferCall('removeAttributes', [keys]) + } + globalAttributes.removeAttributes(keys) } recordError( From 135c4138dd27fc19f87f5c001f58785c0e6bfc91 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 17:15:38 -0700 Subject: [PATCH 3/3] chore(react-native): add global attributes repro to example-legacy Co-authored-by: Cursor --- .../example-legacy/src/ApiScreen.tsx | 5 ++++ .../example-legacy/src/App.tsx | 3 +++ .../example-legacy/src/repro.ts | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/repro.ts diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx index cf1741181a..8fc1b9f31d 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx @@ -13,6 +13,7 @@ import {LDClick} from '@launchdarkly/session-replay-react-native'; import {useLDClient} from '@launchdarkly/react-native-client-sdk'; import {context, SpanStatusCode, type Span} from '@opentelemetry/api'; import {SERVICE_VERSION} from './serviceVersion'; +import {globalAttributesNote, perCallOverrideNote} from './repro'; // Hermes/V8 truncate `error.stack` at Error.stackTraceLimit (often ~10 frames). // Raise it so the recorded error carries the full call chain. @@ -96,6 +97,7 @@ export default function ApiScreen() { span.setAttribute('feature1', value); span.end(); log(`[span] button-pressed + boolVariation(feature1)=${value}`); + log(globalAttributesNote()); }; // -- Flag evaluation ----------------------------------------------------- @@ -258,8 +260,11 @@ export default function ApiScreen() { // primitives, so the iOS sample's nested map is represented as JSON here. 'test-array': [3.14], 'test-nested': JSON.stringify({array: [1]}), + utm_source: 'per_call', }); log('[log] recordLog(logs-button-pressed) with mixed attributes'); + log(globalAttributesNote()); + log(perCallOverrideNote()); }; // -- Track (via Observability API) --------------------------------------- diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx index ede1ecd332..fbf853dff7 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx @@ -30,6 +30,7 @@ import DialogsScreen from './DialogsScreen'; import MaskingScreen from './MaskingScreen'; import TracingScreen from './TracingScreen'; import ApiScreen from './ApiScreen'; +import {applyGlobalAttributes} from './repro'; // Pick the LaunchDarkly instance once for the client-side URLs (they must stay // paired with the mobile key, or identify fails with a 401). The observability @@ -102,6 +103,8 @@ const client = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, { baseUri: endpoints.baseUri, eventsUri: endpoints.eventsUri, }); +// Runs while the observability SDK's async init is still in flight (see repro.ts). +applyGlobalAttributes(); const context = {kind: 'user', key: 'user-key-123abc'}; // Emulates an OTA "soft reload": restarts only the JS runtime, leaving the native diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/repro.ts b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/repro.ts new file mode 100644 index 0000000000..a1ee040a29 --- /dev/null +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/repro.ts @@ -0,0 +1,25 @@ +import {LDObserve} from '@launchdarkly/observability-react-native'; + +/** + * Repro for customer request #1: app-wide attributes (e.g. UTM parameters) + * on every span, log, error and metric. + * + * `applyGlobalAttributes()` runs right after `new ReactNativeLDClient(...)`, + * while the observability SDK's async init is still in flight, so it also + * covers attributes set before init. The span and log buttons on the API tab + * print what to look for on the resulting rows. + */ +export const GLOBAL_ATTRIBUTES = { + utm_campaign: 'spring_promo', + utm_source: 'email', +}; + +export function applyGlobalAttributes() { + LDObserve.setAttributes(GLOBAL_ATTRIBUTES); +} + +export const globalAttributesNote = () => + `[#1] expect utm_campaign=${GLOBAL_ATTRIBUTES.utm_campaign} on this row`; + +export const perCallOverrideNote = () => + '[#1] expect utm_source=per_call (per-call wins over global)';