diff --git a/.changeset/metrics-resource-attribute-getters.md b/.changeset/metrics-resource-attribute-getters.md new file mode 100644 index 0000000000..0b311bb4fc --- /dev/null +++ b/.changeset/metrics-resource-attribute-getters.md @@ -0,0 +1,6 @@ +--- +'posthog-node': patch +'@posthog/core': patch +--- + +Stop a throwing getter in `metrics.resourceAttributes` from breaking every metrics export — the key is recorded as `[Unserializable]` instead. diff --git a/.changeset/node-distributed-tracing.md b/.changeset/node-distributed-tracing.md new file mode 100644 index 0000000000..8d0d1ff0fb --- /dev/null +++ b/.changeset/node-distributed-tracing.md @@ -0,0 +1,7 @@ +--- +'posthog-node': minor +'@posthog/core': minor +'@posthog/types': minor +--- + +Add experimental distributed tracing to `posthog-node`: `startSpan`, `withSpan` and `getActiveSpan` record spans against a new `traces` client option. A service with tracing off still forwards an inbound `traceparent`, including from spans nested inside the one that received it, so a distributed trace is not severed. A `traceparent` may be passed as the one-element array `req.headersDistinct` gives. A `beforeSpanSend` hook sees every span before it is exported and may edit or drop it, and `maxAttributesPerSpan`, `maxEventsPerSpan`, `maxAttributeValueLength`, `maxLiveSpans` and `maxSpanAgeMs` bound what a single span and a single process may hold. diff --git a/packages/core/package.json b/packages/core/package.json index dbb004e0f1..9c2a4b81b2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,7 @@ "lint:fix": "oxlint --report-unused-disable-directives-severity error src --fix", "build": "rslib build", "dev": "rslib build -w", - "test:unit": "vitest run", + "test:unit": "NODE_OPTIONS=--expose-gc vitest run", "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" }, "exports": { diff --git a/packages/core/src/__tests__/posthog.flush.spec.ts b/packages/core/src/__tests__/posthog.flush.spec.ts index 245e0306c7..1f7a9e3b9d 100644 --- a/packages/core/src/__tests__/posthog.flush.spec.ts +++ b/packages/core/src/__tests__/posthog.flush.spec.ts @@ -761,11 +761,12 @@ describe('PostHog Core', () => { }) describe('OTLP batch senders', () => { - // Both share one `_sendOtlpBatch`; the table pins them to the same + // All three share one `_sendOtlpBatch`; the table pins them to the same // classification so a wrapper can't reintroduce a per-signal retry policy. const senders = { logs: (client: PostHogCoreTestClient) => client._sendLogsBatch({ resourceLogs: [] }), metrics: (client: PostHogCoreTestClient) => client._sendMetricsBatch({ resourceMetrics: [] }), + traces: (client: PostHogCoreTestClient) => client._sendTracesBatch({ resourceSpans: [] }), } const cases: [number, string][] = [ diff --git a/packages/core/src/__tests__/posthog.otlp-auth.spec.ts b/packages/core/src/__tests__/posthog.otlp-auth.spec.ts new file mode 100644 index 0000000000..78ea3ba8df --- /dev/null +++ b/packages/core/src/__tests__/posthog.otlp-auth.spec.ts @@ -0,0 +1,49 @@ +import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from '@/testing' + +// One `_sendOtlpBatch` serves logs, metrics and traces, and the endpoint each one +// authenticates against is a single argument away from the wrong scheme. These pin +// all three so a flipped argument fails here rather than shipping the project key +// in a header the logs endpoint does not expect. +describe('OTLP batch auth', () => { + let posthog: PostHogCoreTestClient + let mocks: PostHogCoreTestClientMocks + + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { + host: 'http://example.com', + preloadFeatureFlags: false, + disableCompression: true, + }) + mocks.fetch.mockResolvedValue({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve({ status: 'ok' }), + }) + }) + + const lastCall = (): [string, any] => mocks.fetch.mock.calls[mocks.fetch.mock.calls.length - 1] as [string, any] + + it('sends logs to the query-token endpoint with no Authorization header', async () => { + await posthog._sendLogsBatch({ resourceLogs: [] } as any) + + const [url, options] = lastCall() + expect(url).toBe('http://example.com/i/v1/logs?token=TEST_API_KEY') + expect(options.headers).not.toHaveProperty('Authorization') + }) + + it('sends metrics to the query-token endpoint with no Authorization header', async () => { + await posthog._sendMetricsBatch({ resourceMetrics: [] } as any) + + const [url, options] = lastCall() + expect(url).toBe('http://example.com/i/v1/metrics?token=TEST_API_KEY') + expect(options.headers).not.toHaveProperty('Authorization') + }) + + it('sends traces with bearer auth and no token in the query string', async () => { + await posthog._sendTracesBatch({ resourceSpans: [] } as any) + + const [url, options] = lastCall() + expect(url).toBe('http://example.com/i/v1/traces') + expect(options.headers.Authorization).toBe('Bearer TEST_API_KEY') + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1191911e46..d55a08390f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,24 @@ export type { Metrics, MetricsConfig, } from './metrics/types' +export { PostHogTraces } from './traces' +export { SyncSpanContextManager } from './traces/context' +export { inertSpan, runWithActiveSpan } from './traces/span' +export { resolveTracesConfig } from './traces/config' +export type { ResolvedTracesConfig, SpanContextManager, TraceSdkContext } from './traces/types' +// The `beforeSpanSend` shapes come straight from @posthog/types: hooks see the +// public record, not core's internal one, which also carries `traceState`. +export type { SpanRecord, BeforeSpanSendFn } from '@posthog/types' +export type { + Span, + SpanAttributes, + SpanAttributeValue, + SpanKind, + SpanStatusCode, + SpanTimeInput, + StartSpanOptions, + TracesConfig, +} from './traces/types' export { uuidv7 } from './vendor/uuidv7' export * from './cookie' export * from './posthog-core' diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index 1001cd90f1..73565120ec 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -1,5 +1,6 @@ -import type { OtlpLogsPayload, OtlpMetricsPayload } from '@posthog/types' +import type { OtlpLogsPayload, OtlpMetricsPayload, OtlpTracesPayload } from '@posthog/types' import type { SendMetricsBatchOutcome } from './metrics/types' +import type { SendTracesBatchOutcome } from './traces/types' import { SimpleEventEmitter } from './eventemitter' import { getFeatureFlagValue, minimizeFlagCalledEventProperties, normalizeFlagsResponse } from './featureFlagUtils' import { gzipCompress, isGzipSupported } from './gzip' @@ -238,8 +239,8 @@ export type SendLogsBatchOutcome = /** * Each signal keeps its own exported outcome type because each belongs to a - * separate host contract. The wrappers return this value directly, so one - * drifting out of shape fails to compile. + * separate host contract. The wrappers return this value directly, so any of + * the three drifting out of shape fails to compile. */ type SendOtlpBatchOutcome = | { kind: 'ok' } @@ -1642,9 +1643,9 @@ export abstract class PostHogCoreStateless { } /** - * Shared implementation behind the OTLP senders, which differ only in path. - * Returns a tagged outcome instead of throwing so the queue owners don't - * have to know the core's error class hierarchy. + * Shared implementation behind the three OTLP senders, which differ only in + * path and auth style. Returns a tagged outcome instead of throwing so the + * queue owners don't have to know the core's error class hierarchy. * * Exhausted 408/429/5xx stay `retry-later`, unlike the events `_flush()` * which drops anything that isn't a network error: every OTLP queue is @@ -1653,17 +1654,22 @@ export abstract class PostHogCoreStateless { */ private async _sendOtlpBatch({ path, + auth, payload, }: { - path: 'logs' | 'metrics' - payload: OtlpLogsPayload | OtlpMetricsPayload + path: 'logs' | 'metrics' | 'traces' + auth: 'query-token' | 'bearer' + payload: OtlpLogsPayload | OtlpMetricsPayload | OtlpTracesPayload }): Promise { if (this.disabled) { return { kind: 'fatal', error: new Error('The client is disabled') } } const serialized = JSON.stringify(payload) - const url = `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}` + const url = + auth === 'bearer' + ? `${this.host}/i/v1/${path}` + : `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}` const gzippedPayload = !this.disableCompression ? await this.compressPayload(serialized) : null const fetchOptions: PostHogFetchOptions = { @@ -1671,6 +1677,7 @@ export abstract class PostHogCoreStateless { headers: { ...this.getCustomHeaders(), 'Content-Type': 'application/json', + ...(auth === 'bearer' && { Authorization: `Bearer ${this.apiKey}` }), ...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }), }, body: gzippedPayload || serialized, @@ -1703,11 +1710,23 @@ export abstract class PostHogCoreStateless { } async _sendLogsBatch(payload: OtlpLogsPayload): Promise { - return this._sendOtlpBatch({ path: 'logs', payload }) + return this._sendOtlpBatch({ path: 'logs', auth: 'query-token', payload }) } async _sendMetricsBatch(payload: OtlpMetricsPayload): Promise { - return this._sendOtlpBatch({ path: 'metrics', payload }) + return this._sendOtlpBatch({ path: 'metrics', auth: 'query-token', payload }) + } + + /** + * The `TracesHost._sendTracesBatch` implementation, so `PostHogTraces` can + * use any core-based SDK as its host. + * + * Authenticates with `Authorization: Bearer` rather than the `?token=` query + * parameter the logs and metrics senders use: it's the service's primary auth + * path, and server runtimes have no CORS preflight to avoid. + */ + async _sendTracesBatch(payload: OtlpTracesPayload): Promise { + return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload }) } private fetchWithRetry( diff --git a/packages/core/src/traces/config.spec.ts b/packages/core/src/traces/config.spec.ts new file mode 100644 index 0000000000..eaef6082bc --- /dev/null +++ b/packages/core/src/traces/config.spec.ts @@ -0,0 +1,271 @@ +import { createMockLogger } from '@/testing' +import { resolveTracesConfig } from './config' + +describe('resolveTracesConfig', () => { + it.each([ + ['zero', 0], + ['negative', -1], + ['not a number', NaN], + // Floored, these read as 1, 2 and 3 — caps an order of magnitude below what + // the caller wrote, applied silently. + ['a fraction', 1.5], + ['a large fraction', 200.5], + ['infinity', Infinity], + ])('falls back to the default per-span caps when given %s', (_label, value) => { + const resolved = resolveTracesConfig({ + maxAttributesPerSpan: value, + maxEventsPerSpan: value, + maxAttributeValueLength: value, + }) + expect(resolved.maxAttributesPerSpan).toBe(128) + expect(resolved.maxEventsPerSpan).toBe(128) + expect(resolved.maxAttributeValueLength).toBe(8192) + }) + + it('honours explicit per-span caps', () => { + // Without this the resolver can ignore maxEventsPerSpan entirely and every + // other test still passes, because they all assert the default. + expect(resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7 })).toMatchObject({ + maxAttributesPerSpan: 5, + maxEventsPerSpan: 7, + }) + }) + + it('keeps the per-event attribute cap fixed', () => { + // The cap is internal, so an untyped caller naming it gets the default. + expect(resolveTracesConfig({ maxAttributesPerEvent: 9 } as any).maxAttributesPerEvent).toBe(128) + }) + + it('applies the documented defaults', () => { + expect(resolveTracesConfig(undefined)).toMatchObject({ + flushIntervalMs: 5000, + maxExportBatchSize: 512, + maxQueueSize: 2048, + maxAttributesPerSpan: 128, + maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + maxLiveSpans: 10_000, + maxSpanAgeMs: 3_600_000, + }) + }) + + it('honours an explicit attribute value bound', () => { + expect(resolveTracesConfig({ maxAttributeValueLength: 256 }).maxAttributeValueLength).toBe(256) + }) + + it('honours explicit live-span bounds', () => { + expect(resolveTracesConfig({ maxLiveSpans: 50, maxSpanAgeMs: 30_000 })).toMatchObject({ + maxLiveSpans: 50, + maxSpanAgeMs: 30_000, + }) + }) + + it.each([0, -1, Number.NaN])('falls back to the defaults for unusable live-span bounds (%p)', (value) => { + expect(resolveTracesConfig({ maxLiveSpans: value, maxSpanAgeMs: value })).toMatchObject({ + maxLiveSpans: 10_000, + maxSpanAgeMs: 3_600_000, + }) + }) + + it('leaves serviceName unset so core supplies unknown_service', () => { + expect(resolveTracesConfig({}).serviceName).toBeUndefined() + }) + + it('honours explicit values', () => { + expect( + resolveTracesConfig({ serviceName: 'checkout', flushIntervalMs: 1000, maxExportBatchSize: 50 }) + ).toMatchObject({ + serviceName: 'checkout', + flushIntervalMs: 1000, + maxExportBatchSize: 50, + }) + }) + + it('lets OTLP resource attributes override the named fields', () => { + const resolved = resolveTracesConfig({ + serviceName: 'named', + serviceVersion: '1.0.0', + environment: 'staging', + resourceAttributes: { + 'service.name': 'from-attributes', + 'service.version': '2.0.0', + 'deployment.environment': 'production', + }, + }) + + expect(resolved.serviceName).toBe('from-attributes') + expect(resolved.serviceVersion).toBe('2.0.0') + expect(resolved.environment).toBe('production') + }) + + it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'falls back to the default for an unusable maxExportBatchSize (%p)', + (value) => { + // A non-positive batch size reaches an export loop that cannot make + // progress with it, so it spins forever posting empty batches. + expect(resolveTracesConfig({ maxExportBatchSize: value }).maxExportBatchSize).toBe(512) + } + ) + + it('takes the default for a fractional batch size rather than flooring it', () => { + // Every numeric knob resolves the same way, so a fraction is a value the + // caller did not mean rather than one to round down behind their back. + expect(resolveTracesConfig({ maxExportBatchSize: 10.9 }).maxExportBatchSize).toBe(512) + }) + + it.each([0, -1, 1.5, Number.NaN])('falls back to the default for an unusable flushIntervalMs (%p)', (value) => { + expect(resolveTracesConfig({ flushIntervalMs: value }).flushIntervalMs).toBe(5000) + }) + + it.each([0.5, 10_000.5])('falls back to the defaults for fractional live-span bounds (%p)', (value) => { + expect(resolveTracesConfig({ maxLiveSpans: value, maxSpanAgeMs: value })).toMatchObject({ + maxLiveSpans: 10_000, + maxSpanAgeMs: 3_600_000, + }) + }) + + it('keeps the queue at least as large as the export batch', () => { + // A queue smaller than the flush trigger would stop the depth-based flush + // from ever firing. + expect(resolveTracesConfig({ maxExportBatchSize: 5000 }).maxQueueSize).toBe(5000) + expect(resolveTracesConfig({ maxExportBatchSize: 10 }).maxQueueSize).toBe(2048) + }) + + it('honours an explicit maxQueueSize', () => { + expect(resolveTracesConfig({ maxQueueSize: 100_000 }).maxQueueSize).toBe(100_000) + }) + + it('floors an explicit maxQueueSize at the export batch size', () => { + expect(resolveTracesConfig({ maxExportBatchSize: 512, maxQueueSize: 10 }).maxQueueSize).toBe(512) + }) + + it('attaches the host resource attributes the entrypoint supplies', () => { + expect( + resolveTracesConfig(undefined, { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }).resourceAttributes + ).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }) + }) + + it('lets user resource attributes override the host ones', () => { + expect( + resolveTracesConfig( + { resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } }, + { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' } + ).resourceAttributes + ).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' }) + }) + + it('resolves when the entrypoint supplies no host attributes', () => { + expect(resolveTracesConfig({ resourceAttributes: { 'host.name': 'worker-01' } }).resourceAttributes).toEqual({ + 'host.name': 'worker-01', + }) + }) +}) + +describe('resourceAttributes guarding', () => { + it('ignores a non-object value', () => { + const { resourceAttributes } = resolveTracesConfig({ resourceAttributes: 'oops' as never }) + + expect(Object.keys(resourceAttributes ?? {})).toEqual([]) + }) + + it('ignores an array, which would otherwise spread as numeric keys', () => { + const { resourceAttributes } = resolveTracesConfig({ + resourceAttributes: [{ key: 'service.name' }] as never, + }) + + expect(Object.keys(resourceAttributes ?? {})).not.toContain('0') + }) + + it('drops an identity key that is not a string', () => { + const resolved = resolveTracesConfig({ + serviceName: 'checkout-api', + resourceAttributes: { 'service.name': 12345 as never, region: 'us' }, + }) + + expect(resolved.serviceName).toBe('checkout-api') + expect(resolved.resourceAttributes).toEqual({ region: 'us' }) + }) + + it('ignores a beforeSpanSend entry that is not a function', () => { + // A plain-JS caller passing the wrong shape would otherwise have every span + // dropped by a hook that throws on call, with tracing silently off. + const scrub = (span: any): any => span + const resolved = resolveTracesConfig({ beforeSpanSend: ['not a function', scrub] as never }) + + expect(resolved.beforeSpanSend).toEqual([scrub]) + }) + + it('warns about a dropped hook, since the redaction it was configured for is gone', () => { + const logger = createMockLogger() + const scrub = (span: any): any => span + + resolveTracesConfig({ beforeSpanSend: ['not a function', scrub] as never }, undefined, logger) + + // `critical`, not `warn`: every other level is gated behind `debug: true`. + expect(logger.critical).toHaveBeenCalledWith(expect.stringContaining('ignoring 1 of 2 entries')) + }) + + it('stays quiet for a conditionally disabled hook', () => { + // `[featureEnabled && scrub]` is ordinary JS; nothing was configured to + // redact, so claiming redaction is broken would be false alarm. + const logger = createMockLogger() + + const resolved = resolveTracesConfig({ beforeSpanSend: [false, null, undefined] as never }, undefined, logger) + + expect(resolved.beforeSpanSend).toEqual([]) + expect(logger.critical).not.toHaveBeenCalled() + }) + + it('stays quiet when every hook is callable', () => { + const logger = createMockLogger() + + resolveTracesConfig({ beforeSpanSend: [(span: any): any => span] }, undefined, logger) + + expect(logger.critical).not.toHaveBeenCalled() + }) + + it('resolves to no hooks when beforeSpanSend is the wrong type entirely', () => { + const resolved = resolveTracesConfig({ beforeSpanSend: { scrub: true } as never }) + + expect(resolved.beforeSpanSend).toEqual([]) + }) + + it('does not throw when an identity accessor throws', () => { + const hostile = {} + Object.defineProperty(hostile, 'service.name', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + + expect(() => resolveTracesConfig({ resourceAttributes: hostile as never })).not.toThrow() + }) + + it('does not throw when a non-identity accessor throws', () => { + const hostile = { 'service.name': 'checkout-api' } + Object.defineProperty(hostile, 'region', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + + expect(() => resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' })).not.toThrow() + }) + + it('keeps the readable attributes when one accessor throws', () => { + const hostile = { region: 'us' } + Object.defineProperty(hostile, 'tenant', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + + const resolved = resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' }) + + expect(resolved.resourceAttributes).toMatchObject({ 'os.name': 'Linux', region: 'us' }) + }) +}) diff --git a/packages/core/src/traces/config.ts b/packages/core/src/traces/config.ts new file mode 100644 index 0000000000..bbf1a67b94 --- /dev/null +++ b/packages/core/src/traces/config.ts @@ -0,0 +1,142 @@ +import { assignUserAttributes } from '../utils/json-utils' +import type { ResolvedTracesConfig } from './types' +import type { BeforeSpanSendFn, TracesConfig } from '@posthog/types' +import type { Logger } from '../types' + +// OpenTelemetry's BatchSpanProcessor defaults, which sit comfortably under the +// server's request body cap. +const DEFAULT_FLUSH_INTERVAL_MS = 5000 +const DEFAULT_MAX_EXPORT_BATCH_SIZE = 512 +const DEFAULT_MAX_QUEUE_SIZE = 2048 +// OpenTelemetry's per-span defaults. +const DEFAULT_MAX_ATTRIBUTES_PER_SPAN = 128 +const DEFAULT_MAX_EVENTS_PER_SPAN = 128 +// OpenTelemetry's per-event limit, which is the same number. Fixed rather than +// configurable: `maxAttributesPerSpan` and `maxEventsPerSpan` already give a +// caller room to shape a span, and this one only has to stop an event holding +// an unbounded bag. +const DEFAULT_MAX_ATTRIBUTES_PER_EVENT = 128 +// OpenTelemetry leaves the value length unlimited, which is what lets one +// multi-MB attribute make a span too large for the endpoint to accept — and an +// oversized span is dropped whole. 8 KB bounds a single string: it holds a deep +// stack trace and any realistic header, query string or payload excerpt, and +// keeps a span's own attributes under 1 MB at the attribute cap. A span's total +// size is the product of these caps; the body limit itself is enforced at the +// batch boundary, which is where the whole payload can be weighed at once. +const DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH = 8192 + +// Live-span bounds. A server can legitimately hold thousands of spans open at +// once, and refusing a legitimate span is worse than tolerating a leak, so the +// count bound sits well above realistic concurrency — affordable because live +// accounting is an id and a timestamp per span, not the span. The age bound is +// an hour: production traces routinely run past ten minutes, and a span still +// open after an hour is a leak rather than slow work. +const DEFAULT_MAX_LIVE_SPANS = 10_000 +const DEFAULT_MAX_SPAN_AGE_MS = 3_600_000 + +/** + * Coerces a caller-supplied positive-integer option. `0`, a negative, or `NaN` + * reaching the export loop would stall it. + * + * A fraction takes the default rather than being floored: these are documented + * as positive integers, and silently reading `maxAttributesPerSpan: 1.5` as `1` + * caps a span an order of magnitude below what the caller wrote. + */ +function positiveInteger(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback +} + +const IDENTITY_KEYS = ['service.name', 'service.version', 'deployment.environment'] as const + +/** + * Drops a non-string identity key rather than letting it through: the resolver + * would ignore it, and it would still reach the wire as an int and leave the + * spans unattributable. + */ +function withUsableIdentityKeys(attributes: TracesConfig['resourceAttributes']): TracesConfig['resourceAttributes'] { + // A primitive or array would otherwise be spread into attributes keyed "0", "1", "2". + if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) { + return undefined + } + try { + if (IDENTITY_KEYS.every((key) => !(key in attributes) || typeof attributes[key] === 'string')) { + return attributes + } + const usable = { ...attributes } + for (const key of IDENTITY_KEYS) { + if (key in usable && typeof usable[key] !== 'string') { + delete usable[key] + } + } + return usable + } catch { + // A throwing accessor on the config object must not escape `startSpan`. + return undefined + } +} + +/** + * Keeps only the callable hooks. Anything else is dropped rather than called: an + * untyped caller passing the wrong shape would otherwise have every span dropped + * by a hook that throws, leaving tracing silently off. + * + * Reported at `critical` rather than thrown on: a constructor that throws takes + * the application down, but every other log level is gated behind `debug: true`, + * and an inert redaction hook ships the values it was meant to remove. + */ +function resolveBeforeSpanSend(beforeSpanSend: TracesConfig['beforeSpanSend'], logger?: Logger): BeforeSpanSendFn[] { + if (!beforeSpanSend) { + return [] + } + // `[featureEnabled && scrub]` is ordinary JS, and a caller who wrote it did not + // configure a hook at all — only a value that was meant to be one is worth + // shouting about. + const supplied = [beforeSpanSend].flat().filter((hook) => Boolean(hook)) + const hooks = supplied.filter((hook): hook is BeforeSpanSendFn => typeof hook === 'function') + if (hooks.length !== supplied.length) { + logger?.critical( + `beforeSpanSend: ignoring ${supplied.length - hooks.length} of ${supplied.length} entries that are not functions. ` + + 'Spans export without them, so whatever they were redacting is not redacted.' + ) + } + return hooks +} + +/** + * Resolves the public `traces` config into the shape core `PostHogTraces` consumes. + * OTLP resource attributes take precedence over the named fields, matching the + * logs config. `hostResourceAttributes` are runtime-detected by the entrypoint and + * merge first, so a user-supplied value of the same key wins. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export function resolveTracesConfig( + config: TracesConfig | undefined, + hostResourceAttributes?: Record, + logger?: Logger +): ResolvedTracesConfig { + // Copied key by key rather than spread: a throwing accessor on a user-supplied + // attribute would otherwise escape the first `startSpan`. + const resourceAttributes = assignUserAttributes( + { ...hostResourceAttributes }, + withUsableIdentityKeys(config?.resourceAttributes) + ) + const maxExportBatchSize = positiveInteger(config?.maxExportBatchSize, DEFAULT_MAX_EXPORT_BATCH_SIZE) + return { + serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? config?.serviceName, + serviceVersion: (resourceAttributes?.['service.version'] as string | undefined) ?? config?.serviceVersion, + environment: (resourceAttributes?.['deployment.environment'] as string | undefined) ?? config?.environment, + resourceAttributes, + beforeSpanSend: resolveBeforeSpanSend(config?.beforeSpanSend, logger), + maxAttributesPerSpan: positiveInteger(config?.maxAttributesPerSpan, DEFAULT_MAX_ATTRIBUTES_PER_SPAN), + maxEventsPerSpan: positiveInteger(config?.maxEventsPerSpan, DEFAULT_MAX_EVENTS_PER_SPAN), + maxAttributesPerEvent: DEFAULT_MAX_ATTRIBUTES_PER_EVENT, + maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH), + flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS), + maxExportBatchSize, + // Never below the flush trigger, or the depth-based flush could never fire. + maxQueueSize: Math.max(positiveInteger(config?.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE), maxExportBatchSize), + maxLiveSpans: positiveInteger(config?.maxLiveSpans, DEFAULT_MAX_LIVE_SPANS), + maxSpanAgeMs: positiveInteger(config?.maxSpanAgeMs, DEFAULT_MAX_SPAN_AGE_MS), + } +} diff --git a/packages/core/src/traces/context.ts b/packages/core/src/traces/context.ts new file mode 100644 index 0000000000..60f3324527 --- /dev/null +++ b/packages/core/src/traces/context.ts @@ -0,0 +1,30 @@ +import type { Span } from '@posthog/types' +import type { SpanContextManager } from './types' + +/** + * Synchronous active-span tracking: restores the previous active span when the + * callback returns, which for an async callback means when it returns its + * promise — so spans started after an `await` won't see it as active. + * + * The fallback for runtimes with no ambient async context; Node injects an + * `AsyncLocalStorage`-backed manager instead. `parent` is the escape hatch. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export class SyncSpanContextManager implements SpanContextManager { + private _active: Span | undefined + + active(): Span | undefined { + return this._active + } + + with(span: Span, fn: () => T): T { + const previous = this._active + this._active = span + try { + return fn() + } finally { + this._active = previous + } + } +} diff --git a/packages/core/src/traces/ids.spec.ts b/packages/core/src/traces/ids.spec.ts new file mode 100644 index 0000000000..bbc3833b59 --- /dev/null +++ b/packages/core/src/traces/ids.spec.ts @@ -0,0 +1,115 @@ +import { getRandomBytes, isValidSpanId, isValidTraceId, newSpanId, newTraceId } from './ids' + +describe('trace and span ids', () => { + describe('newTraceId', () => { + it('is 32 lowercase hex characters', () => { + for (let i = 0; i < 50; i++) { + expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/) + } + }) + + it('is never all zeros', () => { + for (let i = 0; i < 50; i++) { + expect(newTraceId()).not.toBe('0'.repeat(32)) + } + }) + + it('does not repeat', () => { + const ids = new Set(Array.from({ length: 200 }, newTraceId)) + expect(ids.size).toBe(200) + }) + }) + + describe('newSpanId', () => { + it('is 16 lowercase hex characters', () => { + for (let i = 0; i < 50; i++) { + expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/) + } + }) + + it('does not repeat', () => { + const ids = new Set(Array.from({ length: 200 }, newSpanId)) + expect(ids.size).toBe(200) + }) + }) + + describe('getRandomBytes', () => { + it('returns the requested length', () => { + expect(getRandomBytes(8)).toHaveLength(8) + expect(getRandomBytes(16)).toHaveLength(16) + }) + + it('falls back to Math.random when crypto is unavailable', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto') + // React Native has no global crypto without a polyfill — the fallback path + // is what keeps span ids working there. + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/) + expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/) + } finally { + if (original) { + Object.defineProperty(globalThis, 'crypto', original) + } + } + }) + + it('falls back when getRandomValues throws', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto') + Object.defineProperty(globalThis, 'crypto', { + value: { + getRandomValues: () => { + throw new Error('not allowed') + }, + }, + configurable: true, + }) + try { + expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/) + } finally { + if (original) { + Object.defineProperty(globalThis, 'crypto', original) + } + } + }) + + it('never emits an all-zero id even when the random source is broken', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto') + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: (array: Uint8Array) => array.fill(0) }, + configurable: true, + }) + try { + // The server zeroes ids it can't use, so an all-zero id would be stored + // and silently orphaned rather than rejected. + expect(newTraceId()).not.toBe('0'.repeat(32)) + expect(newSpanId()).not.toBe('0'.repeat(16)) + } finally { + if (original) { + Object.defineProperty(globalThis, 'crypto', original) + } + } + }) + }) + + describe('validation', () => { + it.each([ + ['a valid trace id', '4bf92f3577b34da6a3ce929d0e0e4736', true], + ['an all-zero trace id', '0'.repeat(32), false], + ['a short trace id', 'abc', false], + ['uppercase hex', '4BF92F3577B34DA6A3CE929D0E0E4736', false], + ['a non-hex string', 'zzf92f3577b34da6a3ce929d0e0e4736', false], + ['a non-string', 12345, false], + ])('isValidTraceId rejects/accepts %s', (_name, value, expected) => { + expect(isValidTraceId(value)).toBe(expected) + }) + + it.each([ + ['a valid span id', '00f067aa0ba902b7', true], + ['an all-zero span id', '0'.repeat(16), false], + ['a trace-length id', '4bf92f3577b34da6a3ce929d0e0e4736', false], + ])('isValidSpanId rejects/accepts %s', (_name, value, expected) => { + expect(isValidSpanId(value)).toBe(expected) + }) + }) +}) diff --git a/packages/core/src/traces/ids.ts b/packages/core/src/traces/ids.ts new file mode 100644 index 0000000000..52a445bf5a --- /dev/null +++ b/packages/core/src/traces/ids.ts @@ -0,0 +1,75 @@ +// W3C Trace Context identifier generation. Trace ids are 16 bytes, span ids 8, +// both lowercase hex on the JSON wire. The ingestion service *zeroes* ids that +// aren't exactly the right length rather than rejecting them, silently orphaning +// the span — so length is load-bearing and every id is validated before it ships. + +const TRACE_ID_BYTES = 16 +const SPAN_ID_BYTES = 8 + +const TRACE_ID_HEX = TRACE_ID_BYTES * 2 +const SPAN_ID_HEX = SPAN_ID_BYTES * 2 + +const INVALID_TRACE_ID = '0'.repeat(TRACE_ID_HEX) +const INVALID_SPAN_ID = '0'.repeat(SPAN_ID_HEX) + +const HEX_RE = /^[0-9a-f]+$/ + +type CryptoLike = { getRandomValues?: (array: Uint8Array) => Uint8Array } + +/** + * Random bytes from the platform's CSPRNG, falling back to `Math.random`. + * + * The fallback exists for React Native, which has no global `crypto` without a + * polyfill. Trace ids need collision resistance, not unpredictability. + */ +export function getRandomBytes(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength) + const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto + if (cryptoLike && typeof cryptoLike.getRandomValues === 'function') { + try { + cryptoLike.getRandomValues(bytes) + return bytes + } catch { + // A locked-down `crypto` throws; fall through to the `Math.random` path. + } + } + for (let i = 0; i < byteLength; i++) { + bytes[i] = Math.floor(Math.random() * 256) + } + return bytes +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = '' + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0') + } + return hex +} + +function randomHexId(byteLength: number): string { + const hex = bytesToHex(getRandomBytes(byteLength)) + // An all-zero id is invalid per W3C and the server treats one as absent, so the + // span would be stored and silently orphaned. Only reachable from a broken source. + return /[^0]/.test(hex) ? hex : hex.slice(0, -1) + '1' +} + +export function newTraceId(): string { + return randomHexId(TRACE_ID_BYTES) +} + +export function newSpanId(): string { + return randomHexId(SPAN_ID_BYTES) +} + +function isValidHexId(value: unknown, length: number, invalid: string): value is string { + return typeof value === 'string' && value.length === length && value !== invalid && HEX_RE.test(value) +} + +export function isValidTraceId(value: unknown): value is string { + return isValidHexId(value, TRACE_ID_HEX, INVALID_TRACE_ID) +} + +export function isValidSpanId(value: unknown): value is string { + return isValidHexId(value, SPAN_ID_HEX, INVALID_SPAN_ID) +} diff --git a/packages/core/src/traces/index.spec.ts b/packages/core/src/traces/index.spec.ts new file mode 100644 index 0000000000..e6b6c60011 --- /dev/null +++ b/packages/core/src/traces/index.spec.ts @@ -0,0 +1,2712 @@ +import { PostHogTraces } from './index' +import { SyncSpanContextManager } from './context' +import { NOOP_SPAN, inertSpan } from './span' +import type { + OtlpSpan, + OtlpTracesPayload, + ResolvedTracesConfig, + SendTracesBatchOutcome, + SpanRecord, + TraceSdkContext, +} from './types' +import type { Logger } from '../types' +import type { Span } from '@posthog/types' +import { createMockLogger } from '@/testing' + +const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736' +const REMOTE_SPAN_ID = '00f067aa0ba902b7' +const DUPLICATED_HEADERS = [`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`, `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00`] + +const resolveForTest = (partial?: Partial): ResolvedTracesConfig => ({ + flushIntervalMs: 5000, + maxExportBatchSize: 512, + maxQueueSize: 2048, + beforeSpanSend: [], + maxAttributesPerSpan: 128, + maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + maxLiveSpans: 10000, + maxSpanAgeMs: 3600000, + ...partial, +}) + +const createMockInstance = (overrides: Record = {}): any => ({ + isDisabled: false, + optedOut: false, + getLibraryId: vi.fn(() => 'posthog-core-tests'), + getLibraryVersion: vi.fn(() => '0.0.0-test'), + _sendTracesBatch: vi.fn((): Promise => Promise.resolve({ kind: 'ok' })), + ...overrides, +}) + +describe('PostHogTraces', () => { + let mockInstance: any + let logger: Logger + let context: TraceSdkContext + + const createTraces = (config?: Partial, instance?: any): PostHogTraces => + new PostHogTraces( + instance ?? mockInstance, + resolveForTest(config), + logger, + () => context, + new SyncSpanContextManager() + ) + + const flushMicrotasks = async (): Promise => { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } + } + + const sentPayloads = (instance?: any): OtlpTracesPayload[] => + (instance ?? mockInstance)._sendTracesBatch.mock.calls.map((c: any[]) => c[0]) + + const sentSpans = (instance?: any): OtlpSpan[] => + sentPayloads(instance).flatMap((p) => p.resourceSpans[0].scopeSpans[0].spans) + + beforeEach(() => { + mockInstance = createMockInstance() + logger = createMockLogger() + context = {} + }) + + describe('startSpan', () => { + it('enqueues exactly one record per span', async () => { + const traces = createTraces() + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + expect(sentSpans()[0].name).toBe('checkout') + }) + + it('gives a root span a fresh trace id and no parent', async () => { + const traces = createTraces() + traces.startSpan('root').end() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).toMatch(/^[0-9a-f]{32}$/) + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/) + expect(span.parentSpanId).toBeUndefined() + }) + + it('does not activate the span it returns', () => { + const traces = createTraces() + const manual = traces.startSpan('manual') + expect(traces.getActiveSpan()).toBeNull() + manual.end() + }) + + it('parents a child to an explicit span handle', async () => { + const traces = createTraces() + const parent = traces.startSpan('parent') + const child = traces.startSpan('child', { parent }) + child.end() + parent.end() + await traces.flush() + + const [childSpan, parentSpan] = sentSpans() + expect(childSpan.traceId).toBe(parentSpan.traceId) + expect(childSpan.parentSpanId).toBe(parentSpan.spanId) + }) + + it('defaults kind to internal and honours an explicit kind', async () => { + const traces = createTraces() + traces.startSpan('a').end() + traces.startSpan('b', { kind: 'server' }).end() + await traces.flush() + + expect(sentSpans().map((s) => s.kind)).toEqual([1, 2]) + }) + + it('returns an inert handle when the SDK is disabled', async () => { + const traces = createTraces({}, createMockInstance({ isDisabled: true })) + const span = traces.startSpan('checkout') + span.end() + + expect(span).toBe(NOOP_SPAN) + expect(span.traceparent()).toBeNull() + }) + + it('returns an inert handle when the user has opted out', () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + expect(traces.startSpan('checkout')).toBe(NOOP_SPAN) + }) + + it('makes a child of a no-op handle a no-op rather than an orphan', () => { + const traces = createTraces() + expect(traces.startSpan('child', { parent: NOOP_SPAN })).toBe(NOOP_SPAN) + }) + }) + + describe('startTime', () => { + it('backdates the span to a supplied start', async () => { + const traces = createTraces() + const start = Date.now() - 60_000 + traces.startSpan('backdated', { startTime: start }).end() + await traces.flush() + + expect(sentSpans()[0].startTimeUnixNano).toBe(`${start}000000`) + }) + + it('accepts a Date', async () => { + const traces = createTraces() + const start = new Date(Date.now() - 5_000) + traces.startSpan('backdated', { startTime: start }).end() + await traces.flush() + + expect(sentSpans()[0].startTimeUnixNano).toBe(`${start.getTime()}000000`) + }) + + it('falls back to now for an unusable start, keeping the record well formed', async () => { + const traces = createTraces() + traces.startSpan('bad', { startTime: Number.NaN }).end() + await traces.flush() + + const [span] = sentSpans() + expect(span.startTimeUnixNano).toMatch(/^\d+$/) + expect(Number(span.endTimeUnixNano)).toBeGreaterThanOrEqual(Number(span.startTimeUnixNano)) + }) + + it('warns when a start is old enough for the server to clamp it', async () => { + const traces = createTraces() + traces.startSpan('stale', { startTime: Date.now() - 48 * 60 * 60 * 1000 }).end() + await traces.flush() + + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('24 hours')) + expect(sentSpans()).toHaveLength(1) + }) + + it('warns when a start is in the future, which costs the span its duration', async () => { + const traces = createTraces() + traces.startSpan('ahead', { startTime: Date.now() + 60 * 60 * 1000 }).end() + await traces.flush() + + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('in the future')) + const [span] = sentSpans() + expect(span.endTimeUnixNano).toBe(span.startTimeUnixNano) + }) + }) + + describe('trace continuation', () => { + it('continues a remote trace from a traceparent string', async () => { + const traces = createTraces() + traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` }).end() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).toBe(TRACE_ID) + expect(span.parentSpanId).toBe(REMOTE_SPAN_ID) + }) + + it('continues a trace the caller sampled out', async () => { + const traces = createTraces() + traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end() + await traces.flush() + + expect(sentSpans()[0].traceId).toBe(TRACE_ID) + }) + + it('propagates the sampled-out flag onward rather than upgrading it to 01', async () => { + // A downstream parent-based sampler would otherwise record a trace its own + // head sampler had already rejected. + const traces = createTraces() + const span = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }) + const child = traces.startSpan('inner', { parent: span }) + + expect(span.traceparent()!.startsWith(`00-${TRACE_ID}-`)).toBe(true) + expect(span.traceparent()!.endsWith('-00')).toBe(true) + // The whole chain agrees, not just the span that read the header. + expect(child.traceparent()!.endsWith('-00')).toBe(true) + + child.end() + span.end() + await traces.flush() + + // Recorded and exported all the same, with the wire agreeing with the header. + const byName = Object.fromEntries(sentSpans().map((sent) => [sent.name, sent.flags])) + expect(byName).toEqual({ handler: 0x300, inner: 0x100 }) + }) + + it('marks a header parent remote and a handle parent local', async () => { + const traces = createTraces() + const remote = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` }) + const local = traces.startSpan('inner', { parent: remote }) + + local.end() + remote.end() + await traces.flush() + + const byName = Object.fromEntries(sentSpans().map((span) => [span.name, span.flags])) + expect(byName).toEqual({ handler: 0x301, inner: 0x101 }) + }) + + it('preserves tracestate opaquely and passes it to children', async () => { + const traces = createTraces() + const parent = traces.startSpan('handler', { + parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`, + tracestate: 'vendor=abc', + }) + const child = traces.startSpan('inner', { parent }) + expect(parent.tracestate()).toBe('vendor=abc') + + child.end() + parent.end() + await traces.flush() + + expect(sentSpans().map((s) => s.traceState)).toEqual(['vendor=abc', 'vendor=abc']) + }) + + it('starts a fresh root on a malformed traceparent without throwing', async () => { + const traces = createTraces() + expect(() => traces.startSpan('handler', { parent: 'garbage' }).end()).not.toThrow() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).not.toBe(TRACE_ID) + expect(span.parentSpanId).toBeUndefined() + }) + + it('continues the trace when the header arrives as a one-element array', async () => { + // What `headersDistinct.traceparent` hands over for a single inbound header. + const traces = createTraces() + traces.startSpan('handler', { parent: [`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`] as unknown as string }).end() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).toBe(TRACE_ID) + expect(span.parentSpanId).toBe(REMOTE_SPAN_ID) + }) + + it('starts a fresh root when the parent is not a span, as two inbound headers are', async () => { + const traces = createTraces() + traces.startSpan('handler', { parent: DUPLICATED_HEADERS as unknown as string }).end() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).not.toBe(TRACE_ID) + expect(span.parentSpanId).toBeUndefined() + }) + + it('ignores a span from another tracer, which reports through spanContext', async () => { + // OTel's shape: no `traceparent()` to read, so it parents to the active + // span rather than continuing a trace the SDK cannot read the ids of. + const otelSpan = { + spanContext: () => ({ traceId: TRACE_ID, spanId: REMOTE_SPAN_ID, traceFlags: 1 }), + } + + const traces = createTraces() + traces.withSpan('handler', () => { + traces.startSpan('child', { parent: otelSpan as unknown as Span }).end() + }) + await traces.flush() + + const child = sentSpans().find((s) => s.name === 'child')! + const handler = sentSpans().find((s) => s.name === 'handler')! + expect(child.traceId).toBe(handler.traceId) + expect(child.traceId).not.toBe(TRACE_ID) + expect(child.parentSpanId).toBe(handler.spanId) + }) + + it('parents to the active span when the parent is not a span', async () => { + const traces = createTraces() + traces.withSpan('handler', () => { + traces.startSpan('child', { parent: DUPLICATED_HEADERS as unknown as string }).end() + }) + await traces.flush() + + const child = sentSpans().find((s) => s.name === 'child')! + const handler = sentSpans().find((s) => s.name === 'handler')! + expect(child.traceId).toBe(handler.traceId) + expect(child.parentSpanId).toBe(handler.spanId) + }) + }) + + describe('withSpan', () => { + it('ends the span and returns the callback result', async () => { + const traces = createTraces() + const result = traces.withSpan('job', () => 'value') + await traces.flush() + + expect(result).toBe('value') + expect(sentSpans()).toHaveLength(1) + expect(sentSpans()[0].status).toBeUndefined() + }) + + it('accepts options before the callback', async () => { + const traces = createTraces() + traces.withSpan('job', { kind: 'server', attributes: { plan: 'pro' } }, () => undefined) + await traces.flush() + + const [span] = sentSpans() + expect(span.kind).toBe(2) + expect(span.attributes).toContainEqual({ key: 'plan', value: { stringValue: 'pro' } }) + }) + + it('runs the callback when an attribute getter throws', async () => { + const traces = createTraces() + const attributes: any = { ok: 1 } + Object.defineProperty(attributes, 'boom', { + enumerable: true, + get() { + throw new Error('getter exploded') + }, + }) + + expect(traces.withSpan('job', { attributes }, () => 'value')).toBe('value') + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['job']) + }) + + it('makes the span active for the callback', () => { + const traces = createTraces() + traces.withSpan('outer', (span) => { + expect(traces.getActiveSpan()).toBe(span) + }) + expect(traces.getActiveSpan()).toBeNull() + }) + + it('nests spans started inside the callback', async () => { + const traces = createTraces() + traces.withSpan('outer', () => { + traces.withSpan('inner', () => undefined) + }) + await traces.flush() + + const inner = sentSpans().find((s) => s.name === 'inner')! + const outer = sentSpans().find((s) => s.name === 'outer')! + expect(inner.traceId).toBe(outer.traceId) + expect(inner.parentSpanId).toBe(outer.spanId) + }) + + it('lets an explicit parent override the active span', async () => { + const traces = createTraces() + const detached = traces.startSpan('detached') + traces.withSpan('outer', () => { + traces.withSpan('inner', { parent: detached }, () => undefined) + }) + detached.end() + await traces.flush() + + const inner = sentSpans().find((s) => s.name === 'inner')! + const detachedSpan = sentSpans().find((s) => s.name === 'detached')! + expect(inner.parentSpanId).toBe(detachedSpan.spanId) + }) + + it('records a thrown error and rethrows it unmodified', async () => { + const traces = createTraces() + const thrown = new TypeError('boom') + + expect(() => + traces.withSpan('job', () => { + throw thrown + }) + ).toThrow(thrown) + + await traces.flush() + const [span] = sentSpans() + expect(span.status).toEqual({ code: 2, message: 'boom' }) + expect(span.events?.[0]).toMatchObject({ + name: 'exception', + attributes: [ + { key: 'exception.type', value: { stringValue: 'TypeError' } }, + { key: 'exception.message', value: { stringValue: 'boom' } }, + { key: 'exception.stacktrace', value: { stringValue: expect.stringContaining('TypeError: boom') } }, + ], + }) + }) + + it('ends an async callback at settle, not when it returns its promise', async () => { + const traces = createTraces({ maxExportBatchSize: 1 }) + let finishWork!: () => void + const work = new Promise((resolve) => { + finishWork = resolve + }) + + const pending = traces.withSpan('job', () => work) + + await Promise.resolve() + expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled() + + finishWork() + await pending + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + }) + + it('covers the awaited duration', async () => { + const traces = createTraces() + const pending = traces.withSpan('job', async () => { + await new Promise((resolve) => setTimeout(resolve, 80)) + }) + await vi.advanceTimersByTimeAsync(80) + await pending + await traces.flush() + + const [span] = sentSpans() + expect(Number(span.endTimeUnixNano)).toBeGreaterThan(Number(span.startTimeUnixNano)) + }) + + it('records a rejection and rethrows it unmodified', async () => { + const traces = createTraces() + const thrown = new Error('async boom') + + await expect(traces.withSpan('job', async () => Promise.reject(thrown))).rejects.toBe(thrown) + + await traces.flush() + expect(sentSpans()[0].status).toEqual({ code: 2, message: 'async boom' }) + }) + + it('treats an explicit ok status as final when the callback throws', async () => { + const traces = createTraces() + expect(() => + traces.withSpan('job', (span) => { + span.setStatus('ok') + throw new Error('boom') + }) + ).toThrow('boom') + + await traces.flush() + const [span] = sentSpans() + expect(span.status).toEqual({ code: 1 }) + // The exception event is still attached — only the status is protected. + expect(span.events?.[0].name).toBe('exception') + }) + + it('runs the callback once with an inert handle when tracing cannot run', async () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + const fn = vi.fn(() => 'value') + + expect(traces.withSpan('job', fn)).toBe('value') + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(NOOP_SPAN) + expect(traces.getActiveSpan()).toBeNull() + await traces.flush() + expect(sentSpans()).toHaveLength(0) + }) + }) + + // A service in the middle of a traced chain must not sever it just because it + // has no tracing of its own — OTel requires the API to carry the parent + // context through when no SDK is recording. + describe('trace context pass-through when tracing is off', () => { + const INBOUND_UNSAMPLED = `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` + + it('echoes an inbound traceparent, flags included, when the SDK is disabled', async () => { + const instance = createMockInstance({ isDisabled: true }) + const traces = createTraces({}, instance) + + const span = traces.startSpan('proxied', { parent: INBOUND_UNSAMPLED, tracestate: 'vendor=abc' }) + span.end() + await traces.flush() + + expect(span.traceparent()).toBe(INBOUND_UNSAMPLED) + expect(span.tracestate()).toBe('vendor=abc') + expect(sentSpans(instance)).toHaveLength(0) + }) + + it('echoes an inbound traceparent when the user has opted out', () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + const span = traces.startSpan('proxied', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` }) + + expect(span.traceparent()).toBe(`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`) + }) + + it('activates the pass-through handle so getActiveSpan can propagate it', () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + + const propagated = traces.withSpan('proxied', { parent: INBOUND_UNSAMPLED }, () => + traces.getActiveSpan()?.traceparent() + ) + + expect(propagated).toBe(INBOUND_UNSAMPLED) + expect(traces.getActiveSpan()).toBeNull() + }) + + it('passes the inbound context through when the live-span limit refuses the span', () => { + const traces = createTraces({ maxLiveSpans: 1 }) + traces.startSpan('holds-the-only-slot') + + const refused = traces.startSpan('refused', { parent: INBOUND_UNSAMPLED }) + + expect(refused.traceparent()).toBe(INBOUND_UNSAMPLED) + }) + + it('has nothing to propagate without a usable parent', () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + + expect(traces.startSpan('no-parent')).toBe(NOOP_SPAN) + expect(traces.startSpan('bad-parent', { parent: 'not-a-traceparent' })).toBe(NOOP_SPAN) + }) + + it('keeps the inbound context in a nested span that names no parent', () => { + const traces = createTraces({}, createMockInstance({ optedOut: true })) + + const propagated = traces.withSpan('outer', { parent: INBOUND_UNSAMPLED }, () => + traces.withSpan('inner', (span) => span.traceparent()) + ) + + expect(propagated).toBe(INBOUND_UNSAMPLED) + }) + + it('parents a recorded span to an active pass-through handle', async () => { + // Tracing is on, but the span that received the header was inert, so its + // child is the first recorded span of the inbound trace. + const foreign = { traceparent: () => INBOUND_UNSAMPLED, tracestate: () => 'vendor=abc' } + const traces = createTraces() + + traces.withSpan('proxied', { parent: foreign as unknown as Span }, () => { + traces.startSpan('child').end() + }) + await traces.flush() + + const child = sentSpans().find((span) => span.name === 'child')! + expect(child.traceId).toBe(TRACE_ID) + expect(child.parentSpanId).toBe(REMOTE_SPAN_ID) + expect(child.traceState).toBe('vendor=abc') + }) + + it('keeps the inbound context when a pass-through handle is used as a parent', () => { + // The child is inert either way; what it must not do is drop the context + // and leave everything downstream of it on a fresh trace. + const traces = createTraces({}, createMockInstance({ optedOut: true })) + const parent = traces.startSpan('proxied', { parent: INBOUND_UNSAMPLED, tracestate: 'vendor=abc' }) + + const child = traces.startSpan('child', { parent }) + + expect(child.traceparent()).toBe(INBOUND_UNSAMPLED) + expect(child.tracestate()).toBe('vendor=abc') + expect(sentSpans()).toHaveLength(0) + }) + + it('records nothing for a child of a pass-through once tracing is back on', async () => { + // Spec: a child of an inert handle is inert, so this must propagate without + // enqueueing a span, even though this instance is recording. + const traces = createTraces() + const parent = inertSpan({ parent: INBOUND_UNSAMPLED }) + + const child = traces.startSpan('child', { parent }) + child.end() + await traces.flush() + + expect(child.traceparent()).toBe(INBOUND_UNSAMPLED) + expect(sentSpans()).toHaveLength(0) + }) + + it('yields a no-op for a child of a no-op', () => { + const traces = createTraces() + expect(traces.startSpan('child', { parent: NOOP_SPAN })).toBe(NOOP_SPAN) + }) + + it('survives a parent whose traceparent throws', () => { + const traces = createTraces() + const hostile = { + traceparent: () => { + throw new Error('nope') + }, + } + + expect(traces.startSpan('child', { parent: hostile as never })).toBe(NOOP_SPAN) + }) + }) + + describe('auto-context', () => { + it('attaches the distinct id and session id as the product join keys', async () => { + context = { distinctId: 'user-123', sessionId: 'session-123' } + const traces = createTraces() + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].attributes).toEqual( + expect.arrayContaining([ + { key: 'posthogDistinctId', value: { stringValue: 'user-123' } }, + { key: 'sessionId', value: { stringValue: 'session-123' } }, + ]) + ) + }) + + it('omits keys with no value', async () => { + context = { distinctId: 'user-123' } + const traces = createTraces() + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].attributes?.map((a) => a.key)).toEqual(['posthogDistinctId']) + }) + + it('freezes the snapshot at span start', async () => { + context = { distinctId: 'a' } + const traces = createTraces() + const span = traces.startSpan('checkout') + context = { distinctId: 'b' } + span.end() + await traces.flush() + + expect(sentSpans()[0].attributes).toContainEqual({ + key: 'posthogDistinctId', + value: { stringValue: 'a' }, + }) + }) + + it('lets user attributes win on collision', async () => { + context = { distinctId: 'a' } + const traces = createTraces() + traces.startSpan('checkout', { attributes: { posthogDistinctId: 'override' } }).end() + await traces.flush() + + expect(sentSpans()[0].attributes).toContainEqual({ + key: 'posthogDistinctId', + value: { stringValue: 'override' }, + }) + }) + + it('maps the client-platform navigation keys', async () => { + context = { currentUrl: 'https://example.com/cart', screenName: 'Cart', appState: 'foreground' } + const traces = createTraces() + traces.startSpan('checkout').end() + await traces.flush() + + const attributes = sentSpans()[0].attributes ?? [] + expect(attributes).toEqual( + expect.arrayContaining([ + { key: 'url.full', value: { stringValue: 'https://example.com/cart' } }, + { key: 'screen.name', value: { stringValue: 'Cart' } }, + { key: 'app.state', value: { stringValue: 'foreground' } }, + ]) + ) + }) + + it('still records the span when reading context throws', async () => { + const traces = new PostHogTraces( + mockInstance, + resolveForTest(), + logger, + () => { + throw new Error('no context') + }, + new SyncSpanContextManager() + ) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + }) + }) + + describe('gating', () => { + it('drops a span whose user opted out mid-trace, without throwing', async () => { + const instance = createMockInstance() + const traces = createTraces({}, instance) + const span = traces.startSpan('checkout') + + instance.optedOut = true + expect(() => span.end()).not.toThrow() + + await traces.flush() + expect(instance._sendTracesBatch).not.toHaveBeenCalled() + }) + + it('counts a span dropped at the end-time gate', async () => { + const instance = createMockInstance() + const traces = createTraces({}, instance) + const span = traces.startSpan('checkout') + + instance.optedOut = true + span.end() + await traces.flush() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out')) + }) + }) + + describe('reset', () => { + it('says so when it discards queued spans', async () => { + // Terminal loss: there is no next flush to retry on, and the export + // failure the caller already saw promises one. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') })), + }) + const traces = createTraces({}, instance) + traces.startSpan('a').end() + traces.startSpan('b').end() + await traces.flush() + + traces.reset() + + expect(logger.critical).toHaveBeenCalledWith(expect.stringContaining('Discarding 2 span(s)')) + }) + + it('stays quiet when nothing was queued', () => { + const traces = createTraces() + + traces.reset() + + expect(logger.critical).not.toHaveBeenCalled() + }) + }) + + describe('flush reentrancy', () => { + it('does not re-send the head batch when a span ends during the flush prefix', async () => { + // `_flushInner` runs synchronously as far as its first await, and it reads + // the resource attributes in that window. A getter there that ends a span + // used to re-enter the flush with no pass yet recorded, and the same head + // batch went out again on every pass — thousands of times, unbounded. + const resourceAttributes: Record = {} + Object.defineProperty(resourceAttributes, 'tenant', { + enumerable: true, + // Reads `traces` only when a flush runs, which is after it is assigned. + get: () => { + traces.startSpan('late').end() + return 'acme' + }, + }) + const instance = createMockInstance() + const traces = createTraces({ maxExportBatchSize: 2, resourceAttributes }, instance) + + traces.startSpan('a').end() + traces.startSpan('b').end() + await traces.flush() + await traces.flush() + + expect(sentPayloads(instance)).toHaveLength(2) + expect(sentSpans(instance).map((s) => s.name)).toEqual(['a', 'b', 'late']) + }) + }) + + describe('beforeSpanSend', () => { + const endOneSpan = (beforeSpanSend: any): PostHogTraces => { + const traces = createTraces({ beforeSpanSend: [beforeSpanSend].flat() }) + traces.startSpan('checkout', { attributes: { userId: 42 } }).end() + return traces + } + + it('drops a span when the hook returns null', async () => { + await endOneSpan(() => null).flush() + expect(sentSpans()).toHaveLength(0) + }) + + it('drops the span when the hook throws', async () => { + await endOneSpan(() => { + throw new Error('scrubber broke') + }).flush() + + expect(sentSpans()).toHaveLength(0) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend failed'), expect.anything()) + }) + + it('counts a span the hook dropped', async () => { + await endOneSpan(() => null).flush() + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend dropped it')) + }) + + it('counts a span dropped because the hook threw', async () => { + // A permanently broken scrubber otherwise drops every span with the drop + // counter reading zero. + await endOneSpan(() => { + throw new Error('scrubber broke') + }).flush() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend failed')) + }) + + it('hands the hook plain values, not the OTLP encoding', () => { + const seen: unknown[] = [] + endOneSpan((span: SpanRecord) => { + seen.push(span.attributes.userId) + return span + }) + + expect(seen).toEqual([42]) + }) + + it('keeps the original ids when a hook rewrites them', async () => { + const traces = createTraces({ + beforeSpanSend: [ + (span: any) => { + span.traceId = '0'.repeat(32) + span.spanId = '1'.repeat(16) + return span + }, + ], + }) + const started = traces.startSpan('checkout') + const originalTraceId = started.traceparent()!.split('-')[1] + started.end() + await traces.flush() + + const [span] = sentSpans() + expect(span.traceId).toBe(originalTraceId) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('identity field')) + }) + + it('exports a span whose record the hook froze', async () => { + // A defensive hook may freeze what it returns. Assigning to a frozen + // property throws even when the value is the one already there, so the + // post-hook pass works on a copy — otherwise every span the hook saw is + // dropped by the fail-closed branch, with only a debug line to say so. + const traces = createTraces({ + beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span, attributes: { route: '/checkout' } })], + }) + const span = traces.startSpan('checkout') + + expect(() => span.end()).not.toThrow() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route']) + }) + + it('exports a span whose attributes the hook froze', async () => { + const traces = createTraces({ + maxAttributesPerSpan: 1, + beforeSpanSend: [ + (span: SpanRecord) => ({ ...span, attributes: Object.freeze({ route: '/checkout', extra: 1 }) as never }), + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route']) + expect(sent.droppedAttributesCount).toBe(1) + }) + + it('reports every limit drop once per span, hook drops included', () => { + const traces = createTraces({ + maxAttributesPerSpan: 1, + maxEventsPerSpan: 1, + beforeSpanSend: [ + (span: SpanRecord) => ({ ...span, attributes: { ...span.attributes, added: 1, alsoAdded: 2 } }), + ], + }) + + const span = traces.startSpan('checkout', { attributes: { route: '/checkout' } }) + span.addEvent('first', { a: 1 }) + span.addEvent('second') + span.end() + + const messages = logger.debug.mock.calls.map(([message]) => String(message)) + expect(messages.filter((message) => message.includes('Span limits discarded'))).toEqual([ + 'Span limits discarded data from "checkout": 2 attributes, 1 events, 0 event attributes', + ]) + }) + + it('stays quiet for a span that lost nothing', () => { + const traces = createTraces() + traces.startSpan('checkout', { attributes: { route: '/checkout' } }).end() + + const messages = logger.debug.mock.calls.map(([message]) => String(message)) + expect(messages.some((message) => message.includes('Span limits discarded'))).toBe(false) + }) + + it('rejects a timestamp the server could not decode', async () => { + const instance = createMockInstance() + const traces = createTraces( + { beforeSpanSend: [(span: SpanRecord) => ({ ...span, startTime: span.startTime * 1e6 })] }, + instance + ) + traces.startSpan('poison').end() + await traces.flush() + + const [span] = sentSpans(instance) + expect(span.startTimeUnixNano.length).toBeLessThanOrEqual(19) + }) + + it('keeps tracestate a rebuilding hook would have dropped', async () => { + const instance = createMockInstance() + const traces = createTraces( + { beforeSpanSend: [(span: SpanRecord) => ({ ...span, traceState: undefined }) as SpanRecord] }, + instance + ) + traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`, tracestate: 'vendor=abc' }).end() + await traces.flush() + + expect(sentSpans(instance)[0].traceState).toBe('vendor=abc') + }) + + it('keeps the trace flags and parent remoteness a rebuilding hook would have dropped', async () => { + const instance = createMockInstance() + const traces = createTraces({ beforeSpanSend: [(span: SpanRecord) => ({ ...span }) as SpanRecord] }, instance) + traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end() + await traces.flush() + + // Sampled-out inbound flag, plus both remoteness bits for a header parent. + expect(sentSpans(instance)[0].flags).toBe(0x300) + }) + + it('keeps them when the hook builds its record from the fields it can see', async () => { + // Spreading carries the propagation fields through even though no public + // type declares them; naming the public fields is what drops them. + const instance = createMockInstance() + const traces = createTraces( + { + beforeSpanSend: [ + (span: SpanRecord) => + ({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentSpanId, + name: span.name, + kind: span.kind, + status: span.status, + attributes: span.attributes, + events: span.events, + startTime: span.startTime, + endTime: span.endTime, + }) as SpanRecord, + ], + }, + instance + ) + traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end() + await traces.flush() + + expect(sentSpans(instance)[0].flags).toBe(0x300) + }) + + it('keeps them when the rebuilding hook also freezes what it returns', async () => { + // Restoring these onto the returned record would throw here, and a throwing + // hook drops the span. + const instance = createMockInstance() + const traces = createTraces( + { beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span }) as SpanRecord] }, + instance + ) + traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end() + await traces.flush() + + expect(sentSpans(instance).map((s) => s.flags)).toEqual([0x300]) + }) + + it('does not resurrect a prototype-named attribute the hook removed', async () => { + // `key in attributes` walks the prototype chain, so a deleted `constructor` + // read back as the inherited function and shipped as [Function]. + const instance = createMockInstance() + const traces = createTraces( + { + beforeSpanSend: [ + (span: SpanRecord) => { + delete (span.attributes as Record).constructor + return span + }, + ], + }, + instance + ) + const span = traces.startSpan('ghost') + span.setAttribute('constructor', 'user-value') + span.setAttribute('safe', 'ok') + span.end() + await traces.flush() + + expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['safe']) + }) + + it('does not let prototype-named ghosts evict what the hook kept', async () => { + // Worse than resurrection: at the cap the ghosts won the slots and the + // attribute the hook deliberately kept was the one dropped. + const instance = createMockInstance() + const traces = createTraces( + { + maxAttributesPerSpan: 2, + beforeSpanSend: [(span: SpanRecord) => ({ ...span, attributes: { onlyThis: 'yes' } }) as SpanRecord], + }, + instance + ) + const span = traces.startSpan('ghosts') + span.setAttribute('toString', 1) + span.setAttribute('valueOf', 2) + span.end() + await traces.flush() + + expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['onlyThis']) + }) + + it('keeps the earliest-set attributes when the hook adds an integer-like key', async () => { + // Object.keys hoists integer-like keys whatever the write order, so a key + // the hook added last outranked one the caller set before it ran. + const instance = createMockInstance() + const traces = createTraces( + { + maxAttributesPerSpan: 3, + beforeSpanSend: [ + (span: SpanRecord) => { + span.attributes['0'] = 'added-last' + return span + }, + ], + }, + instance + ) + const span = traces.startSpan('ordered') + span.setAttribute('alpha', 1) + span.setAttribute('beta', 2) + span.setAttribute('gamma', 3) + span.end() + await traces.flush() + + expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('ignores a forged identity from a frozen hook rather than dropping the span', async () => { + // Writing the id back onto a frozen return throws, and a throwing hook + // drops the span, so forging plus freezing used to lose every span. + const instance = createMockInstance() + const traces = createTraces( + { beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span, traceId: '0'.repeat(32) }) as SpanRecord] }, + instance + ) + traces.startSpan('forged').end() + await traces.flush() + + expect(sentSpans(instance)).toHaveLength(1) + expect(sentSpans(instance)[0].traceId).not.toBe('0'.repeat(32)) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('keeping the original ids')) + }) + + it('exports a child span whose record a frozen hook rebuilt without the parent id', async () => { + // The shape that loses children but keeps roots: a rebuilt record has no + // parentSpanId to match, so restoring it wrote to a frozen object. + const instance = createMockInstance() + const traces = createTraces( + { + beforeSpanSend: [ + (span: SpanRecord) => + Object.freeze({ + traceId: span.traceId, + spanId: span.spanId, + name: span.name, + kind: span.kind, + attributes: span.attributes, + events: span.events, + startTime: span.startTime, + endTime: span.endTime, + }) as SpanRecord, + ], + }, + instance + ) + const root = traces.startSpan('root') + traces.startSpan('child', { parent: root }).end() + root.end() + await traces.flush() + + expect( + sentSpans(instance) + .map((s) => s.name) + .sort() + ).toEqual(['child', 'root']) + }) + + it('runs hooks left to right and stops at the first null', async () => { + const order: string[] = [] + await endOneSpan([ + (span: SpanRecord) => { + order.push('first') + return span + }, + () => { + order.push('second') + return null + }, + (span: SpanRecord) => { + order.push('third') + return span + }, + ]).flush() + + expect(order).toEqual(['first', 'second']) + expect(sentSpans()).toHaveLength(0) + }) + + it('exports the edits a hook made', async () => { + await endOneSpan((span: SpanRecord) => { + delete span.attributes.userId + span.name = 'redacted' + return span + }).flush() + + const [span] = sentSpans() + expect(span.name).toBe('redacted') + expect(span.attributes?.find((attribute) => attribute.key === 'userId')).toBeUndefined() + }) + }) + + describe('beforeSpanSend validity', () => { + it('sanitises an event the hook pushed without a timestamp', async () => { + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + span.events.push({ name: 'audited' } as never) + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + const [event] = sentSpans()[0].events! + expect(event.name).toBe('audited') + expect(event.timeUnixNano).toMatch(/^\d+$/) + }) + + it('clamps an out-of-range timestamp on a hook-supplied event', async () => { + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + span.events.push({ name: 'audited', timestamp: -1 } as never) + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].events![0].timeUnixNano).toMatch(/^\d+$/) + }) + + it('bounds a status message the hook rewrote', async () => { + const traces = createTraces({ + maxAttributeValueLength: 4, + beforeSpanSend: [ + (span) => { + span.status = { code: 'error', message: 'abcdefgh' } + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].status).toEqual({ code: 2, message: 'abcd' }) + }) + + it('keeps the original status when the hook writes an unknown code', async () => { + // An unknown code maps to nothing and encodes as an empty status object, + // which silently loses an error the span really had. + const traces = createTraces({ + beforeSpanSend: [(span) => ({ ...span, status: { code: 'ERROR' as never, message: 'boom' } })], + }) + const span = traces.startSpan('checkout') + span.setStatus('error', 'boom') + span.end() + await traces.flush() + + expect(sentSpans()[0].status).toEqual({ code: 2, message: 'boom' }) + }) + + it('ignores a dropped count the hook invented', async () => { + const traces = createTraces({ + beforeSpanSend: [(span) => ({ ...span, droppedAttributesCount: 'lots' as never })], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].droppedAttributesCount).toBeUndefined() + }) + + it('lets a hook scrub the auto-context keys', async () => { + // The exemption is from the count cap only. A hook is the documented + // scrubbing point, so it has to be able to remove the join keys as well. + context = { distinctId: 'user-1', sessionId: 'session-1' } + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + delete span.attributes.posthogDistinctId + delete span.attributes.sessionId + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].attributes).toBeUndefined() + }) + + it('keeps the error status when the event cap costs the exception event', async () => { + // The status is set independently of the event, so a span whose exception + // event did not fit still exports as failed and still counts the loss. + // That pair is what makes the case findable once traces is live. + const traces = createTraces({ + maxEventsPerSpan: 1, + beforeSpanSend: [ + (span) => { + span.events.push({ name: 'audited', timestamp: Date.now() }) + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('step') + span.recordException(new Error('boom')) + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.events!.map((event) => event.name)).toEqual(['step']) + expect(sent.droppedEventsCount).toBe(2) + expect(sent.status).toEqual({ code: 2, message: 'boom' }) + }) + + it('keeps the original status when the hook mutates the code in place', async () => { + // The hook is documented as editing the record in place, so snapshotting a + // reference to `status` would restore the mutation onto itself. + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + ;(span.status as { code: string }).code = 'ERROR' + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.setStatus('error', 'boom') + span.end() + await traces.flush() + + expect(sentSpans()[0].status).toEqual({ code: 2, message: 'boom' }) + }) + + it('exports a span whose record is a class instance with prototype getters', async () => { + // A spread copies own properties only, so `events` behind a prototype + // getter arrived undefined and the fail-closed branch ate every span. + class Wrapped { + constructor(private readonly _inner: SpanRecord) {} + get traceId(): string { + return this._inner.traceId + } + get spanId(): string { + return this._inner.spanId + } + get name(): string { + return this._inner.name + } + get kind(): SpanRecord['kind'] { + return this._inner.kind + } + get attributes(): SpanRecord['attributes'] { + return this._inner.attributes + } + get events(): SpanRecord['events'] { + return this._inner.events + } + get startTime(): number { + return this._inner.startTime + } + get endTime(): number { + return this._inner.endTime + } + } + const traces = createTraces({ + beforeSpanSend: [(span: SpanRecord) => new Wrapped(span) as unknown as SpanRecord], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans().map((span) => span.name)).toEqual(['checkout']) + }) + + it('survives a hook that leaves a hole in the events array', async () => { + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + span.events.length = 2 + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('step') + span.end() + await traces.flush() + + expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step']) + }) + + it('keeps the span-side dropped count when the hook overwrites the counter', async () => { + const traces = createTraces({ + maxEventsPerSpan: 1, + beforeSpanSend: [ + (span) => { + ;(span as unknown as { droppedEventsCount: unknown }).droppedEventsCount = 'lots' + span.events.push({ name: 'audited', timestamp: Date.now() }) + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('step-0') + span.addEvent('step-1') + span.end() + await traces.flush() + + // One dropped at the span, one by the post-hook re-apply. + expect(sentSpans()[0].droppedEventsCount).toBe(2) + }) + + it('drops only the event the hook made unreadable', async () => { + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + span.events = [span.events[0], null as never, span.events[1]] + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('step-0') + span.addEvent('step-1') + span.end() + await traces.flush() + + expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step-0', 'step-1']) + }) + + it.each([ + ['attributes replaced with null', (span: SpanRecord) => ({ ...span, attributes: null as never })], + ['attributes replaced with an array', (span: SpanRecord) => ({ ...span, attributes: ['a'] as never })], + ['events replaced with null', (span: SpanRecord) => ({ ...span, events: null as never })], + ['an async hook returning a promise', (span: SpanRecord) => Promise.resolve(span) as never], + // Carrying both collections was the whole shape check, so these reached + // the wire as a span named `unknown` at a fallback time with no join keys. + ['only the two collections', () => ({ attributes: {}, events: [] }) as never], + ['no name', (span: SpanRecord) => ({ ...span, name: undefined as never })], + ['no kind', (span: SpanRecord) => ({ ...span, kind: undefined as never })], + ['no start time', (span: SpanRecord) => ({ ...span, startTime: undefined as never })], + ['no end time', (span: SpanRecord) => ({ ...span, endTime: undefined as never })], + ])('drops the span when the hook returns %s', async (_label, beforeSpanSend) => { + // Repairing these would export a nameless span carrying no join keys. + const traces = createTraces({ beforeSpanSend: [beforeSpanSend] }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()).toHaveLength(0) + }) + + it('counts an incomplete hook result as a drop rather than losing it silently', async () => { + const traces = createTraces({ beforeSpanSend: [() => ({ attributes: {}, events: [] }) as never] }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend returned an unusable record')) + }) + + it('still exports a span whose required fields the hook left in place', async () => { + // The shape check reads presence, so an ordinary scrub is untouched by it. + const traces = createTraces({ + beforeSpanSend: [ + (span) => { + delete span.attributes.secret + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { secret: 'shh', keep: 1 } }).end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + expect(sentSpans()[0].name).toBe('checkout') + expect(sentSpans()[0].attributes!.map((a) => a.key)).toContain('keep') + expect(sentSpans()[0].attributes!.map((a) => a.key)).not.toContain('secret') + }) + + it('gives a later hook the real identity after an earlier one froze a forged record', async () => { + // The export reads the snapshot either way, but a hook that samples or + // routes on an id must not see one an earlier hook invented. + const seen: { traceId: string; spanId: string }[] = [] + const traces = createTraces({ + beforeSpanSend: [ + (span) => Object.freeze({ ...span, traceId: '0'.repeat(32), spanId: 'f'.repeat(16) }), + (span) => { + seen.push({ traceId: span.traceId, spanId: span.spanId }) + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + expect(seen[0].traceId).toBe(sentSpans()[0].traceId) + expect(seen[0].spanId).toBe(sentSpans()[0].spanId) + expect(seen[0].traceId).not.toBe('0'.repeat(32)) + }) + + it('leaves the rest of a frozen forged record readable to the next hook', async () => { + // The corrected view is built from the record's own descriptors, so a hook + // reading anything but identity sees exactly what the previous one returned. + let seen: SpanRecord | undefined + const traces = createTraces({ + beforeSpanSend: [ + (span) => Object.freeze({ ...span, name: 'renamed', traceId: '0'.repeat(32) }), + (span) => { + seen = span + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { keep: 1 } }).end() + await traces.flush() + + expect(seen!.name).toBe('renamed') + expect(seen!.attributes.keep).toBe(1) + expect(Object.keys(seen!)).toContain('name') + expect(sentSpans()[0].name).toBe('renamed') + }) + + it('applies the event cap to what a hook leaves behind', async () => { + // A hook can append events or rewrite them, neither of which goes through + // `addEvent`, so the cap has to be re-applied to whatever it returns. + const traces = createTraces({ + maxEventsPerSpan: 1, + beforeSpanSend: [ + (span) => { + span.events.push({ name: 'exception', timestamp: Date.now() }) + span.events.push({ name: 'appended', timestamp: Date.now() }) + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('step-0') + span.end() + await traces.flush() + + expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step-0']) + }) + + it('exports the span when the hook status message refuses to stringify', async () => { + // The encoder downstream only marks the field, so coercing here must not + // be the thing that costs the span. + const traces = createTraces({ + maxAttributeValueLength: 4, + beforeSpanSend: [ + (span) => { + span.status = { + code: 'error', + message: { + toString() { + throw new Error('nope') + }, + } as never, + } + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + expect(sentSpans()[0].status?.code).toBe(2) + }) + + it('bounds a non-string status message the hook wrote', async () => { + const traces = createTraces({ + maxAttributeValueLength: 4, + beforeSpanSend: [ + (span) => { + span.status = { code: 'error', message: { toString: () => 'abcdefgh' } as never } + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans()[0].status).toEqual({ code: 2, message: 'abcd' }) + }) + + it('does not spend cap budget on a value the hook blanked', async () => { + const traces = createTraces({ + maxAttributesPerSpan: 2, + beforeSpanSend: [ + (span) => { + span.attributes.secret = null + span.attributes.scrubbed = true + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { secret: 'sk-live', route: '/checkout' } }).end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route', 'scrubbed']) + expect(sent.droppedAttributesCount).toBeUndefined() + }) + }) + + describe('span limits', () => { + it('keeps the earliest attributes and counts the rest', async () => { + const traces = createTraces({ maxAttributesPerSpan: 3 }) + const span = traces.startSpan('checkout') + for (let i = 0; i < 5; i++) { + span.setAttribute(`key-${i}`, i) + } + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['key-0', 'key-1', 'key-2']) + expect(sent.droppedAttributesCount).toBe(2) + }) + + it('re-applies the attribute cap to what beforeSpanSend added', async () => { + const traces = createTraces({ + maxAttributesPerSpan: 2, + beforeSpanSend: [ + (span) => { + for (let i = 0; i < 5; i++) { + span.attributes[`added-${i}`] = i + } + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { kept: true } }).end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept', 'added-0']) + expect(sent.droppedAttributesCount).toBe(4) + }) + + it('re-applies the event cap to what beforeSpanSend added', async () => { + const traces = createTraces({ + maxEventsPerSpan: 1, + beforeSpanSend: [ + (span) => { + span.events.push({ name: 'added', timestamp: span.startTime }) + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('original') + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.events!.map((event) => event.name)).toEqual(['original']) + expect(sent.droppedEventsCount).toBe(1) + }) + + it('re-applies the event attribute cap to what beforeSpanSend widened', async () => { + const traces = createTraces({ + maxAttributesPerEvent: 2, + beforeSpanSend: [ + (span) => { + span.events[0].attributes = { a: 1, b: 2, c: 3, d: 4 } + return span + }, + ], + }) + const span = traces.startSpan('checkout') + span.addEvent('query', { a: 1 }) + span.end() + await traces.flush() + + const event = sentSpans()[0].events![0] + expect(event.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b']) + expect(event.droppedAttributesCount).toBe(2) + }) + + it('keeps the auto-context keys when beforeSpanSend pushes past the cap', async () => { + context = { distinctId: 'alice', sessionId: 'session-1' } + const traces = createTraces({ + maxAttributesPerSpan: 1, + beforeSpanSend: [ + (span) => { + span.attributes.late = true + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { early: true } }).end() + await traces.flush() + + const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key) + expect(keys).toEqual(expect.arrayContaining(['posthogDistinctId', 'sessionId', 'early'])) + expect(keys).not.toContain('late') + }) + + it('re-applies the value bound to what beforeSpanSend wrote', async () => { + const traces = createTraces({ + maxAttributeValueLength: 8, + beforeSpanSend: [ + (span) => { + span.attributes.enriched = 'y'.repeat(5000) + span.events.push({ name: 'added', timestamp: span.startTime, attributes: { blob: 'z'.repeat(5000) } }) + return span + }, + ], + }) + traces.startSpan('checkout').end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.find((attribute) => attribute.key === 'enriched')!.value).toEqual({ + stringValue: 'yyyyyyyy', + }) + expect(sent.events!.at(-1)!.attributes!.find((attribute) => attribute.key === 'blob')!.value).toEqual({ + stringValue: 'zzzzzzzz', + }) + }) + + it('does not invent a dropped count when beforeSpanSend only removes', async () => { + const traces = createTraces({ + maxAttributesPerSpan: 5, + beforeSpanSend: [ + (span) => { + delete span.attributes.secret + return span + }, + ], + }) + traces.startSpan('checkout', { attributes: { secret: 'x', kept: true } }).end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept']) + expect(sent.droppedAttributesCount).toBeUndefined() + }) + + it('never evicts the auto-context keys', async () => { + context = { distinctId: 'alice', sessionId: 'session-1' } + const traces = createTraces({ maxAttributesPerSpan: 1 }) + const span = traces.startSpan('checkout') + for (let i = 0; i < 5; i++) { + span.setAttribute(`key-${i}`, i) + } + span.end() + await traces.flush() + + const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key) + expect(keys).toEqual(expect.arrayContaining(['posthogDistinctId', 'sessionId', 'key-0'])) + expect(keys).not.toContain('key-1') + }) + + it('caps events and counts the rest', async () => { + const traces = createTraces({ maxEventsPerSpan: 2 }) + const span = traces.startSpan('checkout') + for (let i = 0; i < 4; i++) { + span.addEvent(`event-${i}`) + } + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.events!.map((event) => event.name)).toEqual(['event-0', 'event-1']) + expect(sent.droppedEventsCount).toBe(2) + }) + + it('lets a caller overwrite an attribute it already set while at the cap', async () => { + const traces = createTraces({ maxAttributesPerSpan: 1 }) + const span = traces.startSpan('checkout') + span.setAttribute('plan', 'free') + span.setAttribute('plan', 'pro') + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes).toEqual([{ key: 'plan', value: { stringValue: 'pro' } }]) + expect(sent.droppedAttributesCount).toBeUndefined() + }) + + it('omits the counters when nothing was dropped', async () => { + const traces = createTraces() + traces.startSpan('checkout', { attributes: { plan: 'pro' } }).end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent).not.toHaveProperty('droppedAttributesCount') + expect(sent).not.toHaveProperty('droppedEventsCount') + }) + + it('counts a parsed __proto__ key against the cap instead of smuggling it through', async () => { + // JSON.parse produces an own `__proto__` key; a plain object store would + // swap its prototype and leak every nested key past the cap. + const traces = createTraces({ maxAttributesPerSpan: 2 }) + const parsed = JSON.parse('{"__proto__": {"leaked": 1}, "orderId": "abc"}') + traces.startSpan('checkout', { attributes: parsed }).end() + await traces.flush() + + const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key) + expect(keys).not.toContain('leaked') + expect(keys).toContain('orderId') + }) + + it('does not let reserved property names bypass the cap', async () => { + const traces = createTraces({ maxAttributesPerSpan: 1 }) + const span = traces.startSpan('checkout') + span.setAttribute('kept', 1) + span.setAttribute('toString', 'nope') + span.setAttribute('constructor', 'nope') + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept']) + expect(sent.droppedAttributesCount).toBe(2) + }) + + it('does not spend cap budget on values that are dropped at encode time', async () => { + const traces = createTraces({ maxAttributesPerSpan: 2 }) + const span = traces.startSpan('checkout') + span.setAttribute('skipped-a', undefined) + span.setAttribute('skipped-b', null) + span.setAttribute('orderId', 'abc-123') + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['orderId']) + expect(sent.droppedAttributesCount).toBeUndefined() + }) + + it('still caps a key first seen with an optional value', async () => { + const traces = createTraces({ maxAttributesPerSpan: 2 }) + const span = traces.startSpan('checkout') + for (let i = 0; i < 5; i++) { + span.setAttribute(`field-${i}`, undefined) + } + for (let i = 0; i < 5; i++) { + span.setAttribute(`field-${i}`, i) + } + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes).toHaveLength(2) + expect(sent.droppedAttributesCount).toBe(3) + }) + + it('clears a key that is set back to null', async () => { + const traces = createTraces({ maxAttributesPerSpan: 2 }) + const span = traces.startSpan('checkout') + span.setAttribute('orderId', 'abc-123') + span.setAttribute('orderId', null) + span.setAttribute('a', 1) + span.setAttribute('b', 2) + span.end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b']) + expect(sent.droppedAttributesCount).toBeUndefined() + }) + + it('caps attributes supplied at start', async () => { + const traces = createTraces({ maxAttributesPerSpan: 2 }) + traces.startSpan('checkout', { attributes: { a: 1, b: 2, c: 3 } }).end() + await traces.flush() + + const [sent] = sentSpans() + expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b']) + expect(sent.droppedAttributesCount).toBe(1) + }) + }) + + describe('export', () => { + it('flushes when the queue reaches the batch size', async () => { + const traces = createTraces({ maxExportBatchSize: 2 }) + traces.startSpan('a').end() + expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled() + + traces.startSpan('b').end() + await flushMicrotasks() + + expect(sentSpans()).toHaveLength(2) + }) + + it('does not re-post on every span end while a flush is failing', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 10 }) + for (let i = 0; i < 10; i++) { + traces.startSpan(`span-${i}`).end() + await flushMicrotasks() + } + + expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1) + }) + + it('flushes on the interval timer', async () => { + const traces = createTraces({ flushIntervalMs: 1000 }) + traces.startSpan('a').end() + expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1000) + expect(sentSpans()).toHaveLength(1) + }) + + it('sends one resource and one scope per batch', async () => { + const traces = createTraces({ serviceName: 'checkout-api' }) + traces.startSpan('a').end() + traces.startSpan('b').end() + await traces.flush() + + const [payload] = sentPayloads() + expect(payload.resourceSpans).toHaveLength(1) + expect(payload.resourceSpans[0].scopeSpans).toHaveLength(1) + expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2) + expect(payload.resourceSpans[0].resource.attributes).toContainEqual({ + key: 'service.name', + value: { stringValue: 'checkout-api' }, + }) + }) + + it('splits a backlog across batches', async () => { + const traces = createTraces({ maxExportBatchSize: 2 }) + for (let i = 0; i < 5; i++) { + traces.startSpan(`span-${i}`).end() + } + await traces.flush() + + expect(sentPayloads().length).toBeGreaterThanOrEqual(3) + expect(sentSpans()).toHaveLength(5) + }) + + it('joins an in-flight flush rather than double-sending', async () => { + const traces = createTraces() + traces.startSpan('a').end() + + const [first, second] = [traces.flush(), traces.flush()] + await Promise.all([first, second]) + + expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1) + expect(sentSpans()).toHaveLength(1) + }) + + it('drops the incoming span when the queue is full, keeping queued parents', async () => { + // Queued spans are completed parents whose children may already have been + // exported; evicting them would break assembled traces retroactively. + const traces = createTraces({ maxQueueSize: 2, maxExportBatchSize: 100 }) + traces.startSpan('first').end() + traces.startSpan('second').end() + traces.startSpan('third').end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['first', 'second']) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the queue is full')) + }) + }) + + describe('export failures', () => { + it('halves the batch and resends the same spans on 413', async () => { + const outcomes: SendTracesBatchOutcome[] = [{ kind: 'too-large' }, { kind: 'ok' }, { kind: 'ok' }] + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' })), + }) + const traces = createTraces({ maxExportBatchSize: 4 }, instance) + for (let i = 0; i < 4; i++) { + traces.startSpan(`span-${i}`).end() + } + await traces.flush() + + const batchSizes = sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length) + expect(batchSizes).toEqual([4, 2, 2]) + expect(sentSpans(instance)).toHaveLength(8) + }) + + it('shrinks below the queue depth on 413 rather than resending the same body', async () => { + // The batch the server rejected is what has to get smaller. Halving the + // configured maximum leaves `size` unchanged whenever the queue is + // shallower than it — the ordinary timer-flush case. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn().mockResolvedValueOnce({ kind: 'too-large' }).mockResolvedValue({ kind: 'ok' }), + }) + const traces = createTraces({ maxExportBatchSize: 512 }, instance) + for (let i = 0; i < 3; i++) { + traces.startSpan(`span-${i}`).end() + } + await traces.flush() + + const batchSizes = sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length) + expect(batchSizes).toEqual([3, 1, 2]) + }) + + it('ramps the batch size back up after a 413 shrink', async () => { + // A one-off oversized payload shouldn't permanently halve throughput. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn().mockResolvedValueOnce({ kind: 'too-large' }).mockResolvedValue({ kind: 'ok' }), + }) + const traces = createTraces({ maxExportBatchSize: 4 }, instance) + for (let i = 0; i < 4; i++) { + traces.startSpan(`span-${i}`).end() + } + await traces.flush() + + // Shrunk to 2, then +1 per healthy send across the two batches that drained it. + instance._sendTracesBatch.mockClear() + for (let i = 0; i < 4; i++) { + traces.startSpan(`later-${i}`).end() + } + await traces.flush() + + expect(sentPayloads(instance)[0].resourceSpans[0].scopeSpans[0].spans.length).toBeGreaterThan(2) + }) + + it('drops a single span the server rejects as too large', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'too-large' as const })), + }) + const traces = createTraces({ maxExportBatchSize: 1 }, instance) + traces.startSpan('huge').end() + await traces.flush() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('too large')) + + // The span must actually leave the queue, or it is re-POSTed on every + // flush for the life of the process. + instance._sendTracesBatch.mockResolvedValue({ kind: 'ok' }) + traces.startSpan('later').end() + await traces.flush() + expect(sentSpans(instance).map((s) => s.name)).toEqual(['huge', 'later']) + }) + + it('names the reason for each kind of drop', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'fatal' as const, error: new Error('400') })), + }) + const traces = createTraces({ maxExportBatchSize: 1 }, instance) + traces.startSpan('poison').end() + await traces.flush() + + // A poison batch is not a full queue; telling an operator to reduce span + // volume would send them after the wrong problem. + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('rejected the batch')) + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('queue is full')) + }) + + it('warns again about drops on a later flush', async () => { + // Warning once per process would leave the SDK silent about every + // subsequent drop for the life of the app. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'fatal' as const, error: new Error('400') })), + }) + const traces = createTraces({ maxExportBatchSize: 1 }, instance) + + traces.startSpan('a').end() + await traces.flush() + traces.startSpan('b').end() + await traces.flush() + + expect((logger.warn as vi.Mock).mock.calls.length).toBeGreaterThan(1) + }) + + it('keeps spans queued on a retriable failure', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi + .fn() + .mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') }) + .mockResolvedValue({ kind: 'ok' }), + }) + const traces = createTraces({}, instance) + traces.startSpan('a').end() + + await traces.flush() + expect(sentSpans(instance)).toHaveLength(1) + + await traces.flush() + expect(sentSpans(instance)).toHaveLength(2) + expect(sentSpans(instance)[1].name).toBe('a') + }) + + it('backs off exponentially while sends keep failing', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'retry-later' as const, error: new Error('network') })), + }) + const traces = createTraces({ flushIntervalMs: 5000 }, instance) + traces.startSpan('a').end() + + const sendsPerWindow: number[] = [] + for (let i = 0; i < 8; i++) { + await vi.advanceTimersByTimeAsync(5000) + sendsPerWindow.push(instance._sendTracesBatch.mock.calls.length) + } + + expect(sendsPerWindow).toEqual([1, 2, 2, 3, 3, 3, 3, 4]) + }) + + it('returns to the base interval after a send succeeds', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi + .fn() + .mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') }) + .mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') }) + .mockResolvedValue({ kind: 'ok' }), + }) + const traces = createTraces({ flushIntervalMs: 5000 }, instance) + traces.startSpan('a').end() + + await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(10000) + expect(instance._sendTracesBatch).toHaveBeenCalledTimes(3) + + traces.startSpan('b').end() + await vi.advanceTimersByTimeAsync(5000) + expect(instance._sendTracesBatch).toHaveBeenCalledTimes(4) + }) + + it('drops a poison batch rather than wedging the queue', async () => { + const instance = createMockInstance({ + _sendTracesBatch: vi + .fn() + .mockResolvedValueOnce({ kind: 'fatal', error: new Error('400') }) + .mockResolvedValue({ kind: 'ok' }), + }) + const traces = createTraces({ maxExportBatchSize: 1 }, instance) + traces.startSpan('poison').end() + traces.startSpan('good').end() + await traces.flush() + + expect(sentSpans(instance).map((s) => s.name)).toEqual(['poison', 'good']) + + instance._sendTracesBatch.mockClear() + await traces.flush() + expect(instance._sendTracesBatch).not.toHaveBeenCalled() + }) + + it('does not surface a transport failure through span.end()', async () => { + // Ending a span is application control flow — it must never throw because + // the exporter is broken. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.reject(new Error('transport exploded'))), + }) + const traces = createTraces({ maxExportBatchSize: 1 }, instance) + + expect(() => traces.startSpan('a').end()).not.toThrow() + // Let the background flush settle; the rejection is swallowed there. + await vi.advanceTimersByTimeAsync(0) + }) + + it('surfaces a transport failure through an explicit flush()', async () => { + // flush() is the caller asking to be told, so it propagates — matching + // how the logs and metrics pipelines behave. + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => Promise.reject(new Error('transport exploded'))), + }) + const traces = createTraces({ maxExportBatchSize: 100 }, instance) + traces.startSpan('a').end() + + await expect(traces.flush()).rejects.toThrow('transport exploded') + }) + }) + + describe('poison attributes', () => { + it('encodes a circular attribute instead of blowing the stack', async () => { + const traces = createTraces() + const cyclic: any = { name: 'order' } + cyclic.self = cyclic + + traces.startSpan('checkout', { attributes: { payload: cyclic } }).end() + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + expect(JSON.stringify(sentPayloads()[0])).toContain('[Circular]') + }) + + it('treats a repeated sibling reference as duplication, not a cycle', async () => { + const traces = createTraces() + const shared = { id: 1 } + + traces.startSpan('checkout', { attributes: { a: shared, b: shared } as any }).end() + await traces.flush() + + expect(JSON.stringify(sentPayloads()[0])).not.toContain('[Circular]') + }) + + it('keeps a span whose attribute getter throws, marking only that key', async () => { + // The shared encoder contains a throwing getter at the key it belongs to, + // so the span keeps its name, timing and every other attribute instead of + // being dropped whole. + const traces = createTraces({ maxExportBatchSize: 1 }) + const exploding = { + ok: 1, + get boom() { + throw new Error('getter exploded') + }, + } + + traces.startSpan('poison', { attributes: { payload: exploding as any } }).end() + traces.startSpan('healthy').end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['poison', 'healthy']) + expect(JSON.stringify(sentPayloads()[0])).toContain('[Unserializable]') + expect(JSON.stringify(sentPayloads()[0])).toContain('"intValue":"1"') + }) + + it('keeps a span whose top-level attribute getter throws, marking only that key', async () => { + const traces = createTraces({ maxExportBatchSize: 1 }) + const attributes: any = { ok: 1 } + Object.defineProperty(attributes, 'boom', { + enumerable: true, + get() { + throw new Error('getter exploded') + }, + }) + + expect(() => traces.startSpan('poison', { attributes }).end()).not.toThrow() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['poison']) + expect(JSON.stringify(sentPayloads()[0])).toContain('[Unserializable]') + expect(JSON.stringify(sentPayloads()[0])).toContain('"intValue":"1"') + }) + }) + + describe('consent withdrawn after a span is queued', () => { + it('does not export spans queued before optOut()', async () => { + const instance = createMockInstance() + const traces = createTraces({}, instance) + context = { distinctId: 'alice', sessionId: 'session-1' } + traces.startSpan('checkout').end() + + instance.optedOut = true + await traces.flush() + + expect(instance._sendTracesBatch).not.toHaveBeenCalled() + }) + + it('does not export spans queued before the client is disabled', async () => { + const instance = createMockInstance() + const traces = createTraces({}, instance) + traces.startSpan('checkout').end() + + instance.isDisabled = true + await traces.flush() + + expect(instance._sendTracesBatch).not.toHaveBeenCalled() + }) + + it('counts spans discarded from the queue when consent is withdrawn', async () => { + const instance = createMockInstance() + const traces = createTraces({}, instance) + traces.startSpan('a').end() + traces.startSpan('b').end() + + instance.optedOut = true + await traces.flush() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('2 span(s)')) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out')) + }) + + it('stops draining a backlog when optOut() lands while a batch is in flight', async () => { + const instance = createMockInstance() + instance._sendTracesBatch = vi.fn(() => + Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') }) + ) + const traces = createTraces({ maxExportBatchSize: 2 }, instance) + context = { distinctId: 'alice', sessionId: 'session-1' } + for (let i = 0; i < 6; i++) { + traces.startSpan(`span-${i}`).end() + await flushMicrotasks() + } + expect(instance._sendTracesBatch).toHaveBeenCalledTimes(1) + + instance._sendTracesBatch.mockImplementation(() => + Promise.resolve().then(() => { + instance.optedOut = true + return { kind: 'ok' as const } + }) + ) + await traces.flush() + + expect(instance._sendTracesBatch).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('4 span(s)')) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out')) + }) + + it('stops draining a backlog when the client is disabled while a batch is in flight', async () => { + const instance = createMockInstance() + instance._sendTracesBatch = vi.fn(() => + Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') }) + ) + const traces = createTraces({ maxExportBatchSize: 2 }, instance) + for (let i = 0; i < 6; i++) { + traces.startSpan(`span-${i}`).end() + await flushMicrotasks() + } + + instance._sendTracesBatch.mockImplementation(() => + Promise.resolve().then(() => { + instance.isDisabled = true + return { kind: 'ok' as const } + }) + ) + await traces.flush() + + expect(instance._sendTracesBatch).toHaveBeenCalledTimes(2) + }) + }) + + describe('flush backoff', () => { + it('resumes the depth trigger after a non-retriable drop clears the backlog', async () => { + // A 503 burst raises the consecutive-failure count, which disables the + // depth trigger. Dropping the poison batch is progress, so the count has to + // clear with it or the queue stays on the slow timer while the endpoint is + // healthy. + mockInstance._sendTracesBatch + .mockResolvedValueOnce({ kind: 'retry-later', error: new Error('503') }) + .mockResolvedValueOnce({ kind: 'fatal', error: new Error('400') }) + .mockResolvedValue({ kind: 'ok' }) + + const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 10_000 }) + traces.startSpan('poison').end() + await flushMicrotasks() + await traces.flush() + mockInstance._sendTracesBatch.mockClear() + + // Depth trigger only fires again if the failure count was cleared. + traces.startSpan('after').end() + await flushMicrotasks() + + expect(mockInstance._sendTracesBatch).toHaveBeenCalled() + }) + + it('resumes the depth trigger after a too-large single-span drop', async () => { + mockInstance._sendTracesBatch + .mockResolvedValueOnce({ kind: 'retry-later', error: new Error('503') }) + .mockResolvedValueOnce({ kind: 'too-large' }) + .mockResolvedValue({ kind: 'ok' }) + + const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 10_000 }) + traces.startSpan('huge').end() + await flushMicrotasks() + await traces.flush() + mockInstance._sendTracesBatch.mockClear() + + traces.startSpan('after').end() + await flushMicrotasks() + + expect(mockInstance._sendTracesBatch).toHaveBeenCalled() + }) + }) + + describe('drop accounting', () => { + it('still warns about queue-full drops while the endpoint is failing', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2 }) + for (let i = 0; i < 10; i++) { + traces.startSpan(`span-${i}`).end() + await flushMicrotasks() + } + + const warnings = logger.warn.mock.calls.map((call: any[]) => call[0]) + expect(warnings.join(' ')).toContain('queue is full') + }) + + it('rate-limits the warning instead of one per dropped span', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 10_000 }) + for (let i = 0; i < 30; i++) { + traces.startSpan(`span-${i}`).end() + await flushMicrotasks() + } + + expect(logger.warn.mock.calls.length).toBeLessThanOrEqual(2) + }) + + it('surfaces queue-full drops even when every flush pass exits early', async () => { + // The retriable branch returns before the drain loop ends, so only the + // pass-level `finally` can emit this warning. + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 1000 }) + traces.startSpan('queued-a').end() + traces.startSpan('queued-b').end() + await flushMicrotasks() + + // The first drop opens the rate-limit window itself. + traces.startSpan('dropped-first').end() + await flushMicrotasks() + logger.warn.mockClear() + + // The second lands inside that window, so `_recordDrop` stays quiet and + // only the flush pass's own `finally` can report it. + traces.startSpan('dropped-second').end() + await flushMicrotasks() + expect(logger.warn).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1000) + + expect(logger.warn.mock.calls.map((call: any[]) => call[0]).join(' ')).toContain('queue is full') + }) + + it('warns again once the flush interval has passed', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 1000 }) + for (let i = 0; i < 4; i++) { + traces.startSpan(`first-${i}`).end() + await flushMicrotasks() + } + const afterFirstWindow = logger.warn.mock.calls.length + + await vi.advanceTimersByTimeAsync(1000) + traces.startSpan('later').end() + await flushMicrotasks() + + expect(afterFirstWindow).toBe(1) + expect(logger.warn.mock.calls.length).toBeGreaterThan(afterFirstWindow) + }) + + it('warns once per flush with the total, not the first drop', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'fatal', error: new Error('bad key') }) + const traces = createTraces({ maxExportBatchSize: 5, maxQueueSize: 5 }) + traces.startSpan('a').end() + traces.startSpan('b').end() + traces.startSpan('c').end() + await traces.flush() + + const warnings = logger.warn.mock.calls.map((call: any[]) => call[0]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('3 span(s)') + expect(warnings[0]).toContain('rejected the batch') + }) + }) + + describe('hostile input', () => { + it('still exports when a resourceAttributes accessor throws', async () => { + const hostile: Record = {} + Object.defineProperty(hostile, 'host.name', { + enumerable: true, + get() { + throw new Error('accessor exploded') + }, + }) + // This runs before the flush pass's own error handling, so an unguarded + // read would rethrow on every flush and export nothing, ever. + const traces = createTraces({ resourceAttributes: hostile as never }) + traces.startSpan('checkout').end() + await traces.flush() + + expect(sentSpans().map((span) => span.name)).toEqual(['checkout']) + }) + + it('bounds a long resource attribute value', async () => { + // Resource attributes are caller-supplied like span attributes, and they + // ride on every batch rather than on one span. + const traces = createTraces({ + maxAttributeValueLength: 4, + resourceAttributes: { 'host.name': 'abcdefgh' } as never, + }) + traces.startSpan('checkout').end() + await traces.flush() + + const resource = sentPayloads()[0].resourceSpans[0].resource!.attributes + expect(resource.find((attribute) => attribute.key === 'host.name')?.value).toEqual({ stringValue: 'abcd' }) + }) + + it('does not throw on a Date-like object with no Date slot', () => { + const traces = createTraces() + const fakeDate = Object.create(Date.prototype) + + expect(() => traces.startSpan('checkout', { startTime: fakeDate }).end(fakeDate)).not.toThrow() + }) + + it('does not throw when the parent has a throwing accessor', () => { + const traces = createTraces() + const hostile = { + get traceparent() { + throw new Error('accessor exploded') + }, + } + + expect(() => traces.startSpan('checkout', { parent: hostile as any })).not.toThrow() + }) + }) + + describe('background flush triggers', () => { + it('runs one background drain at a time while the queue stays saturated', async () => { + const traces = createTraces({ maxExportBatchSize: 8, maxQueueSize: 64 }) + let live = 0 + let peak = 0 + const drain = traces.flush.bind(traces) + vi.spyOn(traces, 'flush').mockImplementation(() => { + live++ + peak = Math.max(peak, live) + return drain().finally(() => { + live-- + }) + }) + + for (let i = 0; i < 500; i++) { + traces.startSpan(`span-${i}`).end() + if (i % 50 === 0) { + await flushMicrotasks() + } + } + + // A drain per span end would stack a loop per span, each retaining frames. + expect(peak).toBeLessThanOrEqual(2) + }) + }) + + describe('background flush re-arming', () => { + it('leaves a timer behind for a span that ends as a drain finishes', async () => { + const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 5000 }) + traces.startSpan('a').end() + // Four microtasks in: the drain has returned but its finally has not run, + // so the dedupe guard is still set and the queue was empty when it armed. + let chain: Promise = Promise.resolve() + for (let hop = 0; hop < 4; hop++) { + chain = chain.then(() => undefined) + } + await chain.then(() => { + traces.startSpan('b').end() + }) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(5000) + + expect(sentSpans().map((span) => span.name)).toEqual(['a', 'b']) + }) + }) + + describe('retry backoff', () => { + it('caps the retry delay while the endpoint keeps failing', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 }) + traces.startSpan('a').end() + + const delays: number[] = [] + // Seven retries: the eighth spends the batch's retry budget and drops it. + for (let attempt = 0; attempt < 7; attempt++) { + const before = mockInstance._sendTracesBatch.mock.calls.length + let waited = 0 + while (mockInstance._sendTracesBatch.mock.calls.length === before && waited < 120_000) { + await vi.advanceTimersByTimeAsync(1000) + waited += 1000 + } + delays.push(waited) + } + + expect(Math.max(...delays)).toBeLessThanOrEqual(30_000) + expect(delays.slice(-2)).toEqual([30_000, 30_000]) + }) + }) + + describe('retry budget', () => { + it('drops a batch the endpoint keeps refusing and moves to the next one', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 }) + traces.startSpan('stuck').end() + traces.startSpan('fresher').end() + + // Eight retriable failures spend the head batch's budget. + for (let attempt = 0; attempt < 12; attempt++) { + await vi.advanceTimersByTimeAsync(30_000) + } + + const attempted = mockInstance._sendTracesBatch.mock.calls.flatMap((call: any[]) => + call[0].resourceSpans[0].scopeSpans[0].spans.map((span: OtlpSpan) => span.name) + ) + // The stuck span is given up on, and the one behind it gets its turn. + expect(attempted).toContain('fresher') + expect(logger.warn.mock.calls.map((call: any[]) => call[0]).join(' ')).toContain('8 times in a row') + }) + + it('does not charge fresh spans to a budget they never spent', async () => { + mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') }) + const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 512, maxQueueSize: 2048 }) + traces.startSpan('old').end() + while (mockInstance._sendTracesBatch.mock.calls.length < 7) { + await vi.advanceTimersByTimeAsync(30_000) + } + for (let i = 0; i < 20; i++) { + traces.startSpan(`fresh-${i}`).end() + } + await vi.advanceTimersByTimeAsync(30_000) + + const eighth = mockInstance._sendTracesBatch.mock.calls[7][0] as OtlpTracesPayload + // The head cannot grow to sweep in spans that have never been retried. + expect(eighth.resourceSpans[0].scopeSpans[0].spans.map((span) => span.name)).toEqual(['old']) + }) + + it('gives the halved batch its own budget after a 413', async () => { + let attempt = 0 + const attempted: string[][] = [] + mockInstance._sendTracesBatch.mockImplementation(async (payload: OtlpTracesPayload) => { + attempted.push(payload.resourceSpans[0].scopeSpans[0].spans.map((span) => span.name)) + attempt++ + if (attempt <= 7) { + return { kind: 'retry-later', error: new Error('down') } + } + // The 413 replaces the head batch, so its failure count must not carry over. + return attempt === 8 ? { kind: 'too-large' } : { kind: 'retry-later', error: new Error('down') } + }) + const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 4, maxQueueSize: 32 }) + for (let i = 0; i < 8; i++) { + traces.startSpan(`s${i}`).end() + } + for (let tick = 0; tick < 6; tick++) { + await vi.advanceTimersByTimeAsync(30_000) + } + + const halved = attempted.filter((names) => names.join() === 's0,s1') + expect(halved.length).toBeGreaterThan(1) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('gives a later batch its full budget after a success', async () => { + let attempt = 0 + // Four failures, a success, then four more: seven consecutive failures + // would spend the budget, but the success in between must reset it. + const succeedsOn = [5, 10] + mockInstance._sendTracesBatch.mockImplementation(async () => { + attempt++ + return succeedsOn.includes(attempt) ? { kind: 'ok' } : { kind: 'retry-later', error: new Error('blip') } + }) + const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 }) + traces.startSpan('a').end() + traces.startSpan('b').end() + for (let tick = 0; tick < 12; tick++) { + await vi.advanceTimersByTimeAsync(30_000) + } + + expect([...new Set(sentSpans().map((span) => span.name))]).toEqual(['a', 'b']) + expect(logger.warn).not.toHaveBeenCalled() + }) + }) + + describe('drain progress', () => { + it('drains a span that arrives while a send is in flight', async () => { + // Queue length can't measure progress: one span out and one in leaves it + // unchanged, which would read as "no progress" and strand the new span — + // and shutdown() then discards it. + let onSend = (): void => {} + const instance = createMockInstance({ + _sendTracesBatch: vi.fn(() => { + onSend() + onSend = (): void => {} + return Promise.resolve({ kind: 'ok' as const }) + }), + }) + const traces = createTraces({ maxExportBatchSize: 10 }, instance) + onSend = (): void => traces.startSpan('arrived-mid-flight').end() + + traces.startSpan('first').end() + await traces.flush() + + expect(sentSpans(instance).map((s) => s.name)).toEqual(['first', 'arrived-mid-flight']) + }) + + it('terminates rather than spinning when a batch size of zero slips through', async () => { + // Core must not depend on every host clamping its config. + const traces = createTraces({ maxExportBatchSize: 0 }) + traces.startSpan('a').end() + + await traces.flush() + + expect(sentSpans()).toHaveLength(1) + }) + }) + + describe('live span bounds', () => { + it('returns an inert handle once maxLiveSpans spans are live', async () => { + const traces = createTraces({ maxLiveSpans: 2 }) + + traces.startSpan('live-a') + traces.startSpan('live-b') + const refused = traces.startSpan('refused') + refused.end() + await traces.flush() + + expect(sentSpans()).toHaveLength(0) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the live-span limit (2) was reached')) + }) + + it('frees the slot when a span ends', async () => { + const traces = createTraces({ maxLiveSpans: 1 }) + + traces.startSpan('first').end() + traces.startSpan('second').end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['first', 'second']) + }) + + it('never exports a span evicted for exceeding maxSpanAgeMs', async () => { + const traces = createTraces({ maxSpanAgeMs: 60_000 }) + const leaked = traces.startSpan('leaked') + + await vi.advanceTimersByTimeAsync(61_000) + // Eviction is lazy: the next startSpan sweeps. + traces.startSpan('later').end() + leaked.end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['later']) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('still live after 60000ms')) + }) + + it('returns the slot on age eviction so a leak cannot disable tracing', async () => { + const traces = createTraces({ maxLiveSpans: 1, maxSpanAgeMs: 60_000 }) + traces.startSpan('leaked-forever') + + await vi.advanceTimersByTimeAsync(61_000) + traces.startSpan('after-the-leak').end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['after-the-leak']) + }) + + it('ages from startSpan, not from a caller-supplied startTime', async () => { + const traces = createTraces({ maxSpanAgeMs: 60_000 }) + // Backdated an hour: aging off the supplied time would evict it immediately. + const backdated = traces.startSpan('backdated', { startTime: Date.now() - 3_600_000 }) + + traces.startSpan('sweep-trigger').end() + backdated.end() + await traces.flush() + + expect(sentSpans().map((s) => s.name)).toEqual(['sweep-trigger', 'backdated']) + }) + }) + + describe('reset', () => { + it('abandons an in-flight pass instead of splicing spans it never sent', async () => { + let release!: (outcome: SendTracesBatchOutcome) => void + const instance = createMockInstance({ + _sendTracesBatch: vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ), + }) + const traces = createTraces({ maxExportBatchSize: 10 }, instance) + + traces.startSpan('sent-a').end() + traces.startSpan('sent-b').end() + const inFlight = traces.flush() + await Promise.resolve() + + // shutdown() lost the race and tore the pipeline down. + traces.reset() + traces.startSpan('after-reset').end() + + release({ kind: 'ok' }) + await inFlight + + expect((traces as any)._queue.map((r: any) => r.name)).toEqual(['after-reset']) + }) + + it('clears the queue', async () => { + const traces = createTraces() + traces.startSpan('a').end() + traces.reset() + await traces.flush() + + expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/core/src/traces/index.ts b/packages/core/src/traces/index.ts new file mode 100644 index 0000000000..bf25098deb --- /dev/null +++ b/packages/core/src/traces/index.ts @@ -0,0 +1,979 @@ +import type { Span, SpanAttributes, SpanRecord as HookSpanRecord, StartSpanOptions } from '@posthog/types' +import type { Logger } from '../types' +import type { + OtlpSpan, + ResolvedTracesConfig, + SpanContextManager, + SpanEventRecord, + SpanRecord, + TraceSdkContext, + TracesHost, +} from './types' +import { + PassThroughSpan, + PostHogSpan, + applySpanLimits, + describeError, + inertSpan, + monotonicNow, + runWithActiveSpan, + truncateAttributes, +} from './span' +import { newSpanId, newTraceId } from './ids' +import { parseTraceparent, sanitizeTracestate, traceparentHeader } from './traceparent' +import { clampEndTime, resolveStartTime, resolveSuppliedTime, sanitizeName, toEpochMs } from './sanitize' +import { assignUserAttributes } from '../utils/json-utils' +import { buildOtlpSpan, buildOtlpTracesPayload, buildTracesResourceAttributes } from './otlp' +import { isPromise, safeSetTimeout } from '../utils' + +// Retriable failures on the same head batch before it is dropped, so a stuck +// batch cannot pin the queue while fresher spans are refused at the cap. The +// budget counts attempts, not elapsed time: on the timer path the backoff +// spreads them over minutes, while a host that calls `flush()` per request +// spends them as fast as the requests arrive. +const MAX_RETRIES_PER_BATCH = 8 + +const MAX_FLUSH_BACKOFF_EXPONENT = 6 +const MAX_FLUSH_BACKOFF_MS = 30_000 + +type SpanCallback = (span: Span) => T + +/** Monotonic where the platform has one, wall clock otherwise. Both are ms, and a platform never switches. */ +function clockNow(): number { + return monotonicNow() ?? Date.now() +} + +/** `instanceof` and property access both throw on a hostile proxy; `startSpan` must not. */ +function isOwnSpan(value: unknown): value is PostHogSpan { + try { + return value instanceof PostHogSpan + } catch { + return false + } +} + +/** A `traceparent` header as the parent context it describes, or nothing if it is malformed. */ +function remoteContext(header: string, tracestate: string | undefined): ParentContext | undefined { + const remote = parseTraceparent(header) + if (!remote) { + return undefined + } + return { + traceId: remote.traceId, + parentSpanId: remote.spanId, + traceState: sanitizeTracestate(tracestate), + traceFlags: remote.flags, + isRemote: true, + } +} + +function looksLikeSpan(value: unknown): boolean { + try { + return typeof (value as Span).traceparent === 'function' + } catch { + return false + } +} + +/** + * The rebuilt record with every field named, optional ones included. A field + * added to either half of `SpanRecord` is a compile error at the rebuild until + * it says whether a hook may set that field or the span keeps its own value. + */ +type RebuiltSpanRecord = { [K in keyof Required]: SpanRecord[K] } + +interface SpanIdentity { + traceId: string + spanId: string + parentSpanId?: string + traceState?: string +} + +/** + * Whether a `beforeSpanSend` return value still carries every field the public + * `SpanRecord` declares as required. An array is rejected for `attributes`: it + * would encode as `{ "0": ... }` rather than fail. + * + * Presence, not usability: a field that is there but holds the wrong type is a + * hook editing a real record badly, and the sanitising below is what answers + * that. A field that is absent means the hook returned something that was never + * a span record, and the fallbacks would dress it up as one. + */ +function isSpanRecordShape(record: SpanRecord): boolean { + return ( + !!record.attributes && + typeof record.attributes === 'object' && + !Array.isArray(record.attributes) && + Array.isArray(record.events) && + record.name !== undefined && + record.kind !== undefined && + record.startTime !== undefined && + record.endTime !== undefined + ) +} + +/** Writes `value` onto `record` only when it isn't already there. */ +function restoreField(record: SpanIdentity, field: K, value: SpanIdentity[K]): void { + if (record[field] !== value) { + record[field] = value + } +} + +/** + * A stand-in for a record whose identity could not be written back, carrying the + * original ids and everything else the hook returned. + * + * Built from the descriptors rather than spread so a class instance keeps its + * prototype — `instanceof` and a field exposed as a prototype getter both still + * answer — and so `Object.keys` reads what it read before. Only the four + * identity descriptors are replaced, which is what makes the copy writable where + * the original was frozen. + */ +function withRestoredIdentity(hooked: HookSpanRecord, original: SpanIdentity): HookSpanRecord { + try { + const descriptors = Object.getOwnPropertyDescriptors(hooked) as Record + for (const field of ['traceId', 'spanId', 'parentSpanId', 'traceState'] as const) { + descriptors[field] = { + value: original[field], + enumerable: true, + writable: true, + configurable: true, + } + } + return Object.create(Object.getPrototypeOf(hooked) as object | null, descriptors) as HookSpanRecord + } catch { + // A hostile descriptor read. The export still uses the snapshot, so this + // costs the next hook a correct id rather than the span. + return hooked + } +} + +interface ParentContext { + traceId: string + parentSpanId?: string + traceState?: string + traceFlags?: string + /** True when the parent arrived as a `traceparent` header. */ + isRemote?: boolean +} + +/** + * The traces pipeline: span creation, active-span parenting, and OTLP export. + * Separate from the analytics-events pipeline — own queue, endpoint and flush + * cycle — mirroring logs and metrics. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export class PostHogTraces { + private _queue: SpanRecord[] = [] + private _flushTimer?: ReturnType + // Serializes flushes: a second caller joins the first instead of double-sending the head. + private _flushPromise: Promise | null = null + // A trigger no-ops while a background drain is already pending. + private _backgroundFlush?: Promise + private _maxExportBatchSize: number + // Reset when the warning is emitted, so each warning reports its own window. + private _droppedSinceWarning = 0 + private _lastDropWarningAt = 0 + private _dropReasons = new Set() + private _consecutiveFlushFailures = 0 + // Separate from the backoff counter: this one belongs to whatever batch is at + // the head, and resets whenever that batch is removed or shrunk. + private _headBatchFailures = 0 + // Read only while a budget is in flight, so the head cannot grow to sweep in + // fresh spans and drop them on a budget they never spent. + private _headBatchSize = 0 + // Bumped by reset(); a pass whose generation is stale abandons the queue. + private _generation = 0 + // Live-span accounting: span id -> monotonic start. Ids and numbers only, + // never the span itself, so a handle the caller drops is still collectable + // and the bound can be generous. Insertion order is start order, so the + // oldest entries are at the front and eviction stops at the first live one. + private _liveSpans = new Map() + + constructor( + private readonly _instance: TracesHost, + private readonly _config: ResolvedTracesConfig, + private readonly _logger: Logger, + private readonly _getContext: () => TraceSdkContext, + private readonly _contextManager: SpanContextManager, + /** Told when a span joins the queue, so a serverless host can keep the invocation alive. */ + private readonly _onSpanQueued?: () => void + ) { + this._maxExportBatchSize = _config.maxExportBatchSize + } + + /** + * Starts a span without making it active. Always returns a handle — an inert + * one when tracing cannot run — so calling code never branches. + */ + startSpan(name: string, options?: StartSpanOptions): Span { + if (this._instance.isDisabled || this._instance.optedOut) { + return inertSpan(options, this._contextManager.active()) + } + + const explicitParent = traceparentHeader(options?.parent) + if (explicitParent && typeof explicitParent !== 'string' && !isOwnSpan(explicitParent)) { + if (looksLikeSpan(explicitParent)) { + // Inert like its parent, never an orphan with invented ids — but a + // pass-through parent's inbound context carries to the child rather than + // the trace ending here. + this._logger.debug('Span parent is not a span from this SDK; returning an inert span') + return inertSpan(options, this._contextManager.active()) + } + // No `traceparent()` to read: a span from another tracer exposes + // `spanContext()` instead, and `headersDistinct.traceparent` is a `string[]` + // holding more than one inbound value. Ignored: falls back to the active + // span, or to a new trace. + this._logger.debug('Ignoring an unusable span parent') + } + + const parent = this._resolveParent(explicitParent, options) + + // Swept before the bound is read, so a process that has leaked its way to + // the bound recovers on the first `startSpan` after the leaks age out. + this._evictAgedSpans() + if (this._liveSpans.size >= this._config.maxLiveSpans) { + this._recordDrop( + 1, + `the live-span limit (${this._config.maxLiveSpans}) was reached — spans are being started and never ended` + ) + return inertSpan(options, this._contextManager.active()) + } + + const now = Date.now() + const startTime = resolveStartTime(options?.startTime, now, this._logger) + const spanId = newSpanId() + // Read here rather than from the span: age is elapsed time since this call, + // so a backdated `startTime` neither ages a span early nor exempts it. + this._liveSpans.set(spanId, clockNow()) + + const autoAttributes = this._autoContextAttributes() + + return new PostHogSpan( + { + traceId: parent?.traceId ?? newTraceId(), + spanId, + parentSpanId: parent?.parentSpanId, + traceState: parent?.traceState, + traceFlags: parent?.traceFlags, + parentIsRemote: parent?.isRemote, + name: sanitizeName(name, 'Span name', this._config.maxAttributeValueLength, this._logger), + kind: options?.kind ?? 'internal', + // Auto-context first so user-supplied attributes win on collision. + attributes: assignUserAttributes({ ...autoAttributes }, options?.attributes), + autoAttributeKeys: Object.keys(autoAttributes), + maxAttributes: this._config.maxAttributesPerSpan, + maxEvents: this._config.maxEventsPerSpan, + maxAttributesPerEvent: this._config.maxAttributesPerEvent, + maxAttributeValueLength: this._config.maxAttributeValueLength, + startTime, + backdated: startTime !== now, + }, + (record, autoKeys) => this._onSpanEnd(record, autoKeys), + this._logger + ) + } + + /** + * Runs a callback with a span active for its duration and guarantees the span + * ends — at return for a sync callback, at settle for an async one. + * + * A throw or rejection is recorded on the span and rethrown unmodified: the + * SDK never swallows application control flow. + */ + withSpan(name: string, fn: SpanCallback): T + withSpan(name: string, options: StartSpanOptions, fn: SpanCallback): T + withSpan(name: string, optionsOrFn: StartSpanOptions | SpanCallback, maybeFn?: SpanCallback): T { + const options = typeof optionsOrFn === 'function' ? undefined : optionsOrFn + const fn = (typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn) as SpanCallback + + const span = this.startSpan(name, options) + + try { + const result = runWithActiveSpan(this._contextManager, span, fn) + + if (isPromise(result)) { + return result.then( + (value: unknown) => { + span.end() + return value + }, + (error: unknown) => { + this._recordCallbackError(span, error) + span.end() + throw error + } + ) as T + } + + span.end() + return result + } catch (error) { + this._recordCallbackError(span, error) + span.end() + throw error + } + } + + /** The active span, or `null` outside any `withSpan` callback. */ + getActiveSpan(): Span | null { + return this._contextManager.active() ?? null + } + + /** + * Drains the span queue in repeated passes: joining a single in-flight pass + * would leave spans enqueued after its watermark behind. + * + * A pass reports spans removed — queue length can't stand in, since a send + * concurrent with an arrival leaves it unchanged. + */ + async flush(): Promise { + for (;;) { + if (!this._queue.length) { + return + } + + const inFlight = this._flushPromise + const removed = await (inFlight ?? this._startFlush()) + + // No progress means a retriable failure, an abandoned pass, or spans + // arriving as fast as we send them. Either way, stop rather than spin. + if (!removed) { + return + } + } + } + + private _startFlush(): Promise { + this._clearFlushTimer() + // Deferred by a microtask so the slot below is installed before the pass + // reads anything: `_flushInner` runs synchronously as far as its first + // await, and a resource-attribute getter or `toJSON` that ends a span in + // that window would otherwise re-enter here, find no pass in flight, and + // send the same head batch again — without bound. + // Sampled before the microtask, not inside `_flushInner`: a `reset()` landing + // in the window would otherwise be invisible to this pass, which would then + // drain the post-reset queue alongside the pass `reset()` started. + const startedAtGeneration = this._generation + const promise = Promise.resolve() + .then(() => (startedAtGeneration === this._generation ? this._flushInner() : 0)) + .finally(() => { + // Only clear the slot this call installed: a `reset()` mid-flight may + // already have installed a newer one. + if (this._flushPromise === promise) { + this._flushPromise = null + } + this._armFlushTimerIfQueued() + }) + this._flushPromise = promise + return promise + } + + /** Clears the queue and timer. Used on shutdown and between tests. */ + reset(): void { + this._clearFlushTimer() + if (this._queue.length) { + // Critical, and said here rather than counted: this is the last chance to + // say anything about these spans, the drop warning is gated behind `debug` + // on some hosts, and the only other line the caller sees is the export + // failure promising a retry on a flush that will never come. + this._logger.critical( + `Discarding ${this._queue.length} span(s) that were still queued when tracing was shut down. ` + + 'Raise the shutdown timeout or flush earlier if they matter.' + ) + } + this._queue = [] + this._liveSpans.clear() + this._flushPromise = null + // Abandons any in-flight pass, which would otherwise splice out spans it never sent. + this._generation++ + this._maxExportBatchSize = this._config.maxExportBatchSize + this._droppedSinceWarning = 0 + this._dropReasons.clear() + this._lastDropWarningAt = 0 + this._consecutiveFlushFailures = 0 + this._headBatchFailures = 0 + } + + /** + * Resolves a span's parent: an explicit `parent`, then the active span, then a + * fresh root. A no-op explicit parent is rejected earlier, in `startSpan`. + */ + private _resolveParent(explicit: unknown, options?: StartSpanOptions): ParentContext | undefined { + if (typeof explicit === 'string') { + const remote = remoteContext(explicit, options?.tracestate) + if (!remote) { + this._logger.debug('Ignoring malformed traceparent; starting a new trace') + } + return remote + } + + if (isOwnSpan(explicit)) { + // `tracestate` is ignored for handle parents — the child inherits the + // parent span's tracestate instead. + return explicit.childContext() + } + + const active = this._contextManager.active() + if (isOwnSpan(active)) { + return active.childContext() + } + // A pass-through handle is active when an earlier span in this trace could + // not be recorded. Its context still parents this one, so the inbound trace + // survives a span the SDK declined rather than ending there. + if (active instanceof PassThroughSpan) { + return remoteContext(active.traceparent(), active.tracestate() ?? undefined) + } + return undefined + } + + /** + * PostHog context snapshotted at span start. These are the product's join + * keys — they're what makes a span reachable from a person or a session. + */ + private _autoContextAttributes(): SpanAttributes { + let context: TraceSdkContext + try { + context = this._getContext() + } catch (error) { + this._logger.debug('Failed to read tracing context; span will carry no PostHog attributes', error) + return {} + } + + const attributes: SpanAttributes = {} + if (context.distinctId) { + attributes.posthogDistinctId = context.distinctId + } + if (context.sessionId) { + attributes.sessionId = context.sessionId + } + if (context.currentUrl) { + attributes['url.full'] = context.currentUrl + } + if (context.screenName) { + attributes['screen.name'] = context.screenName + } + if (context.appState) { + attributes['app.state'] = context.appState + } + return attributes + } + + /** + * Records a callback failure on the span: an `exception` event always, plus + * status `error` unless the callback explicitly marked the span `ok`. + */ + private _recordCallbackError(span: Span, error: unknown): void { + if (!(span instanceof PostHogSpan)) { + return + } + const { type, message, stack } = describeError(error) + span.addEvent('exception', { + 'exception.type': type, + 'exception.message': message, + ...(stack && { 'exception.stacktrace': stack }), + }) + if (!span.statusIsExplicitlyOk) { + span.setStatus('error', message) + } + } + + /** + * Drops live accounting for spans older than `maxSpanAgeMs`. An evicted span + * is never exported — its `end()` finds no entry — so one leak returns its + * slot instead of disabling tracing for the rest of the process. + */ + private _evictAgedSpans(): void { + const cutoff = clockNow() - this._config.maxSpanAgeMs + let evicted = 0 + for (const [spanId, startedAt] of this._liveSpans) { + // Insertion order is start order, so the first entry inside the bound ends the sweep. + if (startedAt > cutoff) { + break + } + this._liveSpans.delete(spanId) + evicted++ + } + if (evicted) { + this._recordDrop(evicted, `they were still live after ${this._config.maxSpanAgeMs}ms`) + } + } + + private _onSpanEnd(incoming: SpanRecord, autoKeys: ReadonlySet): void { + // Deleted before any other gate, so a span dropped later still returns its slot. + if (!this._liveSpans.delete(incoming.spanId)) { + // Evicted for age while live: never exported, and already counted as a drop. + return + } + + // Re-checked at end: opting out mid-trace must stop the span exporting. + if (this._instance.isDisabled || this._instance.optedOut) { + this._recordDrop(1, 'the user has opted out') + return + } + + const record = this._runBeforeSpanSend(incoming, autoKeys) + if (!record) { + return + } + this._reportLimitDrops(record) + + if (this._queue.length >= this._config.maxQueueSize) { + // Drop the incoming span, not queued ones: those are completed parents whose + // children may already have shipped. + this._recordDrop( + 1, + `the queue is full (${this._config.maxQueueSize}) — raise the flush frequency or reduce span volume` + ) + return + } + + this._queue.push(record) + try { + this._onSpanQueued?.() + } catch (error) { + this._logger.debug('Span queue notification failed', error) + } + + // Not while a flush is failing: the queue stays above the batch size for the + // whole outage, so every further span end would re-POST immediately and the + // retry backoff would never apply. + if (this._queue.length >= this._maxExportBatchSize && !this._consecutiveFlushFailures) { + this._flushInBackground() + } else { + this._armFlushTimerIfQueued() + } + } + + /** + * One diagnostic per span when its limits discarded anything, which is what + * OTel asks for. Counted after the post-hook pass, so drops a `beforeSpanSend` + * hook caused are included. + */ + private _reportLimitDrops(record: SpanRecord): void { + const attributes = record.droppedAttributesCount ?? 0 + const events = record.droppedEventsCount ?? 0 + let eventAttributes = 0 + for (const event of record.events) { + eventAttributes += event.droppedAttributesCount ?? 0 + } + if (attributes || events || eventAttributes) { + this._logger.debug( + `Span limits discarded data from "${record.name}": ` + + `${attributes} attributes, ${events} events, ${eventAttributes} event attributes` + ) + } + } + + /** + * Runs the `beforeSpanSend` chain, returning the span to enqueue or `null` to + * drop it. + * + * A throwing hook drops the span: the hook is the documented scrubbing point, + * so a broken scrubber must not let the unscrubbed record through. Identity + * fields are restored afterwards, since rewriting them orphans shipped children. + */ + private _runBeforeSpanSend(record: SpanRecord, autoKeys: ReadonlySet): SpanRecord | null { + if (!this._config.beforeSpanSend.length) { + return record + } + + // Snapshotted before any hook runs: a hook that mutates in place would + // otherwise leave nothing to restore from. + const identity = { + traceId: record.traceId, + spanId: record.spanId, + parentSpanId: record.parentSpanId, + traceState: record.traceState, + } + const originalTimes = { startTime: record.startTime, endTime: record.endTime } + // Snapshotted with the rest: the hook mutates the record in place, so reading + // these back afterwards reads whatever the hook left there. + const originalDropped = { + attributes: record.droppedAttributesCount, + events: record.droppedEventsCount, + } + // The order the span itself wrote them in, so the caps below can keep the + // earliest-set entries even when a hook adds an integer-like key. + const keysBeforeHook = Object.keys(record.attributes) + // Read here rather than restored onto the hook's return value: writing them + // back would throw on a frozen record, and neither is on the record a hook + // is handed, so a rebuilding hook always arrives without them. + const originalPropagation = { + traceFlags: record.traceFlags, + parentIsRemote: record.parentIsRemote, + } + // Copied, not referenced: the hook is documented as mutating the record in + // place, and a reference would restore the mutation onto itself. + const originalStatus = record.status && { ...record.status } + let hooked: HookSpanRecord = record + let current = record + try { + for (const hook of this._config.beforeSpanSend) { + const result = hook(hooked) + if (!result) { + this._recordDrop(1, 'beforeSpanSend dropped it') + return null + } + hooked = this._keepSpanIdentity(result, identity) + } + + // Rebuilt field by field before anything below writes to it. The hook's + // return value may be frozen, where every write here would throw, or a + // class instance whose fields are prototype getters a spread would miss. + // Naming them also bounds what can reach the wire. + const rebuilt: RebuiltSpanRecord = { + // All four from the snapshot, never from the hook's return value. A hook + // that forges an id has it ignored, which is the documented behaviour, + // and one that also freezes what it returns keeps its span: writing the + // id back onto a frozen object throws, and a throw here drops the span. + traceId: identity.traceId, + spanId: identity.spanId, + parentSpanId: identity.parentSpanId, + traceState: identity.traceState, + name: hooked.name, + kind: hooked.kind, + status: hooked.status, + attributes: hooked.attributes, + events: hooked.events, + startTime: hooked.startTime, + endTime: hooked.endTime, + // Taken from the span for the same reason as the dropped counts: no + // public type declares them, so a rebuilding hook returns without them + // and a `?? fallback` here would export a sampled-out trace as sampled. + traceFlags: originalPropagation.traceFlags, + parentIsRemote: originalPropagation.parentIsRemote, + // Taken from the span, not from the hook's return value: these are SDK + // bookkeeping that no public type declares, so a hook overwriting them + // must not erase what the span actually dropped. + droppedAttributesCount: originalDropped.attributes, + droppedEventsCount: originalDropped.events, + } + current = rebuilt + // A value missing a required field is not a span record — an `async` hook + // returns a Promise, truthy and `undefined` for every field. Filling the + // gaps in would export a span named `unknown` at a fallback time carrying + // no person or session, joinable to nothing and silent about it. + if (!isSpanRecordShape(current)) { + this._logger.debug('beforeSpanSend did not return a span record; dropping the span') + this._recordDrop(1, 'beforeSpanSend returned an unusable record') + return null + } + + // Re-applied to whatever the hook returned: one undecodable timestamp 400s + // the whole request, taking unrelated spans with it. + current.name = sanitizeName(current.name, 'Span name', this._config.maxAttributeValueLength, this._logger) + // A status the hook rewrote never went through `setStatus`. An unknown code + // encodes as an empty status object, which loses an error the span really had. + if (current.status && current.status.code !== 'ok' && current.status.code !== 'error') { + this._logger.debug('beforeSpanSend set an unknown span status; keeping the original') + current.status = originalStatus + } + current.startTime = toEpochMs(current.startTime) ?? originalTimes.startTime + current.endTime = clampEndTime(toEpochMs(current.endTime) ?? originalTimes.endTime, current.startTime) + // Events a hook pushed never went through `addEvent`, so they carry no + // sanitised name or timestamp; an unvalidated one encodes as `NaN000NaN` + // and the ingestion service refuses the whole batch. + const sanitizedEvents: SpanEventRecord[] = [] + for (const event of current.events) { + try { + sanitizedEvents.push({ + ...event, + name: sanitizeName(event.name, 'Span event name', this._config.maxAttributeValueLength, this._logger), + timestamp: resolveSuppliedTime(event.timestamp, current.startTime, 'event timestamp', this._logger), + }) + } catch { + // A hook can leave a `null` in the array or a throwing accessor on an + // event. That costs the event; the rest of the span still ships. + this._logger.debug('beforeSpanSend left an unreadable span event; dropping it') + } + } + current.events = sanitizedEvents + applySpanLimits( + current, + autoKeys, + this._config.maxAttributesPerSpan, + this._config.maxEventsPerSpan, + this._config.maxAttributesPerEvent, + this._config.maxAttributeValueLength, + keysBeforeHook + ) + return current + } catch (error) { + // Covers the hook and everything done to its return value: a frozen or + // hostile record must not throw out of `end()` into application code. + this._logger.debug('beforeSpanSend failed; dropping the span rather than exporting it unscrubbed', error) + this._recordDrop(1, 'beforeSpanSend failed') + return null + } + } + + /** + * Restores the fields a hook must not change. Runs per hook so a later hook in + * the chain cannot sample on an id an earlier one forged. + */ + private _keepSpanIdentity(hooked: HookSpanRecord, original: SpanIdentity): HookSpanRecord { + if ( + hooked.traceId !== original.traceId || + hooked.spanId !== original.spanId || + hooked.parentSpanId !== original.parentSpanId + ) { + this._logger.debug('beforeSpanSend changed a span identity field; keeping the original ids') + } + // Only the fields that actually differ are written back: assigning to a + // frozen property throws even when the value is the one already there, and + // a hook that freezes what it returns would otherwise drop every span. The + // record this builds is for the next hook in the chain, not for the export. + try { + restoreField(hooked, 'traceId', original.traceId) + restoreField(hooked, 'spanId', original.spanId) + restoreField(hooked, 'parentSpanId', original.parentSpanId) + // A hook that rebuilds the record instead of spreading it would otherwise + // drop tracestate, which is not part of the record the hook is handed. + restoreField(hooked, 'traceState', original.traceState) + } catch { + // Frozen, so the writes above were refused and this record still carries + // whatever identity the hook forged. The export reads the snapshot either + // way, but the next hook in the chain reads this — and would sample or + // route on a forged id, which identity immutability exists to prevent. + return withRestoredIdentity(hooked, original) + } + return hooked + } + + private _recordDrop(count: number, reason: string): void { + this._droppedSinceWarning += count + this._dropReasons.add(reason) + // Drops also happen with no flush in sight — a full queue during an outage — + // so the warning is paced by the clock rather than by the flush loop. + if (Date.now() - this._lastDropWarningAt >= this._config.flushIntervalMs) { + this._warnAboutDrops() + } + } + + /** At most one warning per flush pass, naming the total and every reason behind it. */ + private _warnAboutDrops(): void { + if (!this._droppedSinceWarning) { + return + } + this._lastDropWarningAt = Date.now() + this._logger.warn(`Dropping ${this._droppedSinceWarning} span(s): ${[...this._dropReasons].join('; ')}`) + this._droppedSinceWarning = 0 + this._dropReasons.clear() + } + + /** + * Encodes a batch, dropping any span whose attributes can't be encoded. + * An unguarded throw here would leave the queue unspliced, so every later + * flush would die on the same span. + */ + private _encodeBatch(batch: SpanRecord[]): OtlpSpan[] { + const encoded: OtlpSpan[] = [] + for (const record of batch) { + try { + encoded.push(buildOtlpSpan(record, this._logger)) + } catch (error) { + this._logger.debug('Failed to encode a span; dropping it', error) + this._recordDrop(1, 'its attributes could not be encoded') + } + } + return encoded + } + + /** + * Discards the queue when consent has been withdrawn, returning how many spans + * it dropped. Spans carry `posthogDistinctId` and `sessionId`, so anything still + * queued when the user opts out must not be exported. + */ + private _discardQueueIfConsentWithdrawn(): number { + if (!this._instance.isDisabled && !this._instance.optedOut) { + return 0 + } + const discarded = this._queue.length + this._queue = [] + this._recordDrop(discarded, 'the user has opted out') + this._warnAboutDrops() + return discarded + } + + /** Returns how many spans it removed from the queue, sent or dropped. */ + private async _flushInner(): Promise { + if (!this._queue.length) { + return 0 + } + + const discardedBeforeDrain = this._discardQueueIfConsentWithdrawn() + if (discardedBeforeDrain) { + return discardedBeforeDrain + } + + // Bounded like span attributes: resource attributes are caller-supplied too, + // and they ride on every batch rather than on one span. + const resourceAttributes = truncateAttributes( + buildTracesResourceAttributes(this._config, this._instance.getLibraryId(), this._instance.getLibraryVersion()), + this._config.maxAttributeValueLength + ) + const scopeName = this._instance.getLibraryId() + const scopeVersion = this._instance.getLibraryVersion() + + // Bounded by queue depth at flush start, so mid-drain arrivals ride the next flush. + let remaining = this._queue.length + let removed = 0 + const generation = this._generation + + try { + while (remaining > 0 && this._queue.length > 0) { + // Re-checked per batch: a send suspends, so the user can opt out while one + // batch is in flight and the batches behind it would still export. + const discardedMidDrain = this._discardQueueIfConsentWithdrawn() + if (discardedMidDrain) { + return removed + discardedMidDrain + } + + // Floor at one, or a non-positive batch size loops forever on an empty batch. + const cap = + this._headBatchFailures > 0 + ? Math.min(this._maxExportBatchSize, this._headBatchSize) + : this._maxExportBatchSize + const size = Math.max(1, Math.min(cap, remaining, this._queue.length)) + const batch = this._queue.slice(0, size) + const spans = this._encodeBatch(batch) + + if (!spans.length) { + // Nothing survived encoding; drop the batch rather than re-encoding it forever. + this._queue.splice(0, size) + remaining -= size + removed += size + this._headBatchFailures = 0 + continue + } + + const outcome = await this._instance._sendTracesBatch( + buildOtlpTracesPayload(spans, resourceAttributes, scopeName, scopeVersion, this._logger) + ) + + if (generation !== this._generation) { + // reset() ran mid-send: this pass no longer owns the queue. + return removed + } + + if (outcome.kind === 'ok') { + this._consecutiveFlushFailures = 0 + this._headBatchFailures = 0 + this._queue.splice(0, size) + remaining -= size + removed += size + // Ramp back toward the configured max after a 413 shrink. + if (this._maxExportBatchSize < this._config.maxExportBatchSize) { + this._maxExportBatchSize++ + } + continue + } + + if (outcome.kind === 'too-large') { + if (size === 1) { + // A single span the server won't accept at any size; drop it or it wedges the queue. + this._queue.splice(0, 1) + remaining -= 1 + removed += 1 + this._recordDrop(1, 'the ingestion endpoint rejected it as too large') + this._consecutiveFlushFailures = 0 + this._headBatchFailures = 0 + continue + } + // Halve the batch the server rejected, not the configured maximum: when the + // queue is shallower than the maximum, shrinking it resends an identical body. + this._maxExportBatchSize = Math.max(1, Math.floor(size / 2)) + // A different batch from here on, so its budget starts fresh. + this._headBatchFailures = 0 + this._logger.debug(`Batch too large; retrying the same spans in batches of ${this._maxExportBatchSize}`) + continue + } + + if (outcome.kind === 'retry-later') { + this._consecutiveFlushFailures++ + this._headBatchFailures++ + this._headBatchSize = size + if (this._headBatchFailures < MAX_RETRIES_PER_BATCH) { + // Keep the spans queued; the flush timer picks them up again. + this._logger.debug('Span export failed; retrying on the next flush', outcome.error) + return removed + } + // Out of retries. Drop this batch and start clean on the next one, so a + // permanently failing head cannot hold the queue against fresher spans. + this._queue.splice(0, size) + remaining -= size + removed += size + this._consecutiveFlushFailures = 0 + this._headBatchFailures = 0 + this._recordDrop(size, `the ingestion endpoint failed ${MAX_RETRIES_PER_BATCH} times in a row`) + continue + } + + // Non-retriable (poison batch or bad key); drop it so it can't wedge the queue. + this._logger.debug('Dropping a span batch the ingestion endpoint rejected', outcome.error) + this._queue.splice(0, size) + remaining -= size + removed += size + this._consecutiveFlushFailures = 0 + this._headBatchFailures = 0 + this._recordDrop(size, 'the ingestion endpoint rejected the batch') + } + + return removed + } finally { + // Every exit path, so a queue-full drop during an outage still surfaces — + // the retriable branch returns early. + this._warnAboutDrops() + } + } + + /** + * One background drain at a time. `flush()` is a multi-pass loop that keeps + * going while the queue stays above the batch size, so a trigger per span end + * would stack a loop per span on a busy service — each retaining its frames. + */ + private _flushInBackground(): void { + if (this._backgroundFlush) { + return + } + this._backgroundFlush = this.flush() + .catch((error) => { + // Background flushes have no caller to surface to; an explicit flush() + // still rejects. + this._logger.debug('Background span flush failed', error) + }) + .finally(() => { + this._backgroundFlush = undefined + // A trigger that arrived while this drain was finishing found the guard + // set and the queue empty, so neither path armed a timer. + this._armFlushTimerIfQueued() + }) + } + + private _armFlushTimerIfQueued(): void { + if (this._flushTimer || !this._queue.length) { + return + } + this._flushTimer = safeSetTimeout(() => { + this._flushTimer = undefined + this._flushInBackground() + }, this._nextFlushDelay()) + } + + // Retry delay: base interval, doubling, capped at 30s — never below an interval + // a host configured above the cap. + private _nextFlushDelay(): number { + const exponent = Math.min(Math.max(0, this._consecutiveFlushFailures - 1), MAX_FLUSH_BACKOFF_EXPONENT) + const delay = this._config.flushIntervalMs * 2 ** exponent + return Math.min(delay, Math.max(MAX_FLUSH_BACKOFF_MS, this._config.flushIntervalMs)) + } + + private _clearFlushTimer(): void { + if (this._flushTimer) { + clearTimeout(this._flushTimer) + this._flushTimer = undefined + } + } +} diff --git a/packages/core/src/traces/live-spans.spec.ts b/packages/core/src/traces/live-spans.spec.ts new file mode 100644 index 0000000000..3e9815bafe --- /dev/null +++ b/packages/core/src/traces/live-spans.spec.ts @@ -0,0 +1,69 @@ +import { PostHogTraces } from './index' +import { SyncSpanContextManager } from './context' +import type { ResolvedTracesConfig } from './types' +import type { Logger } from '../types' + +// Live-span accounting keeps an id and a timestamp per span, never the span, so a +// handle the caller drops stays collectable. This probe is what stops a later +// change from turning that accounting into a registry of span objects. +const gc = (globalThis as { gc?: () => void }).gc + +// `--expose-gc` is set by the `test:unit` script. A runner that invokes vitest +// directly has no `gc`, and a probe that cannot force a collection proves nothing. +const itWithGc = gc ? it : process.env.CI ? it : it.skip + +describe('live spans', () => { + const config: ResolvedTracesConfig = { + serviceName: 'svc', + flushIntervalMs: 5000, + maxExportBatchSize: 512, + maxQueueSize: 2048, + maxLiveSpans: 10000, + maxSpanAgeMs: 3600000, + beforeSpanSend: [], + maxAttributesPerSpan: 128, + maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + } + + const createTraces = (): PostHogTraces => + new PostHogTraces( + { + isDisabled: false, + optedOut: false, + getLibraryId: () => 'posthog-core', + getLibraryVersion: () => '0.0.0', + _sendTracesBatch: async () => ({ kind: 'ok' }), + }, + config, + { debug: vi.fn(), warn: vi.fn() } as unknown as Logger, + () => ({}), + new SyncSpanContextManager() + ) + + itWithGc('does not retain spans that never end', async () => { + if (!gc) { + throw new Error('Run this suite with NODE_OPTIONS=--expose-gc; see packages/core test:unit') + } + vi.useRealTimers() + try { + const traces = createTraces() + // Started in their own frame so the handles are unreachable once it returns. + const refs = ((): WeakRef[] => + Array.from({ length: 1000 }, (_unused, i) => new WeakRef(traces.startSpan(`leaked-${i}`) as object)))() + + await new Promise((resolve) => setTimeout(resolve, 50)) + gc() + await new Promise((resolve) => setTimeout(resolve, 50)) + gc() + + // A threshold, not zero: collection timing is not guaranteed, but a registry + // holding every span would keep all 1000 alive. + const alive = refs.filter((ref) => ref.deref() !== undefined).length + expect(alive).toBeLessThan(100) + } finally { + vi.useFakeTimers() + } + }) +}) diff --git a/packages/core/src/traces/otlp.spec.ts b/packages/core/src/traces/otlp.spec.ts new file mode 100644 index 0000000000..4c926668f3 --- /dev/null +++ b/packages/core/src/traces/otlp.spec.ts @@ -0,0 +1,368 @@ +import { + buildOtlpSpan, + buildOtlpTracesPayload, + buildTracesResourceAttributes, + msToUnixNanoString, + spanKindToOtlp, +} from './otlp' +import type { OtlpSpan, ResolvedTracesConfig, SpanRecord } from './types' + +const record = (overrides: Partial = {}): SpanRecord => ({ + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + spanId: '00f067aa0ba902b7', + name: 'checkout', + kind: 'internal', + traceFlags: '01', + parentIsRemote: false, + attributes: {}, + events: [], + startTime: 1_700_000_000_000, + endTime: 1_700_000_000_080, + ...overrides, +}) + +describe('OTLP span encoding', () => { + describe('msToUnixNanoString', () => { + it('encodes milliseconds as a nanosecond string', () => { + expect(msToUnixNanoString(1_700_000_000_000)).toBe('1700000000000000000') + }) + + it('keeps sub-millisecond precision', () => { + expect(msToUnixNanoString(1_700_000_000_000.5)).toBe('1700000000000500000') + }) + + it('stays exact beyond Number.MAX_SAFE_INTEGER', () => { + // The whole point of string concatenation over `ms * 1e6`, which would + // silently lose precision at this magnitude. + const encoded = msToUnixNanoString(1_700_000_000_123) + expect(encoded).toBe('1700000000123000000') + expect(Number(encoded)).toBeGreaterThan(Number.MAX_SAFE_INTEGER) + }) + + it.each([0.9999999, 1.9999999, 999.9999999])( + 'carries a rounded-up fraction into the next millisecond for %p', + (ms) => { + // Without the carry the padded fraction gains a seventh digit, producing + // a malformed timestamp that 400s the whole request. Unreachable from a + // real clock — float64 quantization at epoch-ms magnitude keeps the + // fraction well below the carry — but reachable via a caller-supplied + // `startTime`, which the validity check accepts anywhere in [0, MAX]. + const encoded = msToUnixNanoString(ms) + expect(encoded).toHaveLength(String(Math.round(ms)).length + 6) + expect(encoded).toMatch(/^\d+$/) + } + ) + }) + + describe('spanKindToOtlp', () => { + it.each([ + ['internal', 1], + ['server', 2], + ['client', 3], + ['producer', 4], + ['consumer', 5], + ] as const)('maps %s to %i', (kind, expected) => { + expect(spanKindToOtlp(kind)).toBe(expected) + }) + + it('defaults to internal', () => { + expect(spanKindToOtlp(undefined)).toBe(1) + }) + }) + + describe('buildOtlpSpan', () => { + it('builds the minimal shape', () => { + expect(buildOtlpSpan(record())).toEqual({ + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + spanId: '00f067aa0ba902b7', + name: 'checkout', + kind: 1, + startTimeUnixNano: '1700000000000000000', + endTimeUnixNano: '1700000000080000000', + flags: 0x101, + }) + }) + + it('omits status when it was never set', () => { + expect(buildOtlpSpan(record())).not.toHaveProperty('status') + }) + + it('encodes ok and error status codes', () => { + expect(buildOtlpSpan(record({ status: { code: 'ok' } })).status).toEqual({ code: 1 }) + expect(buildOtlpSpan(record({ status: { code: 'error', message: 'boom' } })).status).toEqual({ + code: 2, + message: 'boom', + }) + }) + + it('includes parent, tracestate, attributes and events when present', () => { + const span = buildOtlpSpan( + record({ + parentSpanId: 'b7ad6b7169203331', + traceState: 'vendor=abc', + attributes: { plan: 'pro' }, + events: [{ name: 'cache miss', timestamp: 1_700_000_000_040 }], + }) + ) + expect(span.parentSpanId).toBe('b7ad6b7169203331') + expect(span.traceState).toBe('vendor=abc') + expect(span.attributes).toEqual([{ key: 'plan', value: { stringValue: 'pro' } }]) + expect(span.events).toEqual([{ name: 'cache miss', timeUnixNano: '1700000000040000000' }]) + }) + + it('carries an event attribute drop as the proto counter', () => { + const span = buildOtlpSpan( + record({ + events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: 3 }], + }) + ) + expect(span.events?.[0]).toMatchObject({ name: 'query', droppedAttributesCount: 3 }) + }) + + it.each([ + ['none were dropped', 0], + ['a hook wrote a negative', -2], + ['a hook wrote a non-number', 'lots' as unknown as number], + ])('omits the event drop counter when %s', (_label, dropped) => { + // Coerced like the span's own counters: a non-integer here is refused for + // the whole request, taking unrelated spans with it. + const span = buildOtlpSpan( + record({ + events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: dropped }], + }) + ) + expect(span.events?.[0].droppedAttributesCount).toBeUndefined() + }) + + it('sets the sampled bit and marks a root span as known-not-remote', () => { + // A root span has no parent context to be remote, which the OTel Go and + // Java exporters also report as known-not-remote rather than unknown. + expect(buildOtlpSpan(record()).flags).toBe(0x101) + }) + + it('marks a local parent as known-not-remote', () => { + expect(buildOtlpSpan(record({ parentSpanId: 'b7ad6b7169203331' })).flags).toBe(0x101) + }) + + it('marks a parent that arrived as a header as remote', () => { + expect(buildOtlpSpan(record({ parentSpanId: 'b7ad6b7169203331', parentIsRemote: true })).flags).toBe(0x301) + }) + + it('propagates an inbound sampled-out flag rather than overriding it', () => { + // The span is still recorded and exported; what the wire says is the + // decision the head sampler made. + expect(buildOtlpSpan(record({ traceFlags: '00', parentIsRemote: true })).flags).toBe(0x300) + }) + + it('falls back to sampled when the flags byte is unusable', () => { + expect(buildOtlpSpan(record({ traceFlags: 'zz' })).flags).toBe(0x101) + }) + }) + + describe('buildTracesResourceAttributes', () => { + const config = (partial: Partial = {}): ResolvedTracesConfig => ({ + flushIntervalMs: 5000, + maxExportBatchSize: 512, + maxQueueSize: 2048, + beforeSpanSend: [], + maxAttributesPerSpan: 128, + maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + maxLiveSpans: 10000, + maxSpanAgeMs: 3600000, + ...partial, + }) + + it('always emits service.name', () => { + // The server reads service_name only from this attribute and stores an + // empty string when it's missing, leaving spans unattributable. + expect(buildTracesResourceAttributes(config(), 'posthog-node', '1.0.0')['service.name']).toBe('unknown_service') + }) + + it('uses the configured service name', () => { + expect(buildTracesResourceAttributes(config({ serviceName: 'checkout' }), 'posthog-node', '1.0.0')).toMatchObject( + { + 'service.name': 'checkout', + } + ) + }) + + it('includes environment and version only when set', () => { + const attributes = buildTracesResourceAttributes( + config({ environment: 'production', serviceVersion: '2.1.0' }), + 'posthog-node', + '1.0.0' + ) + expect(attributes['deployment.environment']).toBe('production') + expect(attributes['service.version']).toBe('2.1.0') + expect(buildTracesResourceAttributes(config(), 'posthog-node', '1.0.0')).not.toHaveProperty( + 'deployment.environment' + ) + }) + + it('protects SDK identity keys from user resource attributes', () => { + const attributes = buildTracesResourceAttributes( + config({ resourceAttributes: { 'telemetry.sdk.name': 'custom', 'host.name': 'web-01' } }), + 'posthog-node', + '1.0.0' + ) + expect(attributes['telemetry.sdk.name']).toBe('posthog-node') + expect(attributes['host.name']).toBe('web-01') + }) + }) + + describe('buildOtlpTracesPayload', () => { + it('produces one resource, one scope, N spans', () => { + const spans = [buildOtlpSpan(record()), buildOtlpSpan(record({ name: 'other' }))] + const payload = buildOtlpTracesPayload(spans, { 'service.name': 'checkout' }, 'posthog-node', '1.0.0') + + expect(payload.resourceSpans).toHaveLength(1) + expect(payload.resourceSpans[0].scopeSpans).toHaveLength(1) + expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2) + expect(payload.resourceSpans[0].scopeSpans[0].scope).toEqual({ name: 'posthog-node', version: '1.0.0' }) + expect(payload.resourceSpans[0].resource.attributes).toEqual([ + { key: 'service.name', value: { stringValue: 'checkout' } }, + ]) + }) + }) + + describe('golden wire fixture', () => { + it('matches the shape the ingestion service accepts', () => { + // Pinned against the OTLP/JSON encoding the capture-logs service's own + // trace fixtures use: hex ids, string nanosecond timestamps, integer kind + // and status enums, and stringified int64 attribute values. + const payload = buildOtlpTracesPayload( + [ + buildOtlpSpan( + record({ + parentSpanId: 'b7ad6b7169203331', + name: 'GET /users/:id', + kind: 'server', + status: { code: 'error', message: 'boom' }, + attributes: { + posthogDistinctId: 'user-123', + sessionId: 'session-123', + 'http.status_code': 500, + 'http.duration_ratio': 0.25, + cached: false, + }, + events: [ + { + name: 'exception', + timestamp: 1_700_000_000_040, + attributes: { 'exception.type': 'TypeError', 'exception.message': 'boom' }, + }, + ], + }) + ), + ], + { 'service.name': 'checkout-api', 'telemetry.sdk.name': 'posthog-node' }, + 'posthog-node', + '1.0.0' + ) + + expect(payload).toEqual({ + resourceSpans: [ + { + resource: { + attributes: [ + { key: 'service.name', value: { stringValue: 'checkout-api' } }, + { key: 'telemetry.sdk.name', value: { stringValue: 'posthog-node' } }, + ], + }, + scopeSpans: [ + { + scope: { name: 'posthog-node', version: '1.0.0' }, + spans: [ + { + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + spanId: '00f067aa0ba902b7', + parentSpanId: 'b7ad6b7169203331', + name: 'GET /users/:id', + kind: 2, + startTimeUnixNano: '1700000000000000000', + endTimeUnixNano: '1700000000080000000', + flags: 0x101, + attributes: [ + { key: 'posthogDistinctId', value: { stringValue: 'user-123' } }, + { key: 'sessionId', value: { stringValue: 'session-123' } }, + { key: 'http.status_code', value: { intValue: '500' } }, + { key: 'http.duration_ratio', value: { doubleValue: 0.25 } }, + { key: 'cached', value: { boolValue: false } }, + ], + events: [ + { + name: 'exception', + timeUnixNano: '1700000000040000000', + attributes: [ + { key: 'exception.type', value: { stringValue: 'TypeError' } }, + { key: 'exception.message', value: { stringValue: 'boom' } }, + ], + }, + ], + status: { code: 2, message: 'boom' }, + }, + ], + }, + ], + }, + ], + }) + }) + }) +}) + +describe('wire string safety', () => { + const lone = 'value \u{1F680}'.slice(0, 7) + + const encode = (record: Partial = {}): OtlpSpan => + buildOtlpSpan({ + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + name: 'checkout', + kind: 'internal', + attributes: {}, + events: [], + startTime: 1, + endTime: 2, + ...record, + } as SpanRecord) + + it('replaces a lone surrogate in every free-text field', () => { + const span = encode({ + name: lone, + traceState: `vendor=${lone}`, + status: { code: 'error', message: lone }, + events: [{ name: lone, timestamp: 1 }], + }) + + expect(span.name).not.toContain('\ud83d') + expect(span.events?.[0].name).not.toContain('\ud83d') + expect(span.status?.message).not.toContain('\ud83d') + expect(span.traceState).not.toContain('\ud83d') + }) + + it('keeps the span when a status message cannot be stringified', () => { + const hostile = { + toString() { + throw new Error('toString exploded') + }, + } + const span = encode({ status: { code: 'error', message: hostile as unknown as string } }) + + expect(span.name).toBe('checkout') + expect(span.status?.message).toBe('[Unserializable]') + }) + + it('coerces a non-string status message', () => { + const span = encode({ status: { code: 'error', message: 500 as unknown as string } }) + + expect(span.status?.message).toBe('500') + }) + + it('falls back to internal for a prototype key passed as a kind', () => { + expect(spanKindToOtlp('__proto__' as never)).toBe(1) + expect(spanKindToOtlp('toString' as never)).toBe(1) + }) +}) diff --git a/packages/core/src/traces/otlp.ts b/packages/core/src/traces/otlp.ts new file mode 100644 index 0000000000..0ca8203293 --- /dev/null +++ b/packages/core/src/traces/otlp.ts @@ -0,0 +1,191 @@ +import type { + OtlpSpan, + OtlpSpanEvent, + OtlpSpanKeyValue, + OtlpTracesPayload, + SpanAttributes, + SpanKind, + SpanStatusCode, +} from '@posthog/types' +import type { Logger } from '../types' +import type { ResolvedTracesConfig, SpanRecord } from './types' +import { nonNegativeCount } from './span' +import { toOtlpKeyValueList } from '../utils/otlp-any-value' +import { UNSERIALIZABLE_VALUE, sanitizeString } from '../utils/json-utils' +import { buildOtlpResourceAttributes } from '../utils/otlp-resource' + +const SPAN_KIND_TO_OTLP: Record = { + internal: 1, + server: 2, + client: 3, + producer: 4, + consumer: 5, +} + +const SPAN_STATUS_TO_OTLP: Record = { + ok: 1, + error: 2, +} + +/** W3C trace flags live in the low byte; the sampled bit is `0x01`. */ +const TRACE_FLAGS_SAMPLED = 0x01 +// OTel's span flags, above the W3C byte: one bit says the parent's remoteness is +// known, the other says it is remote. Both unset reads as "unknown", which this +// SDK never has to say — a string parent is remote, a handle parent is local, and +// a root span's parent context is empty, which is not remote. Setting the known +// bit on a root span is what the OTel Go and Java exporters do too. +const SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE = 0x100 +const SPAN_FLAGS_CONTEXT_IS_REMOTE = 0x200 + +/** The `flags` field for a span: its W3C trace-flags byte, plus OTel's parent-remoteness bits. */ +function spanFlags(record: SpanRecord): number { + const traceFlags = parseInt(record.traceFlags, 16) + const w3c = Number.isFinite(traceFlags) ? traceFlags & 0xff : TRACE_FLAGS_SAMPLED + return w3c | SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE | (record.parentIsRemote ? SPAN_FLAGS_CONTEXT_IS_REMOTE : 0) +} + +/** + * Every free-text string this encoder puts on the wire. A lone surrogate survives + * `JSON.stringify` as a `\uD800` escape that strict parsers refuse, and the + * ingestion service refuses the whole request rather than the one row — so the + * safe path has to be the default, not the exception. + */ +function wireString(value: unknown): string { + if (typeof value === 'string') { + return sanitizeString(value) + } + try { + return sanitizeString(String(value)) + } catch { + // A hostile `toString` costs its own field, not the whole span. + return UNSERIALIZABLE_VALUE + } +} + +export function spanKindToOtlp(kind: SpanKind | undefined): number { + // `hasOwnProperty`, not a plain lookup: `kind: '__proto__'` from an untyped + // caller otherwise resolves to `Object.prototype` and ships `"kind":{}`. + if (kind && Object.prototype.hasOwnProperty.call(SPAN_KIND_TO_OTLP, kind)) { + return SPAN_KIND_TO_OTLP[kind] + } + return SPAN_KIND_TO_OTLP.internal +} + +/** + * Converts a millisecond epoch to the unix-nanosecond string OTLP expects. + * Concatenation rather than multiplication: `Date.now() * 1e6` exceeds + * `Number.MAX_SAFE_INTEGER`. + */ +export function msToUnixNanoString(ms: number): string { + let whole = Math.floor(ms) + let fractionalNanos = Math.round((ms - whole) * 1e6) + // Rounding can carry into the next millisecond. Without this the padded + // fraction gains a seventh digit and the concatenated timestamp is malformed, + // which 400s the entire request — the exact failure client-side validity + // exists to prevent. + if (fractionalNanos >= 1e6) { + whole += 1 + fractionalNanos = 0 + } + return String(whole) + String(fractionalNanos).padStart(6, '0') +} + +function toOtlpEvent(event: SpanRecord['events'][number], logger?: Logger): OtlpSpanEvent { + const encoded: OtlpSpanEvent = { + name: wireString(event.name), + timeUnixNano: msToUnixNanoString(event.timestamp), + } + if (event.attributes) { + const attributes = toOtlpKeyValueList(event.attributes, logger) + if (attributes.length) { + encoded.attributes = attributes + } + } + const dropped = nonNegativeCount(event.droppedAttributesCount) + if (dropped) { + encoded.droppedAttributesCount = dropped + } + return encoded +} + +export function buildOtlpSpan(record: SpanRecord, logger?: Logger): OtlpSpan { + const span: OtlpSpan = { + traceId: record.traceId, + spanId: record.spanId, + name: wireString(record.name), + kind: spanKindToOtlp(record.kind), + startTimeUnixNano: msToUnixNanoString(record.startTime), + endTimeUnixNano: msToUnixNanoString(record.endTime), + flags: spanFlags(record), + } + if (record.parentSpanId) { + span.parentSpanId = record.parentSpanId + } + if (record.traceState) { + span.traceState = wireString(record.traceState) + } + const attributes = toOtlpKeyValueList(record.attributes, logger) + if (attributes.length) { + span.attributes = attributes + } + if (record.events.length) { + span.events = record.events.map((event) => toOtlpEvent(event, logger)) + } + // Coerced: a `beforeSpanSend` hook can write anything onto the record, and a + // non-integer here is refused for the whole request. + const droppedAttributes = nonNegativeCount(record.droppedAttributesCount) + if (droppedAttributes) { + span.droppedAttributesCount = droppedAttributes + } + const droppedEvents = nonNegativeCount(record.droppedEventsCount) + if (droppedEvents) { + span.droppedEventsCount = droppedEvents + } + if (record.status) { + span.status = { + code: SPAN_STATUS_TO_OTLP[record.status.code], + ...(record.status.message && { message: wireString(record.status.message) }), + } + } + return span +} + +/** + * OTLP resource attributes for every batch. User `resourceAttributes` are spread + * first, then SDK-controlled identity keys on top so a stray key can't clobber + * them. `service.name` is always emitted: the server reads `service_name` only + * from that attribute, and spans are unattributable without it. + */ +export function buildTracesResourceAttributes( + config: ResolvedTracesConfig, + sdkName: string, + sdkVersion: string +): SpanAttributes { + return buildOtlpResourceAttributes(config, sdkName, sdkVersion) +} + +/** + * Wraps spans in the OTLP `resourceSpans` envelope: one resource, one scope, N + * spans per batch. The server flattens the scope to `{name}@{version}`. + */ +export function buildOtlpTracesPayload( + spans: OtlpSpan[], + resourceAttributes: SpanAttributes, + scopeName: string, + scopeVersion: string, + logger?: Logger +): OtlpTracesPayload { + return { + resourceSpans: [ + { + resource: { attributes: toOtlpKeyValueList(resourceAttributes, logger) }, + scopeSpans: [ + { + scope: { name: scopeName, version: scopeVersion }, + spans, + }, + ], + }, + ], + } +} diff --git a/packages/core/src/traces/sanitize.ts b/packages/core/src/traces/sanitize.ts new file mode 100644 index 0000000000..701286f4db --- /dev/null +++ b/packages/core/src/traces/sanitize.ts @@ -0,0 +1,116 @@ +// Client-side validity. +// +// The ingestion service 400s the *entire request* when one span fails row +// conversion — a timestamp outside signed 64-bit nanoseconds, say — and a 400 is +// non-retriable, so one bad span destroys every other span in the batch. + +import type { Logger } from '../types' +import type { SpanAttributes, SpanTimeInput } from '@posthog/types' + +const FALLBACK_SPAN_NAME = 'unknown' + +// OTLP declares the timestamp fields `fixed64`, but the service parses them as +// signed 64-bit, so a negative (pre-epoch) value is as invalid as an overflow. +const MAX_TIMESTAMP_MS = 9223372036854 // floor(i64::MAX nanoseconds / 1e6) +const MIN_TIMESTAMP_MS = 0 + +// The server clamps timestamps more than 24h from receive time to now, keeping +// the original in `$originalTimestamp`. +const DEEP_BACKDATE_WARNING_MS = 24 * 60 * 60 * 1000 + +/** + * Span and event names must be non-empty. An empty or non-string name is + * replaced rather than dropped, so a mis-instrumented call site loses its name, + * not its span. `label` names what is being sanitized in the warning. + */ +export function sanitizeName(name: unknown, label: string, maxLength: number, logger?: Logger): string { + if (typeof name === 'string' && name.trim()) { + // Bounded like a status message and an attribute value: a name built from a + // URL or a payload is caller-controlled too, and one large enough takes the + // span past the ingestion body limit, which drops it whole. + return name.length > maxLength ? name.slice(0, maxLength) : name + } + logger?.debug(`${label} must be a non-empty string; using "${FALLBACK_SPAN_NAME}"`) + return FALLBACK_SPAN_NAME +} + +/** + * Normalizes a caller-supplied time to a millisecond epoch. + * + * Returns `undefined` for anything unusable — the wrong type, `NaN`, or outside + * the representable range — leaving the caller to fall back to a derived time. + */ +export function toEpochMs(value: SpanTimeInput | undefined): number | undefined { + if (value === undefined || value === null) { + return undefined + } + let ms: unknown = value + if (value instanceof Date) { + try { + ms = value.getTime() + } catch { + // `Object.create(Date.prototype)` passes `instanceof` without a Date slot. + return undefined + } + } + if (typeof ms !== 'number' || !Number.isFinite(ms)) { + return undefined + } + if (ms < MIN_TIMESTAMP_MS || ms > MAX_TIMESTAMP_MS) { + return undefined + } + return ms +} + +/** + * Resolves a caller-supplied start time, warning when it is deep enough in the + * past that the server will clamp it. + */ +export function resolveStartTime(value: SpanTimeInput | undefined, now: number, logger?: Logger): number { + const supplied = toEpochMs(value) + if (supplied === undefined) { + if (value !== undefined) { + logger?.debug('Span startTime is out of range or not a valid time; using the current time') + } + return now + } + if (now - supplied > DEEP_BACKDATE_WARNING_MS) { + logger?.debug( + 'Span startTime is more than 24 hours in the past; the server will clamp it to receive time and keep the original in $originalTimestamp' + ) + } else if (supplied > now) { + // Warned rather than clamped, matching the deep-backdate rule: the value is + // the caller's. The duration is what suffers, since the end clamps to it. + logger?.debug('Span startTime is in the future; the span may export with a zero duration') + } + return supplied +} + +/** + * Corrects an end time that precedes its start, producing a zero-duration span + * rather than a negative one the server would reject. + */ +export function clampEndTime(endTime: number, startTime: number): number { + return endTime < startTime ? startTime : endTime +} + +/** + * Keeps a caller-supplied end or event time inside the representable range, + * falling back to the span's own clock basis when it is unusable. `label` names + * what is being sanitized in the warning. + */ +export function resolveSuppliedTime( + value: SpanTimeInput | undefined, + derived: number, + label: string, + logger?: Logger +): number { + const supplied = toEpochMs(value) + if (supplied === undefined) { + if (value !== undefined) { + logger?.debug(`Span ${label} is out of range or not a valid time; using the derived time`) + } + return derived + } + return supplied +} diff --git a/packages/core/src/traces/span.spec.ts b/packages/core/src/traces/span.spec.ts new file mode 100644 index 0000000000..ad814772b7 --- /dev/null +++ b/packages/core/src/traces/span.spec.ts @@ -0,0 +1,1122 @@ +import { NOOP_SPAN, PostHogSpan, describeError, truncateAttributeValue } from './span' +import { buildOtlpSpan } from './otlp' +import { resolveTracesConfig } from './config' +import type { SpanInit } from './span' +import type { SpanRecord } from './types' +import type { Logger } from '../types' +import { createMockLogger } from '@/testing' +import { MAX_JSON_SAFE_VALUE_ITEMS, MAX_JSON_SAFE_VALUE_NODES } from '../utils/json-utils' + +const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736' +const SPAN_ID = '00f067aa0ba902b7' + +describe('PostHogSpan', () => { + let ended: SpanRecord[] + let logger: Logger + + const createSpan = (init: Partial = {}): PostHogSpan => + new PostHogSpan( + { + traceId: TRACE_ID, + spanId: SPAN_ID, + name: 'checkout', + kind: 'internal', + attributes: {}, + startTime: Date.now(), + backdated: false, + autoAttributeKeys: [], + maxAttributes: 128, + maxEvents: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + ...init, + }, + (record) => ended.push(record), + logger + ) + + beforeEach(() => { + ended = [] + logger = createMockLogger() + }) + + describe('monotonic clock', () => { + const withMonotonic = (readings: number[], run: () => void): void => { + const original = (globalThis as any).performance + let index = 0 + ;(globalThis as any).performance = { now: () => readings[Math.min(index++, readings.length - 1)] } + try { + run() + } finally { + ;(globalThis as any).performance = original + } + } + + it('measures duration against the monotonic reading, not the wall clock', () => { + const start = Date.now() + withMonotonic([1000, 1025], () => { + const span = createSpan({ startTime: start }) + vi.spyOn(Date, 'now').mockReturnValue(start - 60_000) + span.end() + }) + vi.spyOn(Date, 'now').mockRestore() + + expect(ended[0].endTime - ended[0].startTime).toBe(25) + }) + + it('never reports a negative duration when the monotonic source goes backwards', () => { + const start = Date.now() + withMonotonic([1000, 900], () => { + createSpan({ startTime: start }).end() + }) + + expect(ended[0].endTime).toBe(ended[0].startTime) + }) + + it('places an event inside the span window', () => { + const start = Date.now() + withMonotonic([1000, 1010, 1040], () => { + const span = createSpan({ startTime: start }) + span.addEvent('cache-miss') + span.end() + }) + + const [record] = ended + expect(record.events[0].timestamp).toBeGreaterThanOrEqual(record.startTime) + expect(record.events[0].timestamp).toBeLessThanOrEqual(record.endTime) + }) + + it('uses the wall clock for a backdated span', () => { + withMonotonic([1000, 9999], () => { + createSpan({ startTime: Date.now() - 5000, backdated: true }).end() + }) + + expect(ended[0].endTime - ended[0].startTime).toBeGreaterThanOrEqual(5000) + }) + }) + + it('produces exactly one record on end', () => { + createSpan().end() + expect(ended).toHaveLength(1) + expect(ended[0].name).toBe('checkout') + }) + + it('is idempotent on end', () => { + const span = createSpan() + span.end() + span.end() + expect(ended).toHaveLength(1) + }) + + it('ignores operations after end', () => { + const span = createSpan() + span.end() + span.setAttribute('k', 'v') + span.updateName('renamed') + span.addEvent('late') + + expect(ended[0].attributes).not.toHaveProperty('k') + expect(ended[0].name).toBe('checkout') + expect(ended[0].events).toHaveLength(0) + }) + + it('chains mutators', () => { + const span = createSpan() + span.setAttribute('a', 1).setAttributes({ b: 2 }).setStatus('ok').updateName('renamed') + span.end() + + expect(ended[0].attributes).toEqual({ a: 1, b: 2 }) + expect(ended[0].name).toBe('renamed') + expect(ended[0].status).toEqual({ code: 'ok' }) + }) + + it('replaces the name up until end', () => { + // A route template is often only knowable after routing resolves, and the + // product aggregates by (service, name) — so renaming has to be possible. + const span = createSpan({ name: 'HTTP request' }) + span.updateName('GET /users/:id') + span.end() + expect(ended[0].name).toBe('GET /users/:id') + }) + + it('replaces an empty name rather than dropping the span', () => { + const span = createSpan() + span.updateName(' ') + span.end() + expect(ended[0].name).toBe('unknown') + }) + + it('applies last-write-wins to status', () => { + const span = createSpan() + span.setStatus('ok') + span.setStatus('error', 'boom') + span.end() + expect(ended[0].status).toEqual({ code: 'error', message: 'boom' }) + }) + + it('omits status when never set', () => { + createSpan().end() + expect(ended[0].status).toBeUndefined() + }) + + it('ignores an unrecognized status along with its message', () => { + const rejected = createSpan() + expect((rejected as any).setStatus('OK', 'all good')).toBe(rejected) + rejected.end() + + const corrected = createSpan() + ;(corrected as any).setStatus('OK', 'all good') + corrected.setStatus('error', 'boom') + corrected.end() + + expect(ended[0].status).toBeUndefined() + expect(ended[1].status).toEqual({ code: 'error', message: 'boom' }) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('OK')) + }) + + describe('event cap', () => { + const fillEvents = (span: PostHogSpan, count: number): void => { + for (let i = 0; i < count; i++) { + span.addEvent(`step-${i}`) + } + } + + it('drops an exception event on a span that has filled its events', () => { + // The cap is absolute, so an exception arriving last is dropped like any + // other event. The span keeps its `error` status and `droppedEventsCount` + // reports the loss, which is what makes the case findable in production. + const span = createSpan({ maxEvents: 2 }) + fillEvents(span, 2) + span.recordException(new Error('boom')) + span.end() + + expect(ended[0].events.map((event) => event.name)).toEqual(['step-0', 'step-1']) + expect(ended[0].status).toEqual({ code: 'error', message: 'boom' }) + expect(ended[0].droppedEventsCount).toBe(1) + }) + + it('records an exception like any other event while the cap has room', () => { + const span = createSpan({ maxEvents: 128 }) + for (let i = 0; i < 20; i++) { + span.recordException(new Error(`boom-${i}`)) + } + span.end() + + expect(ended[0].events).toHaveLength(20) + expect(ended[0].droppedEventsCount).toBeUndefined() + }) + + it('does not let an exception-named event bypass the cap', () => { + // The cap counts events, not names: nothing about the name `exception` + // buys a slot, whoever wrote it. + const span = createSpan({ maxEvents: 2 }) + for (let i = 0; i < 7; i++) { + span.addEvent('exception', { mine: i }) + } + span.end() + + expect(ended[0].events).toHaveLength(2) + expect(ended[0].droppedEventsCount).toBe(5) + }) + + it('drops ordinary events past the cap', () => { + const span = createSpan({ maxEvents: 2 }) + fillEvents(span, 5) + span.end() + + expect(ended[0].events.map((event) => event.name)).toEqual(['step-0', 'step-1']) + expect(ended[0].droppedEventsCount).toBe(3) + }) + }) + + describe('event attribute cap', () => { + it('keeps the first attributes and reports the rest as dropped', () => { + const span = createSpan({ maxAttributesPerEvent: 2 }) + span.addEvent('query', { a: 1, b: 2, c: 3, d: 4 }) + span.end() + + expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2 }) + expect(ended[0].events[0].droppedAttributesCount).toBe(2) + }) + + it('leaves the count off an event that lost nothing', () => { + const span = createSpan({ maxAttributesPerEvent: 2 }) + span.addEvent('query', { a: 1, b: 2 }) + span.end() + + expect(ended[0].events[0].droppedAttributesCount).toBeUndefined() + }) + + it('bounds an exception event like any other', () => { + // The SDK's own `exception.*` attributes are width the caller sees too, so + // they spend the cap rather than being exempt from it. + const span = createSpan({ maxAttributesPerEvent: 1 }) + span.recordException(new Error('boom')) + span.end() + + expect(Object.keys(ended[0].events[0].attributes ?? {})).toEqual(['exception.type']) + expect(ended[0].events[0].droppedAttributesCount).toBe(2) + }) + + it('does not read a value past the cap', () => { + // The cap is spent before the value is bounded, so a wide bag does not pay + // for getters on entries that are about to be dropped. + const read: string[] = [] + const watched: any = {} + for (const key of ['a', 'b', 'c']) { + Object.defineProperty(watched, key, { + enumerable: true, + get() { + read.push(key) + return key + }, + }) + } + + const span = createSpan({ maxAttributesPerEvent: 2 }) + span.addEvent('query', watched) + span.end() + + expect(read).toEqual(['a', 'b']) + expect(ended[0].events[0].droppedAttributesCount).toBe(1) + }) + + it('does not let a nullish value spend a slot', () => { + // The encoder drops these, so a caller who blanked a value rather than + // omitting the key must not cost the event a real attribute. Same rule the + // span half of the cap already follows. + const span = createSpan({ maxAttributesPerEvent: 2 }) + span.addEvent('query', { blanked: undefined, cleared: null, real: 1, second: 2 }) + span.end() + + expect(ended[0].events[0].attributes).toEqual({ real: 1, second: 2 }) + expect(ended[0].events[0].droppedAttributesCount).toBeUndefined() + }) + + it('survives an attribute bag whose own keys cannot be read', () => { + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error('ownKeys exploded') + }, + } + ) + const span = createSpan() + expect(() => span.addEvent('query', hostile)).not.toThrow() + expect(() => span.end()).not.toThrow() + + expect(ended[0].events[0].attributes).toEqual({}) + }) + + it('drops the attribute past the shipped default and nothing before it', () => { + // Ties the default the SDK actually ships to the behaviour at its boundary: + // the other cases here pick small caps, so neither half moves the other. + const limit = resolveTracesConfig(undefined).maxAttributesPerEvent + const atLimit = Object.fromEntries(Array.from({ length: limit }, (_, index) => [`k${index}`, index])) + + const span = createSpan({ maxAttributesPerEvent: limit }) + span.addEvent('at-limit', atLimit) + span.addEvent('over-limit', { ...atLimit, extra: 1 }) + span.end() + + expect(ended[0].events[0].attributes).toEqual(atLimit) + expect(ended[0].events[0].droppedAttributesCount).toBeUndefined() + expect(ended[0].events[1].attributes).toEqual(atLimit) + expect(ended[0].events[1].droppedAttributesCount).toBe(1) + }) + + it('clamps a hook-written drop count to what the wire field holds', () => { + // The count is a uint32 on the wire, and a value over it is refused for the + // whole request rather than the one span that carried it. + const span = createSpan() + span.addEvent('query', { a: 1 }) + span.end() + ended[0].events[0].droppedAttributesCount = Number.MAX_SAFE_INTEGER + + expect(buildOtlpSpan(ended[0], logger).events?.[0].droppedAttributesCount).toBe(0xffff_ffff) + }) + + it('counts the span attribute cap separately from an event cap', () => { + // maxAttributesPerSpan does not reach inside events, which is the gap this + // cap closes: a span at its own cap can still carry full-width events. + const span = createSpan({ maxAttributes: 1, maxAttributesPerEvent: 3 }) + span.setAttributes({ kept: 1, dropped: 2 }) + span.addEvent('query', { a: 1, b: 2, c: 3 }) + span.end() + + expect(ended[0].attributes).toEqual({ kept: 1 }) + expect(ended[0].droppedAttributesCount).toBe(1) + expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2, c: 3 }) + expect(ended[0].events[0].droppedAttributesCount).toBeUndefined() + }) + }) + + describe('attribute store hygiene', () => { + it('does not copy a polluted Object.prototype key into the span', () => { + ;(Object.prototype as any).polluted = 'yes' + try { + const span = createSpan({ attributes: { real: 1 } }) + span.end() + + expect(Object.keys(ended[0].attributes)).toEqual(['real']) + } finally { + delete (Object.prototype as any).polluted + } + }) + }) + + describe('poison attributes', () => { + const withThrowingGetter = (): any => { + const attributes: any = { ok: 1 } + Object.defineProperty(attributes, 'boom', { + enumerable: true, + get() { + throw new Error('getter exploded') + }, + }) + return attributes + } + + it('marks only the throwing key on setAttributes', () => { + const span = createSpan() + expect(() => span.setAttributes(withThrowingGetter())).not.toThrow() + span.end() + + expect(ended[0].attributes).toEqual({ ok: 1, boom: '[Unserializable]' }) + }) + + it('marks only the throwing key on addEvent', () => { + const span = createSpan() + expect(() => span.addEvent('checkout.step', withThrowingGetter())).not.toThrow() + span.end() + + expect(ended[0].events[0].attributes).toEqual({ ok: 1, boom: '[Unserializable]' }) + }) + + it('keeps a __proto__ key on setAttribute', () => { + const span = createSpan() + span.setAttribute('__proto__', 'polluted') + span.end() + + expect(Object.keys(ended[0].attributes)).toContain('__proto__') + expect(Object.getPrototypeOf(ended[0].attributes)).toBe(Object.prototype) + }) + + it('copies only own enumerable keys on setAttributes', () => { + const span = createSpan() + span.setAttributes(Object.create({ inherited: 'proto' }, { own: { value: 'yes', enumerable: true } })) + span.end() + + expect(ended[0].attributes).toEqual({ own: 'yes' }) + }) + }) + + describe('recordException', () => { + it('sets error status and attaches an exception event without ending', () => { + const span = createSpan() + span.recordException(new TypeError('boom')) + + expect(ended).toHaveLength(0) + + span.end() + expect(ended[0].status).toEqual({ code: 'error', message: 'boom' }) + expect(ended[0].events).toEqual([ + expect.objectContaining({ + name: 'exception', + attributes: expect.objectContaining({ 'exception.type': 'TypeError', 'exception.message': 'boom' }), + }), + ]) + }) + + it('attaches the stack as exception.stacktrace', () => { + const span = createSpan() + span.recordException(new TypeError('boom')) + span.end() + + const stack = ended[0].events[0].attributes?.['exception.stacktrace'] + expect(stack).toEqual(expect.stringContaining('TypeError: boom')) + }) + + it('bounds the stack by maxAttributeValueLength', () => { + const span = createSpan({ maxAttributeValueLength: 40 }) + const error = new Error('boom') + error.stack = `Error: boom\n${' at somewhere deep\n'.repeat(500)}` + + span.recordException(error) + span.end() + + expect(ended[0].events[0].attributes?.['exception.stacktrace']).toHaveLength(40) + }) + + it('records an exception with no stack without inventing one', () => { + const span = createSpan() + span.recordException('just a string') + span.end() + + expect(ended[0].events[0].attributes).not.toHaveProperty('exception.stacktrace') + }) + }) + + describe('maxAttributeValueLength', () => { + it('truncates a long string attribute without counting it as dropped', () => { + const span = createSpan({ maxAttributeValueLength: 10 }) + span.setAttribute('payload', 'x'.repeat(5000)) + span.end() + + expect(ended[0].attributes.payload).toBe('xxxxxxxxxx') + // The count is for whole entries; a trimmed value is still exported. + expect(ended[0].droppedAttributesCount).toBeUndefined() + }) + + it('truncates the strings inside an array attribute, and leaves other types alone', () => { + const span = createSpan({ maxAttributeValueLength: 3 }) + span.setAttributes({ tags: ['abcdef', 'ab'], count: 1234567, flag: true }) + span.end() + + expect(ended[0].attributes).toMatchObject({ tags: ['abc', 'ab'], count: 1234567, flag: true }) + }) + + it('truncates strings nested inside an object value', () => { + // `setAttribute('payload', { body: res.body })` is the natural way to attach + // a response, and an unbounded one is what pushes a span past the endpoint. + const span = createSpan({ maxAttributeValueLength: 4 }) + span.setAttribute('payload', { body: 'abcdefgh', status: 200 }) + span.end() + + expect(ended[0].attributes.payload).toEqual({ body: 'abcd', status: 200 }) + }) + + it('truncates strings nested inside an array value', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + span.setAttribute('rows', [{ body: 'abcdefgh' }, ['abcdefgh']]) + span.end() + + expect(ended[0].attributes.rows).toEqual([{ body: 'abcd' }, ['abcd']]) + }) + + it('bounds the work a shared subtree costs, not just a cyclic one', () => { + // Siblings pointing at one object are re-walked once per path, so this + // reaches the same leaves ten million times. Only the node budget stops it. + const leaf: string[] = [] + for (let i = 0; i < 1000; i++) { + leaf.push('x'.repeat(2000)) + } + const mid = Array.from({ length: 1000 }, () => leaf) + const shared = Array.from({ length: 10 }, () => mid) + + const bounded = truncateAttributeValue(shared, 8) + + // Counting what the walk shortened, not what the result can reach: past + // the budget the original is handed back by reference. + const cap = MAX_JSON_SAFE_VALUE_NODES * 2 + let shortened = 0 + const stack: unknown[] = [bounded] + while (stack.length && shortened <= cap) { + const value = stack.pop() + if (typeof value === 'string') { + if (value.length === 8) { + shortened++ + } + } else if (Array.isArray(value) && value !== leaf && value !== mid) { + stack.push(...value) + } + } + expect(shortened).toBeLessThanOrEqual(cap) + }) + + it('terminates on a self-referencing value', () => { + const cyclic: any = { body: 'abcdefgh' } + cyclic.self = cyclic + const span = createSpan({ maxAttributeValueLength: 4 }) + + expect(() => { + span.setAttribute('payload', cyclic) + span.end() + }).not.toThrow() + }) + + it('charges a throwing accessor to its own key, still bounding its siblings', () => { + // A lazy ORM relation next to a large field is the shape that matters: if + // the throw abandons the whole walk, the large field ships at full length. + const span = createSpan({ maxAttributeValueLength: 4 }) + const hostile = { + ok: 'abcdefgh', + get boom() { + throw new Error('getter exploded') + }, + } + + expect(() => { + span.setAttribute('payload', hostile as any) + span.end() + }).not.toThrow() + + const payload = ended[0].attributes.payload as Record + expect(payload.ok).toBe('abcd') + expect(payload.boom).toBe('[Unserializable]') + }) + + it('does not walk into a value whose toJSON redacts it', () => { + // Copying the object's own keys would hand the encoder a plain object it + // no longer recognises as self-describing, putting the internals of a + // value that redacts itself on the wire. + class Redacted { + constructor(public secret: string) {} + toJSON(): null { + return null + } + } + const span = createSpan({ maxAttributeValueLength: 10 }) + + span.setAttribute('payload', { inner: new Redacted('S'.repeat(50)) } as any) + span.end() + + // The string the encoder builds from the same `null`, so the wire is + // unchanged, and the secret is nowhere in what the span kept. + expect((ended[0].attributes.payload as any).inner).toBe('null') + expect(JSON.stringify(ended[0].attributes)).not.toContain('S') + }) + + it('materializes a toJSON that resolves to nothing, so a second call cannot answer differently', () => { + // Keeping the object itself left the encoder to probe toJSON again. A + // serializer that answered `null` here could answer with a megabyte + // there, past the bound entirely. + let calls = 0 + const stateful = { + toJSON: () => { + calls++ + return calls === 1 ? null : 'x'.repeat(100) + }, + } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('doc', stateful as any) + span.end() + + expect(calls).toBe(1) + expect(ended[0].attributes.doc).toBe('null') + }) + + it('keeps a stateful toJSON bounded through to the encoded span', () => { + // End to end, because the second call is the encoder's: what the span + // stored has to leave it nothing to call. + let calls = 0 + const stateful = { + toJSON: () => { + calls++ + return calls === 1 ? null : 'x'.repeat(100) + }, + } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('doc', stateful as any) + span.end() + const encoded = buildOtlpSpan(ended[0]) + + expect(calls).toBe(1) + expect(encoded.attributes?.find((attribute) => attribute.key === 'doc')).toEqual({ + key: 'doc', + value: { stringValue: 'null' }, + }) + }) + + it('describes a toJSON resolving to undefined the way the encoder would', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('ghost', { toJSON: () => undefined } as any) + span.end() + + // Not trimmed to the bound: this is the SDK's own marker, like + // `[Circular]`, and `unde` reads as nothing at all. + expect(ended[0].attributes.ghost).toBe('undefined') + }) + + it('bounds event attributes too', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + span.addEvent('cache.miss', { key: 'abcdefgh' }) + span.end() + + expect(ended[0].events[0].attributes).toEqual({ key: 'abcd' }) + }) + + it('walks a value that points at itself twice only once', () => { + // Depth alone is not a bound. Two back-references at each level cost + // 2 ** 20 visits, which is a quarter-second inside the caller's own + // `setAttribute` call, and a third reference is minutes. + let reads = 0 + const cyclic: any = { + get body() { + reads++ + return 'abcdefgh' + }, + } + cyclic.self1 = cyclic + cyclic.self2 = cyclic + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', cyclic) + span.end() + + expect(reads).toBe(1) + }) + + it('bounds the nodes an acyclic value costs, not just its depth', () => { + // Siblings sharing a subtree are not a cycle, so the ancestor set does not + // catch them: 3 ** 12 visits without a node budget. + let reads = 0 + let level: any = { + get body() { + reads++ + return 'abcdefgh' + }, + } + for (let i = 0; i < 12; i++) { + level = { a: level, b: level, c: level } + } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', level) + span.end() + + expect(reads).toBeLessThanOrEqual(10_000) + }) + + it('bounds the value a toJSON produces, which is what the encoder puts on the wire', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('doc', { toJSON: () => 'abcdefgh' } as any) + span.end() + + expect(ended[0].attributes.doc).toBe('abcd') + }) + + it('resolves toJSON exactly once, so a second call cannot dodge the bound', () => { + // Returning the original object when nothing needed shortening left the + // encoder to call toJSON again — a value that answered differently the + // second time reached the wire unbounded. + let calls = 0 + const doc = { + toJSON: () => { + calls++ + return calls === 1 ? 'ab' : 'x'.repeat(4000) + }, + } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('doc', doc as any) + span.end() + + expect(calls).toBe(1) + expect(ended[0].attributes.doc).toBe('ab') + }) + + it('bounds a string that follows a large collection of nulls', () => { + // The encoder drops a nullish value without spending its budget, so a walk + // that charges for one runs out first and leaves the string after it + // unbounded on both sides — a 2 MB value under a bound of 8. + const span = createSpan({ maxAttributeValueLength: 8 }) + + span.setAttribute('payload', { + rows: Array.from({ length: 400 }, () => + Object.fromEntries(Array.from({ length: 50 }, (_unused, index) => [`c${index}`, null])) + ), + html: 'X'.repeat(50000), + }) + span.end() + + expect((ended[0].attributes.payload as any).html).toHaveLength(8) + }) + + it('bounds a string that follows a large collection', () => { + // The traversal budget is spent on containers, not leaves: a big array + // used to exhaust it and leave every later string at full length. + const span = createSpan({ maxAttributeValueLength: 8 }) + + span.setAttribute('payload', { + rows: Array.from({ length: 20000 }, (_, index) => index), + html: 'X'.repeat(50000), + }) + span.end() + + expect((ended[0].attributes.payload as any).html).toHaveLength(8) + }) + + it('keeps a nested __proto__ key as an ordinary entry', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', JSON.parse('{"__proto__": {"body": "abcdefgh"}}')) + span.end() + + const payload = ended[0].attributes.payload as Record + expect(Object.keys(payload)).toEqual(['__proto__']) + expect(Object.getOwnPropertyDescriptor(payload, '__proto__')?.value).toEqual({ body: 'abcd' }) + }) + + it('bounds an event name the SDK records like any other', () => { + const span = createSpan({ maxAttributeValueLength: 8, maxEvents: 4 }) + + span.recordException(new Error('boom')) + span.end() + + expect(ended[0].events[0].name).toBe('exceptio') + expect(ended[0].events[0].attributes?.['exception.type']).toBe('Error') + }) + + it('bounds a span name and an event name, like a status message', () => { + // A name built from a URL is caller-controlled, and one large enough takes + // the span past the ingestion body limit. + const span = createSpan({ maxAttributeValueLength: 12 }) + + span.updateName('abcdefghijklmnop') + span.addEvent('abcdefghijklmnop') + span.end() + + expect(ended[0].name).toBe('abcdefghijkl') + expect(ended[0].events[0].name).toBe('abcdefghijkl') + }) + + it('bounds a status message', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setStatus('error', 'abcdefgh') + span.end() + + expect(ended[0].status).toEqual({ code: 'error', message: 'abcd' }) + }) + + it('bounds the status message recordException sets, like the event attribute', () => { + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.recordException(new Error('abcdefgh')) + span.end() + + expect(ended[0].status?.message).toBe('abcd') + expect(ended[0].events[0].attributes?.['exception.message']).toBe('abcd') + }) + + it('replaces a back-reference with the marker rather than the value itself', () => { + // Handing the raw ancestor back left it inside a copied parent, where the + // encoder's own cycle detection no longer recognised it and walked one + // more level of its strings at full length. + const cyclic: any = { body: 'abcdefgh' } + cyclic.self = cyclic + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', cyclic) + span.end() + + expect(ended[0].attributes.payload).toEqual({ body: 'abcd', self: '[Circular]' }) + }) + + it('still bounds an array whose accessor past the encoder cap throws', () => { + // `slice()` read the whole array to copy it, so one throwing accessor + // beyond the encoder's cap cost every item in range its bound. + const rows: unknown[] = ['abcdefgh'] + for (let index = 1; index < MAX_JSON_SAFE_VALUE_ITEMS + 200; index++) { + rows.push('x') + } + Object.defineProperty(rows, MAX_JSON_SAFE_VALUE_ITEMS + 100, { + get: () => { + throw new Error('lazy relation') + }, + enumerable: true, + configurable: true, + }) + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('rows', rows as any) + span.end() + + expect((ended[0].attributes.rows as unknown[])[0]).toBe('abcd') + }) + + it('stops reading keys where the encoder stops emitting them', () => { + // Every key was read even though the encoder emits at most the cap, so a + // wide object charged `setAttribute` for getters that never ship. + let reads = 0 + const wide: Record = {} + for (let index = 0; index < MAX_JSON_SAFE_VALUE_ITEMS * 2; index++) { + Object.defineProperty(wide, `k${index}`, { + get: () => { + reads++ + return 'v' + }, + enumerable: true, + configurable: true, + }) + } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', wide) + span.end() + + expect(reads).toBe(MAX_JSON_SAFE_VALUE_ITEMS) + }) + + it('copies a nested value the caller goes on to mutate', () => { + // A value that needed no truncation was attached as it came, so the span + // held caller-owned state and shipped whatever it was changed to. + const nested = { body: 'ok' } + const span = createSpan({ maxAttributeValueLength: 4 }) + + span.setAttribute('payload', { nested }) + nested.body = 'abcdefgh' + span.end() + + expect(ended[0].attributes.payload).toEqual({ nested: { body: 'ok' } }) + }) + + it('leaves a Date whole rather than truncating its timestamp', () => { + // The encoder emits a Date from its own branch ahead of any `toJSON`, so + // bounding it here shipped a cut-off timestamp instead of a shorter one. + const span = createSpan({ maxAttributeValueLength: 10 }) + + span.setAttribute('when', new Date('2020-01-02T03:04:05.000Z') as never) + span.end() + + expect(ended[0].attributes.when).toEqual(new Date('2020-01-02T03:04:05.000Z')) + }) + + it('bounds an SDK-attached value, which is exempt from the count cap only', () => { + const span = createSpan({ + maxAttributeValueLength: 4, + attributes: { posthogDistinctId: 'user-12345' }, + autoAttributeKeys: ['posthogDistinctId'], + }) + span.setAttribute('posthogDistinctId', 'user-12345') + span.end() + + expect(ended[0].attributes.posthogDistinctId).toBe('user') + }) + }) + + describe('timestamps', () => { + it('records an end at or after the start', () => { + const span = createSpan() + span.end() + expect(ended[0].endTime).toBeGreaterThanOrEqual(ended[0].startTime) + }) + + it('honours an explicit end time', () => { + const start = 1_700_000_000_000 + const span = createSpan({ startTime: start, backdated: true }) + span.end(start + 5000) + expect(ended[0].endTime).toBe(start + 5000) + }) + + it('accepts a Date as an end time', () => { + const start = 1_700_000_000_000 + const span = createSpan({ startTime: start, backdated: true }) + span.end(new Date(start + 1000)) + expect(ended[0].endTime).toBe(start + 1000) + }) + + it('corrects an end before the start to a zero duration', () => { + const start = 1_700_000_000_000 + const span = createSpan({ startTime: start, backdated: true }) + span.end(start - 5000) + expect(ended[0].endTime).toBe(start) + }) + + it('falls back to the derived end for an out-of-range end time', () => { + const start = 1_700_000_000_000 + const span = createSpan({ startTime: start, backdated: true }) + span.end(Number.MAX_SAFE_INTEGER) + expect(ended[0].endTime).toBeGreaterThanOrEqual(start) + expect(ended[0].endTime).toBeLessThan(9_223_372_036_854) + }) + + it('keeps event timestamps inside the span window', () => { + const span = createSpan() + span.addEvent('cache miss') + span.end() + + const [event] = ended[0].events + expect(event.timestamp).toBeGreaterThanOrEqual(ended[0].startTime) + expect(event.timestamp).toBeLessThanOrEqual(ended[0].endTime) + }) + + it('honours an explicit event timestamp', () => { + const start = 1_700_000_000_000 + const span = createSpan({ startTime: start, backdated: true }) + span.addEvent('cache miss', undefined, start + 40) + span.end(start + 80) + expect(ended[0].events[0].timestamp).toBe(start + 40) + }) + + it('snapshots event attributes so a reused object cannot mutate them', () => { + const span = createSpan() + const reused = { attempt: 1 } + span.addEvent('retry', reused) + reused.attempt = 2 + span.addEvent('retry', reused) + span.end() + + expect(ended[0].events.map((event) => event.attributes)).toEqual([{ attempt: 1 }, { attempt: 2 }]) + }) + }) + + describe('context propagation', () => { + it('produces a sampled traceparent', () => { + expect(createSpan().traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`) + }) + + it('returns null tracestate when it has none', () => { + expect(createSpan().tracestate()).toBeNull() + }) + + it('returns the tracestate it was created with', () => { + expect(createSpan({ traceState: 'vendor=abc' }).tracestate()).toBe('vendor=abc') + }) + + it('propagates the trace flags it was started with', () => { + expect(createSpan({ traceFlags: '00' }).traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`) + expect(createSpan().traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`) + }) + + it('hands a child the flags it propagates, so the whole chain agrees', () => { + expect(createSpan({ traceFlags: '00' }).childContext().traceFlags).toBe('00') + }) + + it('exposes a child context carrying its own span id as the parent', () => { + expect(createSpan({ traceState: 'vendor=abc' }).childContext()).toEqual({ + traceId: TRACE_ID, + parentSpanId: SPAN_ID, + traceState: 'vendor=abc', + traceFlags: '01', + }) + }) + }) +}) + +describe('NoopSpan', () => { + it('supports the full surface without throwing', () => { + expect(() => { + NOOP_SPAN.setAttribute('a', 1) + .setAttributes({ b: 2 }) + .addEvent('x') + .setStatus('error', 'boom') + .recordException(new Error('boom')) + .updateName('renamed') + .end() + }).not.toThrow() + }) + + it('never produces a well-formed traceparent', () => { + // An id that was never recorded must not propagate to another service. + expect(NOOP_SPAN.traceparent()).toBeNull() + expect(NOOP_SPAN.tracestate()).toBeNull() + }) +}) + +describe('attribute store', () => { + it('hands out a record whose attributes behave like an ordinary object', () => { + const ended: SpanRecord[] = [] + const span = new PostHogSpan( + { + traceId: TRACE_ID, + spanId: SPAN_ID, + name: 'checkout', + kind: 'internal', + attributes: { plan: 'pro' }, + startTime: Date.now(), + backdated: false, + autoAttributeKeys: [], + maxAttributes: 128, + maxEvents: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + }, + (record) => ended.push(record) + ) + span.end() + + const { attributes } = ended[0] + expect(Object.getPrototypeOf(attributes)).toBe(Object.prototype) + expect(Object.prototype.hasOwnProperty.call(attributes, 'plan')).toBe(true) + expect(() => JSON.stringify(attributes)).not.toThrow() + }) + + it('keeps a parsed __proto__ key as an ordinary attribute', () => { + const ended: SpanRecord[] = [] + const span = new PostHogSpan( + { + traceId: TRACE_ID, + spanId: SPAN_ID, + name: 'checkout', + kind: 'internal', + attributes: JSON.parse('{"__proto__": {"leaked": 1}, "orderId": "abc"}'), + startTime: Date.now(), + backdated: false, + autoAttributeKeys: [], + maxAttributes: 128, + maxEvents: 128, + maxAttributesPerEvent: 128, + maxAttributeValueLength: 8192, + }, + (record) => ended.push(record) + ) + span.end() + + const keys: string[] = [] + for (const key in ended[0].attributes) { + keys.push(key) + } + expect(keys).not.toContain('leaked') + }) +}) + +describe('describeError', () => { + it.each([ + ['an Error', new Error('boom'), { type: 'Error', message: 'boom' }], + ['a TypeError', new TypeError('bad type'), { type: 'TypeError', message: 'bad type' }], + ['a string', 'just a string', { type: 'string', message: 'just a string' }], + ['an object with a message', { name: 'CustomError', message: 'oops' }, { type: 'CustomError', message: 'oops' }], + ['an object without a name', { message: 'oops' }, { type: 'Object', message: 'oops' }], + ])('describes %s', (_name, error, expected) => { + expect(describeError(error)).toMatchObject(expected) + }) + + it('carries the stack where the thrown value has one, and nothing where it does not', () => { + expect(describeError(new Error('boom')).stack).toEqual(expect.stringContaining('Error: boom')) + expect(describeError('just a string').stack).toBeUndefined() + expect(describeError({ message: 'oops' }).stack).toBeUndefined() + }) + + it('survives a throwing stack accessor', () => { + const hostile = { + message: 'oops', + get stack() { + throw new Error('nope') + }, + } + expect(describeError(hostile)).toEqual({ type: 'Object', message: 'oops' }) + }) + + it('describes a thrown primitive', () => { + // Anything can be thrown in JS, so a non-Error must still produce a usable + // exception event rather than being dropped. + expect(describeError(42)).toEqual({ type: 'number', message: '42' }) + }) + + it('survives a value whose toString throws', () => { + const hostile = { + message: 123, + toString() { + throw new Error('boom from toString') + }, + } + expect(() => describeError(hostile)).not.toThrow() + expect(describeError(hostile)).toEqual({ type: 'object', message: '' }) + }) + + it('survives a value whose message getter throws', () => { + const hostile = { + get message(): string { + throw new Error('boom from getter') + }, + } + expect(() => describeError(hostile)).not.toThrow() + }) +}) diff --git a/packages/core/src/traces/span.ts b/packages/core/src/traces/span.ts new file mode 100644 index 0000000000..f36397b0e7 --- /dev/null +++ b/packages/core/src/traces/span.ts @@ -0,0 +1,822 @@ +import type { Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatusCode, SpanTimeInput } from '@posthog/types' +import type { Logger } from '../types' +import type { SpanContextManager, SpanEventRecord, SpanRecord } from './types' +import { + formatTraceparent, + normalizeTraceparent, + sanitizeTracestate, + traceparentHeader, + TRACE_FLAGS_SAMPLED, +} from './traceparent' +import { clampEndTime, resolveSuppliedTime, sanitizeName } from './sanitize' +import { isArray, isError, isNullish } from '../utils' +import { + CIRCULAR_VALUE, + MAX_JSON_SAFE_VALUE_DEPTH, + MAX_JSON_SAFE_VALUE_ITEMS, + MAX_JSON_SAFE_VALUE_NODES, + UNSERIALIZABLE_VALUE, + assignUserAttributes, +} from '../utils/json-utils' + +/** + * A monotonic millisecond reading where the platform has one, so an NTP + * correction mid-span can't produce a negative duration. + */ +export function monotonicNow(): number | undefined { + const perf = (globalThis as { performance?: { now?: () => number } }).performance + return typeof perf?.now === 'function' ? perf.now() : undefined +} + +export interface SpanInit { + traceId: string + spanId: string + parentSpanId?: string + traceState?: string + /** The trace-flags byte to propagate; the inbound one when continuing a remote trace. */ + traceFlags?: string + /** True when the parent came from a `traceparent` header rather than a local handle. */ + parentIsRemote?: boolean + name: string + kind: SpanKind + attributes: SpanAttributes + /** ms epoch. */ + startTime: number + /** True when the caller supplied an explicit `startTime`. */ + backdated: boolean + /** Keys the SDK attached itself. Exempt from the attribute cap and never evicted. */ + autoAttributeKeys: string[] + maxAttributes: number + maxEvents: number + maxAttributesPerEvent: number + maxAttributeValueLength: number +} + +export class PostHogSpan implements Span { + private readonly _traceId: string + private readonly _spanId: string + private readonly _parentSpanId?: string + private readonly _traceState?: string + private readonly _traceFlags: string + private readonly _parentIsRemote: boolean + private readonly _startTime: number + // Absent on backdated spans and on platforms with no monotonic source. + private readonly _startMono?: number + + private _name: string + private _kind: SpanKind + private _attributes: SpanAttributes + private _events: SpanEventRecord[] = [] + private _status?: { code: SpanStatusCode; message?: string } + private _ended = false + private readonly _autoKeys: Set + private readonly _maxAttributes: number + private readonly _maxEvents: number + private readonly _maxAttributesPerEvent: number + private readonly _maxAttributeValueLength: number + private _userAttributeCount = 0 + private _userEventCount = 0 + private _droppedAttributes = 0 + private _droppedEvents = 0 + + constructor( + init: SpanInit, + private readonly _onEnd: (record: SpanRecord, autoKeys: ReadonlySet) => void, + private readonly _logger?: Logger + ) { + this._traceId = init.traceId + this._spanId = init.spanId + this._parentSpanId = init.parentSpanId + this._traceState = init.traceState + this._traceFlags = init.traceFlags ?? TRACE_FLAGS_SAMPLED + this._parentIsRemote = init.parentIsRemote ?? false + this._name = init.name + this._kind = init.kind + this._autoKeys = new Set(init.autoAttributeKeys) + this._maxAttributes = init.maxAttributes + this._maxEvents = init.maxEvents + this._maxAttributesPerEvent = init.maxAttributesPerEvent + this._maxAttributeValueLength = init.maxAttributeValueLength + // Null-prototype: a `__proto__` key would otherwise swap this object's prototype + // instead of becoming an entry, and `toString` and friends would read as + // already-present. + this._attributes = Object.create(null) as SpanAttributes + // Object.keys, not for...in: the latter walks the prototype chain, so a + // polluted `Object.prototype` key would become an attribute of every span. + for (const key of Object.keys(init.attributes)) { + this._writeAttribute(key, init.attributes[key]) + } + this._startTime = init.startTime + this._startMono = init.backdated ? undefined : monotonicNow() + } + + /** + * "Now" on this span's clock basis: start plus monotonic elapsed where we + * have it, wall clock otherwise. + */ + private _now(): number { + if (this._startMono !== undefined) { + const mono = monotonicNow() + if (mono !== undefined) { + return this._startTime + Math.max(0, mono - this._startMono) + } + } + return Date.now() + } + + /** Guards every mutator: operations after `end()` no-op with a debug warning. */ + private _mutable(operation: string): boolean { + if (this._ended) { + this._logger?.debug(`Ignoring ${operation} on a span that has already ended`) + return false + } + return true + } + + /** + * Writes an attribute unless the span is already at its user-attribute cap. + * + * Overwriting a key already on the span always succeeds — the cap counts + * distinct user keys, not writes — and SDK-attached keys never count toward + * it, so a span at the cap still carries its person and session ids. + */ + private _writeAttribute(key: string, value: SpanAttributeValue): void { + // Nullish removes the key rather than occupying it: storing one would spend no + // budget and make every later write to that key free, exceeding the cap. + if (isNullish(value)) { + if (key in this._attributes && !this._autoKeys.has(key)) { + this._userAttributeCount-- + } + delete this._attributes[key] + return + } + // The cap is checked before the value is bounded: walking a value the span is + // about to drop is the dominant cost of a span that overflows its cap. + if (!this._autoKeys.has(key) && !(key in this._attributes)) { + if (this._userAttributeCount >= this._maxAttributes) { + this._droppedAttributes++ + return + } + this._userAttributeCount++ + } + this._attributes[key] = truncateAttributeValue(value, this._maxAttributeValueLength) + } + + setAttribute(key: string, value: SpanAttributeValue): this { + if (this._mutable('setAttribute')) { + this._writeAttribute(key, value) + } + return this + } + + setAttributes(attributes: SpanAttributes): this { + if (this._mutable('setAttributes')) { + // Read through the shared guard first — own enumerable keys only, and a + // throwing getter costs its own key — then write each through the cap. + const safe: SpanAttributes = assignUserAttributes({}, attributes) + for (const key of Object.keys(safe)) { + this._writeAttribute(key, safe[key]) + } + } + return this + } + + addEvent(name: string, attributes?: SpanAttributes, timestamp?: SpanTimeInput): this { + if (this._mutable('addEvent')) { + // An exception the SDK records spends an ordinary slot like any other + // event. A span that fills its events and then throws therefore keeps its + // `error` status but loses the exception detail, which `droppedEventsCount` + // reports — enough to find the case in production if it turns out to occur. + if (this._userEventCount >= this._maxEvents) { + this._droppedEvents++ + return this + } + this._userEventCount++ + // Copied so a caller reusing one object across events can't mutate a recorded one. + const bounded = + attributes && boundAttributes(attributes, this._maxAttributesPerEvent, this._maxAttributeValueLength) + this._events.push({ + name: sanitizeName(name, 'Span event name', this._maxAttributeValueLength, this._logger), + timestamp: resolveSuppliedTime(timestamp, this._now(), 'event timestamp', this._logger), + ...(bounded && { + attributes: bounded.attributes, + ...(bounded.dropped && { droppedAttributesCount: bounded.dropped }), + }), + }) + } + return this + } + + setStatus(status: SpanStatusCode, message?: string): this { + if (this._mutable('setStatus')) { + if (status !== 'ok' && status !== 'error') { + this._logger?.debug(`Ignoring unknown span status "${String(status)}"; expected "ok" or "error"`) + return this + } + // Bounded like an attribute value: a status message is one more string the + // caller controls, and one large enough takes the span past the body limit. + this._status = { + code: status, + ...(message && { message: truncateString(message, this._maxAttributeValueLength) }), + } + } + return this + } + + /** True when the caller explicitly marked the span `ok`; `withSpan` treats that as final. */ + get statusIsExplicitlyOk(): boolean { + return this._status?.code === 'ok' + } + + recordException(error: unknown): this { + if (!this._mutable('recordException')) { + return this + } + const { type, message, stack } = describeError(error) + this.addEvent(EXCEPTION_EVENT_NAME, { + 'exception.type': type, + 'exception.message': message, + ...(stack && { 'exception.stacktrace': stack }), + }) + // recordException is itself an explicit call, so it follows last-write-wins + // rather than deferring to an earlier `ok`. + return this.setStatus('error', message) + } + + updateName(name: string): this { + if (this._mutable('updateName')) { + this._name = sanitizeName(name, 'Span name', this._maxAttributeValueLength, this._logger) + } + return this + } + + traceparent(): string | null { + return formatTraceparent(this._traceId, this._spanId, this._traceFlags) + } + + tracestate(): string | null { + return this._traceState ?? null + } + + /** Context a child span inherits when this handle is its parent. */ + childContext(): { traceId: string; parentSpanId: string; traceState?: string; traceFlags: string } { + return { + traceId: this._traceId, + parentSpanId: this._spanId, + traceState: this._traceState, + // A child of a continued trace keeps propagating the caller's decision. + traceFlags: this._traceFlags, + } + } + + end(endTime?: SpanTimeInput): void { + if (this._ended) { + this._logger?.debug('Ignoring end() on a span that has already ended') + return + } + this._ended = true + + const derived = this._now() + const resolved = resolveSuppliedTime(endTime, derived, 'end time', this._logger) + + this._onEnd( + { + traceId: this._traceId, + spanId: this._spanId, + ...(this._parentSpanId && { parentSpanId: this._parentSpanId }), + ...(this._traceState && { traceState: this._traceState }), + traceFlags: this._traceFlags, + parentIsRemote: this._parentIsRemote, + name: this._name, + kind: this._kind, + ...(this._status && { status: this._status }), + // Copied out with an ordinary prototype: the store is null-prototype, but a + // record handed to user code should behave like a normal object. + attributes: { ...this._attributes }, + events: this._events, + startTime: this._startTime, + endTime: clampEndTime(resolved, this._startTime), + ...(this._droppedAttributes && { droppedAttributesCount: this._droppedAttributes }), + ...(this._droppedEvents && { droppedEventsCount: this._droppedEvents }), + }, + this._autoKeys + ) + } +} + +const EXCEPTION_EVENT_NAME = 'exception' + +/** The widest value the OTLP `dropped_*_count` fields, declared `uint32`, can carry. */ +const MAX_UINT32 = 0xffff_ffff + +/** A value as its string form, or the encoder's marker when it refuses to produce one. */ +function safeString(value: unknown): string { + try { + return typeof value === 'string' ? value : String(value) + } catch { + return UNSERIALIZABLE_VALUE + } +} + +/** + * A caller-visible counter read back as a number, or 0 for anything else. + * Clamped to the `uint32` the OTLP field is declared as: a `beforeSpanSend` hook + * can write a larger number onto an event, and one that overflows the field is + * refused for the whole request. + */ +export function nonNegativeCount(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return 0 + } + return Math.min(Math.floor(value), MAX_UINT32) +} + +/** + * The record's keys with the ones the span itself set first, in that order. + * + * `Object.keys` hoists integer-like keys to the front whatever the write order, + * so a hook adding `attributes['0']` would otherwise outrank an attribute the + * caller set before the hook ran — and the cap is documented as earliest-set-wins. + */ +function orderedKeys(attributes: SpanAttributes, keysBeforeHook: readonly string[]): string[] { + if (!keysBeforeHook.length) { + return Object.keys(attributes) + } + // The encoder's own predicate: `in` would walk the prototype chain, so a key + // the caller set that collides with Object.prototype survives the hook deleting + // it and reads back as the inherited member, and `hasOwnProperty` would keep a + // key the hook hid by making it non-enumerable, which the encoder never emits. + const beforeHook = keysBeforeHook.filter((key) => Object.prototype.propertyIsEnumerable.call(attributes, key)) + const seen = new Set(beforeHook) + return [...beforeHook, ...Object.keys(attributes).filter((key) => !seen.has(key))] +} + +/** + * Re-applies the per-span caps to a record a `beforeSpanSend` hook has already + * seen. The hook writes to the plain record, not through the span's own guarded + * writer, so an enriching hook would otherwise push a span past the cap it was + * trimmed to and back into the 413 path the cap exists to avoid. + * + * Earliest-set entries win, matching the span-side rule; SDK-attached keys are + * exempt. Counts add to whatever the span already dropped. + */ +export function applySpanLimits( + record: SpanRecord, + autoKeys: ReadonlySet, + maxAttributes: number, + maxEvents: number, + maxAttributesPerEvent: number, + maxAttributeValueLength: number, + keysBeforeHook: readonly string[] = [] +): void { + let kept = 0 + let droppedAttributes = 0 + // Built fresh rather than edited in place: a hook is free to return a record + // whose attributes it froze, and a `delete` on one throws. + const attributes: SpanAttributes = {} + for (const key of orderedKeys(record.attributes, keysBeforeHook)) { + const value = record.attributes[key] + // Matches `_writeAttribute`: the encoder drops these, so a hook that blanks a + // value rather than deleting the key must not evict a real attribute. + if (isNullish(value)) { + continue + } + if (!autoKeys.has(key)) { + if (kept >= maxAttributes) { + droppedAttributes++ + continue + } + kept++ + } + Object.defineProperty(attributes, key, { + value: truncateAttributeValue(value, maxAttributeValueLength), + enumerable: true, + writable: true, + configurable: true, + }) + } + record.attributes = attributes + if (droppedAttributes) { + // Coerced, not trusted: a hook can put anything in the counter, and a + // non-number there would erase the count the span itself accumulated. + record.droppedAttributesCount = nonNegativeCount(record.droppedAttributesCount) + droppedAttributes + } + + // Walked rather than sliced: a hook can append events or rewrite their + // attributes, neither of which goes through `addEvent`, so each one still + // needs its attributes bounded on the way past. + let keptEvents = 0 + let droppedEvents = 0 + const events: SpanEventRecord[] = [] + for (const event of record.events) { + if (keptEvents >= maxEvents) { + droppedEvents++ + continue + } + keptEvents++ + if (event.attributes) { + // A hook can widen an event as freely as it can add one, and neither goes + // through `addEvent`. + const bounded = boundAttributes(event.attributes, maxAttributesPerEvent, maxAttributeValueLength) + event.attributes = bounded.attributes + if (bounded.dropped) { + event.droppedAttributesCount = nonNegativeCount(event.droppedAttributesCount) + bounded.dropped + } + } + events.push(event) + } + record.events = events + if (droppedEvents) { + record.droppedEventsCount = nonNegativeCount(record.droppedEventsCount) + droppedEvents + } + if (record.status?.message) { + // Coerced first: a non-string would reach the encoder to be stringified at + // full length. Guarded, because a throwing `toString` here would cost the + // span, where the encoder downstream only marks the field. + record.status = { + ...record.status, + message: truncateString(safeString(record.status.message), maxAttributeValueLength), + } + } +} + +/** + * An inert handle returned whenever tracing cannot run — traces unconfigured, + * SDK disabled, user opted out. Supports the full surface so caller code never + * branches, and returns `null` from `traceparent()` so an id this SDK never + * recorded cannot propagate. + */ +export class NoopSpan implements Span { + setAttribute(): this { + return this + } + setAttributes(): this { + return this + } + addEvent(): this { + return this + } + setStatus(): this { + return this + } + recordException(): this { + return this + } + updateName(): this { + return this + } + traceparent(): string | null { + return null + } + tracestate(): string | null { + return null + } + end(): void {} +} + +// Typed as `Span`, not `NoopSpan`: the class's methods take no parameters, so the +// concrete type would reject calls the interface allows. +export const NOOP_SPAN: Span = /* @__PURE__ */ new NoopSpan() + +/** + * An inert handle that carries an inbound trace context. Records nothing, and + * echoes the `traceparent` it was handed — including the caller's version and + * sampled flag — so a service with tracing off still forwards the trace it + * received rather than severing it. The ids it propagates are the upstream + * caller's own; this SDK invents none. + */ +export class PassThroughSpan extends NoopSpan { + constructor( + private readonly _traceparent: string, + private readonly _tracestate?: string + ) { + super() + } + + override traceparent(): string { + return this._traceparent + } + + override tracestate(): string | null { + return this._tracestate ?? null + } +} + +/** + * The `stack` of whatever was thrown, as OTel's `exception.stacktrace`. Reads + * the property behind its own guard: a getter on a hostile object throws, and a + * thrown string has no stack at all. The value is bounded like any other + * attribute, by `maxAttributeValueLength`. + */ +function readStack(error: unknown): { stack?: string } { + try { + const stack = (error as { stack?: unknown }).stack + return typeof stack === 'string' && stack ? { stack } : {} + } catch { + return {} + } +} + +/** + * The handle to return when a span cannot be recorded: a pass-through when a + * context is available, the shared no-op otherwise. With no explicit `parent` + * the active handle supplies it, so an inbound trace survives nesting. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export function inertSpan(options?: { parent?: unknown; tracestate?: unknown }, active?: Span): Span { + const parent = traceparentHeader(options?.parent) ?? active + // A handle parent reports its own context, read behind a guard because a + // foreign handle's accessor may throw. A no-op reports none and stays a no-op. + const inbound = typeof parent === 'string' || parent == null ? parent : readHandle(parent, 'traceparent') + const traceparent = normalizeTraceparent(inbound) + if (!traceparent) { + return NOOP_SPAN + } + const tracestate = + typeof parent === 'string' || parent == null ? options?.tracestate : readHandle(parent, 'tracestate') + return new PassThroughSpan(traceparent, sanitizeTracestate(tracestate)) +} + +function readHandle(parent: unknown, method: 'traceparent' | 'tracestate'): unknown { + try { + const fn = (parent as Span)[method] + return typeof fn === 'function' ? fn.call(parent) : undefined + } catch { + return undefined + } +} + +/** One walk's budget, allocated per attribute value. */ +interface TruncateState { + /** Containers on the current path, so a back-reference stops the walk. */ + ancestors: WeakSet + /** Nodes this walk may still visit. */ + remainingNodes: number +} + +function truncateString(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) : value +} + +/** + * Bounds every string reachable from an attribute value to `maxLength` + * characters, including the strings nested inside arrays and objects. Numbers + * and booleans are bounded already. + * + * An unbounded value is the one thing the per-span caps do not stop: a single + * multi-MB attribute makes the whole span too large for the ingestion endpoint, + * and the 413 path then drops that span whole. `setAttribute('payload', { body })` + * is the usual way one arrives, so the bound has to reach inside the value. + * + * Returns the value it was given when nothing needed shortening, so the common + * case allocates nothing. + */ +export function truncateAttributeValue(value: SpanAttributeValue, maxLength: number): SpanAttributeValue { + return truncateValue(value, maxLength, { ancestors: new WeakSet(), remainingNodes: MAX_JSON_SAFE_VALUE_NODES }, 0) +} + +/** + * Walks under the same depth cap, node budget and ancestor set as + * `encodeAnyValue`, charging the same values. The encoder spends one budget + * across the whole attribute bag where this spends one per value, so it runs out + * no later and marks whatever this walk returned whole. + * + * Depth alone does not bound this: a value whose children point back at their + * siblings costs `fanout ** depth` visits, which is minutes of synchronous work + * inside the caller's own `setAttribute` call. + */ +function truncateValue( + value: SpanAttributeValue, + maxLength: number, + state: TruncateState, + depth: number +): SpanAttributeValue { + if (value === null || typeof value !== 'object') { + // Free, as it is in the encoder, which drops a nullish leaf without charging. + if (isNullish(value)) { + return value + } + // A shared subtree is re-walked once per path reaching it, so a leaf that + // skips the charge lets one value cost `budget * items` string copies. + if (state.remainingNodes <= 0) { + return value + } + state.remainingNodes-- + return typeof value === 'string' ? truncateString(value, maxLength) : value + } + if (state.ancestors.has(value)) { + // The marker the encoder would produce, not the value itself. Handing the + // raw ancestor back puts it inside a *copied* parent, where the encoder's + // own cycle detection no longer recognises it and walks one more level of + // its strings at full length. + return CIRCULAR_VALUE + } + if (state.remainingNodes <= 0 || depth >= MAX_JSON_SAFE_VALUE_DEPTH) { + return value + } + state.remainingNodes-- + state.ancestors.add(value) + try { + // A Date is emitted by the encoder from its own branch, ahead of any + // `toJSON` probe, so bounding it here would ship a truncated timestamp + // rather than a shorter one. + if (value instanceof Date) { + return value + } + // The representation the value defines for itself is what the encoder puts + // on the wire, so it is what has to be bounded — a `toJSON` returning a + // megabyte of text is invisible to a walk over the object's own keys. + const resolved = resolveToJson(value) + if (resolved.selfDescribed) { + // Resolving to nothing is the value's answer. Walking its keys anyway + // would build a plain object the encoder no longer treats as + // self-describing, putting the internals of a redacted value on the wire. + // Stored as the string the encoder builds from that same nullish result + // rather than as the value itself: the encoder probes `toJSON` a second + // time, so one that answers `null` here is free to answer with a megabyte + // there, past the bound this walk exists to apply. Left unbounded like the + // other markers — nine characters at most, and trimming it to `unde` would + // only make it unreadable. + return isNullish(resolved.value) + ? String(resolved.value) + : truncateValue(resolved.value, maxLength, state, depth + 1) + } + if (isArray(value)) { + // Only the items the encoder will emit are walked; it stops at the same + // cap, so bounding the rest is work spent on values that never ship. + const walked = Math.min(value.length, MAX_JSON_SAFE_VALUE_ITEMS) + // Accumulated rather than copied from the value: `slice()` reads every + // element, accessors past the cap included, and one of those throwing + // would reach the outer catch and cost the whole array its bound. + const boundedItems: SpanAttributeValue[] = [] + for (let index = 0; index < walked; index++) { + try { + boundedItems.push(truncateValue(value[index], maxLength, state, depth + 1)) + } catch { + // A throwing accessor costs its own item, as it does in the encoder. + boundedItems.push(UNSERIALIZABLE_VALUE) + } + } + // Carried so the encoder still marks what it cut. + if (value.length > walked) { + boundedItems.length = value.length + } + return boundedItems + } + const bounded: SpanAttributes = {} + // Counted the way the encoder counts, so the walk stops where its output + // does: a key it skips costs no slot, and reading past the last one it can + // emit is getter work on values that never ship. + let emittable = 0 + for (const key of Object.keys(value)) { + if (emittable >= MAX_JSON_SAFE_VALUE_ITEMS) { + break + } + let boundedItem: SpanAttributeValue + try { + // Read once: re-reading to compare would run a getter a second time. + boundedItem = truncateValue((value as SpanAttributes)[key], maxLength, state, depth + 1) + } catch { + // A throwing accessor costs its own key. Reaching the walk's own catch + // would abandon the whole value unbounded, which is how a lazy ORM + // relation next to a large field puts that field on the wire whole. + boundedItem = UNSERIALIZABLE_VALUE + } + if (key && !isNullish(boundedItem)) { + emittable++ + } + // defineProperty, not assignment: a nested `__proto__` key would otherwise + // swap the copy's prototype and vanish. + Object.defineProperty(bounded, key, { + value: boundedItem, + enumerable: true, + writable: true, + configurable: true, + }) + } + return bounded + } catch { + // Whatever is left — a hostile `Object.keys`, a `slice` that throws — costs + // this value its bound rather than the span. Per-key reads are guarded + // above, so a single bad property does not reach here. + return value + } finally { + // Siblings pointing at the same object are duplication, not a cycle. + state.ancestors.delete(value) + } +} + +/** + * The value's own serialized form. `selfDescribed` is false when it defines no + * `toJSON`, or when reading one throws — both fall through to the plain walk, + * as they do in the encoder. + */ +function resolveToJson(value: object): { selfDescribed: boolean; value?: SpanAttributeValue } { + try { + const toJSON = (value as { toJSON?: unknown }).toJSON + if (typeof toJSON === 'function') { + return { selfDescribed: true, value: toJSON.call(value) as SpanAttributeValue } + } + } catch { + // Falls through to the plain walk. + } + return { selfDescribed: false } +} + +/** + * A copy of a caller-supplied attribute bag holding at most `max` entries, each + * value bounded to `maxLength`, plus how many entries the cap refused. + * + * Once the cap is spent the remaining keys are counted without being read, so a + * wide object does not pay for the getters on values it is about to drop. + */ +function boundAttributes( + source: SpanAttributes, + max: number, + maxLength: number +): { attributes: SpanAttributes; dropped: number } { + let keys: string[] + try { + keys = Object.keys(source) + } catch { + // A hostile own-keys trap costs the bag, not the event carrying it. + return { attributes: {}, dropped: 0 } + } + const attributes: SpanAttributes = {} + let kept = 0 + let dropped = 0 + for (const key of keys) { + if (kept >= max) { + dropped++ + continue + } + let value: SpanAttributeValue + try { + value = truncateAttributeValue(source[key], maxLength) + } catch { + // A throwing getter costs its own key, as it does in `assignUserAttributes`. + value = UNSERIALIZABLE_VALUE + } + // Nullish spends no slot, matching `_writeAttribute` and the span half of + // `applySpanLimits`: the encoder drops these, so a caller who blanked a value + // rather than omitting the key must not lose a real attribute to it. + if (isNullish(value)) { + continue + } + kept++ + // defineProperty, not assignment: `attributes['__proto__'] = v` hits the + // prototype setter and the attribute vanishes. + Object.defineProperty(attributes, key, { value, enumerable: true, writable: true, configurable: true }) + } + return { attributes, dropped } +} + +/** `truncateAttributeValue` across an attribute bag, in place. */ +export function truncateAttributes(attributes: SpanAttributes, maxLength: number): SpanAttributes { + for (const key of Object.keys(attributes)) { + attributes[key] = truncateAttributeValue(attributes[key], maxLength) + } + return attributes +} + +/** + * Runs `fn` with `span` active, which every scoped helper does the same way. + * + * The shared no-op is never activated, so `getActiveSpan()` inside the callback + * reads null — callbacks should use the handle they're given. A pass-through + * handle is activated, so `getActiveSpan()?.traceparent()` still propagates an + * inbound trace through a service with tracing off. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export function runWithActiveSpan(contextManager: SpanContextManager, span: Span, fn: (span: Span) => T): T { + return span === NOOP_SPAN ? fn(span) : contextManager.with(span, () => fn(span)) +} + +/** + * Extracts the OTel `exception.type` / `exception.message` pair from whatever was + * thrown. Anything can be thrown in JS, so non-Errors are described by type. + */ +export function describeError(error: unknown): { type: string; message: string; stack?: string } { + try { + const stack = readStack(error) + if (isError(error)) { + return { type: error.name || 'Error', message: error.message || '', ...stack } + } + if (typeof error === 'string') { + return { type: 'string', message: error } + } + if (error && typeof error === 'object') { + const maybe = error as { name?: unknown; message?: unknown } + if (typeof maybe.message === 'string') { + return { type: typeof maybe.name === 'string' ? maybe.name : 'Object', message: maybe.message, ...stack } + } + } + return { type: typeof error, message: String(error) } + } catch { + // A hostile `toString` or accessor must not throw a second error: in `withSpan` + // that would replace the application's error and skip the span's `end()`. + return { type: typeof error, message: '' } + } +} diff --git a/packages/core/src/traces/traceparent.spec.ts b/packages/core/src/traces/traceparent.spec.ts new file mode 100644 index 0000000000..7ad5d73246 --- /dev/null +++ b/packages/core/src/traces/traceparent.spec.ts @@ -0,0 +1,184 @@ +import { formatTraceparent, normalizeTraceparent, parseTraceparent, sanitizeTracestate } from './traceparent' + +const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736' +const SPAN_ID = '00f067aa0ba902b7' + +describe('traceparent', () => { + describe('parseTraceparent', () => { + it('parses a sampled header', () => { + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-01`)).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + flags: '01', + }) + }) + + it('continues the trace even when the caller sampled it out, and keeps the flag', () => { + // Every captured span is recorded, so honouring an inbound `00` by + // dropping the parentage would orphan our own spans. The flag itself is + // kept, so what we propagate onward still says what the caller decided. + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-00`)).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + flags: '00', + }) + }) + + it.each([ + ['a reserved bit alongside sampled', '05', '01'], + ['reserved bits with sampled unset', '04', '00'], + ['every bit set', 'ff', '01'], + ])('zeroes %s, which version 00 does not define', (_label, inbound, expected) => { + // We re-emit under version `00`, and W3C requires a vendor to zero every + // flag that version does not define rather than forward it. + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-${inbound}`)?.flags).toBe(expected) + }) + + it('accepts a future version with extra fields', () => { + expect(parseTraceparent(`01-${TRACE_ID}-${SPAN_ID}-01-something`)).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + flags: '01', + }) + }) + + it('rejects version 00 with extra fields, which only a higher version may carry', () => { + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-01-something`)).toBeUndefined() + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-01-`)).toBeUndefined() + }) + + it('trims surrounding whitespace', () => { + expect(parseTraceparent(` 00-${TRACE_ID}-${SPAN_ID}-01 `)).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + flags: '01', + }) + }) + + it('rejects uppercase hex, which W3C requires a vendor to ignore', () => { + expect(parseTraceparent(`00-${TRACE_ID.toUpperCase()}-${SPAN_ID}-01`)).toBeUndefined() + expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID.toUpperCase()}-01`)).toBeUndefined() + }) + + it.each([ + ['garbage', 'garbage'], + ['an empty string', ''], + ['version ff', `ff-${TRACE_ID}-${SPAN_ID}-01`], + ['an all-zero trace id', `00-${'0'.repeat(32)}-${SPAN_ID}-01`], + ['an all-zero span id', `00-${TRACE_ID}-${'0'.repeat(16)}-01`], + ['a short trace id', `00-abc-${SPAN_ID}-01`], + ['a missing field', `00-${TRACE_ID}-${SPAN_ID}`], + ['a non-string', 42], + ['undefined', undefined], + ])('returns undefined for %s', (_name, value) => { + expect(parseTraceparent(value)).toBeUndefined() + }) + }) + + describe('formatTraceparent', () => { + it('sets the sampled flag on a trace started here', () => { + expect(formatTraceparent(TRACE_ID, SPAN_ID)).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`) + }) + + it('propagates the flags byte it was given', () => { + expect(formatTraceparent(TRACE_ID, SPAN_ID, '00')).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`) + }) + + it('round-trips through the parser', () => { + expect(parseTraceparent(formatTraceparent(TRACE_ID, SPAN_ID))).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + flags: '01', + }) + }) + }) + + describe('sanitizeTracestate', () => { + it('preserves a valid vendor list unchanged', () => { + expect(sanitizeTracestate('vendor=abc,other=def')).toBe('vendor=abc,other=def') + }) + + it('trims surrounding whitespace', () => { + expect(sanitizeTracestate(' vendor=abc ')).toBe('vendor=abc') + }) + + it.each([ + ['an empty string', ''], + ['a member without a value', 'vendor'], + ['a non-string', 42], + ['undefined', undefined], + [ + 'more than 32 members, which the list grammar does not admit', + Array.from({ length: 33 }, (_v, i) => `k${i}=v`).join(','), + ], + ['a single member longer than the whole limit', `vendor=${'a'.repeat(600)}`], + ])('discards %s', (_name, value) => { + expect(sanitizeTracestate(value)).toBeUndefined() + }) + + it('drops the largest members first when a valid list is too long', () => { + // W3C names members over 128 characters as the ones to drop first, so the + // small entries survive even though they sit to the right of the big one. + const big = `big=${'a'.repeat(200)}` + const small = Array.from({ length: 4 }, (_v, i) => `k${i}=${'b'.repeat(80)}`) + + expect(sanitizeTracestate([big, ...small].join(','))).toBe(small.join(',')) + }) + + it('drops from the right once no member is oversized', () => { + // 102 characters each, so the fifth crosses 512 and the first four stay. + const members = Array.from({ length: 6 }, (_v, i) => `k${i}=${'a'.repeat(100)}`) + expect(sanitizeTracestate(members.join(','))).toBe(members.slice(0, 4).join(',')) + }) + + it('leaves a valid header inside the limit exactly as received', () => { + const members = Array.from({ length: 32 }, (_v, i) => `k${i}=v`) + expect(sanitizeTracestate(members.join(','))).toBe(members.join(',')) + }) + }) +}) + +describe('tracestate character safety', () => { + it('discards a value carrying CRLF or a lone surrogate', () => { + expect(sanitizeTracestate('vendor=abc\r\nx-injected: 1')).toBeUndefined() + expect(sanitizeTracestate('vendor=\ud800')).toBeUndefined() + }) + + it('keeps a tab-separated vendor list, which W3C allows', () => { + expect(sanitizeTracestate('rojo=00f067aa0ba902b7,\tcongo=t61rcWkgMzE')).toBe( + 'rojo=00f067aa0ba902b7,\tcongo=t61rcWkgMzE' + ) + }) + + it('keeps an ordinary vendor list', () => { + expect(sanitizeTracestate('rojo=00f067aa0ba902b7,congo=t61rcWkgMzE')).toBe( + 'rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' + ) + }) +}) + +describe('normalizeTraceparent', () => { + it('carries version and flags through as received', () => { + expect(normalizeTraceparent(`00-${TRACE_ID}-${SPAN_ID}-00`)).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`) + expect(normalizeTraceparent(`01-${TRACE_ID}-${SPAN_ID}-01`)).toBe(`01-${TRACE_ID}-${SPAN_ID}-01`) + }) + + it("keeps a higher version's trailing fields, so a peer that reads them still can", () => { + expect(normalizeTraceparent(`01-${TRACE_ID}-${SPAN_ID}-01-extra`)).toBe(`01-${TRACE_ID}-${SPAN_ID}-01-extra`) + }) + + it('trims surrounding whitespace', () => { + expect(normalizeTraceparent(` 00-${TRACE_ID}-${SPAN_ID}-01 `)).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`) + }) + + it.each([ + ['a malformed header', 'not-a-traceparent'], + ['the invalid ff version', `ff-${TRACE_ID}-${SPAN_ID}-01`], + ['an all-zero trace id', `00-${'0'.repeat(32)}-${SPAN_ID}-01`], + ['version 00 with trailing fields', `00-${TRACE_ID}-${SPAN_ID}-01-extra`], + ['an uppercase trace id', `00-${TRACE_ID.toUpperCase()}-${SPAN_ID}-01`], + ['a non-string', ['a', 'b']], + ])('rejects %s', (_name, value) => { + expect(normalizeTraceparent(value)).toBeUndefined() + }) +}) diff --git a/packages/core/src/traces/traceparent.ts b/packages/core/src/traces/traceparent.ts new file mode 100644 index 0000000000..8a615de026 --- /dev/null +++ b/packages/core/src/traces/traceparent.ts @@ -0,0 +1,172 @@ +import { isValidSpanId, isValidTraceId } from './ids' + +export interface RemoteSpanContext { + traceId: string + spanId: string + /** The inbound trace-flags byte, e.g. `01` sampled, `00` sampled out. */ + flags: string +} + +// Version `ff` is invalid per the spec, and a higher version may append fields +// after the first four, so the trailing group captures them rather than failing +// the match. +const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/ + +/** + * Parses an incoming `traceparent` header value, returning `undefined` for + * anything malformed so a bad header starts a fresh root rather than throwing. + * + * A trace the caller sampled out (`00`) is still continued — PostHog records + * every captured span — but the inbound flag rides along, so what this SDK + * propagates onward says what the caller decided rather than overriding it. + */ +export function parseTraceparent(value: unknown): RemoteSpanContext | undefined { + const fields = matchTraceparent(value) + return fields && { traceId: fields.traceId, spanId: fields.spanId, flags: definedFlags(fields.flags) } +} + +/** + * Keeps only the flags version `00` defines — the sampled bit. A span continuing + * this trace re-emits the byte under version `00`, and W3C requires a vendor to + * zero every flag that version does not define rather than forward one it cannot + * interpret. + */ +function definedFlags(flags: string): string { + return parseInt(flags, 16) & 0x01 ? TRACE_FLAGS_SAMPLED : TRACE_FLAGS_UNSAMPLED +} + +interface TraceparentFields { + version: string + traceId: string + spanId: string + flags: string +} + +function matchTraceparent(value: unknown): TraceparentFields | undefined { + if (typeof value !== 'string') { + return undefined + } + // W3C spells every field lowercase hex and requires a vendor to ignore a + // `traceparent` whose ids are not, so folding the case here would continue a + // trace that a conformant peer restarts. + const match = TRACEPARENT_RE.exec(value.trim()) + if (!match) { + return undefined + } + const [, version, traceId, spanId, flags, trailing] = match + if (version === 'ff') { + return undefined + } + // Version `00` is defined as exactly `trace-id "-" parent-id "-" trace-flags`. + // W3C scopes the tolerate-what-you-don't-know rule to a *higher* version, so a + // version `00` header with anything appended is malformed. + if (version === '00' && trailing) { + return undefined + } + if (!isValidTraceId(traceId) || !isValidSpanId(spanId)) { + return undefined + } + return { version, traceId, spanId, flags } +} + +/** + * The inbound `traceparent` as received, or `undefined` when it is malformed. + * + * Returned whole rather than rebuilt: a version above `00` may append fields + * this SDK does not read, and rebuilding would forward a header still labelled + * with that version but missing what the version defines. + */ +export function normalizeTraceparent(value: unknown): string | undefined { + return matchTraceparent(value) && (value as string).trim() +} + +/** + * A `traceparent` as the string it is, unwrapping the one-element array Node's + * `headersDistinct` hands over. A longer array is two different inbound values, + * and picking either would be a guess. + */ +export function traceparentHeader(value: unknown): unknown { + return Array.isArray(value) && value.length === 1 ? value[0] : value +} + +/** The W3C sampled bit, set on a trace this SDK started. */ +export const TRACE_FLAGS_SAMPLED = '01' + +/** The same byte with the sampled bit clear, for a trace the caller sampled out. */ +const TRACE_FLAGS_UNSAMPLED = '00' + +/** + * Builds the `traceparent` header value for a span. A span continuing a remote + * trace propagates the flags byte it was handed: a downstream parent-based + * sampler must see the decision the head sampler actually made, not one this + * SDK invented. A trace started here is sampled, because it is recorded. + */ +export function formatTraceparent(traceId: string, spanId: string, flags: string = TRACE_FLAGS_SAMPLED): string { + return `00-${traceId}-${spanId}-${flags}` +} + +// tracestate is a comma-separated list of at most 32 `key=value` members, and +// is carried opaquely — we never interpret the vendor entries. +const TRACESTATE_MAX_MEMBERS = 32 +const TRACESTATE_MAX_LENGTH = 512 + +/** + * Validates an incoming `tracestate` far enough to know it is safe to echo back. + * A malformed one is discarded without invalidating its traceparent, so a bad + * vendor entry never costs us the trace continuation. More than 32 members is + * malformed: W3C's list grammar admits no more. + * + * A valid header over the length W3C asks us to propagate is trimmed instead, + * by whole members. Members over 128 characters go first — W3C names those as + * the ones to drop — and the rest from the right, so the entries nearest the + * caller survive. + */ +export function sanitizeTracestate(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + // W3C restricts tracestate to printable ASCII plus HTAB as optional whitespace. + // A CRLF would make the caller's own propagation throw, and a lone surrogate + // refuses the whole OTLP request. + if (/[^\x20-\x7e\t]/.test(trimmed)) { + return undefined + } + const members = trimmed.split(',') + if (members.length > TRACESTATE_MAX_MEMBERS) { + return undefined + } + for (const member of members) { + // An empty member is tolerated by the spec (list optional-white-space), but + // a member without a `=` is not a key/value pair at all. + if (member.trim() && !member.includes('=')) { + return undefined + } + } + if (trimmed.length <= TRACESTATE_MAX_LENGTH) { + return trimmed + } + return trimToLength(members) +} + +// W3C's own guidance for which members to drop when a list is too long. +const TRACESTATE_LARGE_MEMBER_LENGTH = 128 + +/** The members that fit, dropping the largest first and then from the right. */ +function trimToLength(members: string[]): string | undefined { + const kept = [...members] + const joinedLength = (): number => kept.reduce((total, member) => total + member.length, 0) + kept.length - 1 + + for (let index = kept.length - 1; index >= 0 && joinedLength() > TRACESTATE_MAX_LENGTH; index--) { + if (kept[index].length > TRACESTATE_LARGE_MEMBER_LENGTH) { + kept.splice(index, 1) + } + } + while (kept.length && joinedLength() > TRACESTATE_MAX_LENGTH) { + kept.pop() + } + return kept.length ? kept.join(',') : undefined +} diff --git a/packages/core/src/traces/types.ts b/packages/core/src/traces/types.ts new file mode 100644 index 0000000000..6d95c1b29a --- /dev/null +++ b/packages/core/src/traces/types.ts @@ -0,0 +1,132 @@ +export type { + Span, + SpanAttributes, + SpanAttributeValue, + SpanKind, + SpanStatusCode, + SpanTimeInput, + StartSpanOptions, + TracesConfig, + BeforeSpanSendFn, + OtlpSpan, + OtlpSpanEvent, + OtlpSpanKeyValue, + OtlpSpanStatus, + OtlpTracesPayload, +} from '@posthog/types' + +import type { + BeforeSpanSendFn, + OtlpTracesPayload, + Span, + SpanAttributes, + SpanKind, + SpanRecord as HookSpanRecord, + SpanStatusCode, + TracesConfig, +} from '@posthog/types' + +/** Same tagged outcome shape as `SendLogsBatchOutcome` — one policy for all three signals. */ +export type SendTracesBatchOutcome = + | { kind: 'ok' } + | { kind: 'retry-later'; error: unknown } + | { kind: 'too-large' } + | { kind: 'fatal'; error: unknown } + +/** The minimal host surface `PostHogTraces` depends on; `PostHogCoreStateless` satisfies it structurally. */ +export interface TracesHost { + readonly isDisabled: boolean + readonly optedOut: boolean + _sendTracesBatch(payload: OtlpTracesPayload): Promise + getLibraryId(): string + getLibraryVersion(): string +} + +/** + * PostHog context snapshotted onto every span at start, so traces join back to + * persons and sessions. Each SDK fills the fields that apply to it; absent + * fields add no attribute. Internal to `@posthog/core`. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export interface TraceSdkContext { + distinctId?: string + sessionId?: string + /** Web-only — current page URL. */ + currentUrl?: string + /** Mobile-only — current screen / view name. */ + screenName?: string + /** Mobile-only — app foreground/background state. */ + appState?: 'foreground' | 'background' +} + +export interface SpanEventRecord { + name: string + /** ms epoch. */ + timestamp: number + attributes?: SpanAttributes + /** + * How many of this event's attributes the cap discarded. + * + * Unlike the span-level counts, this one is carried on an object a + * `beforeSpanSend` hook holds: the public event type omits it, so a hook that + * rebuilds its events returns them without it. Events have no identity to + * match a rebuilt array back against, so what a hook drops here stays dropped. + */ + droppedAttributesCount?: number +} + +/** + * A finished span as the SDK carries it, which is the hook-visible record plus + * the fields no hook may rewrite. Declaring only the additions keeps the shared + * half from drifting; a field added here rather than to the public record is a + * field `beforeSpanSend` cannot see, and so cannot corrupt. + */ +export interface SpanRecord extends HookSpanRecord { + /** The hook-visible event plus the SDK's own per-event drop count. */ + events: SpanEventRecord[] + traceState?: string + /** The W3C trace-flags byte this span propagates, e.g. `01` sampled. */ + traceFlags: string + /** True when the parent came from a `traceparent` header rather than a local handle. */ + parentIsRemote: boolean + droppedAttributesCount?: number + droppedEventsCount?: number +} + +/** + * Tracks which span is active, so spans nest without manual parent plumbing. The + * mechanism is platform-specific and stays out of core: node injects an + * `AsyncLocalStorage` implementation over the synchronous default. + */ +export interface SpanContextManager { + /** The active span, or `undefined` when none is active. */ + active(): Span | undefined + /** Run `fn` with `span` active for its (synchronous and async) duration. */ + with(span: Span, fn: () => T): T +} + +/** + * Fields `PostHogTraces` needs resolved at runtime. The host SDK applies its own + * defaults and hands the resolved config to the constructor. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export interface ResolvedTracesConfig extends TracesConfig { + flushIntervalMs: number + maxExportBatchSize: number + /** + * Bound on the in-memory export queue. On overflow the *incoming* span is + * dropped rather than queued ones, whose children may already have shipped. + */ + maxQueueSize: number + beforeSpanSend: BeforeSpanSendFn[] + maxAttributesPerSpan: number + maxEventsPerSpan: number + maxAttributesPerEvent: number + maxAttributeValueLength: number + /** Bound on spans started but not yet ended. At the bound `startSpan` returns a no-op handle. */ + maxLiveSpans: number + /** How long a span may stay live before it stops being accounted for and can never export. */ + maxSpanAgeMs: number +} diff --git a/packages/core/src/utils/json-utils.ts b/packages/core/src/utils/json-utils.ts index 95007246d8..bf92ecc80f 100644 --- a/packages/core/src/utils/json-utils.ts +++ b/packages/core/src/utils/json-utils.ts @@ -153,3 +153,36 @@ export function toJsonSafeValue(value: unknown): unknown { return convert(value, 0) } + +/** + * Copies caller-supplied attributes onto `target`, own enumerable keys only. + * + * Read key by key rather than spread: a getter over a disposed resource or a + * revoked proxy throws on the read itself, before the encoder's guards see it. + */ +export function assignUserAttributes>( + target: T, + source: Record | undefined +): T { + if (!source) { + return target + } + let keys: string[] = [] + try { + keys = Object.keys(source) + } catch { + keys = [] + } + for (const key of keys) { + let value: unknown + try { + value = source[key] + } catch { + value = UNSERIALIZABLE_VALUE + } + // defineProperty, not assignment: `attributes['__proto__'] = v` hits the + // prototype setter and the attribute vanishes. + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) + } + return target +} diff --git a/packages/core/src/utils/otlp-resource.spec.ts b/packages/core/src/utils/otlp-resource.spec.ts index a9b6d8891d..3ab1ea79de 100644 --- a/packages/core/src/utils/otlp-resource.spec.ts +++ b/packages/core/src/utils/otlp-resource.spec.ts @@ -2,6 +2,8 @@ import { buildResourceAttributes } from '../logs/logs-utils' import type { ResolvedPostHogLogsConfig } from '../logs/types' import { buildMetricsResourceAttributes } from '../metrics/metrics-utils' import type { ResolvedPostHogMetricsConfig } from '../metrics/types' +import { buildTracesResourceAttributes } from '../traces/otlp' +import type { ResolvedTracesConfig } from '../traces/types' import { normalizeOsName, osResourceAttributes } from './otlp-resource' const shared = { @@ -25,9 +27,10 @@ const conflicting = { }, } -const bothSignals = (partial: object): Record[] => [ +const allThree = (partial: object): Record[] => [ buildResourceAttributes(partial as ResolvedPostHogLogsConfig, 'posthog-node', '1.0.0'), buildMetricsResourceAttributes(partial as ResolvedPostHogMetricsConfig, 'posthog-node', '1.0.0'), + buildTracesResourceAttributes(partial as ResolvedTracesConfig, 'posthog-node', '1.0.0'), ] describe('shared OTLP resource attributes', () => { @@ -35,14 +38,16 @@ describe('shared OTLP resource attributes', () => { ['a fully populated config', shared], ['a config with conflicting user attributes', conflicting], ['an empty config', {}], - ])('produces the same attributes for logs and metrics given %s', (_label, config) => { - const [logs, metrics] = bothSignals(config) + ])('produces the same attributes for logs, metrics and traces given %s', (_label, config) => { + const [logs, metrics, traces] = allThree(config) expect(metrics).toEqual(logs) + expect(traces).toEqual(logs) expect(Object.keys(metrics)).toEqual(Object.keys(logs)) + expect(Object.keys(traces)).toEqual(Object.keys(logs)) }) it('layers the identity keys over user resource attributes', () => { - for (const attributes of bothSignals(conflicting)) { + for (const attributes of allThree(conflicting)) { expect(attributes).toEqual({ 'service.name': 'checkout', 'service.version': '2.1.0', @@ -55,7 +60,7 @@ describe('shared OTLP resource attributes', () => { }) it('keeps user resource attributes that do not collide', () => { - for (const attributes of bothSignals(shared)) { + for (const attributes of allThree(shared)) { expect(attributes).toEqual({ 'host.name': 'web-01', 'service.name': 'checkout', @@ -68,7 +73,7 @@ describe('shared OTLP resource attributes', () => { }) it('falls back to unknown_service and omits unset optional keys', () => { - for (const attributes of bothSignals({})) { + for (const attributes of allThree({})) { expect(attributes).toEqual({ 'service.name': 'unknown_service', 'telemetry.sdk.name': 'posthog-node', @@ -86,6 +91,12 @@ describe('osResourceAttributes', () => { ['linux', 'Linux'], ['android', 'Android'], ['freebsd', 'FreeBSD'], + ['openbsd', 'OpenBSD'], + ['netbsd', 'NetBSD'], + ['sunos', 'SunOS'], + ['aix', 'AIX'], + ['haiku', 'Haiku'], + ['cygwin', 'Windows'], // detectOS spellings ['Mac OS X', 'macOS'], ['iOS', 'iOS'], @@ -97,7 +108,7 @@ describe('osResourceAttributes', () => { }) it('passes an unmapped name through rather than dropping it', () => { - expect(normalizeOsName('Haiku')).toBe('Haiku') + expect(normalizeOsName('Plan 9')).toBe('Plan 9') expect(normalizeOsName('constructor')).toBe('constructor') }) diff --git a/packages/core/src/utils/otlp-resource.ts b/packages/core/src/utils/otlp-resource.ts index 0f19cbe19e..50963e8488 100644 --- a/packages/core/src/utils/otlp-resource.ts +++ b/packages/core/src/utils/otlp-resource.ts @@ -1,5 +1,7 @@ +import { assignUserAttributes } from './json-utils' + /** - * Shape the logs and metrics resolved configs share for resource + * Shape the logs, metrics and traces resolved configs share for resource * attribution. Generic over the attribute value type so each signal keeps its * own value union. */ @@ -11,7 +13,7 @@ export interface OtlpResourceConfig { } /** - * OTLP resource attributes shared by the logs and metrics envelopes. + * OTLP resource attributes shared by the logs, metrics and traces envelopes. * * User `resourceAttributes` are spread first, then SDK-controlled keys on top so * a stray user key can't clobber the ingestion-attribution ones; the dedicated @@ -26,7 +28,10 @@ export function buildOtlpResourceAttributes( sdkVersion: string ): Record { return { - ...config.resourceAttributes, + // Read key by key: a throwing accessor on a user-supplied attribute runs on + // every flush, before the pipeline's own error handling, and would otherwise + // stop the signal exporting entirely. + ...assignUserAttributes>({}, config.resourceAttributes), 'service.name': config.serviceName || 'unknown_service', ...(config.environment && { 'deployment.environment': config.environment }), ...(config.serviceVersion && { 'service.version': config.serviceVersion }), @@ -47,15 +52,19 @@ export function buildOtlpResourceAttributes( * platforms they cover. */ const OS_NAMES: Record = { - // node:os platform() + // node:os platform(), all eleven of them darwin: 'macOS', win32: 'Windows', + // Cygwin is a POSIX layer over Windows, so it belongs under the same filter. + cygwin: 'Windows', linux: 'Linux', android: 'Android', freebsd: 'FreeBSD', openbsd: 'OpenBSD', + netbsd: 'NetBSD', sunos: 'SunOS', aix: 'AIX', + haiku: 'Haiku', // detectOS 'Mac OS X': 'macOS', } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 0368712a02..aef13ba00c 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -8,6 +8,20 @@ export default defineConfig({ test: { globals: true, setupFiles: ['../../tooling/vitest/setup-fake-timers.ts'], + // `performance.now` is not in vitest's default `toFake` set, and span ageing + // and durations read it, so a test that advances timers must move it too. + fakeTimers: { + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'setImmediate', + 'clearImmediate', + 'Date', + 'performance', + ], + }, clearMocks: true, silent: true, coverage: { diff --git a/packages/node/package.json b/packages/node/package.json index 639b7b7e0f..f9c8b12ee5 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -46,6 +46,7 @@ "devDependencies": { "@edge-runtime/vm": "^5.0.0", "@posthog-tooling/tsconfig-base": "workspace:*", + "@posthog/types": "workspace:^", "@rslib/core": "catalog:", "@types/express": "^5.0.6", "@types/node": "^20.0.0", diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 26e660bbfd..227784b49b 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -547,6 +547,28 @@ }, "path": "src/entrypoints/index.node.ts" }, + { + "category": "Traces", + "description": "The span currently active on this async execution path, or `null` outside any `withSpan` callback.\nOn the edge build this returns `null` after an `await`, because the active span is tracked synchronously there.\n\nSubject to change in a minor release.", + "details": null, + "id": "getActiveSpan", + "showDocs": true, + "title": "getActiveSpan", + "examples": [ + { + "id": "propagate_the_trace_to_another_service", + "name": "Propagate the trace to another service", + "code": "\n\n// Propagate the trace to another service\nconst traceparent = posthog.getActiveSpan()?.traceparent()\nawait fetch(url, { headers: traceparent ? { traceparent } : {} })\n\n\n\n" + } + ], + "releaseTag": "public", + "params": [], + "returnType": { + "id": "Span | null", + "name": "Span | null" + }, + "path": "src/entrypoints/index.node.ts" + }, { "category": "Feature flags", "description": "Get all feature flag values for a specific user.", @@ -1279,6 +1301,41 @@ }, "path": "src/entrypoints/index.node.ts" }, + { + "category": "Traces", + "description": "Starts a span without making it active — for work that can't wrap a callback. Prefer `withSpan`, which ends the span for you.\nAlways returns a handle, so calling code never has to branch: when the `traces` option is absent, the SDK is disabled, or the user has opted out, the handle is inert and nothing is exported. An inert handle given a `parent` header still returns it from `traceparent()`, so a service with tracing off passes a distributed trace through instead of severing it.\n\nSubject to change in a minor release.", + "details": null, + "id": "startSpan", + "showDocs": true, + "title": "startSpan", + "examples": [ + { + "id": "", + "name": "", + "code": "\n\nconst span = posthog.startSpan('checkout', { attributes: { plan: 'pro' } })\nspan.setAttribute('cart.items', 3)\nspan.end()\n\n\n\n" + } + ], + "releaseTag": "public", + "params": [ + { + "description": "", + "isOptional": false, + "type": "string", + "name": "name" + }, + { + "description": "", + "isOptional": true, + "type": "StartSpanOptions", + "name": "options" + } + ], + "returnType": { + "id": "Span", + "name": "Span" + }, + "path": "src/entrypoints/index.node.ts" + }, { "category": "Identification", "description": "Remove properties from a person profile.", @@ -1389,6 +1446,41 @@ }, "path": "src/entrypoints/index.node.ts" }, + { + "category": "Traces", + "description": "Runs a callback with a span active for its duration and ends the span for you — at return for a sync callback, at settle for an async one. Takes an optional `StartSpanOptions` between the name and the callback: `withSpan(name, fn)` or `withSpan(name, options, fn)`.\nSpans started inside the callback nest under it automatically. If the callback throws or rejects, the span records the exception and the original error is rethrown unchanged. A callback that ends the span itself gets the rethrow but not the recording, since the span is already exported by then.\nSpans nest across `await` only on the Node runtime, which tracks the active span with `AsyncLocalStorage`. The edge build restores the active span when the callback returns its promise, so spans started after an `await` there begin a new trace.\n\nSubject to change in a minor release.", + "details": null, + "id": "withSpan", + "showDocs": true, + "title": "withSpan", + "examples": [ + { + "id": "", + "name": "", + "code": "\n\nawait posthog.withSpan('POST /checkout', { parent: req.get('traceparent') }, async (span) => {\n span.setAttribute('plan', user.plan)\n return processOrder()\n})\n\n\n\n" + } + ], + "releaseTag": "public", + "params": [ + { + "description": "", + "isOptional": false, + "type": "string", + "name": "name" + }, + { + "description": "", + "isOptional": false, + "type": "(span: Span) => T", + "name": "fn" + } + ], + "returnType": { + "id": "T", + "name": "T" + }, + "path": "src/entrypoints/index.node.ts" + }, { "category": "", "details": null, @@ -3503,6 +3595,11 @@ "type": "MetricsConfig", "name": "metrics" }, + { + "description": "Configuration for distributed tracing (`startSpan` / `withSpan`). Tracing is\noff until this is set; supplying it is all that's needed to turn it on.\n\nSet `serviceName` so spans can be attributed and grouped per service — the\nproduct aggregates operations by service and span name.\n\n`shutdown()` drains spans that have already ended, within the shutdown\ntimeout; spans still open at that point are discarded.", + "type": "TracesConfig", + "name": "traces" + }, { "description": "Credential that enables local feature flag evaluation and remote config.\n\nAccepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).\nWhen provided, the client can evaluate feature flags locally and decrypt remote\nconfig payloads via `getRemoteConfigPayload`. Prefer this over the deprecated\n`personalApiKey` option; when both are set, `secretKey` takes precedence.", "type": "string", @@ -4004,6 +4101,13 @@ "path": "../core/src/metrics/types.ts", "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n} | {\n kind: 'too-large';\n} | {\n kind: 'fatal';\n error: unknown;\n}" }, + { + "id": "SendTracesBatchOutcome", + "name": "SendTracesBatchOutcome", + "properties": [], + "path": "../core/src/traces/types.ts", + "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n} | {\n kind: 'too-large';\n} | {\n kind: 'fatal';\n error: unknown;\n}" + }, { "id": "SentryIntegrationOptions", "name": "SentryIntegrationOptions", @@ -4057,6 +4161,12 @@ "path": "../core/src/error-tracking/types.ts", "example": "(typeof severityLevels)[number]" }, + { + "id": "SpanContextManager", + "name": "SpanContextManager", + "properties": [], + "path": "../core/src/traces/types.ts" + }, { "id": "SpecificQuestionBranching", "name": "SpecificQuestionBranching", @@ -4655,6 +4765,7 @@ "Error tracking", "Privacy", "Feature flags", + "Traces", "Context" ] } \ No newline at end of file diff --git a/packages/node/src/__tests__/host-os.spec.ts b/packages/node/src/__tests__/host-os.spec.ts new file mode 100644 index 0000000000..a2748e6757 --- /dev/null +++ b/packages/node/src/__tests__/host-os.spec.ts @@ -0,0 +1,45 @@ +import { platform, release } from 'node:os' +import { hostOsResourceAttributes } from '../host-os.node' + +vi.mock('node:os', () => ({ platform: vi.fn(), release: vi.fn() })) + +const mockPlatform = platform as vi.Mock +const mockRelease = release as vi.Mock + +describe('hostOsResourceAttributes', () => { + it('reports the host OS', () => { + mockPlatform.mockReturnValue('linux') + mockRelease.mockReturnValue('6.1.0-27-amd64') + + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'Linux', 'os.version': '6.1.0-27-amd64' }) + }) + + it.each([ + ['darwin', 'macOS'], + ['win32', 'Windows'], + ['freebsd', 'FreeBSD'], + ])('reports %s as the OS name the other SDKs send, not the node:os identifier', (identifier, osName) => { + mockPlatform.mockReturnValue(identifier) + mockRelease.mockReturnValue('1.0.0') + + expect(hostOsResourceAttributes()['os.name']).toBe(osName) + }) + + it('omits a key node:os cannot supply rather than emitting it empty', () => { + mockPlatform.mockReturnValue('linux') + mockRelease.mockReturnValue('') + + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'Linux' }) + }) + + it('returns no attributes when node:os throws', () => { + mockPlatform.mockImplementation(() => { + throw new Error('unsupported') + }) + mockRelease.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(hostOsResourceAttributes()).toEqual({}) + }) +}) diff --git a/packages/node/src/__tests__/traces-edge.spec.ts b/packages/node/src/__tests__/traces-edge.spec.ts new file mode 100644 index 0000000000..1af46550d2 --- /dev/null +++ b/packages/node/src/__tests__/traces-edge.spec.ts @@ -0,0 +1,81 @@ +import { PostHog } from '@/entrypoints/index.edge' +import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types' + +vi.mock('../version', () => ({ version: '1.2.3' })) + +const mockedFetch = vi.spyOn(globalThis, 'fetch').mockImplementation() + +describe('PostHog traces on the edge build', () => { + const createClient = (): PostHog => + new PostHog('phc_test_key', { + host: 'http://example.com', + flushAt: 1, + fetchRetryCount: 0, + disableCompression: true, + traces: { serviceName: 'edge-api' }, + }) + + const sentSpans = (): OtlpSpan[] => + mockedFetch.mock.calls + .filter((call) => (call[0] as string).includes('/i/v1/traces')) + .map(([, init]) => JSON.parse((init as any).body as string) as OtlpTracesPayload) + .flatMap((payload) => payload.resourceSpans[0].scopeSpans[0].spans) + + beforeEach(() => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValue({ status: 200, text: async () => '{}', json: async () => ({}) } as any) + }) + + it('exports spans', async () => { + const client = createClient() + client.startSpan('edge-work').end() + await client.shutdown() + + expect(sentSpans().map((span) => span.name)).toEqual(['edge-work']) + }) + + it('nests spans created synchronously inside withSpan', async () => { + const client = createClient() + client.withSpan('parent', () => { + client.startSpan('child', { parent: client.getActiveSpan()! }).end() + }) + await client.shutdown() + + const spans = sentSpans() + const parent = spans.find((span) => span.name === 'parent')! + const child = spans.find((span) => span.name === 'child')! + expect(child.traceId).toBe(parent.traceId) + expect(child.parentSpanId).toBe(parent.spanId) + }) + + it('starts a new trace for a span created after an await, as documented', async () => { + const client = createClient() + let parentTraceId = '' + await client.withSpan('parent', async (span) => { + parentTraceId = span.traceparent()!.split('-')[1] + await Promise.resolve() + expect(client.getActiveSpan()).toBeNull() + client.startSpan('after-await').end() + }) + await client.shutdown() + + const orphan = sentSpans().find((span) => span.name === 'after-await')! + expect(orphan.traceId).not.toBe(parentTraceId) + expect(orphan.parentSpanId).toBeUndefined() + }) + + it('still nests across an await when the parent is passed explicitly', async () => { + const client = createClient() + await client.withSpan('parent', async (span) => { + await Promise.resolve() + client.startSpan('after-await', { parent: span }).end() + }) + await client.shutdown() + + const spans = sentSpans() + const parent = spans.find((span) => span.name === 'parent')! + const child = spans.find((span) => span.name === 'after-await')! + expect(child.traceId).toBe(parent.traceId) + expect(child.parentSpanId).toBe(parent.spanId) + }) +}) diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts new file mode 100644 index 0000000000..a27b233b34 --- /dev/null +++ b/packages/node/src/__tests__/traces.spec.ts @@ -0,0 +1,682 @@ +import { platform, release } from 'node:os' +import { PostHog } from '@/entrypoints/index.node' +import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types' +import { waitForPromises } from './utils' +import { isGzipSupported, osResourceAttributes } from '@posthog/core' + +vi.mock('../version', () => ({ version: '1.2.3' })) + +const mockedFetch = vi.spyOn(globalThis, 'fetch').mockImplementation() + +describe('PostHog traces', () => { + let posthog: PostHog + + const createClient = (options: Record = {}): PostHog => + new PostHog('phc_test_key', { + host: 'http://example.com', + flushAt: 1, + fetchRetryCount: 0, + disableCompression: true, + traces: { serviceName: 'checkout-api' }, + ...options, + }) + + const traceRequests = (): [string, any][] => + mockedFetch.mock.calls.filter((call) => (call[0] as string).includes('/i/v1/traces')) as [string, any][] + + const sentPayloads = (): OtlpTracesPayload[] => + traceRequests().map(([, init]) => JSON.parse(init.body as string) as OtlpTracesPayload) + + const sentSpans = (): OtlpSpan[] => sentPayloads().flatMap((p) => p.resourceSpans[0].scopeSpans[0].spans) + + const attributeOf = (span: OtlpSpan, key: string): any => span.attributes?.find((a) => a.key === key)?.value + + const attributeOfEvent = (event: NonNullable[number], key: string): any => + event.attributes?.find((a: any) => a.key === key)?.value + + // Traces run their own flush cycle; this advances it without calling flush(). + const DEFAULT_TRACES_FLUSH_INTERVAL_MS = 5000 + const flushTraces = async (): Promise => { + await vi.advanceTimersByTimeAsync(DEFAULT_TRACES_FLUSH_INTERVAL_MS) + await waitForPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + mockedFetch.mockResolvedValue({ + status: 200, + text: () => Promise.resolve('{}'), + json: () => Promise.resolve({}), + } as any) + posthog = createClient() + }) + + afterEach(async () => { + await posthog.shutdown() + }) + + describe('configuration', () => { + it('is off until the traces option is supplied', async () => { + const untraced = createClient({ traces: undefined }) + const span = untraced.startSpan('checkout') + span.end() + await untraced.shutdown() + + expect(span.traceparent()).toBeNull() + expect(traceRequests()).toHaveLength(0) + }) + + it('still runs a withSpan callback when tracing is off', async () => { + const untraced = createClient({ traces: undefined }) + const fn = vi.fn(() => 'value') + + expect(untraced.withSpan('job', fn)).toBe('value') + expect(fn).toHaveBeenCalledTimes(1) + expect(untraced.getActiveSpan()).toBeNull() + await untraced.shutdown() + }) + + it('passes an inbound trace through a service that has tracing off', async () => { + const untraced = createClient({ traces: undefined }) + const inbound = `00-${'4bf92f3577b34da6a3ce929d0e0e4736'}-00f067aa0ba902b7-00` + + const span = untraced.startSpan('proxied', { parent: inbound, tracestate: 'vendor=abc' }) + span.end() + await untraced.shutdown() + + // Echoed verbatim: the ids belong to the upstream caller, which recorded them. + expect(span.traceparent()).toBe(inbound) + expect(span.tracestate()).toBe('vendor=abc') + expect(traceRequests()).toHaveLength(0) + }) + + it('exposes a passed-through trace to getActiveSpan inside withSpan', async () => { + const untraced = createClient({ traces: undefined }) + const inbound = `00-${'4bf92f3577b34da6a3ce929d0e0e4736'}-00f067aa0ba902b7-01` + + const propagated = await untraced.withSpan('proxied', { parent: inbound }, async () => { + // After an await, so this also covers the AsyncLocalStorage path. + await Promise.resolve() + return untraced.getActiveSpan()?.traceparent() + }) + await untraced.shutdown() + + expect(propagated).toBe(inbound) + expect(untraced.getActiveSpan()).toBeNull() + }) + + it('gives a nested span the inbound context, across an await', async () => { + const untraced = createClient({ traces: undefined }) + const inbound = `00-${'4bf92f3577b34da6a3ce929d0e0e4736'}-00f067aa0ba902b7-01` + + const propagated = await untraced.withSpan('outer', { parent: inbound }, async () => { + await Promise.resolve() + return untraced.withSpan('inner', async (span) => { + await Promise.resolve() + return span.traceparent() + }) + }) + await untraced.shutdown() + + // The handle the callback is given, not just `getActiveSpan()`: a nested + // span that names no parent must still carry the trace onward. + expect(propagated).toBe(inbound) + }) + }) + + describe('transport', () => { + it('posts to /i/v1/traces with bearer auth', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const [url, init] = traceRequests()[0] + expect(url).toBe('http://example.com/i/v1/traces') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer phc_test_key') + expect(init.headers['Content-Type']).toBe('application/json') + }) + + it('does not put the project key in the query string', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + expect(traceRequests()[0][0]).not.toContain('token=') + }) + + it('sends the service name as a resource attribute', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + // The server reads service_name only from this attribute; without it the + // spans are stored with an empty service and are unattributable. + expect(sentPayloads()[0].resourceSpans[0].resource.attributes).toContainEqual({ + key: 'service.name', + value: { stringValue: 'checkout-api' }, + }) + }) + + it('identifies the SDK in the scope and resource', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const [resourceSpan] = sentPayloads()[0].resourceSpans + expect(resourceSpan.scopeSpans[0].scope).toEqual({ name: 'posthog-node', version: '1.2.3' }) + expect(resourceSpan.resource.attributes).toContainEqual({ + key: 'telemetry.sdk.name', + value: { stringValue: 'posthog-node' }, + }) + }) + + it('sends the host OS as resource attributes', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const attributes = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(attributes).toContainEqual({ + key: 'os.name', + value: { stringValue: osResourceAttributes(platform(), release())['os.name'] }, + }) + expect(attributes).toContainEqual({ key: 'os.version', value: { stringValue: release() } }) + }) + + it('lets configured resourceAttributes override the host OS', async () => { + posthog = createClient({ traces: { serviceName: 'checkout-api', resourceAttributes: { 'os.name': 'my-os' } } }) + posthog.startSpan('checkout').end() + await flushTraces() + + expect(sentPayloads()[0].resourceSpans[0].resource.attributes).toContainEqual({ + key: 'os.name', + value: { stringValue: 'my-os' }, + }) + }) + }) + + describe('span shape', () => { + it('exports well-formed W3C identifiers', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const [span] = sentSpans() + expect(span.traceId).toMatch(/^[0-9a-f]{32}$/) + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/) + }) + + it('encodes timestamps as nanosecond strings', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const [span] = sentSpans() + expect(typeof span.startTimeUnixNano).toBe('string') + expect(Number(span.endTimeUnixNano)).toBeGreaterThanOrEqual(Number(span.startTimeUnixNano)) + }) + + it('encodes integer attributes as stringified int64', async () => { + posthog.startSpan('checkout', { attributes: { 'http.status_code': 200 } }).end() + await flushTraces() + + expect(attributeOf(sentSpans()[0], 'http.status_code')).toEqual({ intValue: '200' }) + }) + + it('replaces an empty span name rather than poisoning the batch', async () => { + // A malformed span 400s the entire request, and 400 is non-retriable — + // one bad name would silently destroy every other span in the batch. + posthog.startSpan('').end() + await flushTraces() + + expect(sentSpans()[0].name).toBe('unknown') + }) + }) + + describe('active span context', () => { + it('nests spans started inside a withSpan callback', async () => { + posthog.withSpan('outer', () => { + posthog.withSpan('inner', () => undefined) + }) + await flushTraces() + + const inner = sentSpans().find((s) => s.name === 'inner')! + const outer = sentSpans().find((s) => s.name === 'outer')! + expect(inner.traceId).toBe(outer.traceId) + expect(inner.parentSpanId).toBe(outer.spanId) + }) + + it('keeps the span active across an await', async () => { + // This is what AsyncLocalStorage buys over the synchronous fallback: a + // span started after an await still nests correctly. + await posthog.withSpan('outer', async () => { + await Promise.resolve() + posthog.withSpan('inner', () => undefined) + }) + await flushTraces() + + const inner = sentSpans().find((s) => s.name === 'inner')! + const outer = sentSpans().find((s) => s.name === 'outer')! + expect(inner.parentSpanId).toBe(outer.spanId) + }) + + it('isolates concurrent requests from each other', async () => { + await Promise.all([ + posthog.withSpan('request-a', async () => { + await Promise.resolve() + posthog.withSpan('child-a', () => undefined) + }), + posthog.withSpan('request-b', async () => { + await Promise.resolve() + posthog.withSpan('child-b', () => undefined) + }), + ]) + await flushTraces() + + const byName = (name: string): OtlpSpan => sentSpans().find((s) => s.name === name)! + expect(byName('child-a').parentSpanId).toBe(byName('request-a').spanId) + expect(byName('child-b').parentSpanId).toBe(byName('request-b').spanId) + expect(byName('request-a').traceId).not.toBe(byName('request-b').traceId) + }) + + it('reads null outside any callback', () => { + expect(posthog.getActiveSpan()).toBeNull() + }) + }) + + describe('auto-context from the request context', () => { + it('attaches the request distinct id and session id', async () => { + // Fed by the Express/NestJS middleware from the X-POSTHOG-DISTINCT-ID and + // X-POSTHOG-SESSION-ID tracing headers. + posthog.withContext({ distinctId: 'user-123', sessionId: 'session-123' }, () => { + posthog.startSpan('checkout').end() + }) + await flushTraces() + + const [span] = sentSpans() + expect(attributeOf(span, 'posthogDistinctId')).toEqual({ stringValue: 'user-123' }) + expect(attributeOf(span, 'sessionId')).toEqual({ stringValue: 'session-123' }) + }) + + it('omits the keys outside a request context', async () => { + posthog.startSpan('background-job').end() + await flushTraces() + + expect(attributeOf(sentSpans()[0], 'posthogDistinctId')).toBeUndefined() + expect(attributeOf(sentSpans()[0], 'sessionId')).toBeUndefined() + }) + }) + + describe('distributed tracing', () => { + it('continues a trace from an inbound traceparent header', async () => { + const traceId = '4bf92f3577b34da6a3ce929d0e0e4736' + const spanId = '00f067aa0ba902b7' + + posthog.withSpan('POST /checkout', { parent: `00-${traceId}-${spanId}-01` }, () => undefined) + await flushTraces() + + const [span] = sentSpans() + expect(span.traceId).toBe(traceId) + expect(span.parentSpanId).toBe(spanId) + }) + + it('hands the next service the flag the caller sent, not an upgraded one', async () => { + const inbound = `00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00` + let propagated: string | null = null + + await posthog.withSpan('handler', { parent: inbound }, async () => { + propagated = posthog.getActiveSpan()!.traceparent() + }) + await flushTraces() + + expect(propagated).toEqual(expect.stringMatching(/-00$/)) + // Recorded and exported, with the remote-parent bits set. + expect(sentSpans()[0].flags).toBe(0x300) + }) + + it('produces a traceparent for the next service', async () => { + let traceparent: string | null = null + posthog.withSpan('POST /checkout', () => { + traceparent = posthog.getActiveSpan()!.traceparent() + }) + await flushTraces() + + const [span] = sentSpans() + expect(traceparent).toBe(`00-${span.traceId}-${span.spanId}-01`) + }) + }) + + describe('errors', () => { + it('records a thrown error and rethrows it unchanged', async () => { + const thrown = new TypeError('boom') + expect(() => + posthog.withSpan('job', () => { + throw thrown + }) + ).toThrow(thrown) + + await flushTraces() + + const [span] = sentSpans() + expect(span.status).toEqual({ code: 2, message: 'boom' }) + expect(span.events?.[0].name).toBe('exception') + expect(attributeOfEvent(span.events![0], 'exception.stacktrace')).toEqual({ + stringValue: expect.stringContaining('TypeError: boom'), + }) + }) + + it('beforeSpanSend can scrub a stacktrace', async () => { + const scrubbed = createClient({ + traces: { + serviceName: 'checkout-api', + beforeSpanSend: (span: any) => { + for (const event of span.events) { + if (event.attributes?.['exception.stacktrace']) { + event.attributes['exception.stacktrace'] = '[redacted]' + } + } + return span + }, + }, + }) + + expect(() => + scrubbed.withSpan('job', () => { + throw new TypeError('boom') + }) + ).toThrow('boom') + await scrubbed.shutdown() + + const [span] = sentSpans() + expect(attributeOfEvent(span.events![0], 'exception.stacktrace')).toEqual({ stringValue: '[redacted]' }) + }) + }) + + describe('compression', () => { + it('advertises the encoding exactly when it compresses', async () => { + const client = createClient({ disableCompression: false }) + client.startSpan('checkout').end() + await client.shutdown() + + const [, init] = traceRequests()[0] + // Runtimes without gzip send plain text; a header that disagrees with the + // body is what 400s the batch, so the two must always match. + expect(init.headers['Content-Encoding'] === 'gzip').toBe(typeof init.body !== 'string') + expect(typeof init.body !== 'string').toBe(isGzipSupported()) + }) + + it('sends an uncompressed body when compression is disabled', async () => { + const client = createClient() + client.startSpan('checkout').end() + await client.shutdown() + + const [, init] = traceRequests()[0] + expect(init.headers['Content-Encoding']).toBeUndefined() + expect(typeof init.body).toBe('string') + }) + }) + + describe('identity resource attributes', () => { + it('ignores a non-string service.name and keeps the configured one', async () => { + const client = createClient({ + traces: { serviceName: 'checkout-api', resourceAttributes: { 'service.name': 12345 } }, + }) + client.startSpan('checkout').end() + await client.shutdown() + + const resource = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(resource.find((a) => a.key === 'service.name')?.value).toEqual({ stringValue: 'checkout-api' }) + }) + + it('ignores a resourceAttributes value that is not an object', async () => { + const client = createClient({ + traces: { serviceName: 'checkout-api', resourceAttributes: 'oops' as any }, + }) + expect(() => client.startSpan('checkout').end()).not.toThrow() + await client.shutdown() + + const keys = sentPayloads()[0].resourceSpans[0].resource.attributes.map((a) => a.key) + // A spread primitive would arrive as attributes keyed "0", "1", "2", "3". + expect(keys).not.toContain('0') + }) + + it('does not throw when a resourceAttributes accessor throws', () => { + const hostile = {} + Object.defineProperty(hostile, 'service.name', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + const client = createClient({ traces: { serviceName: 'checkout-api', resourceAttributes: hostile as any } }) + + expect(() => client.startSpan('checkout')).not.toThrow() + }) + + it('falls back to unknown_service when the only service.name is not a string', async () => { + // Nothing re-emits a valid name here, so this is what actually covers the + // resolver's type check rather than the encoder's key ordering. + const client = createClient({ traces: { resourceAttributes: { 'service.name': 12345 } } }) + client.startSpan('checkout').end() + await client.shutdown() + + const resource = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(resource.find((a) => a.key === 'service.name')?.value).toEqual({ stringValue: 'unknown_service' }) + }) + + it('drops a non-string deployment.environment instead of shipping it as an int', async () => { + const client = createClient({ + traces: { serviceName: 'checkout-api', resourceAttributes: { 'deployment.environment': 42 } }, + }) + client.startSpan('checkout').end() + await client.shutdown() + + const resource = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(resource.find((a) => a.key === 'deployment.environment')).toBeUndefined() + }) + + it('lets a string service.name in resourceAttributes win', async () => { + const client = createClient({ + traces: { serviceName: 'checkout-api', resourceAttributes: { 'service.name': 'from-attributes' } }, + }) + client.startSpan('checkout').end() + await client.shutdown() + + const resource = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(resource.find((a) => a.key === 'service.name')?.value).toEqual({ stringValue: 'from-attributes' }) + }) + }) + + describe('413 handling', () => { + it('halves the batch on a real 413 rather than retrying it forever', async () => { + // `_sendTracesBatch` classifies the response itself; the core halving + // logic is dead unless that mapping is right, and every core test mocks + // the outcome rather than the status. + let requests = 0 + mockedFetch.mockImplementation(((url: string) => { + if (!url.includes('/i/v1/traces')) { + return Promise.resolve({ status: 200, text: () => Promise.resolve('ok') } as any) + } + requests++ + return Promise.resolve({ + status: requests === 1 ? 413 : 200, + text: () => Promise.resolve(requests === 1 ? 'too large' : '{}'), + } as any) + }) as any) + + const client = createClient({ traces: { serviceName: 'checkout-api', maxExportBatchSize: 2 } }) + client.startSpan('a').end() + client.startSpan('b').end() + await client.shutdown() + + const batchSizes = sentPayloads().map((p) => p.resourceSpans[0].scopeSpans[0].spans.length) + expect(batchSizes).toEqual([2, 1, 1]) + }) + }) + + describe('flush cycle', () => { + it('drains queued spans', async () => { + posthog.startSpan('checkout').end() + await posthog.flush() + + expect(sentSpans()).toHaveLength(1) + }) + + it('resolves when the span export fails', async () => { + mockedFetch.mockRejectedValue(new Error('network down')) + posthog.startSpan('checkout').end() + + await expect(posthog.flush()).resolves.toBeUndefined() + }) + + it('still flushes on its own interval', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + expect(sentSpans()).toHaveLength(1) + }) + }) + + describe('serverless waitUntil', () => { + it('drains spans on the debounced waitUntil flush, not only on shutdown', async () => { + const waitUntil = vi.fn() + // High flushAt and a long event interval so the only thing that can flush + // within the window is the debounced waitUntil cycle. + const client = createClient({ + waitUntil, + waitUntilDebounceMs: 50, + flushAt: 100, + flushInterval: 60_000, + traces: { serviceName: 'checkout-api', flushIntervalMs: 60_000 }, + }) + // No capture(): a handler that only traces must still hold the invocation open. + client.startSpan('handler').end() + + await vi.advanceTimersByTimeAsync(100) + await waitForPromises() + + expect(waitUntil).toHaveBeenCalled() + expect(sentSpans().map((span) => span.name)).toEqual(['handler']) + await client.shutdown() + }) + }) + + describe('beforeSpanSend', () => { + it('scrubs attributes before they leave the process', async () => { + const client = createClient({ + traces: { + serviceName: 'svc', + beforeSpanSend: (span: any) => { + delete span.attributes.password + return span + }, + }, + }) + client.startSpan('login', { attributes: { password: 'hunter2', ok: true } }).end() + await client.shutdown() + + const [span] = sentSpans() + expect(span.attributes?.find((a) => a.key === 'password')).toBeUndefined() + expect(span.attributes?.find((a) => a.key === 'ok')).toBeDefined() + }) + + it('runs an array of hooks through the client option', async () => { + const client = createClient({ + traces: { + serviceName: 'svc', + beforeSpanSend: [ + (span: any) => { + span.attributes.first = true + return span + }, + (span: any) => { + span.attributes.second = true + return span + }, + ], + }, + }) + client.startSpan('checkout').end() + await client.shutdown() + + const keys = sentSpans()[0].attributes?.map((a) => a.key) + expect(keys).toEqual(expect.arrayContaining(['first', 'second'])) + }) + + it('drops a span the hook rejects', async () => { + const client = createClient({ + traces: { + serviceName: 'svc', + beforeSpanSend: (span: any) => (span.attributes['http.route'] === '/health' ? null : span), + }, + }) + client.startSpan('GET /health', { attributes: { 'http.route': '/health' } }).end() + client.startSpan('GET /orders', { attributes: { 'http.route': '/orders' } }).end() + await client.shutdown() + + expect(sentSpans().map((s) => s.name)).toEqual(['GET /orders']) + }) + }) + + describe('span limits', () => { + it('caps attributes and reports how many were dropped', async () => { + const client = createClient({ traces: { serviceName: 'svc', maxAttributesPerSpan: 2 } }) + const span = client.startSpan('checkout') + span.setAttribute('a', 1) + span.setAttribute('b', 2) + span.setAttribute('c', 3) + span.end() + await client.shutdown() + + const [sent] = sentSpans() + expect(sent.attributes?.map((a) => a.key)).toEqual(['a', 'b']) + expect(sent.droppedAttributesCount).toBe(1) + }) + + it('defaults to the OpenTelemetry cap of 128', async () => { + const client = createClient({ traces: { serviceName: 'svc' } }) + const span = client.startSpan('checkout') + for (let i = 0; i < 130; i++) { + span.setAttribute(`key-${i}`, i) + } + span.end() + await client.shutdown() + + const [sent] = sentSpans() + expect(sent.attributes).toHaveLength(128) + expect(sent.droppedAttributesCount).toBe(2) + }) + }) + + describe('shutdown', () => { + it('is bounded by the shutdown timeout when the transport hangs', async () => { + const client = createClient() + client.startSpan('checkout').end() + mockedFetch.mockImplementation(() => new Promise(() => {}) as any) + + const shutdown = client.shutdown(500) + await vi.advanceTimersByTimeAsync(600) + + await expect(shutdown).resolves.toBeUndefined() + }) + + it('discards spans whose raced-out flush settles after teardown', async () => { + let rejectFetch!: (error: Error) => void + mockedFetch.mockImplementation(() => new Promise((_resolve, reject) => (rejectFetch = reject)) as any) + + const client = createClient() + client.startSpan('checkout').end() + const shutdown = client.shutdown(100) + await vi.advanceTimersByTimeAsync(150) + await shutdown + const afterShutdown = traceRequests().length + + rejectFetch(new Error('connection refused')) + await vi.advanceTimersByTimeAsync(60_000) + + expect(traceRequests()).toHaveLength(afterShutdown) + }) + + it('drains queued spans', async () => { + posthog.startSpan('a').end() + posthog.startSpan('b').end() + await posthog.shutdown() + + expect(sentSpans()).toHaveLength(2) + }) + }) +}) diff --git a/packages/node/src/__tests__/waituntil-flush.spec.ts b/packages/node/src/__tests__/waituntil-flush.spec.ts index e16c36cb8c..6cb4855988 100644 --- a/packages/node/src/__tests__/waituntil-flush.spec.ts +++ b/packages/node/src/__tests__/waituntil-flush.spec.ts @@ -12,6 +12,42 @@ function getFlushedBatches(): any[][] { .map((c) => JSON.parse((c[1] as any).body).batch) } +describe('flush combines events and spans', () => { + it('waits for the span export even when the event flush rejects', async () => { + // `Promise.all` settled on the event rejection, so a serverless host could + // freeze the invocation with the span request still open. + vi.useRealTimers() + const order: string[] = [] + mockedFetch.mockImplementation(async (url: any) => { + if (String(url).includes('/i/v1/traces')) { + order.push('traces-start') + await new Promise((resolve) => setTimeout(resolve, 30)) + order.push('traces-done') + return { status: 200, text: () => Promise.resolve('{}'), json: () => Promise.resolve({}) } as any + } + throw new Error('events endpoint down') + }) + const posthog = new PostHog('key', { + host: 'http://example.com', + flushAt: 1, + fetchRetryCount: 0, + traces: { serviceName: 'svc' }, + disableCompression: true, + }) + posthog.capture({ distinctId: 'u', event: 'e' }) + posthog.startSpan('s').end() + + await expect(posthog.flush()).rejects.toThrow() + order.push('flush-returned') + + // Exact, not an index comparison: before the fix `traces-done` is absent + // when flush returns, and `indexOf` gives -1, which passes any `lessThan`. + expect(order).toEqual(['traces-start', 'traces-done', 'flush-returned']) + await posthog.shutdown() + vi.useFakeTimers() + }) +}) + describe('waitUntil debounced flush', () => { vi.useFakeTimers() diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index b45faa4900..8194d73224 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1,6 +1,7 @@ import { version } from './version' import { + allSettled, FeatureFlagValue, getEventUuid, isBlockedUA, @@ -17,14 +18,19 @@ import { PostHogFlagsResponse, PostHogMetrics, PostHogPersistedProperty, + PostHogTraces, + inertSpan, Properties, resolveMetricsConfig, + resolveTracesConfig, + runWithActiveSpan, RetriableOptions, raceWithTimeout, safeSetTimeout, + SyncSpanContextManager, uuidv7, } from '@posthog/core' -import type { Metrics } from '@posthog/core' +import type { Metrics, Span, SpanContextManager, StartSpanOptions, TraceSdkContext } from '@posthog/core' import { AllFlagsOptions, EventMessage, @@ -144,6 +150,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen public readonly options: PostHogOptions protected readonly context?: IPostHogContext private _metrics?: PostHogMetrics + private _traces?: PostHogTraces + private _spanContext?: SpanContextManager private readonly captureMode: CaptureMode private _v1Sender?: V1CaptureSender @@ -281,8 +289,29 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen this.scheduleDebouncedFlush() } + /** + * Concurrent so a serverless handler waits for one round trip, not two. A + * failed span export leaves the spans queued rather than rejecting, since + * callers already treat `flush()` as safe to leave unwrapped. + */ + private _flushEventsAndSpans(): Promise { + const events = this.flushWithPendingPromises() + if (!this._traces) { + return events + } + // Settled, not `all`: `all` rejects the moment the event flush does, and a + // serverless host that treats this promise as the end of the invocation can + // freeze it with the span request still open. The events rejection is still + // the one the caller sees. + return allSettled([events, this._traces.flush().catch(() => {})]).then(([eventsResult]) => { + if (eventsResult.status === 'rejected') { + throw eventsResult.reason + } + }) + } + override async flush(): Promise { - const flushPromise = this.flushWithPendingPromises() + const flushPromise = this._flushEventsAndSpans() const waitUntil = this.options.waitUntil // Only register when no debounce promise is already keeping runtime alive if (waitUntil && !this._waitUntilCycle) { @@ -355,7 +384,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen private async resolveWaitUntilFlush(): Promise { const resolve = this._consumeWaitUntilCycle() try { - await this.flushWithPendingPromises() + await this._flushEventsAndSpans() } catch { // Flush errors are already logged by flush() internals } finally { @@ -601,6 +630,161 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return this._metrics } + /** + * Active-span tracking. Overridden by the Node entrypoint with an + * `AsyncLocalStorage`-backed manager; the edge build keeps this synchronous + * fallback, matching how `initializeContext` already differs between them. + */ + protected initializeSpanContextManager(): SpanContextManager { + return new SyncSpanContextManager() + } + + /** + * Runtime-detected OTLP resource attributes for every span. Overridden by the + * Node entrypoint with the host OS; the edge build contributes none, keeping + * `node:os` out of an edge bundle, which cannot resolve it. + */ + protected hostResourceAttributes(): Record { + return {} + } + + /** + * Active-span tracking, built on first use. Lives on the client rather than + * on the traces pipeline because a client with tracing off still activates a + * pass-through span, so it needs the same store the pipeline would use. + */ + private get _spanContextManager(): SpanContextManager { + if (!this._spanContext) { + this._spanContext = this.initializeSpanContextManager() + } + return this._spanContext + } + + /** + * The traces pipeline, built on first use. Returns `undefined` when the + * `traces` client option is absent — tracing is off until configured. + */ + private get _tracesPipeline(): PostHogTraces | undefined { + if (!this.options.traces) { + return undefined + } + if (!this._traces) { + this._traces = new PostHogTraces( + this, + resolveTracesConfig(this.options.traces, this.hostResourceAttributes(), this._logger), + this._logger, + () => this._tracingContext(), + this._spanContextManager, + // A handler that only traces still has to hold the invocation open. + () => this.scheduleDebouncedFlush() + ) + } + return this._traces + } + + /** + * PostHog context attached to every span, so traces join back to persons and + * sessions. A server process has no ambient identity, so these come from the + * current request context — the Express/NestJS middleware or `withContext`. + */ + private _tracingContext(): TraceSdkContext { + const context = this.context?.get() + return { distinctId: context?.distinctId, sessionId: context?.sessionId } + } + + /** + * Starts a span without making it active — for work that can't wrap a + * callback. Prefer `withSpan`, which ends the span for you. + * + * Always returns a handle, so calling code never has to branch: when the + * `traces` option is absent, the SDK is disabled, or the user has opted out, + * the handle is inert and nothing is exported. An inert handle given a + * `parent` header still returns it from `traceparent()`, so a service with + * tracing off passes a distributed trace through instead of severing it. + * + * {@label Traces} + * + * @experimental Subject to change in a minor release. + * + * @example + * ```ts + * const span = posthog.startSpan('checkout', { attributes: { plan: 'pro' } }) + * span.setAttribute('cart.items', 3) + * span.end() + * ``` + */ + startSpan(name: string, options?: StartSpanOptions): Span { + return this._tracesPipeline?.startSpan(name, options) ?? inertSpan(options, this._spanContextManager.active()) + } + + /** + * Runs a callback with a span active for its duration and ends the span for + * you — at return for a sync callback, at settle for an async one. Takes an + * optional `StartSpanOptions` between the name and the callback: + * `withSpan(name, fn)` or `withSpan(name, options, fn)`. + * + * Spans started inside the callback nest under it automatically. If the + * callback throws or rejects, the span records the exception and the original + * error is rethrown unchanged. A callback that ends the span itself gets the + * rethrow but not the recording, since the span is already exported by then. + * + * Spans nest across `await` only on the Node runtime, which tracks the active + * span with `AsyncLocalStorage`. The edge build restores the active span when + * the callback returns its promise, so spans started after an `await` there + * begin a new trace. + * + * {@label Traces} + * + * @experimental Subject to change in a minor release. + * + * @example + * ```ts + * await posthog.withSpan('POST /checkout', { parent: req.get('traceparent') }, async (span) => { + * span.setAttribute('plan', user.plan) + * return processOrder() + * }) + * ``` + */ + withSpan(name: string, fn: (span: Span) => T): T + withSpan(name: string, options: StartSpanOptions, fn: (span: Span) => T): T + withSpan(name: string, optionsOrFn: StartSpanOptions | ((span: Span) => T), maybeFn?: (span: Span) => T): T { + const options = typeof optionsOrFn === 'function' ? undefined : optionsOrFn + const fn = (typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn) as (span: Span) => T + + const pipeline = this._tracesPipeline + if (!pipeline) { + // Tracing off: still run the callback exactly once, with an inert handle. + // A handle carrying an inbound `parent` is activated, so `getActiveSpan()` + // inside the callback can propagate the trace onward. + return runWithActiveSpan(this._spanContextManager, inertSpan(options, this._spanContextManager.active()), fn) + } + return options ? pipeline.withSpan(name, options, fn) : pipeline.withSpan(name, fn) + } + + /** + * The span currently active on this async execution path, or `null` outside + * any `withSpan` callback. + * + * On the edge build this returns `null` after an `await`, because the active + * span is tracked synchronously there. + * + * {@label Traces} + * + * @experimental Subject to change in a minor release. + * + * @example + * ```ts + * // Propagate the trace to another service + * const traceparent = posthog.getActiveSpan()?.traceparent() + * await fetch(url, { headers: traceparent ? { traceparent } : {} }) + * ``` + */ + getActiveSpan(): Span | null { + // Read from the store directly rather than through the pipeline: the two + // share one manager, and with tracing off there is no pipeline to ask. + return this._spanContextManager.active() ?? null + } + /** * Get the custom user agent string for this client. * @@ -2627,6 +2811,15 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // merged back onto a re-armed timer after teardown. this._metrics.reset() } + if (this._traces) { + // Same treatment as metrics: send what's queued, raced against the shared + // shutdown budget, then reset so a losing flush can't re-arm a timer. + await raceWithTimeout( + this._traces.flush().catch(() => {}), + Math.max(0, shutdownDeadlineMs - Date.now()) + ) + this._traces.reset() + } try { return await super._shutdown(Math.max(0, shutdownDeadlineMs - Date.now())) } finally { diff --git a/packages/node/src/entrypoints/index.node.ts b/packages/node/src/entrypoints/index.node.ts index 1c872b4ed2..c456c50645 100644 --- a/packages/node/src/entrypoints/index.node.ts +++ b/packages/node/src/entrypoints/index.node.ts @@ -7,8 +7,11 @@ import { createRelativePathModifier } from '../extensions/error-tracking/modifie import type { PostHogFetchBodyBytes } from '@posthog/core' import { PostHogBackendClient } from '../client' import { ErrorTracking as CoreErrorTracking } from '@posthog/core' +import type { SpanContextManager } from '@posthog/core' import { PostHogContext } from '../extensions/context/context' +import { AsyncLocalStorageSpanContextManager } from '../extensions/context/span-context.node' import { gzipCompress } from '../gzip.node' +import { hostOsResourceAttributes } from '../host-os.node' export class PostHog extends PostHogBackendClient { getLibraryId(): string { @@ -23,6 +26,14 @@ export class PostHog extends PostHogBackendClient { return new PostHogContext() } + protected override initializeSpanContextManager(): SpanContextManager { + return new AsyncLocalStorageSpanContextManager() + } + + protected override hostResourceAttributes(): Record { + return hostOsResourceAttributes() + } + protected override createErrorPropertiesBuilder(): CoreErrorTracking.ErrorPropertiesBuilder { return new CoreErrorTracking.ErrorPropertiesBuilder( [ diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index a47ed77e8f..35950883a4 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -14,6 +14,21 @@ export type { FeatureFlagErrorType } from '@posthog/core' // and API surface without a direct @posthog/core dependency. export type { CaptureMetricOptions, Metrics, MetricsConfig } from '@posthog/core' +// Tracing types re-exported so consumers can name the `traces` client option and +// the span API without a direct @posthog/core dependency. +export type { + Span, + SpanAttributes, + SpanAttributeValue, + SpanKind, + SpanStatusCode, + SpanTimeInput, + StartSpanOptions, + SpanRecord, + BeforeSpanSendFn, + TracesConfig, +} from '@posthog/core' + // Identity helpers re-exported from core for posthog-node consumers managing // distinct_id outside the browser SDK (e.g. Lambda functions handing out // `download-app` redirects). Closes #2143. diff --git a/packages/node/src/extensions/context/span-context.node.ts b/packages/node/src/extensions/context/span-context.node.ts new file mode 100644 index 0000000000..bf4e1bacd8 --- /dev/null +++ b/packages/node/src/extensions/context/span-context.node.ts @@ -0,0 +1,22 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { Span, SpanContextManager } from '@posthog/core' + +/** + * Active-span tracking backed by `AsyncLocalStorage`, so a span stays active + * across `await` boundaries and through any async work its callback starts. + * + * Lives here rather than in core because core ships to browsers, edge runtimes + * and React Native and must not import `node:async_hooks`; the edge entrypoint + * falls back to core's synchronous manager. + */ +export class AsyncLocalStorageSpanContextManager implements SpanContextManager { + private readonly _storage = new AsyncLocalStorage() + + active(): Span | undefined { + return this._storage.getStore() + } + + with(span: Span, fn: () => T): T { + return this._storage.run(span, fn) + } +} diff --git a/packages/node/src/host-os.node.ts b/packages/node/src/host-os.node.ts new file mode 100644 index 0000000000..246bd2b429 --- /dev/null +++ b/packages/node/src/host-os.node.ts @@ -0,0 +1,22 @@ +import { platform, release } from 'node:os' +import { osResourceAttributes } from '@posthog/core' + +/** + * OTLP `os.name` / `os.version` for the machine running the SDK, so spans can + * be filtered by platform (e.g. "only the Linux workers") in PostHog. + * + * Node-only, like the other `.node` modules: importing `node:os` from a shared + * module would put it in the edge bundle. A failed read omits the key rather + * than throwing out of client construction. + */ +export function hostOsResourceAttributes(): Record { + let osName: string | undefined + let osVersion: string | undefined + try { + osName = platform() + osVersion = release() + } catch {} + // Through the shared builder, so a Node span reports the same `os.name` the + // browser, iOS and Android SDKs send rather than the `node:os` identifier. + return osResourceAttributes(osName, osVersion) +} diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3043b4ee2b..f32e06d581 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -9,6 +9,9 @@ import type { PostHogFetchResponse, PostHogFlagsAndPayloadsResponse, Properties, + Span, + StartSpanOptions, + TracesConfig, } from '@posthog/core' import { ContextData, ContextOptions } from './extensions/context/types' @@ -181,6 +184,25 @@ export type PostHogOptions = Omit stripe.charge(order)) + * ``` + * + * @experimental Subject to change in a minor release. + */ + traces?: TracesConfig /** * Credential that enables local feature flag evaluation and remote config. * @@ -843,6 +865,30 @@ export interface IPostHog { */ readonly metrics: Metrics + /** + * @description Starts a span without making it active, for work that can't wrap a callback. + * Prefer `withSpan`. Always returns a handle — an inert one when tracing is off — so calling + * code never has to branch. + * @experimental Subject to change in a minor release. + */ + startSpan(name: string, options?: StartSpanOptions): Span + + /** + * @description Runs a callback with a span active for its duration and ends the span at return + * (sync) or settle (async). Spans started inside nest automatically; a throw or rejection is + * recorded on the span and rethrown unchanged. + * @experimental Subject to change in a minor release. + */ + withSpan(name: string, fn: (span: Span) => T): T + withSpan(name: string, options: StartSpanOptions, fn: (span: Span) => T): T + + /** + * @description The span currently active on this async execution path, or null outside any + * `withSpan` callback. + * @experimental Subject to change in a minor release. + */ + getActiveSpan(): Span | null + /** * @description Flushes the events still in the queue and clears the feature flags poller to allow for * a clean shutdown. diff --git a/packages/node/vitest.config.ts b/packages/node/vitest.config.ts index 7c4e93baeb..c50b9bb217 100644 --- a/packages/node/vitest.config.ts +++ b/packages/node/vitest.config.ts @@ -10,6 +10,20 @@ export default defineConfig({ test: { globals: true, setupFiles: ['../../tooling/vitest/setup-fake-timers.ts'], + // `performance.now` is not in vitest's default `toFake` set, and span ageing + // and durations read it, so a test that advances timers must move it too. + fakeTimers: { + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'setImmediate', + 'clearImmediate', + 'Date', + 'performance', + ], + }, clearMocks: true, silent: true, pool: 'forks', diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index 4202ac0179..f31cc4df50 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -4454,6 +4454,13 @@ "path": "../core/src/metrics/types.ts", "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n} | {\n kind: 'too-large';\n} | {\n kind: 'fatal';\n error: unknown;\n}" }, + { + "id": "SendTracesBatchOutcome", + "name": "SendTracesBatchOutcome", + "properties": [], + "path": "../core/src/traces/types.ts", + "example": "{\n kind: 'ok';\n} | {\n kind: 'retry-later';\n error: unknown;\n} | {\n kind: 'too-large';\n} | {\n kind: 'fatal';\n error: unknown;\n}" + }, { "id": "SeverityLevel", "name": "SeverityLevel", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d1a399c486..4d550f07f0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -128,3 +128,22 @@ export type { OtlpMetricsPayload, } from './capture-metric' export { OTLP_AGGREGATION_TEMPORALITY_DELTA } from './capture-metric' + +// Distributed tracing types +export type { + SpanKind, + SpanStatusCode, + SpanAttributeValue, + SpanAttributes, + SpanTimeInput, + StartSpanOptions, + Span, + SpanRecord, + BeforeSpanSendFn, + TracesConfig, + OtlpSpanKeyValue, + OtlpSpanEvent, + OtlpSpanStatus, + OtlpSpan, + OtlpTracesPayload, +} from './traces' diff --git a/packages/types/src/traces.ts b/packages/types/src/traces.ts new file mode 100644 index 0000000000..1881574ef1 --- /dev/null +++ b/packages/types/src/traces.ts @@ -0,0 +1,429 @@ +import type { OtlpKeyValue } from './capture-log' + +/** + * Types for the distributed tracing API (`startSpan` / `withSpan` / `getActiveSpan`). + * + * Spans are exported as OpenTelemetry-shaped OTLP records to PostHog's tracing + * endpoint. PostHog does not depend on the OpenTelemetry SDK — these types are the + * SDK-facing surface, and the OTLP integer enums stay a wire-level concern. + */ + +/** + * What kind of work a span represents. Mirrors the OpenTelemetry span kinds. + * + * @default 'internal' + * + * @experimental Subject to change in a minor release. + */ +export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer' + +/** + * Outcome of the operation a span covers. A span that never has a status set is + * exported as `unset`, which is not the same as `ok`. + * + * @experimental Subject to change in a minor release. + */ +export type SpanStatusCode = 'ok' | 'error' + +/** + * A value that can be attached to a span, span event, or resource. + * + * The ingestion service flattens attribute values to strings for storage, so + * primitives are strongly preferred — nested arrays and objects survive only as + * serialized strings and cannot be filtered on. `null` and `undefined` drop the key. + * + * @experimental Subject to change in a minor release. + */ +export type SpanAttributeValue = + | string + | number + | boolean + | bigint + | SpanAttributeValue[] + | { [key: string]: SpanAttributeValue } + | null + | undefined + +export type SpanAttributes = Record + +/** + * A point in time, as a millisecond epoch number or a `Date`. + * + * @experimental Subject to change in a minor release. + */ +export type SpanTimeInput = number | Date + +/** + * Options accepted by `startSpan` and `withSpan`. + * + * @experimental Subject to change in a minor release. + */ +export interface StartSpanOptions { + /** + * What kind of work the span represents. + * + * @default 'internal' + */ + kind?: SpanKind + + /** + * Attributes to set at span start. User-supplied keys win over the + * SDK's auto-attached context attributes. + */ + attributes?: SpanAttributes + + /** + * Parent of this span: either a span handle, or a raw W3C `traceparent` + * string to continue a trace started by another service. + * + * When omitted the parent is the currently active span, or none. Only + * handles returned by this SDK are honoured; any other `Span` yields an + * inert span. + * + * @example Continue an inbound trace + * ```ts + * posthog.withSpan('POST /checkout', { parent: req.get('traceparent') }, handler) + * ``` + */ + parent?: Span | string + + /** + * The W3C `tracestate` value accompanying a `traceparent`-string `parent`. + * Ignored when `parent` is a span handle — those inherit the parent's + * tracestate. Preserved opaquely and passed on to children. + */ + tracestate?: string + + /** + * Backdate the span's start. Values outside the representable range fall + * back to the current time; starts more than 24 hours old are warned about, + * because the server clamps them to receive time. + */ + startTime?: SpanTimeInput +} + +/** + * A handle to a span in progress. + * + * Every method is safe to call at any time, including after `end()` and on + * no-op handles, so calling code never has to branch on whether tracing is on. + * + * @experimental Subject to change in a minor release. + */ +export interface Span { + /** Set a single attribute. Ignored after `end()`. */ + setAttribute(key: string, value: SpanAttributeValue): this + + /** Merge several attributes at once. Ignored after `end()`. */ + setAttributes(attributes: SpanAttributes): this + + /** + * Record a timestamped event within the span, e.g. a cache miss or a retry. + * Defaults to the current time. + * + * One event carries at most 128 attributes; further keys are dropped and + * counted on the exported event. Use `maxEventsPerSpan` to bound how many + * events a span carries. + */ + addEvent(name: string, attributes?: SpanAttributes, timestamp?: SpanTimeInput): this + + /** + * Set the span's outcome. Last write wins. + * + * When a `withSpan` callback throws, the SDK sets `error` automatically — + * unless the callback already set `ok`, which is treated as final. + */ + setStatus(status: SpanStatusCode, message?: string): this + + /** + * Record an exception on the span: sets status `error` and attaches an + * `exception` event carrying `exception.type`, `exception.message` and, + * where the thrown value has one, `exception.stacktrace`. The stack is + * truncated to `maxAttributeValueLength` like any other attribute value, + * and `beforeSpanSend` sees it before it is exported. Does not end the span. + */ + recordException(error: unknown): this + + /** + * Replace the span's name. Useful when the low-cardinality name is only + * known after work begins — a route template resolving mid-request, say. + * + * Span names should be low-cardinality operation names (`GET /users/:id`), + * never interpolated with ids: PostHog aggregates operations by service and + * name, so variable values belong in attributes. + */ + updateName(name: string): this + + /** + * This span's W3C `traceparent` header value (`00---`), + * for propagating the trace to another service. A span continuing a remote + * trace propagates the flags byte it was handed, so a downstream sampler + * sees the decision the head sampler made; a trace started here is sampled. + * + * When tracing is off, a span started with a `parent` header echoes that + * header back — version and sampled flag included — so a service that + * records nothing still keeps a distributed trace whole. With no `parent` + * there is no context to carry and this returns `null`, so an id this SDK + * never recorded cannot propagate. + */ + traceparent(): string | null + + /** This span's W3C `tracestate` value, or `null` when it has none. */ + tracestate(): string | null + + /** + * End the span and queue it for export. Idempotent — later calls no-op. + * + * @param endTime - Override the recorded end. Invalid values fall back to + * the derived end time; an end before the start is corrected to the start. + */ + end(endTime?: SpanTimeInput): void +} + +/** + * A completed span as `beforeSpanSend` sees it: plain values, not the OTLP wire + * encoding — `userId: 42` reads as `42`, not `{ intValue: "42" }`. + * + * @experimental Subject to change in a minor release. + */ +export interface SpanRecord { + /** + * Assignment to any of the three identity fields is ignored with a debug + * warning: rewriting ids orphans children that already shipped. + */ + readonly traceId: string + readonly spanId: string + /** Absent on a root span. */ + readonly parentSpanId?: string + name: string + kind: SpanKind + status?: { code: SpanStatusCode; message?: string } + /** Editable in place; this is where to redact. */ + attributes: SpanAttributes + events: { name: string; /** Millisecond epoch. */ timestamp: number; attributes?: SpanAttributes }[] + /** Millisecond epoch. */ + startTime: number + /** Millisecond epoch. */ + endTime: number +} + +/** + * Inspects, edits or drops a finished span. Return `null` to drop it. + * + * The hook runs synchronously as part of `end()`; a returned promise is not + * awaited and the span is dropped. + * + * @experimental Subject to change in a minor release. + */ +export type BeforeSpanSendFn = (span: SpanRecord) => SpanRecord | null + +/** + * Configuration for distributed tracing, passed as the `traces` client option. + * Tracing stays off until this object is supplied. + * + * @example + * ```ts + * const posthog = new PostHog('phc_...', { traces: { serviceName: 'checkout-api' } }) + * ``` + * + * @experimental Subject to change in a minor release. + */ +export interface TracesConfig { + /** + * Name of the service producing these spans, attached as the OTLP + * `service.name` resource attribute. PostHog groups operations by service + * and span name, so this is what makes spans attributable. + * + * @default 'unknown_service' + */ + serviceName?: string + + /** Service version, attached as OTLP `service.version`. */ + serviceVersion?: string + + /** + * Deployment environment (e.g. `'production'`, `'staging'`), attached as + * OTLP `deployment.environment`. + */ + environment?: string + + /** + * Extra OTLP resource attributes attached to every batch. + * + * A string `service.name`, `service.version` or `deployment.environment` here + * wins over the `serviceName` / `serviceVersion` / `environment` fields — set + * it either way. `telemetry.sdk.*` is SDK-controlled and always wins. + */ + resourceAttributes?: SpanAttributes + + /** + * How often queued spans are flushed, in milliseconds. Spans also flush when + * the queue reaches `maxExportBatchSize` and on `shutdown()`. + * + * @default 5000 + */ + flushIntervalMs?: number + + /** + * Maximum spans per outbound request, and the queue depth that triggers an + * immediate flush. On a 413 the SDK halves this, retries the same spans, then + * ramps back up. + * + * @default 512 + */ + maxExportBatchSize?: number + + /** + * Bound on the in-memory export queue. When it is full the incoming span is + * dropped rather than evicting a queued one, whose children may already have + * been exported. Never lower than `maxExportBatchSize`. + * + * @default 2048 + */ + maxQueueSize?: number + + /** + * Runs on every finished span before it is queued. Edit the span in place, + * or return `null` to drop it. An array runs left to right, and the first + * hook to return `null` stops the chain. + * + * This is the place to scrub sensitive attributes, so a hook that throws + * drops the span rather than exporting an unscrubbed one. + * + * @example Drop health checks and redact a header + * ```ts + * traces: { + * beforeSpanSend: (span) => { + * if (span.attributes['http.route'] === '/health') return null + * delete span.attributes['http.request.header.authorization'] + * return span + * }, + * } + * ``` + */ + beforeSpanSend?: BeforeSpanSendFn | BeforeSpanSendFn[] + + /** + * Maximum user-supplied attributes on a single span. Attributes the SDK + * attaches itself — `posthogDistinctId`, `sessionId` and friends — are + * exempt and are never evicted, because they are what links a span to a + * person and a session. + * + * On overflow the earliest-set attributes are kept and later ones are + * dropped, with the number dropped reported on the exported span. + * + * @default 128 + */ + maxAttributesPerSpan?: number + + /** + * Maximum events on a single span. On overflow the earliest events are kept + * and later ones are dropped, with the number dropped reported on the + * exported span. + * + * The cap is absolute: an `exception` event the SDK records on your behalf + * spends an ordinary slot like any other, so a span that fills its events + * and then throws keeps its `error` status but not the exception detail. + * Raise the cap on spans that record many events and can also fail. + * + * @default 128 + */ + maxEventsPerSpan?: number + + /** + * Maximum length of a string attribute value. Longer values are truncated, + * and the bound reaches every string the value contains, including the ones + * nested inside arrays and objects. It applies to span attributes, event + * attributes, span names, event names, status messages and resource + * attributes alike — including `exception.stacktrace`. + * + * The bound is what keeps one large value from making a span too large for + * the ingestion endpoint, which drops an oversized span whole. + * + * @default 8192 + */ + maxAttributeValueLength?: number + + /** + * Bound on how many spans may be live (started but not ended) at once. At + * the bound `startSpan` returns an inert handle, so code that leaks spans + * cannot grow the SDK's bookkeeping without limit. The SDK tracks only an + * id and a timestamp per live span, never the span itself, so a high bound + * is inexpensive. + * + * @default 10000 + */ + maxLiveSpans?: number + + /** + * How long a span may stay live before the SDK stops accounting for it, in + * milliseconds. An evicted span is never exported, and its slot is returned + * so one leak cannot disable tracing for the rest of the process. Measured + * as monotonic elapsed time since `startSpan`, so a caller-supplied + * `startTime` neither ages a span early nor exempts it. + * + * @default 3600000 + */ + maxSpanAgeMs?: number +} + +// ============================================================================ +// OTLP wire types +// +// `AnyValue` and `KeyValue` are the same shapes the logs and metrics payloads +// use, and one shared encoder produces all three, so spans alias them rather +// than redeclaring them. The alias names remain so the span types below read as +// span types. +// ============================================================================ + +export type OtlpSpanKeyValue = OtlpKeyValue + +export interface OtlpSpanEvent { + name: string + timeUnixNano: string + attributes?: OtlpSpanKeyValue[] + /** Attributes dropped by the SDK's per-event attribute cap. Omitted when none were. */ + droppedAttributesCount?: number +} + +export interface OtlpSpanStatus { + /** unset 0, ok 1, error 2. */ + code: number + message?: string +} + +export interface OtlpSpan { + /** 32-char lowercase hex. */ + traceId: string + /** 16-char lowercase hex. */ + spanId: string + parentSpanId?: string + traceState?: string + name: string + /** unspecified 0, internal 1, server 2, client 3, producer 4, consumer 5. */ + kind: number + startTimeUnixNano: string + endTimeUnixNano: string + attributes?: OtlpSpanKeyValue[] + events?: OtlpSpanEvent[] + status?: OtlpSpanStatus + /** + * W3C trace flags in the low byte — the sampled bit as this span propagates + * it — plus OTel's parent-remoteness bits (`0x100` known, `0x200` remote). + */ + flags?: number + /** User attributes dropped by `maxAttributesPerSpan`. Omitted when none were. */ + droppedAttributesCount?: number + /** Events dropped by `maxEventsPerSpan`. Omitted when none were. */ + droppedEventsCount?: number +} + +export interface OtlpTracesPayload { + resourceSpans: Array<{ + resource: { attributes: OtlpSpanKeyValue[] } + scopeSpans: Array<{ + scope: { name: string; version?: string } + spans: OtlpSpan[] + }> + }> +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 978bdb446b..bb7228898d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,6 +773,9 @@ importers: '@posthog-tooling/tsconfig-base': specifier: workspace:* version: link:../../tooling/tsconfig-base + '@posthog/types': + specifier: workspace:^ + version: link:../types '@rslib/core': specifier: 'catalog:' version: 0.23.2(@microsoft/api-extractor@7.58.9(@types/node@20.19.9))(@typescript/native-preview@7.0.0-dev.20260216.1)(core-js@3.49.0)(typescript@5.9.3)