Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
86bac7b
feat(node): beforeSpanSend hook and per-span limits
turnipdabeets Sep 2, 2026
ab5ccb1
Merge feat/traces-node-mvp into feat/traces-before-span-send
turnipdabeets Sep 3, 2026
dad18df
fix(traces): take the propagation fields from the span, not the hook
turnipdabeets Sep 3, 2026
268bb81
refactor(traces): derive the internal span record from the hook-visib…
turnipdabeets Sep 3, 2026
308c686
fix(traces): close four gaps in the per-span attribute bound
turnipdabeets Sep 3, 2026
d7959f6
fix(traces): keep a span whose hook froze a rebuilt record
turnipdabeets Sep 3, 2026
7c484b7
fix(traces): report an inert beforeSpanSend entry at critical
turnipdabeets Sep 3, 2026
c3c20a5
test(traces): cover the hook shape that actually drops the propagatio…
turnipdabeets Sep 3, 2026
d046d2f
fix(traces): keep earliest-set attributes and bound span names
turnipdabeets Sep 3, 2026
3baf6bc
Merge remote-tracking branch 'origin/feat/traces-before-span-send' in…
turnipdabeets Sep 3, 2026
e32c6e9
fix(traces): charge every value against the attribute traversal budget
turnipdabeets Sep 3, 2026
dff40e9
fix(traces): stop a hook-deleted prototype key from coming back
turnipdabeets Sep 3, 2026
cb14919
Merge remote-tracking branch 'origin/feat/traces-before-span-send' in…
turnipdabeets Sep 3, 2026
deacd9c
fix(traces): stop nullish leaves spending the truncation budget
turnipdabeets Sep 3, 2026
41179c6
fix(traces): wait for the span export before a flush settles
turnipdabeets Sep 3, 2026
1b11488
docs(traces): tighten the truncation comments and the traces changesets
turnipdabeets Sep 3, 2026
8997a16
fix(traces): recheck consent between span batches
turnipdabeets Sep 3, 2026
dac06cc
Merge branch 'feat/traces-node-mvp' into feat/traces-before-span-send
turnipdabeets Sep 4, 2026
39fb861
fix(traces): materialize a toJSON that resolves to nothing
turnipdabeets Sep 4, 2026
b4f08b7
fix(traces): take the default for a fractional numeric option
turnipdabeets Sep 4, 2026
28883b0
fix(traces): drop a beforeSpanSend result missing a required field
turnipdabeets Sep 4, 2026
da5384f
fix(traces): give a later beforeSpanSend hook the real span identity
turnipdabeets Sep 4, 2026
3f95aeb
test(traces): pin the toJSON bound through buildOtlpSpan
turnipdabeets Sep 4, 2026
b8425c8
fix(traces): reserve exception slots by provenance, not event name
turnipdabeets Sep 4, 2026
0bb43aa
fix(traces): make maxEventsPerSpan an absolute cap
turnipdabeets Sep 4, 2026
152cd1c
Merge feat/traces-node-mvp into feat/traces-before-span-send
turnipdabeets Sep 4, 2026
1644346
docs(traces): drop the exception reserve from the span-limits changeset
turnipdabeets Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/node-before-span-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': minor
'@posthog/core': minor
'@posthog/types': minor
---

Add a `traces.beforeSpanSend` hook to edit a finished span before it is queued, or drop it by returning `null` — a hook that throws, or returns anything that is not a span record, also drops the span.
7 changes: 7 additions & 0 deletions .changeset/node-exception-stacktrace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': minor
'@posthog/core': minor
'@posthog/types': minor
---

Attach `exception.stacktrace` to the exception events recorded by `recordException` and by a throwing `withSpan` callback — remove it in `traces.beforeSpanSend` to keep your server's file paths out of PostHog.
7 changes: 7 additions & 0 deletions .changeset/node-span-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': minor
'@posthog/core': minor
'@posthog/types': minor
---

Cap spans at 128 user attributes, 128 events and 8192 characters per string, configurable with `traces.maxAttributesPerSpan`, `traces.maxEventsPerSpan` and `traces.maxAttributeValueLength`. The earliest entries are kept, and a span that lost any reports how many as `droppedAttributesCount` and `droppedEventsCount`. The event cap is absolute, so an `exception` event the SDK records for you spends an ordinary slot.
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ 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'
// Same barrel convention as logs and metrics for the user-facing tracing types.
export type {
Span,
Expand Down
96 changes: 93 additions & 3 deletions packages/core/src/traces/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,53 @@
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('applies the documented defaults', () => {
expect(resolveTracesConfig(undefined)).toMatchObject({
flushIntervalMs: 5000,
maxExportBatchSize: 512,
maxQueueSize: 2048,
maxAttributesPerSpan: 128,
maxEventsPerSpan: 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,
Expand Down Expand Up @@ -65,14 +102,23 @@ describe('resolveTracesConfig', () => {
}
)

it('floors a fractional batch size to an integer', () => {
expect(resolveTracesConfig({ maxExportBatchSize: 10.9 }).maxExportBatchSize).toBe(10)
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, Number.NaN])('falls back to the default for an unusable flushIntervalMs (%p)', (value) => {
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.
Expand Down Expand Up @@ -135,6 +181,50 @@ describe('resourceAttributes guarding', () => {
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', {
Expand Down
55 changes: 52 additions & 3 deletions packages/core/src/traces/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { assignUserAttributes } from '../utils/json-utils'
import type { ResolvedTracesConfig } from './types'
import type { TracesConfig } from '@posthog/types'
import type { BeforeSpanSendFn, TracesConfig } from '@posthog/types'
import type { Logger } from '../types'

// OpenTelemetry's BatchSpanProcessor defaults, which sit comfortably under the
// server's 2 MB 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 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 holds a deep stack trace and any
// realistic header, query string or payload excerpt, and keeps a span at the
// attribute cap under 1 MB, comfortably inside the 2 MB body cap.
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
Expand All @@ -20,9 +30,13 @@ 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.isFinite(value) && value >= 1 ? Math.floor(value) : fallback
return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback
}

const IDENTITY_KEYS = ['service.name', 'service.version', 'deployment.environment'] as const
Expand Down Expand Up @@ -54,6 +68,36 @@ function withUsableIdentityKeys(attributes: TracesConfig['resourceAttributes']):
}
}

/**
* 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.
*
* Dropping one is reported rather than thrown on. `beforeSpanSend` is where
* redaction lives, so a configuration that silently filters nothing ships the
* values it was meant to remove — but a client constructor that throws takes the
* application down with it, which is the worse of the two. `critical`, because
* every other level is gated behind `debug: true`, and a redaction hook that is
* quietly inert is exactly what an operator has to hear about without opting in.
*/
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
Expand All @@ -62,7 +106,8 @@ function withUsableIdentityKeys(attributes: TracesConfig['resourceAttributes']):
*/
export function resolveTracesConfig(
config: TracesConfig | undefined,
hostResourceAttributes?: Record<string, string>
hostResourceAttributes?: Record<string, string>,
logger?: Logger
): ResolvedTracesConfig {
// Copied key by key rather than spread: a throwing accessor on a user-supplied
// attribute would otherwise escape the first `startSpan`.
Expand All @@ -76,6 +121,10 @@ export function resolveTracesConfig(
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),
maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH),
Comment thread
turnipdabeets marked this conversation as resolved.
flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS),
maxExportBatchSize,
// Never below the flush trigger, or the depth-based flush could never fire.
Expand Down
Loading