Skip to content

Commit a78b6c3

Browse files
committed
feat(traces): propagate the inbound sampled flag and parent remoteness
A continued trace now carries the caller's trace-flags byte in both `traceparent()` and the exported span, rather than always sending `01`, so a downstream parent-based sampler is not handed a decision this SDK invented. Exported spans also set OTel's parent-remoteness bits.
1 parent bf5e7af commit a78b6c3

12 files changed

Lines changed: 184 additions & 29 deletions

File tree

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+
Propagate the inbound W3C sampled flag on a continued trace instead of always sending `01`, so a downstream parent-based sampler sees the decision the head sampler made. Spans are still recorded and exported either way. Exported spans also carry OpenTelemetry's parent-remoteness bits, so a span that entered the service over HTTP is distinguishable from one started locally.

packages/core/src/traces/index.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,40 @@ describe('PostHogTraces', () => {
192192
expect(sentSpans()[0].traceId).toBe(TRACE_ID)
193193
})
194194

195+
it('propagates the sampled-out flag onward rather than upgrading it to 01', async () => {
196+
// A downstream parent-based sampler would otherwise record a trace its own
197+
// head sampler had already rejected.
198+
const traces = createTraces()
199+
const span = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` })
200+
const child = traces.startSpan('inner', { parent: span })
201+
202+
expect(span.traceparent()!.startsWith(`00-${TRACE_ID}-`)).toBe(true)
203+
expect(span.traceparent()!.endsWith('-00')).toBe(true)
204+
// The whole chain agrees, not just the span that read the header.
205+
expect(child.traceparent()!.endsWith('-00')).toBe(true)
206+
207+
child.end()
208+
span.end()
209+
await traces.flush()
210+
211+
// Recorded and exported all the same, with the wire agreeing with the header.
212+
const byName = Object.fromEntries(sentSpans().map((sent) => [sent.name, sent.flags]))
213+
expect(byName).toEqual({ handler: 0x300, inner: 0x100 })
214+
})
215+
216+
it('marks a header parent remote and a handle parent local', async () => {
217+
const traces = createTraces()
218+
const remote = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` })
219+
const local = traces.startSpan('inner', { parent: remote })
220+
221+
local.end()
222+
remote.end()
223+
await traces.flush()
224+
225+
const byName = Object.fromEntries(sentSpans().map((span) => [span.name, span.flags]))
226+
expect(byName).toEqual({ handler: 0x301, inner: 0x101 })
227+
})
228+
195229
it('preserves tracestate opaquely and passes it to children', async () => {
196230
const traces = createTraces()
197231
const parent = traces.startSpan('handler', {

packages/core/src/traces/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ interface ParentContext {
5353
traceId: string
5454
parentSpanId?: string
5555
traceState?: string
56+
traceFlags?: string
57+
/** True when the parent arrived as a `traceparent` header. */
58+
isRemote?: boolean
5659
}
5760

5861
/**
@@ -147,6 +150,8 @@ export class PostHogTraces {
147150
spanId,
148151
parentSpanId: parent?.parentSpanId,
149152
traceState: parent?.traceState,
153+
traceFlags: parent?.traceFlags,
154+
parentIsRemote: parent?.isRemote,
150155
name: sanitizeName(name, 'Span name', this._logger),
151156
kind: options?.kind ?? 'internal',
152157
// Auto-context first so user-supplied attributes win on collision.
@@ -280,6 +285,8 @@ export class PostHogTraces {
280285
traceId: remote.traceId,
281286
parentSpanId: remote.spanId,
282287
traceState: sanitizeTracestate(options?.tracestate),
288+
traceFlags: remote.flags,
289+
isRemote: true,
283290
}
284291
}
285292

packages/core/src/traces/otlp.spec.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const record = (overrides: Partial<SpanRecord> = {}): SpanRecord => ({
1212
spanId: '00f067aa0ba902b7',
1313
name: 'checkout',
1414
kind: 'internal',
15+
traceFlags: '01',
16+
parentIsRemote: false,
1517
attributes: {},
1618
events: [],
1719
startTime: 1_700_000_000_000,
@@ -77,7 +79,7 @@ describe('OTLP span encoding', () => {
7779
kind: 1,
7880
startTimeUnixNano: '1700000000000000000',
7981
endTimeUnixNano: '1700000000080000000',
80-
flags: 1,
82+
flags: 0x101,
8183
})
8284
})
8385

@@ -108,8 +110,22 @@ describe('OTLP span encoding', () => {
108110
expect(span.events).toEqual([{ name: 'cache miss', timeUnixNano: '1700000000040000000' }])
109111
})
110112

111-
it('always sets the sampled trace flag', () => {
112-
expect(buildOtlpSpan(record()).flags).toBe(1)
113+
it('sets the sampled bit and marks a local parent as known-not-remote', () => {
114+
expect(buildOtlpSpan(record()).flags).toBe(0x101)
115+
})
116+
117+
it('marks a parent that arrived as a header as remote', () => {
118+
expect(buildOtlpSpan(record({ parentSpanId: 'b7ad6b7169203331', parentIsRemote: true })).flags).toBe(0x301)
119+
})
120+
121+
it('propagates an inbound sampled-out flag rather than overriding it', () => {
122+
// The span is still recorded and exported; what the wire says is the
123+
// decision the head sampler made.
124+
expect(buildOtlpSpan(record({ traceFlags: '00', parentIsRemote: true })).flags).toBe(0x300)
125+
})
126+
127+
it('falls back to sampled when the flags byte is unusable', () => {
128+
expect(buildOtlpSpan(record({ traceFlags: 'zz' })).flags).toBe(0x101)
113129
})
114130
})
115131

@@ -232,7 +248,7 @@ describe('OTLP span encoding', () => {
232248
kind: 2,
233249
startTimeUnixNano: '1700000000000000000',
234250
endTimeUnixNano: '1700000000080000000',
235-
flags: 1,
251+
flags: 0x101,
236252
attributes: [
237253
{ key: 'posthogDistinctId', value: { stringValue: 'user-123' } },
238254
{ key: 'sessionId', value: { stringValue: 'session-123' } },

packages/core/src/traces/otlp.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,24 @@ const SPAN_STATUS_TO_OTLP: Record<SpanStatusCode, number> = {
2626
error: 2,
2727
}
2828

29-
/** W3C trace flags: the sampled bit, always set because every captured span is recorded. */
30-
const TRACE_FLAGS_SAMPLED = 1
29+
/** W3C trace flags live in the low byte; the sampled bit is `0x01`. */
30+
const TRACE_FLAGS_SAMPLED = 0x01
31+
// OTel's span flags, above the W3C byte: one bit says the parent's remoteness is
32+
// known, the other says it is remote. Both unset reads as "unknown", which this
33+
// SDK never has to say — a string parent is remote, a handle parent is local.
34+
const SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE = 0x100
35+
const SPAN_FLAGS_CONTEXT_IS_REMOTE = 0x200
36+
37+
/**
38+
* The `flags` field for a span: its W3C trace-flags byte, plus OTel's
39+
* parent-remoteness bits. Nothing reads the remoteness today, but a span
40+
* exported without it can never be backfilled with it.
41+
*/
42+
function spanFlags(record: SpanRecord): number {
43+
const traceFlags = parseInt(record.traceFlags, 16)
44+
const w3c = Number.isFinite(traceFlags) ? traceFlags & 0xff : TRACE_FLAGS_SAMPLED
45+
return w3c | SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE | (record.parentIsRemote ? SPAN_FLAGS_CONTEXT_IS_REMOTE : 0)
46+
}
3147

3248
/**
3349
* Every free-text string this encoder puts on the wire. A lone surrogate survives
@@ -97,7 +113,7 @@ export function buildOtlpSpan(record: SpanRecord, logger?: Logger): OtlpSpan {
97113
kind: spanKindToOtlp(record.kind),
98114
startTimeUnixNano: msToUnixNanoString(record.startTime),
99115
endTimeUnixNano: msToUnixNanoString(record.endTime),
100-
flags: TRACE_FLAGS_SAMPLED,
116+
flags: spanFlags(record),
101117
}
102118
if (record.parentSpanId) {
103119
span.parentSpanId = record.parentSpanId

packages/core/src/traces/span.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,21 @@ describe('PostHogSpan', () => {
309309
expect(createSpan({ traceState: 'vendor=abc' }).tracestate()).toBe('vendor=abc')
310310
})
311311

312+
it('propagates the trace flags it was started with', () => {
313+
expect(createSpan({ traceFlags: '00' }).traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`)
314+
expect(createSpan().traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`)
315+
})
316+
317+
it('hands a child the flags it propagates, so the whole chain agrees', () => {
318+
expect(createSpan({ traceFlags: '00' }).childContext().traceFlags).toBe('00')
319+
})
320+
312321
it('exposes a child context carrying its own span id as the parent', () => {
313322
expect(createSpan({ traceState: 'vendor=abc' }).childContext()).toEqual({
314323
traceId: TRACE_ID,
315324
parentSpanId: SPAN_ID,
316325
traceState: 'vendor=abc',
326+
traceFlags: '01',
317327
})
318328
})
319329
})

packages/core/src/traces/span.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatusCode, SpanTimeInput } from '@posthog/types'
22
import type { Logger } from '../types'
33
import type { SpanEventRecord, SpanRecord } from './types'
4-
import { formatTraceparent, normalizeTraceparent, sanitizeTracestate } from './traceparent'
4+
import { formatTraceparent, normalizeTraceparent, sanitizeTracestate, TRACE_FLAGS_SAMPLED } from './traceparent'
55
import { assignUserAttributes, clampEndTime, resolveSuppliedTime, sanitizeName } from './sanitize'
66
import { isError } from '../utils'
77

@@ -19,6 +19,10 @@ export interface SpanInit {
1919
spanId: string
2020
parentSpanId?: string
2121
traceState?: string
22+
/** The trace-flags byte to propagate; the inbound one when continuing a remote trace. */
23+
traceFlags?: string
24+
/** True when the parent came from a `traceparent` header rather than a local handle. */
25+
parentIsRemote?: boolean
2226
name: string
2327
kind: SpanKind
2428
attributes: SpanAttributes
@@ -33,6 +37,8 @@ export class PostHogSpan implements Span {
3337
private readonly _spanId: string
3438
private readonly _parentSpanId?: string
3539
private readonly _traceState?: string
40+
private readonly _traceFlags: string
41+
private readonly _parentIsRemote: boolean
3642
private readonly _startTime: number
3743
// Absent on backdated spans and on platforms with no monotonic source.
3844
private readonly _startMono?: number
@@ -53,6 +59,8 @@ export class PostHogSpan implements Span {
5359
this._spanId = init.spanId
5460
this._parentSpanId = init.parentSpanId
5561
this._traceState = init.traceState
62+
this._traceFlags = init.traceFlags ?? TRACE_FLAGS_SAMPLED
63+
this._parentIsRemote = init.parentIsRemote ?? false
5664
this._name = init.name
5765
this._kind = init.kind
5866
this._attributes = init.attributes
@@ -147,16 +155,22 @@ export class PostHogSpan implements Span {
147155
}
148156

149157
traceparent(): string | null {
150-
return formatTraceparent(this._traceId, this._spanId)
158+
return formatTraceparent(this._traceId, this._spanId, this._traceFlags)
151159
}
152160

153161
tracestate(): string | null {
154162
return this._traceState ?? null
155163
}
156164

157165
/** Context a child span inherits when this handle is its parent. */
158-
childContext(): { traceId: string; parentSpanId: string; traceState?: string } {
159-
return { traceId: this._traceId, parentSpanId: this._spanId, traceState: this._traceState }
166+
childContext(): { traceId: string; parentSpanId: string; traceState?: string; traceFlags: string } {
167+
return {
168+
traceId: this._traceId,
169+
parentSpanId: this._spanId,
170+
traceState: this._traceState,
171+
// A child of a continued trace keeps propagating the caller's decision.
172+
traceFlags: this._traceFlags,
173+
}
160174
}
161175

162176
end(endTime?: SpanTimeInput): void {
@@ -174,6 +188,8 @@ export class PostHogSpan implements Span {
174188
spanId: this._spanId,
175189
...(this._parentSpanId && { parentSpanId: this._parentSpanId }),
176190
...(this._traceState && { traceState: this._traceState }),
191+
traceFlags: this._traceFlags,
192+
parentIsRemote: this._parentIsRemote,
177193
name: this._name,
178194
kind: this._kind,
179195
...(this._status && { status: this._status }),

packages/core/src/traces/traceparent.spec.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,37 @@ const SPAN_ID = '00f067aa0ba902b7'
66
describe('traceparent', () => {
77
describe('parseTraceparent', () => {
88
it('parses a sampled header', () => {
9-
expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-01`)).toEqual({ traceId: TRACE_ID, spanId: SPAN_ID })
9+
expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-01`)).toEqual({
10+
traceId: TRACE_ID,
11+
spanId: SPAN_ID,
12+
flags: '01',
13+
})
1014
})
1115

12-
it('continues the trace even when the caller sampled it out', () => {
13-
// Every captured span is recorded, so honouring an inbound `00` would
14-
// orphan our own spans rather than save anything.
15-
expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-00`)).toEqual({ traceId: TRACE_ID, spanId: SPAN_ID })
16+
it('continues the trace even when the caller sampled it out, and keeps the flag', () => {
17+
// Every captured span is recorded, so honouring an inbound `00` by
18+
// dropping the parentage would orphan our own spans. The flag itself is
19+
// kept, so what we propagate onward still says what the caller decided.
20+
expect(parseTraceparent(`00-${TRACE_ID}-${SPAN_ID}-00`)).toEqual({
21+
traceId: TRACE_ID,
22+
spanId: SPAN_ID,
23+
flags: '00',
24+
})
1625
})
1726

1827
it('accepts a future version with extra fields', () => {
1928
expect(parseTraceparent(`01-${TRACE_ID}-${SPAN_ID}-01-something`)).toEqual({
2029
traceId: TRACE_ID,
2130
spanId: SPAN_ID,
31+
flags: '01',
2232
})
2333
})
2434

2535
it('normalizes case and surrounding whitespace', () => {
2636
expect(parseTraceparent(` 00-${TRACE_ID.toUpperCase()}-${SPAN_ID.toUpperCase()}-01 `)).toEqual({
2737
traceId: TRACE_ID,
2838
spanId: SPAN_ID,
39+
flags: '01',
2940
})
3041
})
3142

@@ -45,12 +56,20 @@ describe('traceparent', () => {
4556
})
4657

4758
describe('formatTraceparent', () => {
48-
it('always sets the sampled flag', () => {
59+
it('sets the sampled flag on a trace started here', () => {
4960
expect(formatTraceparent(TRACE_ID, SPAN_ID)).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`)
5061
})
5162

63+
it('propagates the flags byte it was given', () => {
64+
expect(formatTraceparent(TRACE_ID, SPAN_ID, '00')).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`)
65+
})
66+
5267
it('round-trips through the parser', () => {
53-
expect(parseTraceparent(formatTraceparent(TRACE_ID, SPAN_ID))).toEqual({ traceId: TRACE_ID, spanId: SPAN_ID })
68+
expect(parseTraceparent(formatTraceparent(TRACE_ID, SPAN_ID))).toEqual({
69+
traceId: TRACE_ID,
70+
spanId: SPAN_ID,
71+
flags: '01',
72+
})
5473
})
5574
})
5675

packages/core/src/traces/traceparent.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { isValidSpanId, isValidTraceId } from './ids'
33
export interface RemoteSpanContext {
44
traceId: string
55
spanId: string
6+
/** The inbound trace-flags byte, e.g. `01` sampled, `00` sampled out. */
7+
flags: string
68
}
79

810
// `00-<32 hex>-<16 hex>-<2 hex>`. Version `ff` is invalid per the spec; other
@@ -13,13 +15,13 @@ const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2
1315
* Parses an incoming `traceparent` header value, returning `undefined` for
1416
* anything malformed so a bad header starts a fresh root rather than throwing.
1517
*
16-
* Incoming trace flags are deliberately ignored: we continue the trace even when
17-
* the caller sampled it out (`00`), because PostHog records every captured span
18-
* and dropping the parentage would orphan our own spans.
18+
* A trace the caller sampled out (`00`) is still continued — PostHog records
19+
* every captured span — but the inbound flag rides along, so what this SDK
20+
* propagates onward says what the caller decided rather than overriding it.
1921
*/
2022
export function parseTraceparent(value: unknown): RemoteSpanContext | undefined {
2123
const fields = matchTraceparent(value)
22-
return fields && { traceId: fields.traceId, spanId: fields.spanId }
24+
return fields && { traceId: fields.traceId, spanId: fields.spanId, flags: fields.flags }
2325
}
2426

2527
interface TraceparentFields {
@@ -57,12 +59,17 @@ export function normalizeTraceparent(value: unknown): string | undefined {
5759
return fields && `${fields.version}-${fields.traceId}-${fields.spanId}-${fields.flags}`
5860
}
5961

62+
/** The W3C sampled bit, set on a trace this SDK started. */
63+
export const TRACE_FLAGS_SAMPLED = '01'
64+
6065
/**
61-
* Builds the `traceparent` header value for a span. The sampled flag is always
62-
* set, because a span we exported is by definition recorded.
66+
* Builds the `traceparent` header value for a span. A span continuing a remote
67+
* trace propagates the flags byte it was handed: a downstream parent-based
68+
* sampler must see the decision the head sampler actually made, not one this
69+
* SDK invented. A trace started here is sampled, because it is recorded.
6370
*/
64-
export function formatTraceparent(traceId: string, spanId: string): string {
65-
return `00-${traceId}-${spanId}-01`
71+
export function formatTraceparent(traceId: string, spanId: string, flags: string = TRACE_FLAGS_SAMPLED): string {
72+
return `00-${traceId}-${spanId}-${flags}`
6673
}
6774

6875
// tracestate is a comma-separated list of at most 32 `key=value` members, and

packages/core/src/traces/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export interface SpanRecord {
6464
spanId: string
6565
parentSpanId?: string
6666
traceState?: string
67+
/** The W3C trace-flags byte this span propagates, e.g. `01` sampled. */
68+
traceFlags: string
69+
/** True when the parent came from a `traceparent` header rather than a local handle. */
70+
parentIsRemote: boolean
6771
name: string
6872
kind: SpanKind
6973
status?: { code: SpanStatusCode; message?: string }

0 commit comments

Comments
 (0)