Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/traces-age-sweep-at-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@posthog/core': patch
'posthog-node': patch
'@posthog/types': patch
---

Stop dropping long spans that end: `maxSpanAgeMs` now evicts spans only once `maxLiveSpans` is reached, so a span that runs past the age limit and then ends is exported, and its children are no longer orphaned.
25 changes: 19 additions & 6 deletions packages/core/src/traces/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3192,11 +3192,11 @@ describe('PostHogTraces', () => {
})

it('never exports a span evicted for exceeding maxSpanAgeMs', async () => {
const traces = createTraces({ maxSpanAgeMs: 60_000 })
const traces = createTraces({ maxLiveSpans: 1, maxSpanAgeMs: 60_000 })
const leaked = traces.startSpan('leaked')

await vi.advanceTimersByTimeAsync(61_000)
// Eviction is lazy: the next startSpan sweeps.
// Eviction is lazy: the next startSpan at the bound sweeps.
traces.startSpan('later').end()
leaked.end()
await traces.flush()
Expand All @@ -3205,9 +3205,22 @@ describe('PostHogTraces', () => {
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('still live after 60000ms'))
})

it('exports a long span that ends while under the bound', async () => {
const traces = createTraces({ maxSpanAgeMs: 60_000 })
const longRunning = traces.startSpan('batch')

await vi.advanceTimersByTimeAsync(61_000)
traces.startSpan('probe').end()
longRunning.end()
await traces.flush()

expect(sentSpans().map((s) => s.name)).toEqual(['probe', 'batch'])
})

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')
expect(traces.startSpan('refused')).toBe(NOOP_SPAN)

await vi.advanceTimersByTimeAsync(61_000)
traces.startSpan('after-the-leak').end()
Expand All @@ -3217,15 +3230,15 @@ describe('PostHogTraces', () => {
})

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 traces = createTraces({ maxLiveSpans: 1, maxSpanAgeMs: 60_000 })
// Backdated an hour: aging off the supplied time would evict it at the next sweep.
const backdated = traces.startSpan('backdated', { startTime: Date.now() - 3_600_000 })

traces.startSpan('sweep-trigger').end()
expect(traces.startSpan('sweep-trigger')).toBe(NOOP_SPAN)
backdated.end()
await traces.flush()

expect(sentSpans().map((s) => s.name)).toEqual(['sweep-trigger', 'backdated'])
expect(sentSpans().map((s) => s.name)).toEqual(['backdated'])
})
})

Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/traces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,12 @@ export class PostHogTraces {

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()
// Swept only at the bound, so a long span that does end is still exported,
// while a process that has leaked its way to the bound recovers on the
// first `startSpan` after the leaks age out.
if (this._liveSpans.size >= this._config.maxLiveSpans) {
this._evictAgedSpans()
}
if (this._liveSpans.size >= this._config.maxLiveSpans) {
this._recordDrop(
1,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/traces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ export interface ResolvedTracesConfig extends TracesConfig {
maxEventsPerSpan: number
maxAttributesPerEvent: number
maxAttributeValueLength: number
/** Bound on spans started but not yet ended. At the bound `startSpan` returns a no-op handle. */
/** Bound on spans started but not yet ended. At the bound `startSpan` evicts aged spans, then returns a no-op handle if still at the bound. */
maxLiveSpans: number
/** How long a span may stay live before it stops being accounted for and can never export. */
/** Age at which a live span counts as leaked. Checked only at `maxLiveSpans`, where aged spans are evicted and never exported. */
maxSpanAgeMs: number
}
8 changes: 5 additions & 3 deletions packages/types/src/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,11 @@ export interface TracesConfig {
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
* Age at which a live span counts as leaked, in milliseconds. Checked only
* once `maxLiveSpans` is reached: spans this old are then evicted and never
* exported, and their slots are returned so one leak cannot disable tracing
* for the rest of the process. Below the bound, a long span that ends is
* exported as usual. Measured
* as monotonic elapsed time since `startSpan`, so a caller-supplied
* `startTime` neither ages a span early nor exempts it.
*
Expand Down
Loading