Skip to content

Commit f4f6fa8

Browse files
committed
feat(node): distributed tracing spans
Adds startSpan, withSpan and getActiveSpan to posthog-node behind a new traces client option, encoding spans as OTLP JSON without an OpenTelemetry dependency.
1 parent 6723395 commit f4f6fa8

34 files changed

Lines changed: 4738 additions & 16 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'posthog-node': minor
3+
'@posthog/core': minor
4+
'@posthog/types': minor
5+
---
6+
7+
Add distributed tracing to `posthog-node`: `withSpan`, `startSpan` and `getActiveSpan`, enabled by a new `traces` client option. `IPostHog` gains these three members, so anything implementing that interface (hand-written test doubles, DI wrappers) needs them added.

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"lint:fix": "eslint src --fix",
4444
"build": "rslib build",
4545
"dev": "rslib build -w",
46-
"test:unit": "jest",
46+
"test:unit": "NODE_OPTIONS=--expose-gc jest",
4747
"package": "pnpm pack --out $PACKAGE_DEST/%s.tgz"
4848
},
4949
"exports": {

packages/core/src/__tests__/posthog.flush.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,11 +762,12 @@ describe('PostHog Core', () => {
762762
})
763763

764764
describe('OTLP batch senders', () => {
765-
// Both share one `_sendOtlpBatch`; the table pins them to the same
765+
// All three share one `_sendOtlpBatch`; the table pins them to the same
766766
// classification so a wrapper can't reintroduce a per-signal retry policy.
767767
const senders = {
768768
logs: (client: PostHogCoreTestClient) => client._sendLogsBatch({ resourceLogs: [] }),
769769
metrics: (client: PostHogCoreTestClient) => client._sendMetricsBatch({ resourceMetrics: [] }),
770+
traces: (client: PostHogCoreTestClient) => client._sendTracesBatch({ resourceSpans: [] }),
770771
}
771772

772773
const cases: [number, string][] = [

packages/core/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ export type {
8686
Metrics,
8787
MetricsConfig,
8888
} from './metrics/types'
89+
export { PostHogTraces } from './traces'
90+
export { SyncSpanContextManager } from './traces/context'
91+
export { NOOP_SPAN } from './traces/span'
92+
export type { ResolvedTracesConfig, SpanContextManager, TraceSdkContext } from './traces/types'
93+
// Same barrel convention as logs and metrics for the user-facing tracing types.
94+
export type {
95+
Span,
96+
SpanAttributes,
97+
SpanAttributeValue,
98+
SpanKind,
99+
SpanStatusCode,
100+
SpanTimeInput,
101+
StartSpanOptions,
102+
TracesConfig,
103+
} from './traces/types'
89104
export { uuidv7 } from './vendor/uuidv7'
90105
export * from './cookie'
91106
export * from './posthog-core'

packages/core/src/posthog-core-stateless.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { OtlpLogsPayload, OtlpMetricsPayload } from '@posthog/types'
1+
import type { OtlpLogsPayload, OtlpMetricsPayload, OtlpTracesPayload } from '@posthog/types'
22
import type { SendMetricsBatchOutcome } from './metrics/types'
3+
import type { SendTracesBatchOutcome } from './traces/types'
34
import { SimpleEventEmitter } from './eventemitter'
45
import { getFeatureFlagValue, minimizeFlagCalledEventProperties, normalizeFlagsResponse } from './featureFlagUtils'
56
import { gzipCompress, isGzipSupported } from './gzip'
@@ -238,8 +239,8 @@ export type SendLogsBatchOutcome =
238239

239240
/**
240241
* Each signal keeps its own exported outcome type because each belongs to a
241-
* separate host contract. The wrappers return this value directly, so one
242-
* drifting out of shape fails to compile.
242+
* separate host contract. The wrappers return this value directly, so any of
243+
* the three drifting out of shape fails to compile.
243244
*/
244245
type SendOtlpBatchOutcome =
245246
| { kind: 'ok' }
@@ -1642,9 +1643,9 @@ export abstract class PostHogCoreStateless {
16421643
}
16431644

16441645
/**
1645-
* Shared implementation behind the OTLP senders, which differ only in path.
1646-
* Returns a tagged outcome instead of throwing so the queue owners don't
1647-
* have to know the core's error class hierarchy.
1646+
* Shared implementation behind the three OTLP senders, which differ only in
1647+
* path and auth style. Returns a tagged outcome instead of throwing so the
1648+
* queue owners don't have to know the core's error class hierarchy.
16481649
*
16491650
* Exhausted 408/429/5xx stay `retry-later`, unlike the events `_flush()`
16501651
* which drops anything that isn't a network error: every OTLP queue is
@@ -1653,24 +1654,30 @@ export abstract class PostHogCoreStateless {
16531654
*/
16541655
private async _sendOtlpBatch({
16551656
path,
1657+
auth,
16561658
payload,
16571659
}: {
1658-
path: 'logs' | 'metrics'
1659-
payload: OtlpLogsPayload | OtlpMetricsPayload
1660+
path: 'logs' | 'metrics' | 'traces'
1661+
auth: 'query-token' | 'bearer'
1662+
payload: OtlpLogsPayload | OtlpMetricsPayload | OtlpTracesPayload
16601663
}): Promise<SendOtlpBatchOutcome> {
16611664
if (this.disabled) {
16621665
return { kind: 'fatal', error: new Error('The client is disabled') }
16631666
}
16641667

16651668
const serialized = JSON.stringify(payload)
1666-
const url = `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}`
1669+
const url =
1670+
auth === 'bearer'
1671+
? `${this.host}/i/v1/${path}`
1672+
: `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}`
16671673

16681674
const gzippedPayload = !this.disableCompression ? await this.compressPayload(serialized) : null
16691675
const fetchOptions: PostHogFetchOptions = {
16701676
method: 'POST',
16711677
headers: {
16721678
...this.getCustomHeaders(),
16731679
'Content-Type': 'application/json',
1680+
...(auth === 'bearer' && { Authorization: `Bearer ${this.apiKey}` }),
16741681
...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
16751682
},
16761683
body: gzippedPayload || serialized,
@@ -1703,11 +1710,23 @@ export abstract class PostHogCoreStateless {
17031710
}
17041711

17051712
async _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome> {
1706-
return this._sendOtlpBatch({ path: 'logs', payload })
1713+
return this._sendOtlpBatch({ path: 'logs', auth: 'query-token', payload })
17071714
}
17081715

17091716
async _sendMetricsBatch(payload: OtlpMetricsPayload): Promise<SendMetricsBatchOutcome> {
1710-
return this._sendOtlpBatch({ path: 'metrics', payload })
1717+
return this._sendOtlpBatch({ path: 'metrics', auth: 'query-token', payload })
1718+
}
1719+
1720+
/**
1721+
* The `TracesHost._sendTracesBatch` implementation, so `PostHogTraces` can
1722+
* use any core-based SDK as its host.
1723+
*
1724+
* Authenticates with `Authorization: Bearer` rather than the `?token=` query
1725+
* parameter the logs and metrics senders use: it's the service's primary auth
1726+
* path, and server runtimes have no CORS preflight to avoid.
1727+
*/
1728+
async _sendTracesBatch(payload: OtlpTracesPayload): Promise<SendTracesBatchOutcome> {
1729+
return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload })
17111730
}
17121731

17131732
private fetchWithRetry<T>(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Span } from '@posthog/types'
2+
import type { SpanContextManager } from './types'
3+
4+
/**
5+
* Synchronous active-span tracking: restores the previous active span when the
6+
* callback returns, which for an async callback means when it returns its
7+
* promise — so spans started after an `await` won't see it as active.
8+
*
9+
* The fallback for runtimes with no ambient async context; Node injects an
10+
* `AsyncLocalStorage`-backed manager instead. `parent` is the escape hatch.
11+
*/
12+
export class SyncSpanContextManager implements SpanContextManager {
13+
private _active: Span | undefined
14+
15+
active(): Span | undefined {
16+
return this._active
17+
}
18+
19+
with<T>(span: Span, fn: () => T): T {
20+
const previous = this._active
21+
this._active = span
22+
try {
23+
return fn()
24+
} finally {
25+
this._active = previous
26+
}
27+
}
28+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { getRandomBytes, isValidSpanId, isValidTraceId, newSpanId, newTraceId } from './ids'
2+
3+
describe('trace and span ids', () => {
4+
describe('newTraceId', () => {
5+
it('is 32 lowercase hex characters', () => {
6+
for (let i = 0; i < 50; i++) {
7+
expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
8+
}
9+
})
10+
11+
it('is never all zeros', () => {
12+
for (let i = 0; i < 50; i++) {
13+
expect(newTraceId()).not.toBe('0'.repeat(32))
14+
}
15+
})
16+
17+
it('does not repeat', () => {
18+
const ids = new Set(Array.from({ length: 200 }, newTraceId))
19+
expect(ids.size).toBe(200)
20+
})
21+
})
22+
23+
describe('newSpanId', () => {
24+
it('is 16 lowercase hex characters', () => {
25+
for (let i = 0; i < 50; i++) {
26+
expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/)
27+
}
28+
})
29+
30+
it('does not repeat', () => {
31+
const ids = new Set(Array.from({ length: 200 }, newSpanId))
32+
expect(ids.size).toBe(200)
33+
})
34+
})
35+
36+
describe('getRandomBytes', () => {
37+
it('returns the requested length', () => {
38+
expect(getRandomBytes(8)).toHaveLength(8)
39+
expect(getRandomBytes(16)).toHaveLength(16)
40+
})
41+
42+
it('falls back to Math.random when crypto is unavailable', () => {
43+
const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
44+
// React Native has no global crypto without a polyfill — the fallback path
45+
// is what keeps span ids working there.
46+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
47+
try {
48+
expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
49+
expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/)
50+
} finally {
51+
if (original) {
52+
Object.defineProperty(globalThis, 'crypto', original)
53+
}
54+
}
55+
})
56+
57+
it('falls back when getRandomValues throws', () => {
58+
const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
59+
Object.defineProperty(globalThis, 'crypto', {
60+
value: {
61+
getRandomValues: () => {
62+
throw new Error('not allowed')
63+
},
64+
},
65+
configurable: true,
66+
})
67+
try {
68+
expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
69+
} finally {
70+
if (original) {
71+
Object.defineProperty(globalThis, 'crypto', original)
72+
}
73+
}
74+
})
75+
76+
it('never emits an all-zero id even when the random source is broken', () => {
77+
const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
78+
Object.defineProperty(globalThis, 'crypto', {
79+
value: { getRandomValues: (array: Uint8Array) => array.fill(0) },
80+
configurable: true,
81+
})
82+
try {
83+
// The server zeroes ids it can't use, so an all-zero id would be stored
84+
// and silently orphaned rather than rejected.
85+
expect(newTraceId()).not.toBe('0'.repeat(32))
86+
expect(newSpanId()).not.toBe('0'.repeat(16))
87+
} finally {
88+
if (original) {
89+
Object.defineProperty(globalThis, 'crypto', original)
90+
}
91+
}
92+
})
93+
})
94+
95+
describe('validation', () => {
96+
it.each([
97+
['a valid trace id', '4bf92f3577b34da6a3ce929d0e0e4736', true],
98+
['an all-zero trace id', '0'.repeat(32), false],
99+
['a short trace id', 'abc', false],
100+
['uppercase hex', '4BF92F3577B34DA6A3CE929D0E0E4736', false],
101+
['a non-hex string', 'zzf92f3577b34da6a3ce929d0e0e4736', false],
102+
['a non-string', 12345, false],
103+
])('isValidTraceId rejects/accepts %s', (_name, value, expected) => {
104+
expect(isValidTraceId(value)).toBe(expected)
105+
})
106+
107+
it.each([
108+
['a valid span id', '00f067aa0ba902b7', true],
109+
['an all-zero span id', '0'.repeat(16), false],
110+
['a trace-length id', '4bf92f3577b34da6a3ce929d0e0e4736', false],
111+
])('isValidSpanId rejects/accepts %s', (_name, value, expected) => {
112+
expect(isValidSpanId(value)).toBe(expected)
113+
})
114+
})
115+
})

packages/core/src/traces/ids.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// W3C Trace Context identifier generation. Trace ids are 16 bytes, span ids 8,
2+
// both lowercase hex on the JSON wire. The ingestion service *zeroes* ids that
3+
// aren't exactly the right length rather than rejecting them, silently orphaning
4+
// the span — so length is load-bearing and every id is validated before it ships.
5+
6+
const TRACE_ID_BYTES = 16
7+
const SPAN_ID_BYTES = 8
8+
9+
const TRACE_ID_HEX = TRACE_ID_BYTES * 2
10+
const SPAN_ID_HEX = SPAN_ID_BYTES * 2
11+
12+
const INVALID_TRACE_ID = '0'.repeat(TRACE_ID_HEX)
13+
const INVALID_SPAN_ID = '0'.repeat(SPAN_ID_HEX)
14+
15+
const HEX_RE = /^[0-9a-f]+$/
16+
17+
type CryptoLike = { getRandomValues?: (array: Uint8Array) => Uint8Array }
18+
19+
/**
20+
* Random bytes from the platform's CSPRNG, falling back to `Math.random`.
21+
*
22+
* The fallback exists for React Native, which has no global `crypto` without a
23+
* polyfill. Trace ids need collision resistance, not unpredictability.
24+
*/
25+
export function getRandomBytes(byteLength: number): Uint8Array {
26+
const bytes = new Uint8Array(byteLength)
27+
const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto
28+
if (cryptoLike && typeof cryptoLike.getRandomValues === 'function') {
29+
try {
30+
cryptoLike.getRandomValues(bytes)
31+
return bytes
32+
} catch {}
33+
}
34+
for (let i = 0; i < byteLength; i++) {
35+
bytes[i] = Math.floor(Math.random() * 256)
36+
}
37+
return bytes
38+
}
39+
40+
function bytesToHex(bytes: Uint8Array): string {
41+
let hex = ''
42+
for (let i = 0; i < bytes.length; i++) {
43+
hex += bytes[i].toString(16).padStart(2, '0')
44+
}
45+
return hex
46+
}
47+
48+
function randomHexId(byteLength: number): string {
49+
const hex = bytesToHex(getRandomBytes(byteLength))
50+
// An all-zero id is invalid per W3C and the server treats one as absent, so the
51+
// span would be stored and silently orphaned. Only reachable from a broken source.
52+
return /[^0]/.test(hex) ? hex : hex.slice(0, -1) + '1'
53+
}
54+
55+
export function newTraceId(): string {
56+
return randomHexId(TRACE_ID_BYTES)
57+
}
58+
59+
export function newSpanId(): string {
60+
return randomHexId(SPAN_ID_BYTES)
61+
}
62+
63+
function isValidHexId(value: unknown, length: number, invalid: string): value is string {
64+
return typeof value === 'string' && value.length === length && value !== invalid && HEX_RE.test(value)
65+
}
66+
67+
export function isValidTraceId(value: unknown): value is string {
68+
return isValidHexId(value, TRACE_ID_HEX, INVALID_TRACE_ID)
69+
}
70+
71+
export function isValidSpanId(value: unknown): value is string {
72+
return isValidHexId(value, SPAN_ID_HEX, INVALID_SPAN_ID)
73+
}

0 commit comments

Comments
 (0)