Skip to content

Commit d274e48

Browse files
committed
Add trace sampling support (head + tail) for logfire-node and logfire-browser
Implements a two-layer sampling system matching the Python SDK: - Head sampling: probabilistic at trace creation via ParentBasedSampler - Tail sampling: callback-based with span buffering via TailSamplingProcessor - SamplingOptions type, SpanLevel class, checkTraceIdRatio, levelOrDuration factory - LOGFIRE_TRACE_SAMPLE_RATE env var support in logfire-node - Example script in examples/node/sampling.ts
1 parent 26b09ea commit d274e48

10 files changed

Lines changed: 688 additions & 16 deletions

File tree

examples/node/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"start": "node --experimental-strip-types --disable-warning=ExperimentalWarning index.ts",
99
"start-and-throw-error": "TRIGGER_ERROR=true node --experimental-strip-types --disable-warning=ExperimentalWarning index.ts",
10+
"sampling": "node --experimental-strip-types --disable-warning=ExperimentalWarning sampling.ts",
1011
"debug": "node --experimental-strip-types --disable-warning=ExperimentalWarning --inspect-brk index.ts",
1112
"typecheck": "tsc"
1213
},

examples/node/sampling.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as logfire from '@pydantic/logfire-node'
2+
3+
// --- Configuration ---
4+
// This example demonstrates three sampling modes. Uncomment the one you want to try.
5+
6+
// MODE 1: Head sampling only — keep 50% of traces (probabilistic, decided at trace creation)
7+
// logfire.configure({
8+
// serviceName: 'sampling-demo',
9+
// console: true,
10+
// sendToLogfire: false,
11+
// sampling: { head: 0.5 },
12+
// })
13+
14+
// MODE 2: Tail sampling only — keep traces that have warning+ spans or last > 2 seconds
15+
// logfire.configure({
16+
// serviceName: 'sampling-demo',
17+
// console: true,
18+
// sendToLogfire: false,
19+
// sampling: logfire.levelOrDuration({ levelThreshold: 'warning', durationThreshold: 2.0 }),
20+
// })
21+
22+
// MODE 3: Tail sampling with a custom callback
23+
logfire.configure({
24+
serviceName: 'sampling-demo',
25+
sampling: {
26+
tail: (spanInfo) => {
27+
// Keep any trace that contains an error-level span
28+
if (spanInfo.level.gte('error')) return 1.0
29+
// Keep traces running longer than 1.5 seconds
30+
if (spanInfo.duration > 1.5) return 1.0
31+
// Drop everything else
32+
return 0.0
33+
},
34+
},
35+
})
36+
37+
console.log('\n--- Trace 1: quick info-level trace (should be DROPPED) ---')
38+
logfire.span('fast operation', {
39+
callback: () => {
40+
logfire.info('all good here')
41+
},
42+
})
43+
44+
console.log('\n--- Trace 2: trace with an error (should be KEPT) ---')
45+
logfire.span('operation with error', {
46+
callback: () => {
47+
logfire.info('starting work')
48+
logfire.error('something broke', { detail: 'disk full' })
49+
logfire.info('attempted recovery')
50+
},
51+
})
52+
53+
console.log('\n--- Trace 3: slow trace (should be KEPT after delay) ---')
54+
await logfire.span('slow operation', {
55+
callback: async () => {
56+
logfire.info('step 1')
57+
await new Promise((resolve) => setTimeout(resolve, 2000))
58+
logfire.info('step 2 - this triggers the duration threshold')
59+
},
60+
})
61+
62+
console.log('\n--- Done ---')
63+
console.log('Check the console output above. You should see spans from traces 2 and 3,')
64+
console.log('but NOT from trace 1 (it was dropped by tail sampling).')
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Context, HrTime } from '@opentelemetry/api'
2+
import { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base'
3+
4+
import { checkTraceIdRatio, SpanLevel, type TailSamplingSpanInfo } from './sampling'
5+
6+
interface BufferedSpan {
7+
context: Context | null
8+
event: 'end' | 'start'
9+
span: ReadableSpan | Span
10+
}
11+
12+
interface TraceBuffer {
13+
spans: BufferedSpan[]
14+
startTime: HrTime
15+
}
16+
17+
// Sentinel value indicating a buffer was already flushed
18+
const FLUSHED = Symbol('flushed')
19+
20+
type TailCallback = (spanInfo: TailSamplingSpanInfo) => number
21+
22+
function hrTimeToSeconds(hrTime: HrTime): number {
23+
return hrTime[0] + hrTime[1] / 1e9
24+
}
25+
26+
export class TailSamplingProcessor implements SpanProcessor {
27+
private buffers = new Map<string, TraceBuffer | typeof FLUSHED>()
28+
private tail: TailCallback
29+
private wrapped: SpanProcessor
30+
31+
constructor(wrapped: SpanProcessor, tail: TailCallback) {
32+
this.wrapped = wrapped
33+
this.tail = tail
34+
}
35+
36+
async forceFlush(): Promise<void> {
37+
return this.wrapped.forceFlush()
38+
}
39+
40+
onEnd(span: ReadableSpan): void {
41+
const traceId = span.spanContext().traceId
42+
const entry = this.buffers.get(traceId)
43+
44+
if (entry === FLUSHED) {
45+
this.wrapped.onEnd(span)
46+
return
47+
}
48+
49+
if (!entry) {
50+
this.wrapped.onEnd(span)
51+
return
52+
}
53+
54+
entry.spans.push({ context: null, event: 'end', span })
55+
56+
const isRoot = !span.parentSpanContext
57+
if (isRoot) {
58+
// Root span ended — check one last time, then discard if still buffered
59+
if (!this.checkSpan(span, null, 'end', entry)) {
60+
this.buffers.delete(traceId)
61+
}
62+
return
63+
}
64+
65+
this.checkSpan(span, null, 'end', entry)
66+
}
67+
68+
onStart(span: Span, parentContext: Context): void {
69+
const traceId = span.spanContext().traceId
70+
const entry = this.buffers.get(traceId)
71+
72+
if (entry === FLUSHED) {
73+
this.wrapped.onStart(span, parentContext)
74+
return
75+
}
76+
77+
const readable = span as unknown as ReadableSpan
78+
const isRoot = !readable.parentSpanContext
79+
if (isRoot && !entry) {
80+
const buffer: TraceBuffer = { spans: [], startTime: readable.startTime }
81+
this.buffers.set(traceId, buffer)
82+
buffer.spans.push({ context: parentContext, event: 'start', span })
83+
this.checkSpan(span as unknown as ReadableSpan, parentContext, 'start', buffer)
84+
return
85+
}
86+
87+
if (entry) {
88+
entry.spans.push({ context: parentContext, event: 'start', span })
89+
this.checkSpan(span as unknown as ReadableSpan, parentContext, 'start', entry)
90+
return
91+
}
92+
93+
// No buffer and not root — trace started before this processor was active
94+
this.wrapped.onStart(span, parentContext)
95+
}
96+
97+
async shutdown(): Promise<void> {
98+
this.buffers.clear()
99+
return this.wrapped.shutdown()
100+
}
101+
102+
private checkSpan(span: ReadableSpan, context: Context | null, event: 'end' | 'start', buffer: TraceBuffer): boolean {
103+
const duration = hrTimeToSeconds(span.startTime) - hrTimeToSeconds(buffer.startTime)
104+
const level = SpanLevel.fromSpan(span)
105+
106+
const info: TailSamplingSpanInfo = { context, duration, event, level, span }
107+
const rate = this.tail(info)
108+
109+
if (rate >= 1.0 || (rate > 0.0 && checkTraceIdRatio(span.spanContext().traceId, rate))) {
110+
this.flushBuffer(span.spanContext().traceId, buffer)
111+
return true
112+
}
113+
114+
return false
115+
}
116+
117+
private flushBuffer(traceId: string, buffer: TraceBuffer): void {
118+
this.buffers.set(traceId, FLUSHED)
119+
120+
for (const { context, event, span } of buffer.spans) {
121+
if (event === 'start') {
122+
// context is always set for 'start' events
123+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
124+
this.wrapped.onStart(span as Span, context!)
125+
} else {
126+
this.wrapped.onEnd(span as ReadableSpan)
127+
}
128+
}
129+
}
130+
}

packages/logfire-api/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const ATTRIBUTES_MESSAGE_KEY = `${LOGFIRE_ATTRIBUTES_NAMESPACE}.msg`
1313
export const ATTRIBUTES_SCRUBBED_KEY = `${LOGFIRE_ATTRIBUTES_NAMESPACE}.scrubbed`
1414
/** Key for exception fingerprint used for issue grouping */
1515
export const ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY = `${LOGFIRE_ATTRIBUTES_NAMESPACE}.exception.fingerprint`
16+
export const ATTRIBUTES_SAMPLE_RATE_KEY = `${LOGFIRE_ATTRIBUTES_NAMESPACE}.sample_rate`
1617
export const DEFAULT_OTEL_SCOPE = 'logfire'
1718
export const JSON_SCHEMA_KEY = 'logfire.json_schema'
1819
export const JSON_NULL_FIELDS_KEY = 'logfire.null_args'

packages/logfire-api/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@ import { computeFingerprint } from './fingerprint'
1616
import { logfireFormatWithExtras } from './formatter'
1717
import { logfireApiConfig, serializeAttributes } from './logfireApiConfig'
1818
import * as logfireApiConfigExports from './logfireApiConfig'
19+
import * as samplingExports from './sampling'
20+
import { TailSamplingProcessor } from './TailSamplingProcessor'
1921
import * as ULIDGeneratorExports from './ULIDGenerator'
2022

2123
export * from './AttributeScrubber'
2224
export { canonicalizeError, computeFingerprint } from './fingerprint'
2325
export { configureLogfireApi, logfireApiConfig, resolveBaseUrl, resolveSendToLogfire } from './logfireApiConfig'
2426
export type { LogfireApiConfig, LogfireApiConfigOptions, ScrubbingOptions } from './logfireApiConfig'
27+
export * from './sampling'
2528
export { serializeAttributes } from './serializeAttributes'
29+
export { TailSamplingProcessor } from './TailSamplingProcessor'
2630
export * from './ULIDGenerator'
2731

2832
export const Level = {
@@ -215,10 +219,12 @@ export function reportError(message: string, error: Error, extraAttributes: Reco
215219
const defaultExport = {
216220
...AttributeScrubbingExports,
217221
...fingerprintExports,
222+
...samplingExports,
218223
...ULIDGeneratorExports,
219224
...logfireApiConfigExports,
220225

221226
serializeAttributes,
227+
TailSamplingProcessor,
222228
Level,
223229
startSpan,
224230
span,

0 commit comments

Comments
 (0)