Skip to content

Commit 5a6d164

Browse files
Vadman97claude
andauthored
feat: forward LD context keys to errors, logs, and custom spans (#530)
## Summary - Spread `_ldContextKeys` (set via `LDObserve.setLDContextKeyAttributes`) into all OTel data types where it makes semantic sense, not just metrics. - `startSpan` wrapper now applies contextKeys to every span before invoking the caller, so user-created custom spans inherit `pathTemplate` / `owner` / `account` / route attributes automatically. Caller `setAttributes` calls win on key collisions (OTel merges with last-write-wins). - `_recordLog` spreads contextKeys into the `addEvent('log', …)` payload so log events carry the same dimensions as their containing span. - `_recordErrorMessage` no longer needs an explicit spread because the `startSpan` wrapper handles it; comment added at the call site. ## Why Previously `_ldContextKeys` only flowed into `recordCount` / `recordGauge` / `recordHistogram` / `recordUpDownCounter` (#510 / commit `e49d4812f`), so error, log, and custom-span queries could not group by team or route. This meant LD Observability dashboards couldn't replicate Datadog RUM's per-team and per-route error and log breakdowns. After this change, queries like `errors group_by context.contextKeys.pathTemplate` and `logs group_by context.contextKeys.owner` produce real values, closing the last big gap relative to the Datadog frontend-errors dashboard. ## Coverage matrix | Data type | Before | After | |---|---|---| | Counter / Gauge / Histogram / UpDownCounter metric | ✅ | ✅ | | Click / page-view / user-interaction spans | ✅ | ✅ | | Errors (`recordError` / `_recordErrorMessage`) | ❌ | ✅ (via `startSpan` wrapper) | | Logs (`_recordLog` `addEvent`) | ❌ | ✅ | | Custom spans created via `startSpan` | ❌ | ✅ | ## Test plan - [x] `yarn vitest run src/__tests__/sdk.test.ts` — 14/14 passing locally - new tests cover: error span attrs, log addEvent attrs, custom span attrs, override semantics, no-context-keys no-op - [x] `yarn turbo run build --filter '@launchdarkly/observability'` — clean build - [x] `yarn format-check` — passes - [ ] Verify in catfood: queries on `errors` / `logs` grouped by `context.contextKeys.pathTemplate` / `owner` / `accountId` populate after a gonfalon SDK bump 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core tracing/logging/error instrumentation by injecting `_ldContextKeys` into `startSpan` and log events, which could change span attributes and override behavior across all spans. > > **Overview** > LD context keys set via `setLDContextKeyAttributes` are now forwarded beyond metrics: `startSpan` injects them onto every created span (including user custom spans), and `_recordLog` includes them in `addEvent('log', ...)` attributes. > > Error recording relies on the `startSpan` wrapper for context key attachment (instead of explicitly spreading them in `_recordErrorMessage`), and new Vitest coverage asserts propagation plus *last-write-wins* override semantics and the no-context-keys no-op case. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 642f16d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 998f2c5 commit 5a6d164

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

sdk/highlight-run/src/__tests__/sdk.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,5 +223,155 @@ describe('SDK', () => {
223223
undefined,
224224
)
225225
})
226+
227+
describe('LD context keys propagation', () => {
228+
const mockTracer = (span: any) => {
229+
const tracer = {
230+
startActiveSpan: vi.fn((_name: string, ...args: any[]) => {
231+
const callback = args[args.length - 1]
232+
return callback(span)
233+
}),
234+
}
235+
vi.spyOn(otel, 'getTracer').mockReturnValue(tracer as any)
236+
return tracer
237+
}
238+
239+
const makeSpan = () => ({
240+
setAttributes: vi.fn(),
241+
setAttribute: vi.fn(),
242+
addEvent: vi.fn(),
243+
recordException: vi.fn(),
244+
setStatus: vi.fn(),
245+
end: vi.fn(),
246+
spanContext: vi.fn(() => ({
247+
traceId: 'trace',
248+
spanId: 'span',
249+
traceFlags: 1,
250+
})),
251+
updateName: vi.fn(),
252+
isRecording: vi.fn(() => true),
253+
})
254+
255+
it('attaches context keys to error span attributes', () => {
256+
const span = makeSpan()
257+
mockTracer(span)
258+
259+
observeImpl.setLDContextKeyAttributes({
260+
pathTemplate: '/projects/:projectKey/flags',
261+
owner: 'team-fm-foundations',
262+
})
263+
264+
observeImpl.recordError(new Error('boom'), 'oops', {
265+
errorCode: 'E123',
266+
})
267+
268+
// First call: contextKeys injected by the startSpan wrapper.
269+
expect(span.setAttributes).toHaveBeenNthCalledWith(1, {
270+
'context.contextKeys.pathTemplate':
271+
'/projects/:projectKey/flags',
272+
'context.contextKeys.owner': 'team-fm-foundations',
273+
})
274+
// Second call: error-specific attributes from _recordErrorMessage.
275+
expect(span.setAttributes).toHaveBeenNthCalledWith(
276+
2,
277+
expect.objectContaining({
278+
event: expect.stringContaining('oops'),
279+
errorCode: 'E123',
280+
}),
281+
)
282+
})
283+
284+
it('caller payload wins over context keys with the same name', () => {
285+
const span = makeSpan()
286+
mockTracer(span)
287+
288+
observeImpl.setLDContextKeyAttributes({ owner: 'team-a' })
289+
290+
observeImpl.recordError(new Error('boom'), 'oops', {
291+
'context.contextKeys.owner': 'team-override',
292+
})
293+
294+
// Second setAttributes call wins on the wire because OTel
295+
// merges with last-write-wins semantics for matching keys.
296+
expect(span.setAttributes).toHaveBeenLastCalledWith(
297+
expect.objectContaining({
298+
'context.contextKeys.owner': 'team-override',
299+
}),
300+
)
301+
})
302+
303+
it('attaches context keys to log addEvent attributes', () => {
304+
const span = makeSpan()
305+
mockTracer(span)
306+
307+
observeImpl.setLDContextKeyAttributes({
308+
pathTemplate: '/login',
309+
})
310+
311+
observeImpl.recordLog('hello world', 'info', {
312+
traceId: 'tid',
313+
})
314+
315+
expect(span.addEvent).toHaveBeenCalledWith(
316+
'log',
317+
expect.objectContaining({
318+
'context.contextKeys.pathTemplate': '/login',
319+
traceId: 'tid',
320+
}),
321+
)
322+
})
323+
324+
it('caller metadata wins over context keys on logs', () => {
325+
const span = makeSpan()
326+
mockTracer(span)
327+
328+
observeImpl.setLDContextKeyAttributes({
329+
pathTemplate: '/login',
330+
})
331+
332+
observeImpl.recordLog('hello', 'info', {
333+
'context.contextKeys.pathTemplate': '/override',
334+
})
335+
336+
expect(span.addEvent).toHaveBeenCalledWith(
337+
'log',
338+
expect.objectContaining({
339+
'context.contextKeys.pathTemplate': '/override',
340+
}),
341+
)
342+
})
343+
344+
it('attaches context keys to user-created spans via startSpan', () => {
345+
const span = makeSpan()
346+
mockTracer(span)
347+
348+
observeImpl.setLDContextKeyAttributes({
349+
pathTemplate: '/projects/:projectKey/flags',
350+
accountId: 'acct-123',
351+
})
352+
353+
observeImpl.startSpan('user.custom.span', (s) => {
354+
s?.setAttribute('foo', 'bar')
355+
})
356+
357+
expect(span.setAttributes).toHaveBeenCalledWith({
358+
'context.contextKeys.pathTemplate':
359+
'/projects/:projectKey/flags',
360+
'context.contextKeys.accountId': 'acct-123',
361+
})
362+
// Caller's setAttribute call is also issued.
363+
expect(span.setAttribute).toHaveBeenCalledWith('foo', 'bar')
364+
})
365+
366+
it('does not call setAttributes when no context keys are set', () => {
367+
const span = makeSpan()
368+
mockTracer(span)
369+
370+
observeImpl.startSpan('user.custom.span', () => {})
371+
372+
// startSpan should not inject anything when contextKeys are unset.
373+
expect(span.setAttributes).not.toHaveBeenCalled()
374+
})
375+
})
226376
})
227377
})

sdk/highlight-run/src/sdk/observe.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export class ObserveSDK implements Observe {
247247
[ATTR_LOG_SEVERITY]: level,
248248
[ATTR_LOG_MESSAGE]: msg,
249249
'code.stacktrace': stackTrace,
250+
...(this._ldContextKeys ?? {}),
250251
...metadata,
251252
})
252253
if (this._options.reportConsoleErrors && level === 'error') {
@@ -299,6 +300,8 @@ export class ObserveSDK implements Observe {
299300
recordException(span, errorMsg.error ?? new Error(errorMsg.event), {
300301
[ATTR_EXCEPTION_ID]: errorMsg.id,
301302
})
303+
// LD context keys are applied to the span by the startSpan
304+
// wrapper above; only the error-specific attributes are added here.
302305
span?.setAttributes({
303306
event: errorMsg.event,
304307
type: errorMsg.type,
@@ -451,6 +454,11 @@ export class ObserveSDK implements Observe {
451454
}
452455

453456
const wrapCallback = (span: Span, callback: (span: Span) => any) => {
457+
// Apply LD context keys before invoking the caller so any
458+
// span.setAttributes the caller makes wins on key collisions.
459+
if (this._ldContextKeys) {
460+
span.setAttributes(this._ldContextKeys)
461+
}
454462
const result = callback(span)
455463
if (result instanceof Promise) {
456464
return result.finally(() => span.end())

0 commit comments

Comments
 (0)