From bb4b6d7668c4ad2ed73422dfda5d4509d1429779 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 17:23:45 -0700 Subject: [PATCH] feat(o11y): add setAttributes and removeAttributes Co-authored-by: Cursor --- sdk/@launchdarkly/o11y/README.md | 4 + sdk/@launchdarkly/o11y/src/api/observe.ts | 31 ++ sdk/@launchdarkly/o11y/src/client/index.tsx | 5 + .../o11y/src/client/otel/index.ts | 4 +- sdk/@launchdarkly/o11y/src/sdk/LDObserve.ts | 11 + .../o11y/src/sdk/globalAttributes.test.ts | 291 ++++++++++++++++++ .../o11y/src/sdk/globalAttributes.ts | 139 +++++++++ sdk/@launchdarkly/o11y/src/sdk/observe.ts | 18 ++ sdk/@launchdarkly/observability/README.md | 18 ++ 9 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 sdk/@launchdarkly/o11y/src/sdk/globalAttributes.test.ts create mode 100644 sdk/@launchdarkly/o11y/src/sdk/globalAttributes.ts diff --git a/sdk/@launchdarkly/o11y/README.md b/sdk/@launchdarkly/o11y/README.md index 0702b4862a..2240cd5e61 100644 --- a/sdk/@launchdarkly/o11y/README.md +++ b/sdk/@launchdarkly/o11y/README.md @@ -12,5 +12,9 @@ Most applications should install `@launchdarkly/observability` and/or `@launchdarkly/session-replay` rather than depending on this package directly. See the [SDK reference](https://docs.launchdarkly.com/sdk/observability). +To add attributes to every span, log, and metric, use +`LDObserve.setAttributes` / `LDObserve.removeAttributes`; see +[Global attributes](https://github.com/launchdarkly/observability-sdk/tree/main/sdk/%40launchdarkly/observability#global-attributes). + Session replay recording is powered by the [launchdarkly/rrweb](https://github.com/launchdarkly/rrweb) fork. diff --git a/sdk/@launchdarkly/o11y/src/api/observe.ts b/sdk/@launchdarkly/o11y/src/api/observe.ts index 7cfac85f10..2b7810f978 100644 --- a/sdk/@launchdarkly/o11y/src/api/observe.ts +++ b/sdk/@launchdarkly/o11y/src/api/observe.ts @@ -168,6 +168,37 @@ export interface Observe { metadata?: Attributes, ) => void + /** + * Set attributes that are added to every span, log, and metric recorded + * after the call, including automatically instrumented telemetry. Merges + * by key into the existing global attributes, which last for the page + * lifetime (they are not persisted and survive session changes). + * + * Attributes set explicitly on a span, log, or metric take precedence; + * global attributes only fill in keys that are missing. A span gets the + * global attributes current when it starts. SDK-managed session keys + * (`highlight.session_id`, `session.id`) and values that are not valid + * OpenTelemetry attribute values are ignored. + * + * Can be called before the SDK has initialized or started. + * + * @example + * ```typescript + * LDObserve.setAttributes({ 'app.tenant': 'acme', 'app.build': 42 }) + * ``` + */ + setAttributes: (attributes: Attributes) => void + /** + * Remove global attributes previously set with `setAttributes`. + * Telemetry recorded after the call no longer carries them. + * + * @example + * ```typescript + * LDObserve.removeAttributes(['app.tenant']) + * ``` + */ + removeAttributes: (keys: string[]) => void + register( client: LDClient, environmentMetadata: LDPluginEnvironmentMetadata, diff --git a/sdk/@launchdarkly/o11y/src/client/index.tsx b/sdk/@launchdarkly/o11y/src/client/index.tsx index 400d1cc403..5f1d0ece9d 100644 --- a/sdk/@launchdarkly/o11y/src/client/index.tsx +++ b/sdk/@launchdarkly/o11y/src/client/index.tsx @@ -103,6 +103,7 @@ import { setSessionSecureID, } from './utils/sessionStorage/highlightSession' import { SESSION_STORAGE_KEYS } from './utils/sessionStorage/sessionStorageKeys' +import { getGlobalAttributes } from '../sdk/globalAttributes' import { getItem, LocalStorageKeys, @@ -1400,6 +1401,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, this._gauges.set(metric.name, gauge) } gauge.record(metric.value, { + ...getGlobalAttributes(), ...metric.tags?.reduce((a, b) => ({ ...a, [b.name]: b.value }), {}), group: metric.group, category: metric.category, @@ -1421,6 +1423,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, this._counters.set(metric.name, counter) } counter.add(metric.value, { + ...getGlobalAttributes(), ...metric.tags?.reduce((a, b) => ({ ...a, [b.name]: b.value }), {}), group: metric.group, category: metric.category, @@ -1443,6 +1446,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, this._histograms.set(metric.name, histogram) } histogram.record(metric.value, { + ...getGlobalAttributes(), ...metric.tags?.reduce((a, b) => ({ ...a, [b.name]: b.value }), {}), group: metric.group, category: metric.category, @@ -1461,6 +1465,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, this._up_down_counters.set(metric.name, up_down_counter) } up_down_counter.add(metric.value, { + ...getGlobalAttributes(), ...metric.tags?.reduce((a, b) => ({ ...a, [b.name]: b.value }), {}), group: metric.group, category: metric.category, diff --git a/sdk/@launchdarkly/o11y/src/client/otel/index.ts b/sdk/@launchdarkly/o11y/src/client/otel/index.ts index fb96ccda9d..a3029ce08f 100644 --- a/sdk/@launchdarkly/o11y/src/client/otel/index.ts +++ b/sdk/@launchdarkly/o11y/src/client/otel/index.ts @@ -76,6 +76,7 @@ import { } from '../utils/sessionStorage/highlightSession' import { isSdkDebugLogging, sdkLog } from '../utils/sdkLogger' import { ProductAnalyticsEvents } from '../types/observe' +import { applyGlobalAttributesToSpan } from '../../sdk/globalAttributes' export type Callback = (span?: Span) => any @@ -647,7 +648,7 @@ export const registerFlushOnUnload = ( } } -class CustomBatchSpanProcessor extends BatchSpanProcessor { +export class CustomBatchSpanProcessor extends BatchSpanProcessor { private _pendingSpans = new Set>() // When set, forceFlush/shutdown return immediately without waiting for // in-flight response-body reads. Used on page unload where waiting risks @@ -660,6 +661,7 @@ class CustomBatchSpanProcessor extends BatchSpanProcessor { onStart(span: SDKSpan, parentContext: Context): void { span.setAttribute(SESSION_ID_ATTRIBUTE, getPersistentSessionSecureID()) + applyGlobalAttributesToSpan(span) super.onStart(span, parentContext) } diff --git a/sdk/@launchdarkly/o11y/src/sdk/LDObserve.ts b/sdk/@launchdarkly/o11y/src/sdk/LDObserve.ts index 4c95de6c57..149d1af6d5 100644 --- a/sdk/@launchdarkly/o11y/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/o11y/src/sdk/LDObserve.ts @@ -8,6 +8,7 @@ import { BufferedClass } from './buffer' import { getNoopSpan } from '../client/otel/utils' import { ConsoleMethods } from '../client/types/client' import { internalLogOnce } from './util' +import { removeGlobalAttributes, setGlobalAttributes } from './globalAttributes' /** * Resolves the span callback from whichever argument position it was passed in, @@ -136,6 +137,16 @@ class _LDObserve extends BufferedClass implements Observe { return this._bufferCall('setLDContextKeyAttributes', [contextKeys]) } + // Never buffered: the attributes must apply to telemetry recorded as soon + // as the SDK starts, including automatic spans emitted during init. + setAttributes(attributes: Attributes) { + setGlobalAttributes(attributes) + } + + removeAttributes(keys: string[]) { + removeGlobalAttributes(keys) + } + getLDContextKeyAttributes(): Attributes | undefined { // A getter: read directly once attached rather than buffering. return this._isLoaded diff --git a/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.test.ts b/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.test.ts new file mode 100644 index 0000000000..4095d098b5 --- /dev/null +++ b/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + InMemorySpanExporter, + WebTracerProvider, +} from '@opentelemetry/sdk-trace-web' +import * as otel from '../client/otel' +import { CustomBatchSpanProcessor } from '../client/otel' +import { + clearGlobalAttributes, + getGlobalAttributes, + removeGlobalAttributes, + setGlobalAttributes, +} from './globalAttributes' +import { LDObserve } from './LDObserve' +import { ObserveSDK } from './observe' +import { CONTEXT_SCOPE_CONTEXT_KEYS_ATTR } from '../integrations/launchdarkly' + +const baseOptions = { + backendUrl: 'https://pub.highlight.io', + otel: { otlpEndpoint: 'https://otel.highlight.io' }, + projectId: '1', + sessionSecureId: 'test-session', + environment: 'test', +} + +const newFacade = () => + new ( + LDObserve as unknown as { constructor: new () => typeof LDObserve } + ).constructor() + +describe('global attributes store', () => { + beforeEach(() => clearGlobalAttributes()) + + it('merges by key and removes keys', () => { + setGlobalAttributes({ a: 'one', b: 2 }) + setGlobalAttributes({ b: 3, c: true }) + expect(getGlobalAttributes()).toEqual({ a: 'one', b: 3, c: true }) + + removeGlobalAttributes(['a', 'missing']) + expect(getGlobalAttributes()).toEqual({ b: 3, c: true }) + }) + + it('ignores reserved session keys', () => { + setGlobalAttributes({ + 'highlight.session_id': 'spoofed', + 'session.id': 'spoofed', + 'highlight.record': false, + kept: 'yes', + }) + expect(getGlobalAttributes()).toEqual({ kept: 'yes' }) + }) + + it('ignores values that are not OpenTelemetry attribute values', () => { + setGlobalAttributes({ + undef: undefined, + nul: null as unknown as string, + obj: { nested: 1 } as unknown as string, + mixed: [1, 'two'] as unknown as string[], + list: ['a', 'b'], + '': 'empty key', + }) + expect(getGlobalAttributes()).toEqual({ list: ['a', 'b'] }) + }) + + it('keeps an existing value when the new one is invalid', () => { + setGlobalAttributes({ a: 'one' }) + setGlobalAttributes({ a: undefined }) + expect(getGlobalAttributes()).toEqual({ a: 'one' }) + }) + + it('publishes immutable snapshots', () => { + const list = ['a'] + setGlobalAttributes({ list }) + const before = getGlobalAttributes() + list.push('b') + setGlobalAttributes({ other: 1 }) + + expect(Object.isFrozen(before)).toBe(true) + expect(before).toEqual({ list: ['a'] }) + expect(getGlobalAttributes()).toEqual({ list: ['a'], other: 1 }) + }) +}) + +describe('LDObserve global attributes', () => { + beforeEach(() => clearGlobalAttributes()) + + it('applies immediately before the sdk is initialized', () => { + const facade = newFacade() + facade.setAttributes({ tenant: 'acme' }) + expect(getGlobalAttributes()).toEqual({ tenant: 'acme' }) + + facade.removeAttributes(['tenant']) + expect(getGlobalAttributes()).toEqual({}) + }) + + it('is never buffered with manualStart before start()', () => { + const facade = newFacade() + facade.load(new ObserveSDK(baseOptions), { ready: false }) + + facade.setAttributes({ tenant: 'acme' }) + + expect(getGlobalAttributes()).toEqual({ tenant: 'acme' }) + const buffered = ( + facade as unknown as { _callBuffer: Array<{ method: string }> } + )._callBuffer + expect(buffered).toHaveLength(0) + }) +}) + +describe('global attributes on telemetry', () => { + let exporter: InMemorySpanExporter + let provider: WebTracerProvider + + beforeEach(() => { + clearGlobalAttributes() + exporter = new InMemorySpanExporter() + provider = new WebTracerProvider({ + spanProcessors: [new CustomBatchSpanProcessor(exporter)], + }) + }) + + afterEach(async () => { + vi.restoreAllMocks() + await provider.shutdown() + clearGlobalAttributes() + }) + + const finishedSpans = async () => { + await provider.forceFlush() + return exporter.getFinishedSpans() + } + + describe('span processor (automatic spans)', () => { + it('fills missing keys; start and later attributes win', async () => { + LDObserve.setAttributes({ + tenant: 'acme', + 'http.method': 'global', + 'http.url': 'global', + 'highlight.session_id': 'spoofed', + }) + + // Mirrors the fetch/XHR instrumentations: attributes passed at + // start, then more set on the span after it has started. + const span = provider + .getTracer('test') + .startSpan('HTTP GET', { attributes: { 'http.method': 'GET' } }) + span.setAttribute('http.url', 'https://example.com') + span.end() + + const [exported] = await finishedSpans() + expect(exported.attributes).toMatchObject({ + tenant: 'acme', + 'http.method': 'GET', + 'http.url': 'https://example.com', + }) + expect(exported.attributes['highlight.session_id']).not.toBe( + 'spoofed', + ) + }) + + it('captures the values current when the span starts', async () => { + LDObserve.setAttributes({ tenant: 'acme', plan: 'free' }) + const span = provider.getTracer('test').startSpan('long') + LDObserve.setAttributes({ tenant: 'other' }) + LDObserve.removeAttributes(['plan']) + span.end() + provider.getTracer('test').startSpan('next').end() + + const [long, next] = await finishedSpans() + expect(long.attributes).toMatchObject({ + tenant: 'acme', + plan: 'free', + }) + expect(next.attributes.tenant).toBe('other') + expect(next.attributes.plan).toBeUndefined() + }) + }) + + describe('ObserveSDK', () => { + let observe: ObserveSDK + + beforeEach(() => { + observe = new ObserveSDK(baseOptions) + vi.spyOn(otel, 'getTracer').mockReturnValue( + provider.getTracer('test'), + ) + }) + + it('adds globals to manual spans; per-call attributes win', async () => { + observe.setAttributes({ tenant: 'acme', step: 'global' }) + observe.startSpan( + 'checkout', + { attributes: { step: 'payment' } }, + () => {}, + ) + observe.startManualSpan('manual', (span) => { + span.setAttribute('tenant', 'override') + span.end() + }) + + const [checkout, manual] = await finishedSpans() + expect(checkout.attributes).toMatchObject({ + tenant: 'acme', + step: 'payment', + }) + expect(manual.attributes.tenant).toBe('override') + }) + + it('keeps LD context keys over globals', async () => { + observe.setLDContextKeyAttributes({ user: 'u1' }) + const key = `${CONTEXT_SCOPE_CONTEXT_KEYS_ATTR}.user` + observe.setAttributes({ [key]: 'global' }) + observe.startSpan('span', () => {}) + + const [span] = await finishedSpans() + expect(span.attributes[key]).toBe('u1') + }) + + it('adds globals to errors; the error payload wins', async () => { + observe.setAttributes({ tenant: 'acme', source: 'global' }) + observe.recordError(new Error('boom'), undefined, { + orderId: 'o-1', + }) + + const [span] = await finishedSpans() + expect(span.name).toBe('highlight.exception') + expect(span.attributes).toMatchObject({ + tenant: 'acme', + source: 'frontend', + orderId: 'o-1', + }) + }) + + it('adds globals to logs; log fields and metadata win', async () => { + observe.setAttributes({ + tenant: 'acme', + orderId: 'global', + [otel.ATTR_LOG_MESSAGE]: 'global', + }) + observe.recordLog('checkout complete', 'info', { orderId: 'o-1' }) + + const [span] = await finishedSpans() + expect(span.name).toBe(otel.LOG_SPAN_NAME) + expect(span.attributes.tenant).toBe('acme') + const [event] = span.events + expect(event.name).toBe('log') + expect(event.attributes).toMatchObject({ + tenant: 'acme', + orderId: 'o-1', + [otel.ATTR_LOG_MESSAGE]: 'checkout complete', + [otel.ATTR_LOG_SEVERITY]: 'info', + }) + }) + + it('adds globals to metrics; metric attributes win', () => { + const add = vi.fn() + const record = vi.fn() + vi.spyOn(otel, 'getMeter').mockReturnValue({ + createCounter: vi.fn().mockReturnValue({ add }), + createGauge: vi.fn().mockReturnValue({ record }), + createHistogram: vi.fn().mockReturnValue({ record }), + createUpDownCounter: vi.fn().mockReturnValue({ add }), + } as any) + + observe.setAttributes({ tenant: 'acme', route: 'global' }) + observe.recordCount({ + name: 'clicks', + value: 1, + attributes: { route: '/cart' }, + }) + observe.recordGauge({ name: 'gauge', value: 1 }) + observe.recordHistogram({ name: 'histogram', value: 1 }) + observe.recordUpDownCounter({ name: 'updown', value: 1 }) + + expect(add.mock.calls[0][1]).toMatchObject({ + tenant: 'acme', + route: '/cart', + }) + expect(add.mock.calls[0][1]['highlight.session_id']).toBeDefined() + for (const [, attributes] of [ + ...record.mock.calls, + add.mock.calls[1], + ]) { + expect(attributes).toMatchObject({ + tenant: 'acme', + route: 'global', + }) + } + }) + }) +}) diff --git a/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.ts b/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.ts new file mode 100644 index 0000000000..aa5fc596a7 --- /dev/null +++ b/sdk/@launchdarkly/o11y/src/sdk/globalAttributes.ts @@ -0,0 +1,139 @@ +import type { Attributes, AttributeValue } from '@opentelemetry/api' +import { internalLog } from './util' + +/** + * Keys the SDK manages itself. Setting them globally would corrupt session + * attribution (or, for `highlight.record`, drop every span), so they are + * ignored by `setAttributes`. + */ +export const RESERVED_GLOBAL_ATTRIBUTE_KEYS: ReadonlySet = new Set([ + 'highlight.session_id', + 'session.id', + 'highlight.record', +]) + +type GlobalAttributesState = { snapshot: Readonly } + +const EMPTY: Readonly = Object.freeze({}) + +/** + * `@launchdarkly/observability` and `@launchdarkly/session-replay` each bundle + * their own copy of this module while sharing one `LDObserve` facade, so the + * attributes live on the global object and every copy on the page sees them. + */ +const GLOBAL_ATTRIBUTES_STATE_KEY = Symbol.for( + '@launchdarkly/o11y.globalAttributes', +) + +const state: GlobalAttributesState = (() => { + const holder = globalThis as unknown as Record< + symbol, + GlobalAttributesState + > + return (holder[GLOBAL_ATTRIBUTES_STATE_KEY] ??= { snapshot: EMPTY }) +})() + +const isPrimitive = (v: unknown): v is string | number | boolean => + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' + +const sanitizeValue = (value: unknown): AttributeValue | undefined => { + if (isPrimitive(value)) { + return value + } + if (!Array.isArray(value)) { + return undefined + } + let elementType: string | undefined + for (const element of value) { + if (element === null || element === undefined) { + continue + } + if (!isPrimitive(element)) { + return undefined + } + elementType ??= typeof element + if (typeof element !== elementType) { + return undefined + } + } + return Object.freeze(value.slice()) as AttributeValue +} + +/** The current global attributes. The returned object is frozen. */ +export const getGlobalAttributes = (): Readonly => state.snapshot + +/** + * Merges `attributes` by key into the global attributes. Reserved keys and + * values that are not valid OpenTelemetry attribute values are ignored. + */ +export const setGlobalAttributes = (attributes: Attributes) => { + if (!attributes || typeof attributes !== 'object') { + return + } + let next: Attributes | undefined + for (const [key, value] of Object.entries(attributes)) { + if (!key) { + continue + } + if (RESERVED_GLOBAL_ATTRIBUTE_KEYS.has(key)) { + internalLog( + 'setAttributes', + 'debug', + `Ignoring reserved attribute key "${key}".`, + ) + continue + } + const sanitized = sanitizeValue(value) + if (sanitized === undefined) { + internalLog( + 'setAttributes', + 'debug', + `Ignoring invalid value for attribute key "${key}".`, + ) + continue + } + next ??= { ...state.snapshot } + next[key] = sanitized + } + if (next) { + state.snapshot = Object.freeze(next) + } +} + +/** Removes `keys` from the global attributes. */ +export const removeGlobalAttributes = (keys: string[]) => { + if (!Array.isArray(keys)) { + return + } + let next: Attributes | undefined + for (const key of keys) { + if (typeof key === 'string' && key in (next ?? state.snapshot)) { + next ??= { ...state.snapshot } + delete next[key] + } + } + if (next) { + state.snapshot = Object.freeze(next) + } +} + +/** Removes every global attribute. Intended for tests. */ +export const clearGlobalAttributes = () => { + state.snapshot = EMPTY +} + +/** + * Sets each global attribute the span does not already have, so attributes + * passed when starting the span (and any set on it later) take precedence. + */ +export const applyGlobalAttributesToSpan = (span: { + readonly attributes: Attributes + setAttribute(key: string, value: AttributeValue): unknown +}) => { + const globals = state.snapshot + for (const key in globals) { + if (span.attributes[key] === undefined) { + span.setAttribute(key, globals[key]!) + } + } +} diff --git a/sdk/@launchdarkly/o11y/src/sdk/observe.ts b/sdk/@launchdarkly/o11y/src/sdk/observe.ts index 8e916f9179..775fba2024 100644 --- a/sdk/@launchdarkly/o11y/src/sdk/observe.ts +++ b/sdk/@launchdarkly/o11y/src/sdk/observe.ts @@ -88,6 +88,11 @@ import { sanitizeUrl, } from '../client/listeners/network-listener/utils/network-sanitizer' import { setSdkDebugLogging } from '../client/utils/sdkLogger' +import { + getGlobalAttributes, + removeGlobalAttributes, + setGlobalAttributes, +} from './globalAttributes' export class ObserveSDK implements Observe { /** Verbose project ID that is exposed to users. Legacy users may still be using ints. */ @@ -169,6 +174,14 @@ export class ObserveSDK implements Observe { return this._ldContextKeys } + setAttributes(attributes: Attributes): void { + setGlobalAttributes(attributes) + } + + removeAttributes(keys: string[]): void { + removeGlobalAttributes(keys) + } + public async start() { if (this._started) { return @@ -258,6 +271,7 @@ export class ObserveSDK implements Observe { ? trace.map((s) => s.toString()).join('\n') : undefined span?.addEvent('log', { + ...getGlobalAttributes(), [ATTR_LOG_SEVERITY]: level, [ATTR_LOG_MESSAGE]: msg, 'code.stacktrace': stackTrace, @@ -380,6 +394,7 @@ export class ObserveSDK implements Observe { this._counters.set(metric.name, counter) } counter.add(metric.value, { + ...getGlobalAttributes(), ...(this._ldContextKeys ?? {}), ...metric.attributes, 'highlight.session_id': getPersistentSessionSecureID(), @@ -397,6 +412,7 @@ export class ObserveSDK implements Observe { this._gauges.set(metric.name, gauge) } const attributes: Attributes = { + ...getGlobalAttributes(), ...(this._ldContextKeys ?? {}), ...metric.attributes, } @@ -438,6 +454,7 @@ export class ObserveSDK implements Observe { this._histograms.set(metric.name, histogram) } histogram.record(metric.value, { + ...getGlobalAttributes(), ...(this._ldContextKeys ?? {}), ...metric.attributes, 'highlight.session_id': getPersistentSessionSecureID(), @@ -455,6 +472,7 @@ export class ObserveSDK implements Observe { this._up_down_counters.set(metric.name, up_down_counter) } up_down_counter.add(metric.value, { + ...getGlobalAttributes(), ...(this._ldContextKeys ?? {}), ...metric.attributes, 'highlight.session_id': getPersistentSessionSecureID(), diff --git a/sdk/@launchdarkly/observability/README.md b/sdk/@launchdarkly/observability/README.md index c8f6f5a20c..bad61752c5 100644 --- a/sdk/@launchdarkly/observability/README.md +++ b/sdk/@launchdarkly/observability/README.md @@ -45,6 +45,24 @@ const client = init3( Refer to the [SDK documentation](https://docs.launchdarkly.com/sdk/client-side/javascript#getting-started) for instructions on getting started with using the SDK. +## Global attributes + +Use `LDObserve.setAttributes(attributes)` to add attributes to every span, log, and metric the SDK records after the call, including automatic telemetry (network requests, document load, user interactions, errors, flag evaluations, and track events). Calls merge by key; `LDObserve.removeAttributes(keys)` removes keys. + +```ts +LDObserve.setAttributes({ 'app.tenant': 'acme', 'app.build': 42 }) + +// Later, e.g. on sign-out: +LDObserve.removeAttributes(['app.tenant']) +``` + +- Attributes set explicitly on a span, log, or metric take precedence: global attributes only fill in missing keys. A span gets the global attributes current when it starts. +- Values must be OpenTelemetry attribute values (string, number, boolean, or a homogeneous array of them); other values are ignored. SDK-managed session keys (`highlight.session_id`, `session.id`) cannot be set. +- Global attributes are kept in memory for the lifetime of the page. They are not persisted and are not cleared when the session changes. +- Calls take effect immediately, even before the SDK has initialized or, with `manualStart`, before `LDObserve.start()`. + +Global attributes differ from `LDRecord.addSessionProperties` (session replay), which attaches properties to the recorded session itself rather than to each span, log, and metric. + ## Session continuity A session continues across full page loads on the same origin until it has been idle for 15 minutes; a full page load on a different origin starts a new session.