Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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',
Comment thread
Vadman97 marked this conversation as resolved.
Resolved
otlpEndpoint: 'http://127.0.0.1:1',
Comment thread
Vadman97 marked this conversation as resolved.
Resolved
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<string, unknown>
}

const findLog = (body: string) => {
const log = exportedLogs.find((l) => l.body === body)
expect(log).toBeDefined()
return log!.attributes as Record<string, unknown>
}

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'
Comment thread
Vadman97 marked this conversation as resolved.
Resolved
await new Promise<void>((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',
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,13 @@ export type InstrumentationManagerOptions = Required<ReactNativeOptions> & {
// 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.',
]
Expand Down Expand Up @@ -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<ReactNativeOptions> & {
Expand Down Expand Up @@ -182,10 +187,30 @@ export class InstrumentationManager {
return this._globalAttributesSnapshot
}

// Replaces the context key attributes with `context.contextKeys.<kind>`
// for each kind in `contextKeys` (e.g. `{ user: 'u1', device: 'd1' }`).
public setContextKeyAttributes(
contextKeys: Record<string, AttributeValue | undefined>,
): 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading