@zuplo/cli 7.7.7 → 7.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@posthog/core/dist/error-tracking/index.d.ts +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
- package/node_modules/@posthog/core/dist/index.d.ts +7 -0
- package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/index.js +82 -21
- package/node_modules/@posthog/core/dist/index.mjs +4 -0
- package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
- package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/logs/index.js +28 -22
- package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
- package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
- package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
- package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
- package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
- package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
- package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
- package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
- package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
- package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/config.js +101 -0
- package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/context.js +53 -0
- package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
- package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/index.js +551 -0
- package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
- package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/span.js +533 -0
- package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/types.js +18 -0
- package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
- package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
- package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
- package/node_modules/@posthog/core/package.json +3 -3
- package/node_modules/@posthog/core/src/error-tracking/index.ts +24 -1
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
- package/node_modules/@posthog/core/src/index.ts +18 -0
- package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
- package/node_modules/@posthog/core/src/logs/index.ts +63 -31
- package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
- package/node_modules/@posthog/core/src/logs/types.ts +2 -2
- package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
- package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
- package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
- package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
- package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
- package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
- package/node_modules/@posthog/core/src/traces/config.ts +142 -0
- package/node_modules/@posthog/core/src/traces/context.ts +30 -0
- package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
- package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
- package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
- package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
- package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
- package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
- package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
- package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
- package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
- package/node_modules/@posthog/core/src/traces/span.ts +822 -0
- package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
- package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
- package/node_modules/@posthog/core/src/traces/types.ts +140 -0
- package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
- package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
- package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
- package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
- package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
- package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
- package/node_modules/@posthog/types/dist/index.d.ts +1 -0
- package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
- package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
- package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
- package/node_modules/@posthog/types/dist/traces.js +18 -0
- package/node_modules/@posthog/types/dist/traces.mjs +1 -0
- package/node_modules/@posthog/types/package.json +1 -1
- package/node_modules/@posthog/types/src/index.ts +19 -0
- package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
- package/node_modules/@posthog/types/src/traces.ts +429 -0
- package/node_modules/@zuplo/core/package.json +1 -1
- package/node_modules/@zuplo/graphql/package.json +1 -1
- package/node_modules/@zuplo/openapi-tools/package.json +1 -1
- package/node_modules/@zuplo/otel/package.json +1 -1
- package/node_modules/@zuplo/runtime/out/esm/{chunk-FEB2VFS7.js → chunk-KNAYKBEE.js} +96 -96
- package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
- package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
- package/node_modules/@zuplo/runtime/package.json +1 -1
- package/package.json +11 -11
- package/node_modules/@zuplo/runtime/out/esm/chunk-FEB2VFS7.js.map +0 -1
- /package/node_modules/@zuplo/runtime/out/esm/{chunk-FEB2VFS7.js.LEGAL.txt → chunk-KNAYKBEE.js.LEGAL.txt} +0 -0
|
@@ -0,0 +1,3187 @@
|
|
|
1
|
+
import { PostHogTraces } from './index'
|
|
2
|
+
import { SyncSpanContextManager } from './context'
|
|
3
|
+
import { NOOP_SPAN, inertSpan } from './span'
|
|
4
|
+
import type {
|
|
5
|
+
OtlpSpan,
|
|
6
|
+
OtlpTracesPayload,
|
|
7
|
+
ResolvedTracesConfig,
|
|
8
|
+
SendTracesBatchOutcome,
|
|
9
|
+
SpanRecord,
|
|
10
|
+
TraceSdkContext,
|
|
11
|
+
} from './types'
|
|
12
|
+
import type { Logger } from '../types'
|
|
13
|
+
import type { Span } from '@posthog/types'
|
|
14
|
+
import { createMockLogger } from '@/testing'
|
|
15
|
+
|
|
16
|
+
const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736'
|
|
17
|
+
const REMOTE_SPAN_ID = '00f067aa0ba902b7'
|
|
18
|
+
const DUPLICATED_HEADERS = [`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`, `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00`]
|
|
19
|
+
|
|
20
|
+
const resolveForTest = (partial?: Partial<ResolvedTracesConfig>): ResolvedTracesConfig => ({
|
|
21
|
+
flushIntervalMs: 5000,
|
|
22
|
+
maxExportBatchSize: 512,
|
|
23
|
+
maxQueueSize: 2048,
|
|
24
|
+
beforeSpanSend: [],
|
|
25
|
+
maxAttributesPerSpan: 128,
|
|
26
|
+
maxEventsPerSpan: 128,
|
|
27
|
+
maxAttributesPerEvent: 128,
|
|
28
|
+
maxAttributeValueLength: 8192,
|
|
29
|
+
maxLiveSpans: 10000,
|
|
30
|
+
maxSpanAgeMs: 3600000,
|
|
31
|
+
...partial,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const createMockInstance = (overrides: Record<string, any> = {}): any => ({
|
|
35
|
+
isDisabled: false,
|
|
36
|
+
optedOut: false,
|
|
37
|
+
getLibraryId: vi.fn(() => 'posthog-core-tests'),
|
|
38
|
+
getLibraryVersion: vi.fn(() => '0.0.0-test'),
|
|
39
|
+
_sendTracesBatch: vi.fn((): Promise<SendTracesBatchOutcome> => Promise.resolve({ kind: 'ok' })),
|
|
40
|
+
...overrides,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('PostHogTraces', () => {
|
|
44
|
+
let mockInstance: any
|
|
45
|
+
let logger: Logger
|
|
46
|
+
let context: TraceSdkContext
|
|
47
|
+
|
|
48
|
+
const createTraces = (config?: Partial<ResolvedTracesConfig>, instance?: any): PostHogTraces =>
|
|
49
|
+
new PostHogTraces(
|
|
50
|
+
instance ?? mockInstance,
|
|
51
|
+
resolveForTest(config),
|
|
52
|
+
logger,
|
|
53
|
+
() => context,
|
|
54
|
+
new SyncSpanContextManager()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const flushMicrotasks = async (): Promise<void> => {
|
|
58
|
+
for (let i = 0; i < 5; i++) {
|
|
59
|
+
await Promise.resolve()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sentPayloads = (instance?: any): OtlpTracesPayload[] =>
|
|
64
|
+
(instance ?? mockInstance)._sendTracesBatch.mock.calls.map((c: any[]) => c[0])
|
|
65
|
+
|
|
66
|
+
const sentSpans = (instance?: any): OtlpSpan[] =>
|
|
67
|
+
sentPayloads(instance).flatMap((p) => p.resourceSpans[0].scopeSpans[0].spans)
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
mockInstance = createMockInstance()
|
|
71
|
+
logger = createMockLogger()
|
|
72
|
+
context = {}
|
|
73
|
+
// Retry delays carry jitter; pinned to its midpoint so every timing
|
|
74
|
+
// assertion here measures the backoff itself and cannot flake.
|
|
75
|
+
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('startSpan', () => {
|
|
79
|
+
it('enqueues exactly one record per span', async () => {
|
|
80
|
+
const traces = createTraces()
|
|
81
|
+
traces.startSpan('checkout').end()
|
|
82
|
+
await traces.flush()
|
|
83
|
+
|
|
84
|
+
expect(sentSpans()).toHaveLength(1)
|
|
85
|
+
expect(sentSpans()[0].name).toBe('checkout')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('gives a root span a fresh trace id and no parent', async () => {
|
|
89
|
+
const traces = createTraces()
|
|
90
|
+
traces.startSpan('root').end()
|
|
91
|
+
await traces.flush()
|
|
92
|
+
|
|
93
|
+
const [span] = sentSpans()
|
|
94
|
+
expect(span.traceId).toMatch(/^[0-9a-f]{32}$/)
|
|
95
|
+
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/)
|
|
96
|
+
expect(span.parentSpanId).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not activate the span it returns', () => {
|
|
100
|
+
const traces = createTraces()
|
|
101
|
+
const manual = traces.startSpan('manual')
|
|
102
|
+
expect(traces.getActiveSpan()).toBeNull()
|
|
103
|
+
manual.end()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('parents a child to an explicit span handle', async () => {
|
|
107
|
+
const traces = createTraces()
|
|
108
|
+
const parent = traces.startSpan('parent')
|
|
109
|
+
const child = traces.startSpan('child', { parent })
|
|
110
|
+
child.end()
|
|
111
|
+
parent.end()
|
|
112
|
+
await traces.flush()
|
|
113
|
+
|
|
114
|
+
const [childSpan, parentSpan] = sentSpans()
|
|
115
|
+
expect(childSpan.traceId).toBe(parentSpan.traceId)
|
|
116
|
+
expect(childSpan.parentSpanId).toBe(parentSpan.spanId)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('defaults kind to internal and honours an explicit kind', async () => {
|
|
120
|
+
const traces = createTraces()
|
|
121
|
+
traces.startSpan('a').end()
|
|
122
|
+
traces.startSpan('b', { kind: 'server' }).end()
|
|
123
|
+
await traces.flush()
|
|
124
|
+
|
|
125
|
+
expect(sentSpans().map((s) => s.kind)).toEqual([1, 2])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns an inert handle when the SDK is disabled', async () => {
|
|
129
|
+
const traces = createTraces({}, createMockInstance({ isDisabled: true }))
|
|
130
|
+
const span = traces.startSpan('checkout')
|
|
131
|
+
span.end()
|
|
132
|
+
|
|
133
|
+
expect(span).toBe(NOOP_SPAN)
|
|
134
|
+
expect(span.traceparent()).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns an inert handle when the user has opted out', () => {
|
|
138
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
139
|
+
expect(traces.startSpan('checkout')).toBe(NOOP_SPAN)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('makes a child of a no-op handle a no-op rather than an orphan', () => {
|
|
143
|
+
const traces = createTraces()
|
|
144
|
+
expect(traces.startSpan('child', { parent: NOOP_SPAN })).toBe(NOOP_SPAN)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('startTime', () => {
|
|
149
|
+
it('backdates the span to a supplied start', async () => {
|
|
150
|
+
const traces = createTraces()
|
|
151
|
+
const start = Date.now() - 60_000
|
|
152
|
+
traces.startSpan('backdated', { startTime: start }).end()
|
|
153
|
+
await traces.flush()
|
|
154
|
+
|
|
155
|
+
expect(sentSpans()[0].startTimeUnixNano).toBe(`${start}000000`)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('accepts a Date', async () => {
|
|
159
|
+
const traces = createTraces()
|
|
160
|
+
const start = new Date(Date.now() - 5_000)
|
|
161
|
+
traces.startSpan('backdated', { startTime: start }).end()
|
|
162
|
+
await traces.flush()
|
|
163
|
+
|
|
164
|
+
expect(sentSpans()[0].startTimeUnixNano).toBe(`${start.getTime()}000000`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('falls back to now for an unusable start, keeping the record well formed', async () => {
|
|
168
|
+
const traces = createTraces()
|
|
169
|
+
traces.startSpan('bad', { startTime: Number.NaN }).end()
|
|
170
|
+
await traces.flush()
|
|
171
|
+
|
|
172
|
+
const [span] = sentSpans()
|
|
173
|
+
expect(span.startTimeUnixNano).toMatch(/^\d+$/)
|
|
174
|
+
expect(Number(span.endTimeUnixNano)).toBeGreaterThanOrEqual(Number(span.startTimeUnixNano))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('warns when a start is old enough for the server to clamp it', async () => {
|
|
178
|
+
const traces = createTraces()
|
|
179
|
+
traces.startSpan('stale', { startTime: Date.now() - 48 * 60 * 60 * 1000 }).end()
|
|
180
|
+
await traces.flush()
|
|
181
|
+
|
|
182
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('24 hours'))
|
|
183
|
+
expect(sentSpans()).toHaveLength(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('warns when a start is in the future, which costs the span its duration', async () => {
|
|
187
|
+
const traces = createTraces()
|
|
188
|
+
traces.startSpan('ahead', { startTime: Date.now() + 60 * 60 * 1000 }).end()
|
|
189
|
+
await traces.flush()
|
|
190
|
+
|
|
191
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('in the future'))
|
|
192
|
+
const [span] = sentSpans()
|
|
193
|
+
expect(span.endTimeUnixNano).toBe(span.startTimeUnixNano)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('trace continuation', () => {
|
|
198
|
+
it('continues a remote trace from a traceparent string', async () => {
|
|
199
|
+
const traces = createTraces()
|
|
200
|
+
traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` }).end()
|
|
201
|
+
await traces.flush()
|
|
202
|
+
|
|
203
|
+
const [span] = sentSpans()
|
|
204
|
+
expect(span.traceId).toBe(TRACE_ID)
|
|
205
|
+
expect(span.parentSpanId).toBe(REMOTE_SPAN_ID)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('continues a trace the caller sampled out', async () => {
|
|
209
|
+
const traces = createTraces()
|
|
210
|
+
traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end()
|
|
211
|
+
await traces.flush()
|
|
212
|
+
|
|
213
|
+
expect(sentSpans()[0].traceId).toBe(TRACE_ID)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('propagates the sampled-out flag onward rather than upgrading it to 01', async () => {
|
|
217
|
+
// A downstream parent-based sampler would otherwise record a trace its own
|
|
218
|
+
// head sampler had already rejected.
|
|
219
|
+
const traces = createTraces()
|
|
220
|
+
const span = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` })
|
|
221
|
+
const child = traces.startSpan('inner', { parent: span })
|
|
222
|
+
|
|
223
|
+
expect(span.traceparent()!.startsWith(`00-${TRACE_ID}-`)).toBe(true)
|
|
224
|
+
expect(span.traceparent()!.endsWith('-00')).toBe(true)
|
|
225
|
+
// The whole chain agrees, not just the span that read the header.
|
|
226
|
+
expect(child.traceparent()!.endsWith('-00')).toBe(true)
|
|
227
|
+
|
|
228
|
+
child.end()
|
|
229
|
+
span.end()
|
|
230
|
+
await traces.flush()
|
|
231
|
+
|
|
232
|
+
// Recorded and exported all the same, with the wire agreeing with the header.
|
|
233
|
+
const byName = Object.fromEntries(sentSpans().map((sent) => [sent.name, sent.flags]))
|
|
234
|
+
expect(byName).toEqual({ handler: 0x300, inner: 0x100 })
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('marks a header parent remote and a handle parent local', async () => {
|
|
238
|
+
const traces = createTraces()
|
|
239
|
+
const remote = traces.startSpan('handler', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` })
|
|
240
|
+
const local = traces.startSpan('inner', { parent: remote })
|
|
241
|
+
|
|
242
|
+
local.end()
|
|
243
|
+
remote.end()
|
|
244
|
+
await traces.flush()
|
|
245
|
+
|
|
246
|
+
const byName = Object.fromEntries(sentSpans().map((span) => [span.name, span.flags]))
|
|
247
|
+
expect(byName).toEqual({ handler: 0x301, inner: 0x101 })
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('preserves tracestate opaquely and passes it to children', async () => {
|
|
251
|
+
const traces = createTraces()
|
|
252
|
+
const parent = traces.startSpan('handler', {
|
|
253
|
+
parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`,
|
|
254
|
+
tracestate: 'vendor=abc',
|
|
255
|
+
})
|
|
256
|
+
const child = traces.startSpan('inner', { parent })
|
|
257
|
+
expect(parent.tracestate()).toBe('vendor=abc')
|
|
258
|
+
|
|
259
|
+
child.end()
|
|
260
|
+
parent.end()
|
|
261
|
+
await traces.flush()
|
|
262
|
+
|
|
263
|
+
expect(sentSpans().map((s) => s.traceState)).toEqual(['vendor=abc', 'vendor=abc'])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('starts a fresh root on a malformed traceparent without throwing', async () => {
|
|
267
|
+
const traces = createTraces()
|
|
268
|
+
expect(() => traces.startSpan('handler', { parent: 'garbage' }).end()).not.toThrow()
|
|
269
|
+
await traces.flush()
|
|
270
|
+
|
|
271
|
+
const [span] = sentSpans()
|
|
272
|
+
expect(span.traceId).not.toBe(TRACE_ID)
|
|
273
|
+
expect(span.parentSpanId).toBeUndefined()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('continues the trace when the header arrives as a one-element array', async () => {
|
|
277
|
+
// What `headersDistinct.traceparent` hands over for a single inbound header.
|
|
278
|
+
const traces = createTraces()
|
|
279
|
+
traces.startSpan('handler', { parent: [`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`] as unknown as string }).end()
|
|
280
|
+
await traces.flush()
|
|
281
|
+
|
|
282
|
+
const [span] = sentSpans()
|
|
283
|
+
expect(span.traceId).toBe(TRACE_ID)
|
|
284
|
+
expect(span.parentSpanId).toBe(REMOTE_SPAN_ID)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('starts a fresh root when the parent is not a span, as two inbound headers are', async () => {
|
|
288
|
+
const traces = createTraces()
|
|
289
|
+
traces.startSpan('handler', { parent: DUPLICATED_HEADERS as unknown as string }).end()
|
|
290
|
+
await traces.flush()
|
|
291
|
+
|
|
292
|
+
const [span] = sentSpans()
|
|
293
|
+
expect(span.traceId).not.toBe(TRACE_ID)
|
|
294
|
+
expect(span.parentSpanId).toBeUndefined()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('ignores a span from another tracer, which reports through spanContext', async () => {
|
|
298
|
+
// OTel's shape: no `traceparent()` to read, so it parents to the active
|
|
299
|
+
// span rather than continuing a trace the SDK cannot read the ids of.
|
|
300
|
+
const otelSpan = {
|
|
301
|
+
spanContext: () => ({ traceId: TRACE_ID, spanId: REMOTE_SPAN_ID, traceFlags: 1 }),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const traces = createTraces()
|
|
305
|
+
traces.withSpan('handler', () => {
|
|
306
|
+
traces.startSpan('child', { parent: otelSpan as unknown as Span }).end()
|
|
307
|
+
})
|
|
308
|
+
await traces.flush()
|
|
309
|
+
|
|
310
|
+
const child = sentSpans().find((s) => s.name === 'child')!
|
|
311
|
+
const handler = sentSpans().find((s) => s.name === 'handler')!
|
|
312
|
+
expect(child.traceId).toBe(handler.traceId)
|
|
313
|
+
expect(child.traceId).not.toBe(TRACE_ID)
|
|
314
|
+
expect(child.parentSpanId).toBe(handler.spanId)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('parents to the active span when the parent is not a span', async () => {
|
|
318
|
+
const traces = createTraces()
|
|
319
|
+
traces.withSpan('handler', () => {
|
|
320
|
+
traces.startSpan('child', { parent: DUPLICATED_HEADERS as unknown as string }).end()
|
|
321
|
+
})
|
|
322
|
+
await traces.flush()
|
|
323
|
+
|
|
324
|
+
const child = sentSpans().find((s) => s.name === 'child')!
|
|
325
|
+
const handler = sentSpans().find((s) => s.name === 'handler')!
|
|
326
|
+
expect(child.traceId).toBe(handler.traceId)
|
|
327
|
+
expect(child.parentSpanId).toBe(handler.spanId)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('withSpan', () => {
|
|
332
|
+
it('ends the span and returns the callback result', async () => {
|
|
333
|
+
const traces = createTraces()
|
|
334
|
+
const result = traces.withSpan('job', () => 'value')
|
|
335
|
+
await traces.flush()
|
|
336
|
+
|
|
337
|
+
expect(result).toBe('value')
|
|
338
|
+
expect(sentSpans()).toHaveLength(1)
|
|
339
|
+
expect(sentSpans()[0].status).toBeUndefined()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('accepts options before the callback', async () => {
|
|
343
|
+
const traces = createTraces()
|
|
344
|
+
traces.withSpan('job', { kind: 'server', attributes: { plan: 'pro' } }, () => undefined)
|
|
345
|
+
await traces.flush()
|
|
346
|
+
|
|
347
|
+
const [span] = sentSpans()
|
|
348
|
+
expect(span.kind).toBe(2)
|
|
349
|
+
expect(span.attributes).toContainEqual({ key: 'plan', value: { stringValue: 'pro' } })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('runs the callback when an attribute getter throws', async () => {
|
|
353
|
+
const traces = createTraces()
|
|
354
|
+
const attributes: any = { ok: 1 }
|
|
355
|
+
Object.defineProperty(attributes, 'boom', {
|
|
356
|
+
enumerable: true,
|
|
357
|
+
get() {
|
|
358
|
+
throw new Error('getter exploded')
|
|
359
|
+
},
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
expect(traces.withSpan('job', { attributes }, () => 'value')).toBe('value')
|
|
363
|
+
await traces.flush()
|
|
364
|
+
|
|
365
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['job'])
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('makes the span active for the callback', () => {
|
|
369
|
+
const traces = createTraces()
|
|
370
|
+
traces.withSpan('outer', (span) => {
|
|
371
|
+
expect(traces.getActiveSpan()).toBe(span)
|
|
372
|
+
})
|
|
373
|
+
expect(traces.getActiveSpan()).toBeNull()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('nests spans started inside the callback', async () => {
|
|
377
|
+
const traces = createTraces()
|
|
378
|
+
traces.withSpan('outer', () => {
|
|
379
|
+
traces.withSpan('inner', () => undefined)
|
|
380
|
+
})
|
|
381
|
+
await traces.flush()
|
|
382
|
+
|
|
383
|
+
const inner = sentSpans().find((s) => s.name === 'inner')!
|
|
384
|
+
const outer = sentSpans().find((s) => s.name === 'outer')!
|
|
385
|
+
expect(inner.traceId).toBe(outer.traceId)
|
|
386
|
+
expect(inner.parentSpanId).toBe(outer.spanId)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('lets an explicit parent override the active span', async () => {
|
|
390
|
+
const traces = createTraces()
|
|
391
|
+
const detached = traces.startSpan('detached')
|
|
392
|
+
traces.withSpan('outer', () => {
|
|
393
|
+
traces.withSpan('inner', { parent: detached }, () => undefined)
|
|
394
|
+
})
|
|
395
|
+
detached.end()
|
|
396
|
+
await traces.flush()
|
|
397
|
+
|
|
398
|
+
const inner = sentSpans().find((s) => s.name === 'inner')!
|
|
399
|
+
const detachedSpan = sentSpans().find((s) => s.name === 'detached')!
|
|
400
|
+
expect(inner.parentSpanId).toBe(detachedSpan.spanId)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('records a thrown error and rethrows it unmodified', async () => {
|
|
404
|
+
const traces = createTraces()
|
|
405
|
+
const thrown = new TypeError('boom')
|
|
406
|
+
|
|
407
|
+
expect(() =>
|
|
408
|
+
traces.withSpan('job', () => {
|
|
409
|
+
throw thrown
|
|
410
|
+
})
|
|
411
|
+
).toThrow(thrown)
|
|
412
|
+
|
|
413
|
+
await traces.flush()
|
|
414
|
+
const [span] = sentSpans()
|
|
415
|
+
expect(span.status).toEqual({ code: 2, message: 'boom' })
|
|
416
|
+
expect(span.events?.[0]).toMatchObject({
|
|
417
|
+
name: 'exception',
|
|
418
|
+
attributes: [
|
|
419
|
+
{ key: 'exception.type', value: { stringValue: 'TypeError' } },
|
|
420
|
+
{ key: 'exception.message', value: { stringValue: 'boom' } },
|
|
421
|
+
{ key: 'exception.stacktrace', value: { stringValue: expect.stringContaining('TypeError: boom') } },
|
|
422
|
+
],
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('ends an async callback at settle, not when it returns its promise', async () => {
|
|
427
|
+
const traces = createTraces({ maxExportBatchSize: 1 })
|
|
428
|
+
let finishWork!: () => void
|
|
429
|
+
const work = new Promise<void>((resolve) => {
|
|
430
|
+
finishWork = resolve
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const pending = traces.withSpan('job', () => work)
|
|
434
|
+
|
|
435
|
+
await Promise.resolve()
|
|
436
|
+
expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled()
|
|
437
|
+
|
|
438
|
+
finishWork()
|
|
439
|
+
await pending
|
|
440
|
+
await traces.flush()
|
|
441
|
+
|
|
442
|
+
expect(sentSpans()).toHaveLength(1)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('covers the awaited duration', async () => {
|
|
446
|
+
const traces = createTraces()
|
|
447
|
+
const pending = traces.withSpan('job', async () => {
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
449
|
+
})
|
|
450
|
+
await vi.advanceTimersByTimeAsync(80)
|
|
451
|
+
await pending
|
|
452
|
+
await traces.flush()
|
|
453
|
+
|
|
454
|
+
const [span] = sentSpans()
|
|
455
|
+
expect(Number(span.endTimeUnixNano)).toBeGreaterThan(Number(span.startTimeUnixNano))
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('records a rejection and rethrows it unmodified', async () => {
|
|
459
|
+
const traces = createTraces()
|
|
460
|
+
const thrown = new Error('async boom')
|
|
461
|
+
|
|
462
|
+
await expect(traces.withSpan('job', async () => Promise.reject(thrown))).rejects.toBe(thrown)
|
|
463
|
+
|
|
464
|
+
await traces.flush()
|
|
465
|
+
expect(sentSpans()[0].status).toEqual({ code: 2, message: 'async boom' })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('treats an explicit ok status as final when the callback throws', async () => {
|
|
469
|
+
const traces = createTraces()
|
|
470
|
+
expect(() =>
|
|
471
|
+
traces.withSpan('job', (span) => {
|
|
472
|
+
span.setStatus('ok')
|
|
473
|
+
throw new Error('boom')
|
|
474
|
+
})
|
|
475
|
+
).toThrow('boom')
|
|
476
|
+
|
|
477
|
+
await traces.flush()
|
|
478
|
+
const [span] = sentSpans()
|
|
479
|
+
expect(span.status).toEqual({ code: 1 })
|
|
480
|
+
// The exception event is still attached — only the status is protected.
|
|
481
|
+
expect(span.events?.[0].name).toBe('exception')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('runs the callback once with an inert handle when tracing cannot run', async () => {
|
|
485
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
486
|
+
const fn = vi.fn(() => 'value')
|
|
487
|
+
|
|
488
|
+
expect(traces.withSpan('job', fn)).toBe('value')
|
|
489
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
490
|
+
expect(fn).toHaveBeenCalledWith(NOOP_SPAN)
|
|
491
|
+
expect(traces.getActiveSpan()).toBeNull()
|
|
492
|
+
await traces.flush()
|
|
493
|
+
expect(sentSpans()).toHaveLength(0)
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// A service in the middle of a traced chain must not sever it just because it
|
|
498
|
+
// has no tracing of its own — OTel requires the API to carry the parent
|
|
499
|
+
// context through when no SDK is recording.
|
|
500
|
+
describe('trace context pass-through when tracing is off', () => {
|
|
501
|
+
const INBOUND_UNSAMPLED = `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00`
|
|
502
|
+
|
|
503
|
+
it('echoes an inbound traceparent, flags included, when the SDK is disabled', async () => {
|
|
504
|
+
const instance = createMockInstance({ isDisabled: true })
|
|
505
|
+
const traces = createTraces({}, instance)
|
|
506
|
+
|
|
507
|
+
const span = traces.startSpan('proxied', { parent: INBOUND_UNSAMPLED, tracestate: 'vendor=abc' })
|
|
508
|
+
span.end()
|
|
509
|
+
await traces.flush()
|
|
510
|
+
|
|
511
|
+
expect(span.traceparent()).toBe(INBOUND_UNSAMPLED)
|
|
512
|
+
expect(span.tracestate()).toBe('vendor=abc')
|
|
513
|
+
expect(sentSpans(instance)).toHaveLength(0)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('echoes an inbound traceparent when the user has opted out', () => {
|
|
517
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
518
|
+
const span = traces.startSpan('proxied', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01` })
|
|
519
|
+
|
|
520
|
+
expect(span.traceparent()).toBe(`00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('activates the pass-through handle so getActiveSpan can propagate it', () => {
|
|
524
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
525
|
+
|
|
526
|
+
const propagated = traces.withSpan('proxied', { parent: INBOUND_UNSAMPLED }, () =>
|
|
527
|
+
traces.getActiveSpan()?.traceparent()
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
expect(propagated).toBe(INBOUND_UNSAMPLED)
|
|
531
|
+
expect(traces.getActiveSpan()).toBeNull()
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('passes the inbound context through when the live-span limit refuses the span', () => {
|
|
535
|
+
const traces = createTraces({ maxLiveSpans: 1 })
|
|
536
|
+
traces.startSpan('holds-the-only-slot')
|
|
537
|
+
|
|
538
|
+
const refused = traces.startSpan('refused', { parent: INBOUND_UNSAMPLED })
|
|
539
|
+
|
|
540
|
+
expect(refused.traceparent()).toBe(INBOUND_UNSAMPLED)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('has nothing to propagate without a usable parent', () => {
|
|
544
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
545
|
+
|
|
546
|
+
expect(traces.startSpan('no-parent')).toBe(NOOP_SPAN)
|
|
547
|
+
expect(traces.startSpan('bad-parent', { parent: 'not-a-traceparent' })).toBe(NOOP_SPAN)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('keeps the inbound context in a nested span that names no parent', () => {
|
|
551
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
552
|
+
|
|
553
|
+
const propagated = traces.withSpan('outer', { parent: INBOUND_UNSAMPLED }, () =>
|
|
554
|
+
traces.withSpan('inner', (span) => span.traceparent())
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
expect(propagated).toBe(INBOUND_UNSAMPLED)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('parents a recorded span to an active pass-through handle', async () => {
|
|
561
|
+
// Tracing is on, but the span that received the header was inert, so its
|
|
562
|
+
// child is the first recorded span of the inbound trace.
|
|
563
|
+
const foreign = { traceparent: () => INBOUND_UNSAMPLED, tracestate: () => 'vendor=abc' }
|
|
564
|
+
const traces = createTraces()
|
|
565
|
+
|
|
566
|
+
traces.withSpan('proxied', { parent: foreign as unknown as Span }, () => {
|
|
567
|
+
traces.startSpan('child').end()
|
|
568
|
+
})
|
|
569
|
+
await traces.flush()
|
|
570
|
+
|
|
571
|
+
const child = sentSpans().find((span) => span.name === 'child')!
|
|
572
|
+
expect(child.traceId).toBe(TRACE_ID)
|
|
573
|
+
expect(child.parentSpanId).toBe(REMOTE_SPAN_ID)
|
|
574
|
+
expect(child.traceState).toBe('vendor=abc')
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('keeps the inbound context when a pass-through handle is used as a parent', () => {
|
|
578
|
+
// The child is inert either way; what it must not do is drop the context
|
|
579
|
+
// and leave everything downstream of it on a fresh trace.
|
|
580
|
+
const traces = createTraces({}, createMockInstance({ optedOut: true }))
|
|
581
|
+
const parent = traces.startSpan('proxied', { parent: INBOUND_UNSAMPLED, tracestate: 'vendor=abc' })
|
|
582
|
+
|
|
583
|
+
const child = traces.startSpan('child', { parent })
|
|
584
|
+
|
|
585
|
+
expect(child.traceparent()).toBe(INBOUND_UNSAMPLED)
|
|
586
|
+
expect(child.tracestate()).toBe('vendor=abc')
|
|
587
|
+
expect(sentSpans()).toHaveLength(0)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('records nothing for a child of a pass-through once tracing is back on', async () => {
|
|
591
|
+
// Spec: a child of an inert handle is inert, so this must propagate without
|
|
592
|
+
// enqueueing a span, even though this instance is recording.
|
|
593
|
+
const traces = createTraces()
|
|
594
|
+
const parent = inertSpan({ parent: INBOUND_UNSAMPLED })
|
|
595
|
+
|
|
596
|
+
const child = traces.startSpan('child', { parent })
|
|
597
|
+
child.end()
|
|
598
|
+
await traces.flush()
|
|
599
|
+
|
|
600
|
+
expect(child.traceparent()).toBe(INBOUND_UNSAMPLED)
|
|
601
|
+
expect(sentSpans()).toHaveLength(0)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('yields a no-op for a child of a no-op', () => {
|
|
605
|
+
const traces = createTraces()
|
|
606
|
+
expect(traces.startSpan('child', { parent: NOOP_SPAN })).toBe(NOOP_SPAN)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('survives a parent whose traceparent throws', () => {
|
|
610
|
+
const traces = createTraces()
|
|
611
|
+
const hostile = {
|
|
612
|
+
traceparent: () => {
|
|
613
|
+
throw new Error('nope')
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
expect(traces.startSpan('child', { parent: hostile as never })).toBe(NOOP_SPAN)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
describe('auto-context', () => {
|
|
622
|
+
it('attaches the distinct id and session id as the product join keys', async () => {
|
|
623
|
+
context = { distinctId: 'user-123', sessionId: 'session-123' }
|
|
624
|
+
const traces = createTraces()
|
|
625
|
+
traces.startSpan('checkout').end()
|
|
626
|
+
await traces.flush()
|
|
627
|
+
|
|
628
|
+
expect(sentSpans()[0].attributes).toEqual(
|
|
629
|
+
expect.arrayContaining([
|
|
630
|
+
{ key: 'posthogDistinctId', value: { stringValue: 'user-123' } },
|
|
631
|
+
{ key: 'sessionId', value: { stringValue: 'session-123' } },
|
|
632
|
+
])
|
|
633
|
+
)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('omits keys with no value', async () => {
|
|
637
|
+
context = { distinctId: 'user-123' }
|
|
638
|
+
const traces = createTraces()
|
|
639
|
+
traces.startSpan('checkout').end()
|
|
640
|
+
await traces.flush()
|
|
641
|
+
|
|
642
|
+
expect(sentSpans()[0].attributes?.map((a) => a.key)).toEqual(['posthogDistinctId'])
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('freezes the snapshot at span start', async () => {
|
|
646
|
+
context = { distinctId: 'a' }
|
|
647
|
+
const traces = createTraces()
|
|
648
|
+
const span = traces.startSpan('checkout')
|
|
649
|
+
context = { distinctId: 'b' }
|
|
650
|
+
span.end()
|
|
651
|
+
await traces.flush()
|
|
652
|
+
|
|
653
|
+
expect(sentSpans()[0].attributes).toContainEqual({
|
|
654
|
+
key: 'posthogDistinctId',
|
|
655
|
+
value: { stringValue: 'a' },
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('lets user attributes win on collision', async () => {
|
|
660
|
+
context = { distinctId: 'a' }
|
|
661
|
+
const traces = createTraces()
|
|
662
|
+
traces.startSpan('checkout', { attributes: { posthogDistinctId: 'override' } }).end()
|
|
663
|
+
await traces.flush()
|
|
664
|
+
|
|
665
|
+
expect(sentSpans()[0].attributes).toContainEqual({
|
|
666
|
+
key: 'posthogDistinctId',
|
|
667
|
+
value: { stringValue: 'override' },
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('maps the client-platform navigation keys', async () => {
|
|
672
|
+
context = { currentUrl: 'https://example.com/cart', screenName: 'Cart', appState: 'foreground' }
|
|
673
|
+
const traces = createTraces()
|
|
674
|
+
traces.startSpan('checkout').end()
|
|
675
|
+
await traces.flush()
|
|
676
|
+
|
|
677
|
+
const attributes = sentSpans()[0].attributes ?? []
|
|
678
|
+
expect(attributes).toEqual(
|
|
679
|
+
expect.arrayContaining([
|
|
680
|
+
{ key: 'url.full', value: { stringValue: 'https://example.com/cart' } },
|
|
681
|
+
{ key: 'screen.name', value: { stringValue: 'Cart' } },
|
|
682
|
+
{ key: 'app.state', value: { stringValue: 'foreground' } },
|
|
683
|
+
])
|
|
684
|
+
)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('still records the span when reading context throws', async () => {
|
|
688
|
+
const traces = new PostHogTraces(
|
|
689
|
+
mockInstance,
|
|
690
|
+
resolveForTest(),
|
|
691
|
+
logger,
|
|
692
|
+
() => {
|
|
693
|
+
throw new Error('no context')
|
|
694
|
+
},
|
|
695
|
+
new SyncSpanContextManager()
|
|
696
|
+
)
|
|
697
|
+
traces.startSpan('checkout').end()
|
|
698
|
+
await traces.flush()
|
|
699
|
+
|
|
700
|
+
expect(sentSpans()).toHaveLength(1)
|
|
701
|
+
})
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
describe('gating', () => {
|
|
705
|
+
it('drops a span whose user opted out mid-trace, without throwing', async () => {
|
|
706
|
+
const instance = createMockInstance()
|
|
707
|
+
const traces = createTraces({}, instance)
|
|
708
|
+
const span = traces.startSpan('checkout')
|
|
709
|
+
|
|
710
|
+
instance.optedOut = true
|
|
711
|
+
expect(() => span.end()).not.toThrow()
|
|
712
|
+
|
|
713
|
+
await traces.flush()
|
|
714
|
+
expect(instance._sendTracesBatch).not.toHaveBeenCalled()
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('counts a span dropped at the end-time gate', async () => {
|
|
718
|
+
const instance = createMockInstance()
|
|
719
|
+
const traces = createTraces({}, instance)
|
|
720
|
+
const span = traces.startSpan('checkout')
|
|
721
|
+
|
|
722
|
+
instance.optedOut = true
|
|
723
|
+
span.end()
|
|
724
|
+
await traces.flush()
|
|
725
|
+
|
|
726
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out'))
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
describe('reset', () => {
|
|
731
|
+
it('says so when it discards queued spans', async () => {
|
|
732
|
+
// Terminal loss: there is no next flush to retry on, and the export
|
|
733
|
+
// failure the caller already saw promises one.
|
|
734
|
+
const instance = createMockInstance({
|
|
735
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') })),
|
|
736
|
+
})
|
|
737
|
+
const traces = createTraces({}, instance)
|
|
738
|
+
traces.startSpan('a').end()
|
|
739
|
+
traces.startSpan('b').end()
|
|
740
|
+
await traces.flush()
|
|
741
|
+
|
|
742
|
+
traces.reset()
|
|
743
|
+
|
|
744
|
+
expect(logger.critical).toHaveBeenCalledWith(expect.stringContaining('Discarding 2 span(s)'))
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('stays quiet when nothing was queued', () => {
|
|
748
|
+
const traces = createTraces()
|
|
749
|
+
|
|
750
|
+
traces.reset()
|
|
751
|
+
|
|
752
|
+
expect(logger.critical).not.toHaveBeenCalled()
|
|
753
|
+
})
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
describe('flush reentrancy', () => {
|
|
757
|
+
it('does not re-send the head batch when a span ends during the flush prefix', async () => {
|
|
758
|
+
// `_flushInner` runs synchronously as far as its first await, and it reads
|
|
759
|
+
// the resource attributes in that window. A getter there that ends a span
|
|
760
|
+
// used to re-enter the flush with no pass yet recorded, and the same head
|
|
761
|
+
// batch went out again on every pass — thousands of times, unbounded.
|
|
762
|
+
const resourceAttributes: Record<string, unknown> = {}
|
|
763
|
+
Object.defineProperty(resourceAttributes, 'tenant', {
|
|
764
|
+
enumerable: true,
|
|
765
|
+
// Reads `traces` only when a flush runs, which is after it is assigned.
|
|
766
|
+
get: () => {
|
|
767
|
+
traces.startSpan('late').end()
|
|
768
|
+
return 'acme'
|
|
769
|
+
},
|
|
770
|
+
})
|
|
771
|
+
const instance = createMockInstance()
|
|
772
|
+
const traces = createTraces({ maxExportBatchSize: 2, resourceAttributes }, instance)
|
|
773
|
+
|
|
774
|
+
traces.startSpan('a').end()
|
|
775
|
+
traces.startSpan('b').end()
|
|
776
|
+
await traces.flush()
|
|
777
|
+
await traces.flush()
|
|
778
|
+
|
|
779
|
+
expect(sentPayloads(instance)).toHaveLength(2)
|
|
780
|
+
expect(sentSpans(instance).map((s) => s.name)).toEqual(['a', 'b', 'late'])
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
describe('beforeSpanSend', () => {
|
|
785
|
+
const endOneSpan = (beforeSpanSend: any): PostHogTraces => {
|
|
786
|
+
const traces = createTraces({ beforeSpanSend: [beforeSpanSend].flat() })
|
|
787
|
+
traces.startSpan('checkout', { attributes: { userId: 42 } }).end()
|
|
788
|
+
return traces
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
it('drops a span when the hook returns null', async () => {
|
|
792
|
+
await endOneSpan(() => null).flush()
|
|
793
|
+
expect(sentSpans()).toHaveLength(0)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('drops the span when the hook throws', async () => {
|
|
797
|
+
await endOneSpan(() => {
|
|
798
|
+
throw new Error('scrubber broke')
|
|
799
|
+
}).flush()
|
|
800
|
+
|
|
801
|
+
expect(sentSpans()).toHaveLength(0)
|
|
802
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend failed'), expect.anything())
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('counts a span the hook dropped', async () => {
|
|
806
|
+
await endOneSpan(() => null).flush()
|
|
807
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend dropped it'))
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('counts a span dropped because the hook threw', async () => {
|
|
811
|
+
// A permanently broken scrubber otherwise drops every span with the drop
|
|
812
|
+
// counter reading zero.
|
|
813
|
+
await endOneSpan(() => {
|
|
814
|
+
throw new Error('scrubber broke')
|
|
815
|
+
}).flush()
|
|
816
|
+
|
|
817
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend failed'))
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('hands the hook plain values, not the OTLP encoding', () => {
|
|
821
|
+
const seen: unknown[] = []
|
|
822
|
+
endOneSpan((span: SpanRecord) => {
|
|
823
|
+
seen.push(span.attributes.userId)
|
|
824
|
+
return span
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
expect(seen).toEqual([42])
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('keeps the original ids when a hook rewrites them', async () => {
|
|
831
|
+
const traces = createTraces({
|
|
832
|
+
beforeSpanSend: [
|
|
833
|
+
(span: any) => {
|
|
834
|
+
span.traceId = '0'.repeat(32)
|
|
835
|
+
span.spanId = '1'.repeat(16)
|
|
836
|
+
return span
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
})
|
|
840
|
+
const started = traces.startSpan('checkout')
|
|
841
|
+
const originalTraceId = started.traceparent()!.split('-')[1]
|
|
842
|
+
started.end()
|
|
843
|
+
await traces.flush()
|
|
844
|
+
|
|
845
|
+
const [span] = sentSpans()
|
|
846
|
+
expect(span.traceId).toBe(originalTraceId)
|
|
847
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('identity field'))
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('exports a span whose record the hook froze', async () => {
|
|
851
|
+
// A defensive hook may freeze what it returns. Assigning to a frozen
|
|
852
|
+
// property throws even when the value is the one already there, so the
|
|
853
|
+
// post-hook pass works on a copy — otherwise every span the hook saw is
|
|
854
|
+
// dropped by the fail-closed branch, with only a debug line to say so.
|
|
855
|
+
const traces = createTraces({
|
|
856
|
+
beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span, attributes: { route: '/checkout' } })],
|
|
857
|
+
})
|
|
858
|
+
const span = traces.startSpan('checkout')
|
|
859
|
+
|
|
860
|
+
expect(() => span.end()).not.toThrow()
|
|
861
|
+
await traces.flush()
|
|
862
|
+
|
|
863
|
+
const [sent] = sentSpans()
|
|
864
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route'])
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('exports a span whose attributes the hook froze', async () => {
|
|
868
|
+
const traces = createTraces({
|
|
869
|
+
maxAttributesPerSpan: 1,
|
|
870
|
+
beforeSpanSend: [
|
|
871
|
+
(span: SpanRecord) => ({ ...span, attributes: Object.freeze({ route: '/checkout', extra: 1 }) as never }),
|
|
872
|
+
],
|
|
873
|
+
})
|
|
874
|
+
traces.startSpan('checkout').end()
|
|
875
|
+
await traces.flush()
|
|
876
|
+
|
|
877
|
+
const [sent] = sentSpans()
|
|
878
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route'])
|
|
879
|
+
expect(sent.droppedAttributesCount).toBe(1)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('reports every limit drop once per span, hook drops included', () => {
|
|
883
|
+
const traces = createTraces({
|
|
884
|
+
maxAttributesPerSpan: 1,
|
|
885
|
+
maxEventsPerSpan: 1,
|
|
886
|
+
beforeSpanSend: [
|
|
887
|
+
(span: SpanRecord) => ({ ...span, attributes: { ...span.attributes, added: 1, alsoAdded: 2 } }),
|
|
888
|
+
],
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
const span = traces.startSpan('checkout', { attributes: { route: '/checkout' } })
|
|
892
|
+
span.addEvent('first', { a: 1 })
|
|
893
|
+
span.addEvent('second')
|
|
894
|
+
span.end()
|
|
895
|
+
|
|
896
|
+
const messages = logger.debug.mock.calls.map(([message]) => String(message))
|
|
897
|
+
expect(messages.filter((message) => message.includes('Span limits discarded'))).toEqual([
|
|
898
|
+
'Span limits discarded data from "checkout": 2 attributes, 1 events, 0 event attributes',
|
|
899
|
+
])
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('stays quiet for a span that lost nothing', () => {
|
|
903
|
+
const traces = createTraces()
|
|
904
|
+
traces.startSpan('checkout', { attributes: { route: '/checkout' } }).end()
|
|
905
|
+
|
|
906
|
+
const messages = logger.debug.mock.calls.map(([message]) => String(message))
|
|
907
|
+
expect(messages.some((message) => message.includes('Span limits discarded'))).toBe(false)
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it('rejects a timestamp the server could not decode', async () => {
|
|
911
|
+
const instance = createMockInstance()
|
|
912
|
+
const traces = createTraces(
|
|
913
|
+
{ beforeSpanSend: [(span: SpanRecord) => ({ ...span, startTime: span.startTime * 1e6 })] },
|
|
914
|
+
instance
|
|
915
|
+
)
|
|
916
|
+
traces.startSpan('poison').end()
|
|
917
|
+
await traces.flush()
|
|
918
|
+
|
|
919
|
+
const [span] = sentSpans(instance)
|
|
920
|
+
expect(span.startTimeUnixNano.length).toBeLessThanOrEqual(19)
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it('keeps tracestate a rebuilding hook would have dropped', async () => {
|
|
924
|
+
const instance = createMockInstance()
|
|
925
|
+
const traces = createTraces(
|
|
926
|
+
{ beforeSpanSend: [(span: SpanRecord) => ({ ...span, traceState: undefined }) as SpanRecord] },
|
|
927
|
+
instance
|
|
928
|
+
)
|
|
929
|
+
traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-01`, tracestate: 'vendor=abc' }).end()
|
|
930
|
+
await traces.flush()
|
|
931
|
+
|
|
932
|
+
expect(sentSpans(instance)[0].traceState).toBe('vendor=abc')
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
it('keeps the trace flags and parent remoteness a rebuilding hook would have dropped', async () => {
|
|
936
|
+
const instance = createMockInstance()
|
|
937
|
+
const traces = createTraces({ beforeSpanSend: [(span: SpanRecord) => ({ ...span }) as SpanRecord] }, instance)
|
|
938
|
+
traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end()
|
|
939
|
+
await traces.flush()
|
|
940
|
+
|
|
941
|
+
// Sampled-out inbound flag, plus both remoteness bits for a header parent.
|
|
942
|
+
expect(sentSpans(instance)[0].flags).toBe(0x300)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it('keeps them when the hook builds its record from the fields it can see', async () => {
|
|
946
|
+
// Spreading carries the propagation fields through even though no public
|
|
947
|
+
// type declares them; naming the public fields is what drops them.
|
|
948
|
+
const instance = createMockInstance()
|
|
949
|
+
const traces = createTraces(
|
|
950
|
+
{
|
|
951
|
+
beforeSpanSend: [
|
|
952
|
+
(span: SpanRecord) =>
|
|
953
|
+
({
|
|
954
|
+
traceId: span.traceId,
|
|
955
|
+
spanId: span.spanId,
|
|
956
|
+
parentSpanId: span.parentSpanId,
|
|
957
|
+
name: span.name,
|
|
958
|
+
kind: span.kind,
|
|
959
|
+
status: span.status,
|
|
960
|
+
attributes: span.attributes,
|
|
961
|
+
events: span.events,
|
|
962
|
+
startTime: span.startTime,
|
|
963
|
+
endTime: span.endTime,
|
|
964
|
+
}) as SpanRecord,
|
|
965
|
+
],
|
|
966
|
+
},
|
|
967
|
+
instance
|
|
968
|
+
)
|
|
969
|
+
traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end()
|
|
970
|
+
await traces.flush()
|
|
971
|
+
|
|
972
|
+
expect(sentSpans(instance)[0].flags).toBe(0x300)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it('keeps them when the rebuilding hook also freezes what it returns', async () => {
|
|
976
|
+
// Restoring these onto the returned record would throw here, and a throwing
|
|
977
|
+
// hook drops the span.
|
|
978
|
+
const instance = createMockInstance()
|
|
979
|
+
const traces = createTraces(
|
|
980
|
+
{ beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span }) as SpanRecord] },
|
|
981
|
+
instance
|
|
982
|
+
)
|
|
983
|
+
traces.startSpan('child', { parent: `00-${TRACE_ID}-${REMOTE_SPAN_ID}-00` }).end()
|
|
984
|
+
await traces.flush()
|
|
985
|
+
|
|
986
|
+
expect(sentSpans(instance).map((s) => s.flags)).toEqual([0x300])
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
it('does not resurrect a prototype-named attribute the hook removed', async () => {
|
|
990
|
+
// `key in attributes` walks the prototype chain, so a deleted `constructor`
|
|
991
|
+
// read back as the inherited function and shipped as [Function].
|
|
992
|
+
const instance = createMockInstance()
|
|
993
|
+
const traces = createTraces(
|
|
994
|
+
{
|
|
995
|
+
beforeSpanSend: [
|
|
996
|
+
(span: SpanRecord) => {
|
|
997
|
+
delete (span.attributes as Record<string, unknown>).constructor
|
|
998
|
+
return span
|
|
999
|
+
},
|
|
1000
|
+
],
|
|
1001
|
+
},
|
|
1002
|
+
instance
|
|
1003
|
+
)
|
|
1004
|
+
const span = traces.startSpan('ghost')
|
|
1005
|
+
span.setAttribute('constructor', 'user-value')
|
|
1006
|
+
span.setAttribute('safe', 'ok')
|
|
1007
|
+
span.end()
|
|
1008
|
+
await traces.flush()
|
|
1009
|
+
|
|
1010
|
+
expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['safe'])
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
it('does not let prototype-named ghosts evict what the hook kept', async () => {
|
|
1014
|
+
// Worse than resurrection: at the cap the ghosts won the slots and the
|
|
1015
|
+
// attribute the hook deliberately kept was the one dropped.
|
|
1016
|
+
const instance = createMockInstance()
|
|
1017
|
+
const traces = createTraces(
|
|
1018
|
+
{
|
|
1019
|
+
maxAttributesPerSpan: 2,
|
|
1020
|
+
beforeSpanSend: [(span: SpanRecord) => ({ ...span, attributes: { onlyThis: 'yes' } }) as SpanRecord],
|
|
1021
|
+
},
|
|
1022
|
+
instance
|
|
1023
|
+
)
|
|
1024
|
+
const span = traces.startSpan('ghosts')
|
|
1025
|
+
span.setAttribute('toString', 1)
|
|
1026
|
+
span.setAttribute('valueOf', 2)
|
|
1027
|
+
span.end()
|
|
1028
|
+
await traces.flush()
|
|
1029
|
+
|
|
1030
|
+
expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['onlyThis'])
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('keeps the earliest-set attributes when the hook adds an integer-like key', async () => {
|
|
1034
|
+
// Object.keys hoists integer-like keys whatever the write order, so a key
|
|
1035
|
+
// the hook added last outranked one the caller set before it ran.
|
|
1036
|
+
const instance = createMockInstance()
|
|
1037
|
+
const traces = createTraces(
|
|
1038
|
+
{
|
|
1039
|
+
maxAttributesPerSpan: 3,
|
|
1040
|
+
beforeSpanSend: [
|
|
1041
|
+
(span: SpanRecord) => {
|
|
1042
|
+
span.attributes['0'] = 'added-last'
|
|
1043
|
+
return span
|
|
1044
|
+
},
|
|
1045
|
+
],
|
|
1046
|
+
},
|
|
1047
|
+
instance
|
|
1048
|
+
)
|
|
1049
|
+
const span = traces.startSpan('ordered')
|
|
1050
|
+
span.setAttribute('alpha', 1)
|
|
1051
|
+
span.setAttribute('beta', 2)
|
|
1052
|
+
span.setAttribute('gamma', 3)
|
|
1053
|
+
span.end()
|
|
1054
|
+
await traces.flush()
|
|
1055
|
+
|
|
1056
|
+
expect(sentSpans(instance)[0].attributes.map((a) => a.key)).toEqual(['alpha', 'beta', 'gamma'])
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
it('ignores a forged identity from a frozen hook rather than dropping the span', async () => {
|
|
1060
|
+
// Writing the id back onto a frozen return throws, and a throwing hook
|
|
1061
|
+
// drops the span, so forging plus freezing used to lose every span.
|
|
1062
|
+
const instance = createMockInstance()
|
|
1063
|
+
const traces = createTraces(
|
|
1064
|
+
{ beforeSpanSend: [(span: SpanRecord) => Object.freeze({ ...span, traceId: '0'.repeat(32) }) as SpanRecord] },
|
|
1065
|
+
instance
|
|
1066
|
+
)
|
|
1067
|
+
traces.startSpan('forged').end()
|
|
1068
|
+
await traces.flush()
|
|
1069
|
+
|
|
1070
|
+
expect(sentSpans(instance)).toHaveLength(1)
|
|
1071
|
+
expect(sentSpans(instance)[0].traceId).not.toBe('0'.repeat(32))
|
|
1072
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('keeping the original ids'))
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
it('exports a child span whose record a frozen hook rebuilt without the parent id', async () => {
|
|
1076
|
+
// The shape that loses children but keeps roots: a rebuilt record has no
|
|
1077
|
+
// parentSpanId to match, so restoring it wrote to a frozen object.
|
|
1078
|
+
const instance = createMockInstance()
|
|
1079
|
+
const traces = createTraces(
|
|
1080
|
+
{
|
|
1081
|
+
beforeSpanSend: [
|
|
1082
|
+
(span: SpanRecord) =>
|
|
1083
|
+
Object.freeze({
|
|
1084
|
+
traceId: span.traceId,
|
|
1085
|
+
spanId: span.spanId,
|
|
1086
|
+
name: span.name,
|
|
1087
|
+
kind: span.kind,
|
|
1088
|
+
attributes: span.attributes,
|
|
1089
|
+
events: span.events,
|
|
1090
|
+
startTime: span.startTime,
|
|
1091
|
+
endTime: span.endTime,
|
|
1092
|
+
}) as SpanRecord,
|
|
1093
|
+
],
|
|
1094
|
+
},
|
|
1095
|
+
instance
|
|
1096
|
+
)
|
|
1097
|
+
const root = traces.startSpan('root')
|
|
1098
|
+
traces.startSpan('child', { parent: root }).end()
|
|
1099
|
+
root.end()
|
|
1100
|
+
await traces.flush()
|
|
1101
|
+
|
|
1102
|
+
expect(
|
|
1103
|
+
sentSpans(instance)
|
|
1104
|
+
.map((s) => s.name)
|
|
1105
|
+
.sort()
|
|
1106
|
+
).toEqual(['child', 'root'])
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
it('runs hooks left to right and stops at the first null', async () => {
|
|
1110
|
+
const order: string[] = []
|
|
1111
|
+
await endOneSpan([
|
|
1112
|
+
(span: SpanRecord) => {
|
|
1113
|
+
order.push('first')
|
|
1114
|
+
return span
|
|
1115
|
+
},
|
|
1116
|
+
() => {
|
|
1117
|
+
order.push('second')
|
|
1118
|
+
return null
|
|
1119
|
+
},
|
|
1120
|
+
(span: SpanRecord) => {
|
|
1121
|
+
order.push('third')
|
|
1122
|
+
return span
|
|
1123
|
+
},
|
|
1124
|
+
]).flush()
|
|
1125
|
+
|
|
1126
|
+
expect(order).toEqual(['first', 'second'])
|
|
1127
|
+
expect(sentSpans()).toHaveLength(0)
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
it('exports the edits a hook made', async () => {
|
|
1131
|
+
await endOneSpan((span: SpanRecord) => {
|
|
1132
|
+
delete span.attributes.userId
|
|
1133
|
+
span.name = 'redacted'
|
|
1134
|
+
return span
|
|
1135
|
+
}).flush()
|
|
1136
|
+
|
|
1137
|
+
const [span] = sentSpans()
|
|
1138
|
+
expect(span.name).toBe('redacted')
|
|
1139
|
+
expect(span.attributes?.find((attribute) => attribute.key === 'userId')).toBeUndefined()
|
|
1140
|
+
})
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
describe('beforeSpanSend validity', () => {
|
|
1144
|
+
it('sanitises an event the hook pushed without a timestamp', async () => {
|
|
1145
|
+
const traces = createTraces({
|
|
1146
|
+
beforeSpanSend: [
|
|
1147
|
+
(span) => {
|
|
1148
|
+
span.events.push({ name: 'audited' } as never)
|
|
1149
|
+
return span
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
})
|
|
1153
|
+
traces.startSpan('checkout').end()
|
|
1154
|
+
await traces.flush()
|
|
1155
|
+
|
|
1156
|
+
const [event] = sentSpans()[0].events!
|
|
1157
|
+
expect(event.name).toBe('audited')
|
|
1158
|
+
expect(event.timeUnixNano).toMatch(/^\d+$/)
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
it('clamps an out-of-range timestamp on a hook-supplied event', async () => {
|
|
1162
|
+
const traces = createTraces({
|
|
1163
|
+
beforeSpanSend: [
|
|
1164
|
+
(span) => {
|
|
1165
|
+
span.events.push({ name: 'audited', timestamp: -1 } as never)
|
|
1166
|
+
return span
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
})
|
|
1170
|
+
traces.startSpan('checkout').end()
|
|
1171
|
+
await traces.flush()
|
|
1172
|
+
|
|
1173
|
+
expect(sentSpans()[0].events![0].timeUnixNano).toMatch(/^\d+$/)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
it('bounds a status message the hook rewrote', async () => {
|
|
1177
|
+
const traces = createTraces({
|
|
1178
|
+
maxAttributeValueLength: 4,
|
|
1179
|
+
beforeSpanSend: [
|
|
1180
|
+
(span) => {
|
|
1181
|
+
span.status = { code: 'error', message: 'abcdefgh' }
|
|
1182
|
+
return span
|
|
1183
|
+
},
|
|
1184
|
+
],
|
|
1185
|
+
})
|
|
1186
|
+
traces.startSpan('checkout').end()
|
|
1187
|
+
await traces.flush()
|
|
1188
|
+
|
|
1189
|
+
expect(sentSpans()[0].status).toEqual({ code: 2, message: 'abcd' })
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
it('keeps the original status when the hook writes an unknown code', async () => {
|
|
1193
|
+
// An unknown code maps to nothing and encodes as an empty status object,
|
|
1194
|
+
// which silently loses an error the span really had.
|
|
1195
|
+
const traces = createTraces({
|
|
1196
|
+
beforeSpanSend: [(span) => ({ ...span, status: { code: 'ERROR' as never, message: 'boom' } })],
|
|
1197
|
+
})
|
|
1198
|
+
const span = traces.startSpan('checkout')
|
|
1199
|
+
span.setStatus('error', 'boom')
|
|
1200
|
+
span.end()
|
|
1201
|
+
await traces.flush()
|
|
1202
|
+
|
|
1203
|
+
expect(sentSpans()[0].status).toEqual({ code: 2, message: 'boom' })
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it('ignores a dropped count the hook invented', async () => {
|
|
1207
|
+
const traces = createTraces({
|
|
1208
|
+
beforeSpanSend: [(span) => ({ ...span, droppedAttributesCount: 'lots' as never })],
|
|
1209
|
+
})
|
|
1210
|
+
traces.startSpan('checkout').end()
|
|
1211
|
+
await traces.flush()
|
|
1212
|
+
|
|
1213
|
+
expect(sentSpans()[0].droppedAttributesCount).toBeUndefined()
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
it('lets a hook scrub the auto-context keys', async () => {
|
|
1217
|
+
// The exemption is from the count cap only. A hook is the documented
|
|
1218
|
+
// scrubbing point, so it has to be able to remove the join keys as well.
|
|
1219
|
+
context = { distinctId: 'user-1', sessionId: 'session-1' }
|
|
1220
|
+
const traces = createTraces({
|
|
1221
|
+
beforeSpanSend: [
|
|
1222
|
+
(span) => {
|
|
1223
|
+
delete span.attributes.posthogDistinctId
|
|
1224
|
+
delete span.attributes.sessionId
|
|
1225
|
+
return span
|
|
1226
|
+
},
|
|
1227
|
+
],
|
|
1228
|
+
})
|
|
1229
|
+
traces.startSpan('checkout').end()
|
|
1230
|
+
await traces.flush()
|
|
1231
|
+
|
|
1232
|
+
expect(sentSpans()[0].attributes).toBeUndefined()
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('keeps the error status when the event cap costs the exception event', async () => {
|
|
1236
|
+
// The status is set independently of the event, so a span whose exception
|
|
1237
|
+
// event did not fit still exports as failed and still counts the loss.
|
|
1238
|
+
// That pair is what makes the case findable once traces is live.
|
|
1239
|
+
const traces = createTraces({
|
|
1240
|
+
maxEventsPerSpan: 1,
|
|
1241
|
+
beforeSpanSend: [
|
|
1242
|
+
(span) => {
|
|
1243
|
+
span.events.push({ name: 'audited', timestamp: Date.now() })
|
|
1244
|
+
return span
|
|
1245
|
+
},
|
|
1246
|
+
],
|
|
1247
|
+
})
|
|
1248
|
+
const span = traces.startSpan('checkout')
|
|
1249
|
+
span.addEvent('step')
|
|
1250
|
+
span.recordException(new Error('boom'))
|
|
1251
|
+
span.end()
|
|
1252
|
+
await traces.flush()
|
|
1253
|
+
|
|
1254
|
+
const [sent] = sentSpans()
|
|
1255
|
+
expect(sent.events!.map((event) => event.name)).toEqual(['step'])
|
|
1256
|
+
expect(sent.droppedEventsCount).toBe(2)
|
|
1257
|
+
expect(sent.status).toEqual({ code: 2, message: 'boom' })
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
it('keeps the original status when the hook mutates the code in place', async () => {
|
|
1261
|
+
// The hook is documented as editing the record in place, so snapshotting a
|
|
1262
|
+
// reference to `status` would restore the mutation onto itself.
|
|
1263
|
+
const traces = createTraces({
|
|
1264
|
+
beforeSpanSend: [
|
|
1265
|
+
(span) => {
|
|
1266
|
+
;(span.status as { code: string }).code = 'ERROR'
|
|
1267
|
+
return span
|
|
1268
|
+
},
|
|
1269
|
+
],
|
|
1270
|
+
})
|
|
1271
|
+
const span = traces.startSpan('checkout')
|
|
1272
|
+
span.setStatus('error', 'boom')
|
|
1273
|
+
span.end()
|
|
1274
|
+
await traces.flush()
|
|
1275
|
+
|
|
1276
|
+
expect(sentSpans()[0].status).toEqual({ code: 2, message: 'boom' })
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
it('exports a span whose record is a class instance with prototype getters', async () => {
|
|
1280
|
+
// A spread copies own properties only, so `events` behind a prototype
|
|
1281
|
+
// getter arrived undefined and the fail-closed branch ate every span.
|
|
1282
|
+
class Wrapped {
|
|
1283
|
+
constructor(private readonly _inner: SpanRecord) {}
|
|
1284
|
+
get traceId(): string {
|
|
1285
|
+
return this._inner.traceId
|
|
1286
|
+
}
|
|
1287
|
+
get spanId(): string {
|
|
1288
|
+
return this._inner.spanId
|
|
1289
|
+
}
|
|
1290
|
+
get name(): string {
|
|
1291
|
+
return this._inner.name
|
|
1292
|
+
}
|
|
1293
|
+
get kind(): SpanRecord['kind'] {
|
|
1294
|
+
return this._inner.kind
|
|
1295
|
+
}
|
|
1296
|
+
get attributes(): SpanRecord['attributes'] {
|
|
1297
|
+
return this._inner.attributes
|
|
1298
|
+
}
|
|
1299
|
+
get events(): SpanRecord['events'] {
|
|
1300
|
+
return this._inner.events
|
|
1301
|
+
}
|
|
1302
|
+
get startTime(): number {
|
|
1303
|
+
return this._inner.startTime
|
|
1304
|
+
}
|
|
1305
|
+
get endTime(): number {
|
|
1306
|
+
return this._inner.endTime
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const traces = createTraces({
|
|
1310
|
+
beforeSpanSend: [(span: SpanRecord) => new Wrapped(span) as unknown as SpanRecord],
|
|
1311
|
+
})
|
|
1312
|
+
traces.startSpan('checkout').end()
|
|
1313
|
+
await traces.flush()
|
|
1314
|
+
|
|
1315
|
+
expect(sentSpans().map((span) => span.name)).toEqual(['checkout'])
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
it('survives a hook that leaves a hole in the events array', async () => {
|
|
1319
|
+
const traces = createTraces({
|
|
1320
|
+
beforeSpanSend: [
|
|
1321
|
+
(span) => {
|
|
1322
|
+
span.events.length = 2
|
|
1323
|
+
return span
|
|
1324
|
+
},
|
|
1325
|
+
],
|
|
1326
|
+
})
|
|
1327
|
+
const span = traces.startSpan('checkout')
|
|
1328
|
+
span.addEvent('step')
|
|
1329
|
+
span.end()
|
|
1330
|
+
await traces.flush()
|
|
1331
|
+
|
|
1332
|
+
expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step'])
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
it('keeps the span-side dropped count when the hook overwrites the counter', async () => {
|
|
1336
|
+
const traces = createTraces({
|
|
1337
|
+
maxEventsPerSpan: 1,
|
|
1338
|
+
beforeSpanSend: [
|
|
1339
|
+
(span) => {
|
|
1340
|
+
;(span as unknown as { droppedEventsCount: unknown }).droppedEventsCount = 'lots'
|
|
1341
|
+
span.events.push({ name: 'audited', timestamp: Date.now() })
|
|
1342
|
+
return span
|
|
1343
|
+
},
|
|
1344
|
+
],
|
|
1345
|
+
})
|
|
1346
|
+
const span = traces.startSpan('checkout')
|
|
1347
|
+
span.addEvent('step-0')
|
|
1348
|
+
span.addEvent('step-1')
|
|
1349
|
+
span.end()
|
|
1350
|
+
await traces.flush()
|
|
1351
|
+
|
|
1352
|
+
// One dropped at the span, one by the post-hook re-apply.
|
|
1353
|
+
expect(sentSpans()[0].droppedEventsCount).toBe(2)
|
|
1354
|
+
})
|
|
1355
|
+
|
|
1356
|
+
it('drops only the event the hook made unreadable', async () => {
|
|
1357
|
+
const traces = createTraces({
|
|
1358
|
+
beforeSpanSend: [
|
|
1359
|
+
(span) => {
|
|
1360
|
+
span.events = [span.events[0], null as never, span.events[1]]
|
|
1361
|
+
return span
|
|
1362
|
+
},
|
|
1363
|
+
],
|
|
1364
|
+
})
|
|
1365
|
+
const span = traces.startSpan('checkout')
|
|
1366
|
+
span.addEvent('step-0')
|
|
1367
|
+
span.addEvent('step-1')
|
|
1368
|
+
span.end()
|
|
1369
|
+
await traces.flush()
|
|
1370
|
+
|
|
1371
|
+
expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step-0', 'step-1'])
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
it.each([
|
|
1375
|
+
['attributes replaced with null', (span: SpanRecord) => ({ ...span, attributes: null as never })],
|
|
1376
|
+
['attributes replaced with an array', (span: SpanRecord) => ({ ...span, attributes: ['a'] as never })],
|
|
1377
|
+
['events replaced with null', (span: SpanRecord) => ({ ...span, events: null as never })],
|
|
1378
|
+
['an async hook returning a promise', (span: SpanRecord) => Promise.resolve(span) as never],
|
|
1379
|
+
// Carrying both collections was the whole shape check, so these reached
|
|
1380
|
+
// the wire as a span named `unknown` at a fallback time with no join keys.
|
|
1381
|
+
['only the two collections', () => ({ attributes: {}, events: [] }) as never],
|
|
1382
|
+
['no name', (span: SpanRecord) => ({ ...span, name: undefined as never })],
|
|
1383
|
+
['no kind', (span: SpanRecord) => ({ ...span, kind: undefined as never })],
|
|
1384
|
+
['no start time', (span: SpanRecord) => ({ ...span, startTime: undefined as never })],
|
|
1385
|
+
['no end time', (span: SpanRecord) => ({ ...span, endTime: undefined as never })],
|
|
1386
|
+
])('drops the span when the hook returns %s', async (_label, beforeSpanSend) => {
|
|
1387
|
+
// Repairing these would export a nameless span carrying no join keys.
|
|
1388
|
+
const traces = createTraces({ beforeSpanSend: [beforeSpanSend] })
|
|
1389
|
+
traces.startSpan('checkout').end()
|
|
1390
|
+
await traces.flush()
|
|
1391
|
+
|
|
1392
|
+
expect(sentSpans()).toHaveLength(0)
|
|
1393
|
+
})
|
|
1394
|
+
|
|
1395
|
+
it('counts an incomplete hook result as a drop rather than losing it silently', async () => {
|
|
1396
|
+
const traces = createTraces({ beforeSpanSend: [() => ({ attributes: {}, events: [] }) as never] })
|
|
1397
|
+
traces.startSpan('checkout').end()
|
|
1398
|
+
await traces.flush()
|
|
1399
|
+
|
|
1400
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('beforeSpanSend returned an unusable record'))
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
it('still exports a span whose required fields the hook left in place', async () => {
|
|
1404
|
+
// The shape check reads presence, so an ordinary scrub is untouched by it.
|
|
1405
|
+
const traces = createTraces({
|
|
1406
|
+
beforeSpanSend: [
|
|
1407
|
+
(span) => {
|
|
1408
|
+
delete span.attributes.secret
|
|
1409
|
+
return span
|
|
1410
|
+
},
|
|
1411
|
+
],
|
|
1412
|
+
})
|
|
1413
|
+
traces.startSpan('checkout', { attributes: { secret: 'shh', keep: 1 } }).end()
|
|
1414
|
+
await traces.flush()
|
|
1415
|
+
|
|
1416
|
+
expect(sentSpans()).toHaveLength(1)
|
|
1417
|
+
expect(sentSpans()[0].name).toBe('checkout')
|
|
1418
|
+
expect(sentSpans()[0].attributes!.map((a) => a.key)).toContain('keep')
|
|
1419
|
+
expect(sentSpans()[0].attributes!.map((a) => a.key)).not.toContain('secret')
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
it('gives a later hook the real identity after an earlier one froze a forged record', async () => {
|
|
1423
|
+
// The export reads the snapshot either way, but a hook that samples or
|
|
1424
|
+
// routes on an id must not see one an earlier hook invented.
|
|
1425
|
+
const seen: { traceId: string; spanId: string }[] = []
|
|
1426
|
+
const traces = createTraces({
|
|
1427
|
+
beforeSpanSend: [
|
|
1428
|
+
(span) => Object.freeze({ ...span, traceId: '0'.repeat(32), spanId: 'f'.repeat(16) }),
|
|
1429
|
+
(span) => {
|
|
1430
|
+
seen.push({ traceId: span.traceId, spanId: span.spanId })
|
|
1431
|
+
return span
|
|
1432
|
+
},
|
|
1433
|
+
],
|
|
1434
|
+
})
|
|
1435
|
+
traces.startSpan('checkout').end()
|
|
1436
|
+
await traces.flush()
|
|
1437
|
+
|
|
1438
|
+
expect(sentSpans()).toHaveLength(1)
|
|
1439
|
+
expect(seen[0].traceId).toBe(sentSpans()[0].traceId)
|
|
1440
|
+
expect(seen[0].spanId).toBe(sentSpans()[0].spanId)
|
|
1441
|
+
expect(seen[0].traceId).not.toBe('0'.repeat(32))
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
it('leaves the rest of a frozen forged record readable to the next hook', async () => {
|
|
1445
|
+
// The corrected view is built from the record's own descriptors, so a hook
|
|
1446
|
+
// reading anything but identity sees exactly what the previous one returned.
|
|
1447
|
+
let seen: SpanRecord | undefined
|
|
1448
|
+
const traces = createTraces({
|
|
1449
|
+
beforeSpanSend: [
|
|
1450
|
+
(span) => Object.freeze({ ...span, name: 'renamed', traceId: '0'.repeat(32) }),
|
|
1451
|
+
(span) => {
|
|
1452
|
+
seen = span
|
|
1453
|
+
return span
|
|
1454
|
+
},
|
|
1455
|
+
],
|
|
1456
|
+
})
|
|
1457
|
+
traces.startSpan('checkout', { attributes: { keep: 1 } }).end()
|
|
1458
|
+
await traces.flush()
|
|
1459
|
+
|
|
1460
|
+
expect(seen!.name).toBe('renamed')
|
|
1461
|
+
expect(seen!.attributes.keep).toBe(1)
|
|
1462
|
+
expect(Object.keys(seen!)).toContain('name')
|
|
1463
|
+
expect(sentSpans()[0].name).toBe('renamed')
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
it('applies the event cap to what a hook leaves behind', async () => {
|
|
1467
|
+
// A hook can append events or rewrite them, neither of which goes through
|
|
1468
|
+
// `addEvent`, so the cap has to be re-applied to whatever it returns.
|
|
1469
|
+
const traces = createTraces({
|
|
1470
|
+
maxEventsPerSpan: 1,
|
|
1471
|
+
beforeSpanSend: [
|
|
1472
|
+
(span) => {
|
|
1473
|
+
span.events.push({ name: 'exception', timestamp: Date.now() })
|
|
1474
|
+
span.events.push({ name: 'appended', timestamp: Date.now() })
|
|
1475
|
+
return span
|
|
1476
|
+
},
|
|
1477
|
+
],
|
|
1478
|
+
})
|
|
1479
|
+
const span = traces.startSpan('checkout')
|
|
1480
|
+
span.addEvent('step-0')
|
|
1481
|
+
span.end()
|
|
1482
|
+
await traces.flush()
|
|
1483
|
+
|
|
1484
|
+
expect(sentSpans()[0].events!.map((event) => event.name)).toEqual(['step-0'])
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
it('exports the span when the hook status message refuses to stringify', async () => {
|
|
1488
|
+
// The encoder downstream only marks the field, so coercing here must not
|
|
1489
|
+
// be the thing that costs the span.
|
|
1490
|
+
const traces = createTraces({
|
|
1491
|
+
maxAttributeValueLength: 4,
|
|
1492
|
+
beforeSpanSend: [
|
|
1493
|
+
(span) => {
|
|
1494
|
+
span.status = {
|
|
1495
|
+
code: 'error',
|
|
1496
|
+
message: {
|
|
1497
|
+
toString() {
|
|
1498
|
+
throw new Error('nope')
|
|
1499
|
+
},
|
|
1500
|
+
} as never,
|
|
1501
|
+
}
|
|
1502
|
+
return span
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
})
|
|
1506
|
+
traces.startSpan('checkout').end()
|
|
1507
|
+
await traces.flush()
|
|
1508
|
+
|
|
1509
|
+
expect(sentSpans()).toHaveLength(1)
|
|
1510
|
+
expect(sentSpans()[0].status?.code).toBe(2)
|
|
1511
|
+
})
|
|
1512
|
+
|
|
1513
|
+
it('bounds a non-string status message the hook wrote', async () => {
|
|
1514
|
+
const traces = createTraces({
|
|
1515
|
+
maxAttributeValueLength: 4,
|
|
1516
|
+
beforeSpanSend: [
|
|
1517
|
+
(span) => {
|
|
1518
|
+
span.status = { code: 'error', message: { toString: () => 'abcdefgh' } as never }
|
|
1519
|
+
return span
|
|
1520
|
+
},
|
|
1521
|
+
],
|
|
1522
|
+
})
|
|
1523
|
+
traces.startSpan('checkout').end()
|
|
1524
|
+
await traces.flush()
|
|
1525
|
+
|
|
1526
|
+
expect(sentSpans()[0].status).toEqual({ code: 2, message: 'abcd' })
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
it('does not spend cap budget on a value the hook blanked', async () => {
|
|
1530
|
+
const traces = createTraces({
|
|
1531
|
+
maxAttributesPerSpan: 2,
|
|
1532
|
+
beforeSpanSend: [
|
|
1533
|
+
(span) => {
|
|
1534
|
+
span.attributes.secret = null
|
|
1535
|
+
span.attributes.scrubbed = true
|
|
1536
|
+
return span
|
|
1537
|
+
},
|
|
1538
|
+
],
|
|
1539
|
+
})
|
|
1540
|
+
traces.startSpan('checkout', { attributes: { secret: 'sk-live', route: '/checkout' } }).end()
|
|
1541
|
+
await traces.flush()
|
|
1542
|
+
|
|
1543
|
+
const [sent] = sentSpans()
|
|
1544
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['route', 'scrubbed'])
|
|
1545
|
+
expect(sent.droppedAttributesCount).toBeUndefined()
|
|
1546
|
+
})
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
describe('span limits', () => {
|
|
1550
|
+
it('keeps the earliest attributes and counts the rest', async () => {
|
|
1551
|
+
const traces = createTraces({ maxAttributesPerSpan: 3 })
|
|
1552
|
+
const span = traces.startSpan('checkout')
|
|
1553
|
+
for (let i = 0; i < 5; i++) {
|
|
1554
|
+
span.setAttribute(`key-${i}`, i)
|
|
1555
|
+
}
|
|
1556
|
+
span.end()
|
|
1557
|
+
await traces.flush()
|
|
1558
|
+
|
|
1559
|
+
const [sent] = sentSpans()
|
|
1560
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['key-0', 'key-1', 'key-2'])
|
|
1561
|
+
expect(sent.droppedAttributesCount).toBe(2)
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
it('re-applies the attribute cap to what beforeSpanSend added', async () => {
|
|
1565
|
+
const traces = createTraces({
|
|
1566
|
+
maxAttributesPerSpan: 2,
|
|
1567
|
+
beforeSpanSend: [
|
|
1568
|
+
(span) => {
|
|
1569
|
+
for (let i = 0; i < 5; i++) {
|
|
1570
|
+
span.attributes[`added-${i}`] = i
|
|
1571
|
+
}
|
|
1572
|
+
return span
|
|
1573
|
+
},
|
|
1574
|
+
],
|
|
1575
|
+
})
|
|
1576
|
+
traces.startSpan('checkout', { attributes: { kept: true } }).end()
|
|
1577
|
+
await traces.flush()
|
|
1578
|
+
|
|
1579
|
+
const [sent] = sentSpans()
|
|
1580
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept', 'added-0'])
|
|
1581
|
+
expect(sent.droppedAttributesCount).toBe(4)
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
it('re-applies the event cap to what beforeSpanSend added', async () => {
|
|
1585
|
+
const traces = createTraces({
|
|
1586
|
+
maxEventsPerSpan: 1,
|
|
1587
|
+
beforeSpanSend: [
|
|
1588
|
+
(span) => {
|
|
1589
|
+
span.events.push({ name: 'added', timestamp: span.startTime })
|
|
1590
|
+
return span
|
|
1591
|
+
},
|
|
1592
|
+
],
|
|
1593
|
+
})
|
|
1594
|
+
const span = traces.startSpan('checkout')
|
|
1595
|
+
span.addEvent('original')
|
|
1596
|
+
span.end()
|
|
1597
|
+
await traces.flush()
|
|
1598
|
+
|
|
1599
|
+
const [sent] = sentSpans()
|
|
1600
|
+
expect(sent.events!.map((event) => event.name)).toEqual(['original'])
|
|
1601
|
+
expect(sent.droppedEventsCount).toBe(1)
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
it('re-applies the event attribute cap to what beforeSpanSend widened', async () => {
|
|
1605
|
+
const traces = createTraces({
|
|
1606
|
+
maxAttributesPerEvent: 2,
|
|
1607
|
+
beforeSpanSend: [
|
|
1608
|
+
(span) => {
|
|
1609
|
+
span.events[0].attributes = { a: 1, b: 2, c: 3, d: 4 }
|
|
1610
|
+
return span
|
|
1611
|
+
},
|
|
1612
|
+
],
|
|
1613
|
+
})
|
|
1614
|
+
const span = traces.startSpan('checkout')
|
|
1615
|
+
span.addEvent('query', { a: 1 })
|
|
1616
|
+
span.end()
|
|
1617
|
+
await traces.flush()
|
|
1618
|
+
|
|
1619
|
+
const event = sentSpans()[0].events![0]
|
|
1620
|
+
expect(event.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b'])
|
|
1621
|
+
expect(event.droppedAttributesCount).toBe(2)
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
it('keeps the auto-context keys when beforeSpanSend pushes past the cap', async () => {
|
|
1625
|
+
context = { distinctId: 'alice', sessionId: 'session-1' }
|
|
1626
|
+
const traces = createTraces({
|
|
1627
|
+
maxAttributesPerSpan: 1,
|
|
1628
|
+
beforeSpanSend: [
|
|
1629
|
+
(span) => {
|
|
1630
|
+
span.attributes.late = true
|
|
1631
|
+
return span
|
|
1632
|
+
},
|
|
1633
|
+
],
|
|
1634
|
+
})
|
|
1635
|
+
traces.startSpan('checkout', { attributes: { early: true } }).end()
|
|
1636
|
+
await traces.flush()
|
|
1637
|
+
|
|
1638
|
+
const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key)
|
|
1639
|
+
expect(keys).toEqual(expect.arrayContaining(['posthogDistinctId', 'sessionId', 'early']))
|
|
1640
|
+
expect(keys).not.toContain('late')
|
|
1641
|
+
})
|
|
1642
|
+
|
|
1643
|
+
it('re-applies the value bound to what beforeSpanSend wrote', async () => {
|
|
1644
|
+
const traces = createTraces({
|
|
1645
|
+
maxAttributeValueLength: 8,
|
|
1646
|
+
beforeSpanSend: [
|
|
1647
|
+
(span) => {
|
|
1648
|
+
span.attributes.enriched = 'y'.repeat(5000)
|
|
1649
|
+
span.events.push({ name: 'added', timestamp: span.startTime, attributes: { blob: 'z'.repeat(5000) } })
|
|
1650
|
+
return span
|
|
1651
|
+
},
|
|
1652
|
+
],
|
|
1653
|
+
})
|
|
1654
|
+
traces.startSpan('checkout').end()
|
|
1655
|
+
await traces.flush()
|
|
1656
|
+
|
|
1657
|
+
const [sent] = sentSpans()
|
|
1658
|
+
expect(sent.attributes!.find((attribute) => attribute.key === 'enriched')!.value).toEqual({
|
|
1659
|
+
stringValue: 'yyyyyyyy',
|
|
1660
|
+
})
|
|
1661
|
+
expect(sent.events!.at(-1)!.attributes!.find((attribute) => attribute.key === 'blob')!.value).toEqual({
|
|
1662
|
+
stringValue: 'zzzzzzzz',
|
|
1663
|
+
})
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
it('does not invent a dropped count when beforeSpanSend only removes', async () => {
|
|
1667
|
+
const traces = createTraces({
|
|
1668
|
+
maxAttributesPerSpan: 5,
|
|
1669
|
+
beforeSpanSend: [
|
|
1670
|
+
(span) => {
|
|
1671
|
+
delete span.attributes.secret
|
|
1672
|
+
return span
|
|
1673
|
+
},
|
|
1674
|
+
],
|
|
1675
|
+
})
|
|
1676
|
+
traces.startSpan('checkout', { attributes: { secret: 'x', kept: true } }).end()
|
|
1677
|
+
await traces.flush()
|
|
1678
|
+
|
|
1679
|
+
const [sent] = sentSpans()
|
|
1680
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept'])
|
|
1681
|
+
expect(sent.droppedAttributesCount).toBeUndefined()
|
|
1682
|
+
})
|
|
1683
|
+
|
|
1684
|
+
it('never evicts the auto-context keys', async () => {
|
|
1685
|
+
context = { distinctId: 'alice', sessionId: 'session-1' }
|
|
1686
|
+
const traces = createTraces({ maxAttributesPerSpan: 1 })
|
|
1687
|
+
const span = traces.startSpan('checkout')
|
|
1688
|
+
for (let i = 0; i < 5; i++) {
|
|
1689
|
+
span.setAttribute(`key-${i}`, i)
|
|
1690
|
+
}
|
|
1691
|
+
span.end()
|
|
1692
|
+
await traces.flush()
|
|
1693
|
+
|
|
1694
|
+
const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key)
|
|
1695
|
+
expect(keys).toEqual(expect.arrayContaining(['posthogDistinctId', 'sessionId', 'key-0']))
|
|
1696
|
+
expect(keys).not.toContain('key-1')
|
|
1697
|
+
})
|
|
1698
|
+
|
|
1699
|
+
it('caps events and counts the rest', async () => {
|
|
1700
|
+
const traces = createTraces({ maxEventsPerSpan: 2 })
|
|
1701
|
+
const span = traces.startSpan('checkout')
|
|
1702
|
+
for (let i = 0; i < 4; i++) {
|
|
1703
|
+
span.addEvent(`event-${i}`)
|
|
1704
|
+
}
|
|
1705
|
+
span.end()
|
|
1706
|
+
await traces.flush()
|
|
1707
|
+
|
|
1708
|
+
const [sent] = sentSpans()
|
|
1709
|
+
expect(sent.events!.map((event) => event.name)).toEqual(['event-0', 'event-1'])
|
|
1710
|
+
expect(sent.droppedEventsCount).toBe(2)
|
|
1711
|
+
})
|
|
1712
|
+
|
|
1713
|
+
it('lets a caller overwrite an attribute it already set while at the cap', async () => {
|
|
1714
|
+
const traces = createTraces({ maxAttributesPerSpan: 1 })
|
|
1715
|
+
const span = traces.startSpan('checkout')
|
|
1716
|
+
span.setAttribute('plan', 'free')
|
|
1717
|
+
span.setAttribute('plan', 'pro')
|
|
1718
|
+
span.end()
|
|
1719
|
+
await traces.flush()
|
|
1720
|
+
|
|
1721
|
+
const [sent] = sentSpans()
|
|
1722
|
+
expect(sent.attributes).toEqual([{ key: 'plan', value: { stringValue: 'pro' } }])
|
|
1723
|
+
expect(sent.droppedAttributesCount).toBeUndefined()
|
|
1724
|
+
})
|
|
1725
|
+
|
|
1726
|
+
it('omits the counters when nothing was dropped', async () => {
|
|
1727
|
+
const traces = createTraces()
|
|
1728
|
+
traces.startSpan('checkout', { attributes: { plan: 'pro' } }).end()
|
|
1729
|
+
await traces.flush()
|
|
1730
|
+
|
|
1731
|
+
const [sent] = sentSpans()
|
|
1732
|
+
expect(sent).not.toHaveProperty('droppedAttributesCount')
|
|
1733
|
+
expect(sent).not.toHaveProperty('droppedEventsCount')
|
|
1734
|
+
})
|
|
1735
|
+
|
|
1736
|
+
it('counts a parsed __proto__ key against the cap instead of smuggling it through', async () => {
|
|
1737
|
+
// JSON.parse produces an own `__proto__` key; a plain object store would
|
|
1738
|
+
// swap its prototype and leak every nested key past the cap.
|
|
1739
|
+
const traces = createTraces({ maxAttributesPerSpan: 2 })
|
|
1740
|
+
const parsed = JSON.parse('{"__proto__": {"leaked": 1}, "orderId": "abc"}')
|
|
1741
|
+
traces.startSpan('checkout', { attributes: parsed }).end()
|
|
1742
|
+
await traces.flush()
|
|
1743
|
+
|
|
1744
|
+
const keys = sentSpans()[0].attributes!.map((attribute) => attribute.key)
|
|
1745
|
+
expect(keys).not.toContain('leaked')
|
|
1746
|
+
expect(keys).toContain('orderId')
|
|
1747
|
+
})
|
|
1748
|
+
|
|
1749
|
+
it('does not let reserved property names bypass the cap', async () => {
|
|
1750
|
+
const traces = createTraces({ maxAttributesPerSpan: 1 })
|
|
1751
|
+
const span = traces.startSpan('checkout')
|
|
1752
|
+
span.setAttribute('kept', 1)
|
|
1753
|
+
span.setAttribute('toString', 'nope')
|
|
1754
|
+
span.setAttribute('constructor', 'nope')
|
|
1755
|
+
span.end()
|
|
1756
|
+
await traces.flush()
|
|
1757
|
+
|
|
1758
|
+
const [sent] = sentSpans()
|
|
1759
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['kept'])
|
|
1760
|
+
expect(sent.droppedAttributesCount).toBe(2)
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
it('does not spend cap budget on values that are dropped at encode time', async () => {
|
|
1764
|
+
const traces = createTraces({ maxAttributesPerSpan: 2 })
|
|
1765
|
+
const span = traces.startSpan('checkout')
|
|
1766
|
+
span.setAttribute('skipped-a', undefined)
|
|
1767
|
+
span.setAttribute('skipped-b', null)
|
|
1768
|
+
span.setAttribute('orderId', 'abc-123')
|
|
1769
|
+
span.end()
|
|
1770
|
+
await traces.flush()
|
|
1771
|
+
|
|
1772
|
+
const [sent] = sentSpans()
|
|
1773
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['orderId'])
|
|
1774
|
+
expect(sent.droppedAttributesCount).toBeUndefined()
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
it('still caps a key first seen with an optional value', async () => {
|
|
1778
|
+
const traces = createTraces({ maxAttributesPerSpan: 2 })
|
|
1779
|
+
const span = traces.startSpan('checkout')
|
|
1780
|
+
for (let i = 0; i < 5; i++) {
|
|
1781
|
+
span.setAttribute(`field-${i}`, undefined)
|
|
1782
|
+
}
|
|
1783
|
+
for (let i = 0; i < 5; i++) {
|
|
1784
|
+
span.setAttribute(`field-${i}`, i)
|
|
1785
|
+
}
|
|
1786
|
+
span.end()
|
|
1787
|
+
await traces.flush()
|
|
1788
|
+
|
|
1789
|
+
const [sent] = sentSpans()
|
|
1790
|
+
expect(sent.attributes).toHaveLength(2)
|
|
1791
|
+
expect(sent.droppedAttributesCount).toBe(3)
|
|
1792
|
+
})
|
|
1793
|
+
|
|
1794
|
+
it('clears a key that is set back to null', async () => {
|
|
1795
|
+
const traces = createTraces({ maxAttributesPerSpan: 2 })
|
|
1796
|
+
const span = traces.startSpan('checkout')
|
|
1797
|
+
span.setAttribute('orderId', 'abc-123')
|
|
1798
|
+
span.setAttribute('orderId', null)
|
|
1799
|
+
span.setAttribute('a', 1)
|
|
1800
|
+
span.setAttribute('b', 2)
|
|
1801
|
+
span.end()
|
|
1802
|
+
await traces.flush()
|
|
1803
|
+
|
|
1804
|
+
const [sent] = sentSpans()
|
|
1805
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b'])
|
|
1806
|
+
expect(sent.droppedAttributesCount).toBeUndefined()
|
|
1807
|
+
})
|
|
1808
|
+
|
|
1809
|
+
it('caps attributes supplied at start', async () => {
|
|
1810
|
+
const traces = createTraces({ maxAttributesPerSpan: 2 })
|
|
1811
|
+
traces.startSpan('checkout', { attributes: { a: 1, b: 2, c: 3 } }).end()
|
|
1812
|
+
await traces.flush()
|
|
1813
|
+
|
|
1814
|
+
const [sent] = sentSpans()
|
|
1815
|
+
expect(sent.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b'])
|
|
1816
|
+
expect(sent.droppedAttributesCount).toBe(1)
|
|
1817
|
+
})
|
|
1818
|
+
})
|
|
1819
|
+
|
|
1820
|
+
describe('export', () => {
|
|
1821
|
+
it('flushes when the queue reaches the batch size', async () => {
|
|
1822
|
+
const traces = createTraces({ maxExportBatchSize: 2 })
|
|
1823
|
+
traces.startSpan('a').end()
|
|
1824
|
+
expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled()
|
|
1825
|
+
|
|
1826
|
+
traces.startSpan('b').end()
|
|
1827
|
+
await flushMicrotasks()
|
|
1828
|
+
|
|
1829
|
+
expect(sentSpans()).toHaveLength(2)
|
|
1830
|
+
})
|
|
1831
|
+
|
|
1832
|
+
it('does not re-post on every span end while a flush is failing', async () => {
|
|
1833
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
1834
|
+
const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 10 })
|
|
1835
|
+
for (let i = 0; i < 10; i++) {
|
|
1836
|
+
traces.startSpan(`span-${i}`).end()
|
|
1837
|
+
await flushMicrotasks()
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
1841
|
+
})
|
|
1842
|
+
|
|
1843
|
+
it('flushes on the interval timer', async () => {
|
|
1844
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
1845
|
+
traces.startSpan('a').end()
|
|
1846
|
+
expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled()
|
|
1847
|
+
|
|
1848
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
1849
|
+
expect(sentSpans()).toHaveLength(1)
|
|
1850
|
+
})
|
|
1851
|
+
|
|
1852
|
+
it('sends one resource and one scope per batch', async () => {
|
|
1853
|
+
const traces = createTraces({ serviceName: 'checkout-api' })
|
|
1854
|
+
traces.startSpan('a').end()
|
|
1855
|
+
traces.startSpan('b').end()
|
|
1856
|
+
await traces.flush()
|
|
1857
|
+
|
|
1858
|
+
const [payload] = sentPayloads()
|
|
1859
|
+
expect(payload.resourceSpans).toHaveLength(1)
|
|
1860
|
+
expect(payload.resourceSpans[0].scopeSpans).toHaveLength(1)
|
|
1861
|
+
expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2)
|
|
1862
|
+
expect(payload.resourceSpans[0].resource.attributes).toContainEqual({
|
|
1863
|
+
key: 'service.name',
|
|
1864
|
+
value: { stringValue: 'checkout-api' },
|
|
1865
|
+
})
|
|
1866
|
+
})
|
|
1867
|
+
|
|
1868
|
+
it('splits a backlog across batches', async () => {
|
|
1869
|
+
const traces = createTraces({ maxExportBatchSize: 2 })
|
|
1870
|
+
for (let i = 0; i < 5; i++) {
|
|
1871
|
+
traces.startSpan(`span-${i}`).end()
|
|
1872
|
+
}
|
|
1873
|
+
await traces.flush()
|
|
1874
|
+
|
|
1875
|
+
expect(sentPayloads().length).toBeGreaterThanOrEqual(3)
|
|
1876
|
+
expect(sentSpans()).toHaveLength(5)
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
it('joins an in-flight flush rather than double-sending', async () => {
|
|
1880
|
+
const traces = createTraces()
|
|
1881
|
+
traces.startSpan('a').end()
|
|
1882
|
+
|
|
1883
|
+
const [first, second] = [traces.flush(), traces.flush()]
|
|
1884
|
+
await Promise.all([first, second])
|
|
1885
|
+
|
|
1886
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
1887
|
+
expect(sentSpans()).toHaveLength(1)
|
|
1888
|
+
})
|
|
1889
|
+
|
|
1890
|
+
it('drops the incoming span when the queue is full, keeping queued parents', async () => {
|
|
1891
|
+
// Queued spans are completed parents whose children may already have been
|
|
1892
|
+
// exported; evicting them would break assembled traces retroactively.
|
|
1893
|
+
const traces = createTraces({ maxQueueSize: 2, maxExportBatchSize: 100 })
|
|
1894
|
+
traces.startSpan('first').end()
|
|
1895
|
+
traces.startSpan('second').end()
|
|
1896
|
+
traces.startSpan('third').end()
|
|
1897
|
+
await traces.flush()
|
|
1898
|
+
|
|
1899
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['first', 'second'])
|
|
1900
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the queue is full'))
|
|
1901
|
+
})
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
describe('export failures', () => {
|
|
1905
|
+
it('halves the batch and resends the same spans on 413', async () => {
|
|
1906
|
+
const outcomes: SendTracesBatchOutcome[] = [{ kind: 'too-large' }, { kind: 'ok' }, { kind: 'ok' }]
|
|
1907
|
+
const instance = createMockInstance({
|
|
1908
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' })),
|
|
1909
|
+
})
|
|
1910
|
+
const traces = createTraces({ maxExportBatchSize: 4 }, instance)
|
|
1911
|
+
for (let i = 0; i < 4; i++) {
|
|
1912
|
+
traces.startSpan(`span-${i}`).end()
|
|
1913
|
+
}
|
|
1914
|
+
await traces.flush()
|
|
1915
|
+
|
|
1916
|
+
const batchSizes = sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length)
|
|
1917
|
+
expect(batchSizes).toEqual([4, 2, 2])
|
|
1918
|
+
expect(sentSpans(instance)).toHaveLength(8)
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
it('shrinks below the queue depth on 413 rather than resending the same body', async () => {
|
|
1922
|
+
// The batch the server rejected is what has to get smaller. Halving the
|
|
1923
|
+
// configured maximum leaves `size` unchanged whenever the queue is
|
|
1924
|
+
// shallower than it — the ordinary timer-flush case.
|
|
1925
|
+
const instance = createMockInstance({
|
|
1926
|
+
_sendTracesBatch: vi.fn().mockResolvedValueOnce({ kind: 'too-large' }).mockResolvedValue({ kind: 'ok' }),
|
|
1927
|
+
})
|
|
1928
|
+
const traces = createTraces({ maxExportBatchSize: 512 }, instance)
|
|
1929
|
+
for (let i = 0; i < 3; i++) {
|
|
1930
|
+
traces.startSpan(`span-${i}`).end()
|
|
1931
|
+
}
|
|
1932
|
+
await traces.flush()
|
|
1933
|
+
|
|
1934
|
+
const batchSizes = sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length)
|
|
1935
|
+
expect(batchSizes).toEqual([3, 1, 2])
|
|
1936
|
+
})
|
|
1937
|
+
|
|
1938
|
+
it('splits a locally measured batch without shrinking the next drain', async () => {
|
|
1939
|
+
// The SDK measured this body itself, so it knows the oversized span is gone
|
|
1940
|
+
// once the batch is isolated. The next drain starts at full size instead of
|
|
1941
|
+
// ramping back one healthy batch at a time.
|
|
1942
|
+
const instance = createMockInstance({
|
|
1943
|
+
_sendTracesBatch: vi
|
|
1944
|
+
.fn()
|
|
1945
|
+
.mockResolvedValueOnce({ kind: 'too-large', measuredLocally: true })
|
|
1946
|
+
.mockResolvedValue({ kind: 'ok' }),
|
|
1947
|
+
})
|
|
1948
|
+
const traces = createTraces({ maxExportBatchSize: 512 }, instance)
|
|
1949
|
+
for (let i = 0; i < 8; i++) {
|
|
1950
|
+
traces.startSpan(`span-${i}`).end()
|
|
1951
|
+
}
|
|
1952
|
+
await traces.flush()
|
|
1953
|
+
expect(sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length)).toEqual([8, 4, 4])
|
|
1954
|
+
|
|
1955
|
+
instance._sendTracesBatch.mockClear()
|
|
1956
|
+
for (let i = 0; i < 8; i++) {
|
|
1957
|
+
traces.startSpan(`later-${i}`).end()
|
|
1958
|
+
}
|
|
1959
|
+
await traces.flush()
|
|
1960
|
+
|
|
1961
|
+
// One batch, not the 6-then-2 that a persistent shrink plus its +1 ramp gives.
|
|
1962
|
+
expect(sentPayloads(instance).map((p) => p.resourceSpans[0].scopeSpans[0].spans.length)).toEqual([8])
|
|
1963
|
+
})
|
|
1964
|
+
|
|
1965
|
+
it('ramps the batch size back up after a 413 shrink', async () => {
|
|
1966
|
+
// A one-off oversized payload shouldn't permanently halve throughput.
|
|
1967
|
+
const instance = createMockInstance({
|
|
1968
|
+
_sendTracesBatch: vi.fn().mockResolvedValueOnce({ kind: 'too-large' }).mockResolvedValue({ kind: 'ok' }),
|
|
1969
|
+
})
|
|
1970
|
+
const traces = createTraces({ maxExportBatchSize: 4 }, instance)
|
|
1971
|
+
for (let i = 0; i < 4; i++) {
|
|
1972
|
+
traces.startSpan(`span-${i}`).end()
|
|
1973
|
+
}
|
|
1974
|
+
await traces.flush()
|
|
1975
|
+
|
|
1976
|
+
// Shrunk to 2, then +1 per healthy send across the two batches that drained it.
|
|
1977
|
+
instance._sendTracesBatch.mockClear()
|
|
1978
|
+
for (let i = 0; i < 4; i++) {
|
|
1979
|
+
traces.startSpan(`later-${i}`).end()
|
|
1980
|
+
}
|
|
1981
|
+
await traces.flush()
|
|
1982
|
+
|
|
1983
|
+
expect(sentPayloads(instance)[0].resourceSpans[0].scopeSpans[0].spans.length).toBeGreaterThan(2)
|
|
1984
|
+
})
|
|
1985
|
+
|
|
1986
|
+
it('drops a single span the server rejects as too large', async () => {
|
|
1987
|
+
const instance = createMockInstance({
|
|
1988
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'too-large' as const })),
|
|
1989
|
+
})
|
|
1990
|
+
const traces = createTraces({ maxExportBatchSize: 1 }, instance)
|
|
1991
|
+
traces.startSpan('huge').end()
|
|
1992
|
+
await traces.flush()
|
|
1993
|
+
|
|
1994
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('too large'))
|
|
1995
|
+
|
|
1996
|
+
// The span must actually leave the queue, or it is re-POSTed on every
|
|
1997
|
+
// flush for the life of the process.
|
|
1998
|
+
instance._sendTracesBatch.mockResolvedValue({ kind: 'ok' })
|
|
1999
|
+
traces.startSpan('later').end()
|
|
2000
|
+
await traces.flush()
|
|
2001
|
+
expect(sentSpans(instance).map((s) => s.name)).toEqual(['huge', 'later'])
|
|
2002
|
+
})
|
|
2003
|
+
|
|
2004
|
+
it('names the reason for each kind of drop', async () => {
|
|
2005
|
+
const instance = createMockInstance({
|
|
2006
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'fatal' as const, error: new Error('400') })),
|
|
2007
|
+
})
|
|
2008
|
+
const traces = createTraces({ maxExportBatchSize: 1 }, instance)
|
|
2009
|
+
traces.startSpan('poison').end()
|
|
2010
|
+
await traces.flush()
|
|
2011
|
+
|
|
2012
|
+
// A poison batch is not a full queue; telling an operator to reduce span
|
|
2013
|
+
// volume would send them after the wrong problem.
|
|
2014
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('rejected the batch'))
|
|
2015
|
+
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('queue is full'))
|
|
2016
|
+
})
|
|
2017
|
+
|
|
2018
|
+
it('warns again about drops on a later flush', async () => {
|
|
2019
|
+
// Warning once per process would leave the SDK silent about every
|
|
2020
|
+
// subsequent drop for the life of the app.
|
|
2021
|
+
const instance = createMockInstance({
|
|
2022
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'fatal' as const, error: new Error('400') })),
|
|
2023
|
+
})
|
|
2024
|
+
const traces = createTraces({ maxExportBatchSize: 1 }, instance)
|
|
2025
|
+
|
|
2026
|
+
traces.startSpan('a').end()
|
|
2027
|
+
await traces.flush()
|
|
2028
|
+
traces.startSpan('b').end()
|
|
2029
|
+
await traces.flush()
|
|
2030
|
+
|
|
2031
|
+
expect((logger.warn as vi.Mock).mock.calls.length).toBeGreaterThan(1)
|
|
2032
|
+
})
|
|
2033
|
+
|
|
2034
|
+
it('keeps spans queued on a retriable failure', async () => {
|
|
2035
|
+
const instance = createMockInstance({
|
|
2036
|
+
_sendTracesBatch: vi
|
|
2037
|
+
.fn()
|
|
2038
|
+
.mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') })
|
|
2039
|
+
.mockResolvedValue({ kind: 'ok' }),
|
|
2040
|
+
})
|
|
2041
|
+
const traces = createTraces({}, instance)
|
|
2042
|
+
traces.startSpan('a').end()
|
|
2043
|
+
|
|
2044
|
+
await traces.flush()
|
|
2045
|
+
expect(sentSpans(instance)).toHaveLength(1)
|
|
2046
|
+
|
|
2047
|
+
await traces.flush()
|
|
2048
|
+
expect(sentSpans(instance)).toHaveLength(2)
|
|
2049
|
+
expect(sentSpans(instance)[1].name).toBe('a')
|
|
2050
|
+
})
|
|
2051
|
+
|
|
2052
|
+
it('backs off exponentially while sends keep failing', async () => {
|
|
2053
|
+
const instance = createMockInstance({
|
|
2054
|
+
_sendTracesBatch: vi.fn(() => Promise.resolve({ kind: 'retry-later' as const, error: new Error('network') })),
|
|
2055
|
+
})
|
|
2056
|
+
const traces = createTraces({ flushIntervalMs: 5000 }, instance)
|
|
2057
|
+
traces.startSpan('a').end()
|
|
2058
|
+
|
|
2059
|
+
const sendsPerWindow: number[] = []
|
|
2060
|
+
for (let i = 0; i < 8; i++) {
|
|
2061
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2062
|
+
sendsPerWindow.push(instance._sendTracesBatch.mock.calls.length)
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
expect(sendsPerWindow).toEqual([1, 2, 2, 3, 3, 3, 3, 4])
|
|
2066
|
+
})
|
|
2067
|
+
|
|
2068
|
+
it('returns to the base interval after a send succeeds', async () => {
|
|
2069
|
+
const instance = createMockInstance({
|
|
2070
|
+
_sendTracesBatch: vi
|
|
2071
|
+
.fn()
|
|
2072
|
+
.mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') })
|
|
2073
|
+
.mockResolvedValueOnce({ kind: 'retry-later', error: new Error('network') })
|
|
2074
|
+
.mockResolvedValue({ kind: 'ok' }),
|
|
2075
|
+
})
|
|
2076
|
+
const traces = createTraces({ flushIntervalMs: 5000 }, instance)
|
|
2077
|
+
traces.startSpan('a').end()
|
|
2078
|
+
|
|
2079
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2080
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2081
|
+
await vi.advanceTimersByTimeAsync(10000)
|
|
2082
|
+
expect(instance._sendTracesBatch).toHaveBeenCalledTimes(3)
|
|
2083
|
+
|
|
2084
|
+
traces.startSpan('b').end()
|
|
2085
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2086
|
+
expect(instance._sendTracesBatch).toHaveBeenCalledTimes(4)
|
|
2087
|
+
})
|
|
2088
|
+
|
|
2089
|
+
it('drops a poison batch rather than wedging the queue', async () => {
|
|
2090
|
+
const instance = createMockInstance({
|
|
2091
|
+
_sendTracesBatch: vi
|
|
2092
|
+
.fn()
|
|
2093
|
+
.mockResolvedValueOnce({ kind: 'fatal', error: new Error('400') })
|
|
2094
|
+
.mockResolvedValue({ kind: 'ok' }),
|
|
2095
|
+
})
|
|
2096
|
+
const traces = createTraces({ maxExportBatchSize: 1 }, instance)
|
|
2097
|
+
traces.startSpan('poison').end()
|
|
2098
|
+
traces.startSpan('good').end()
|
|
2099
|
+
await traces.flush()
|
|
2100
|
+
|
|
2101
|
+
expect(sentSpans(instance).map((s) => s.name)).toEqual(['poison', 'good'])
|
|
2102
|
+
|
|
2103
|
+
instance._sendTracesBatch.mockClear()
|
|
2104
|
+
await traces.flush()
|
|
2105
|
+
expect(instance._sendTracesBatch).not.toHaveBeenCalled()
|
|
2106
|
+
})
|
|
2107
|
+
|
|
2108
|
+
it('does not surface a transport failure through span.end()', async () => {
|
|
2109
|
+
// Ending a span is application control flow — it must never throw because
|
|
2110
|
+
// the exporter is broken.
|
|
2111
|
+
const instance = createMockInstance({
|
|
2112
|
+
_sendTracesBatch: vi.fn(() => Promise.reject(new Error('transport exploded'))),
|
|
2113
|
+
})
|
|
2114
|
+
const traces = createTraces({ maxExportBatchSize: 1 }, instance)
|
|
2115
|
+
|
|
2116
|
+
expect(() => traces.startSpan('a').end()).not.toThrow()
|
|
2117
|
+
// Let the background flush settle; the rejection is swallowed there.
|
|
2118
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
2119
|
+
})
|
|
2120
|
+
|
|
2121
|
+
it('surfaces a transport failure through an explicit flush()', async () => {
|
|
2122
|
+
// flush() is the caller asking to be told, so it propagates — matching
|
|
2123
|
+
// how the logs and metrics pipelines behave.
|
|
2124
|
+
const instance = createMockInstance({
|
|
2125
|
+
_sendTracesBatch: vi.fn(() => Promise.reject(new Error('transport exploded'))),
|
|
2126
|
+
})
|
|
2127
|
+
const traces = createTraces({ maxExportBatchSize: 100 }, instance)
|
|
2128
|
+
traces.startSpan('a').end()
|
|
2129
|
+
|
|
2130
|
+
await expect(traces.flush()).rejects.toThrow('transport exploded')
|
|
2131
|
+
})
|
|
2132
|
+
})
|
|
2133
|
+
|
|
2134
|
+
describe('poison attributes', () => {
|
|
2135
|
+
it('encodes a circular attribute instead of blowing the stack', async () => {
|
|
2136
|
+
const traces = createTraces()
|
|
2137
|
+
const cyclic: any = { name: 'order' }
|
|
2138
|
+
cyclic.self = cyclic
|
|
2139
|
+
|
|
2140
|
+
traces.startSpan('checkout', { attributes: { payload: cyclic } }).end()
|
|
2141
|
+
await traces.flush()
|
|
2142
|
+
|
|
2143
|
+
expect(sentSpans()).toHaveLength(1)
|
|
2144
|
+
expect(JSON.stringify(sentPayloads()[0])).toContain('[Circular]')
|
|
2145
|
+
})
|
|
2146
|
+
|
|
2147
|
+
it('treats a repeated sibling reference as duplication, not a cycle', async () => {
|
|
2148
|
+
const traces = createTraces()
|
|
2149
|
+
const shared = { id: 1 }
|
|
2150
|
+
|
|
2151
|
+
traces.startSpan('checkout', { attributes: { a: shared, b: shared } as any }).end()
|
|
2152
|
+
await traces.flush()
|
|
2153
|
+
|
|
2154
|
+
expect(JSON.stringify(sentPayloads()[0])).not.toContain('[Circular]')
|
|
2155
|
+
})
|
|
2156
|
+
|
|
2157
|
+
it('keeps a span whose attribute getter throws, marking only that key', async () => {
|
|
2158
|
+
// The shared encoder contains a throwing getter at the key it belongs to,
|
|
2159
|
+
// so the span keeps its name, timing and every other attribute instead of
|
|
2160
|
+
// being dropped whole.
|
|
2161
|
+
const traces = createTraces({ maxExportBatchSize: 1 })
|
|
2162
|
+
const exploding = {
|
|
2163
|
+
ok: 1,
|
|
2164
|
+
get boom() {
|
|
2165
|
+
throw new Error('getter exploded')
|
|
2166
|
+
},
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
traces.startSpan('poison', { attributes: { payload: exploding as any } }).end()
|
|
2170
|
+
traces.startSpan('healthy').end()
|
|
2171
|
+
await traces.flush()
|
|
2172
|
+
|
|
2173
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['poison', 'healthy'])
|
|
2174
|
+
expect(JSON.stringify(sentPayloads()[0])).toContain('[Unserializable]')
|
|
2175
|
+
expect(JSON.stringify(sentPayloads()[0])).toContain('"intValue":"1"')
|
|
2176
|
+
})
|
|
2177
|
+
|
|
2178
|
+
it('keeps a span whose top-level attribute getter throws, marking only that key', async () => {
|
|
2179
|
+
const traces = createTraces({ maxExportBatchSize: 1 })
|
|
2180
|
+
const attributes: any = { ok: 1 }
|
|
2181
|
+
Object.defineProperty(attributes, 'boom', {
|
|
2182
|
+
enumerable: true,
|
|
2183
|
+
get() {
|
|
2184
|
+
throw new Error('getter exploded')
|
|
2185
|
+
},
|
|
2186
|
+
})
|
|
2187
|
+
|
|
2188
|
+
expect(() => traces.startSpan('poison', { attributes }).end()).not.toThrow()
|
|
2189
|
+
await traces.flush()
|
|
2190
|
+
|
|
2191
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['poison'])
|
|
2192
|
+
expect(JSON.stringify(sentPayloads()[0])).toContain('[Unserializable]')
|
|
2193
|
+
expect(JSON.stringify(sentPayloads()[0])).toContain('"intValue":"1"')
|
|
2194
|
+
})
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
describe('consent withdrawn after a span is queued', () => {
|
|
2198
|
+
it('does not export spans queued before optOut()', async () => {
|
|
2199
|
+
const instance = createMockInstance()
|
|
2200
|
+
const traces = createTraces({}, instance)
|
|
2201
|
+
context = { distinctId: 'alice', sessionId: 'session-1' }
|
|
2202
|
+
traces.startSpan('checkout').end()
|
|
2203
|
+
|
|
2204
|
+
instance.optedOut = true
|
|
2205
|
+
await traces.flush()
|
|
2206
|
+
|
|
2207
|
+
expect(instance._sendTracesBatch).not.toHaveBeenCalled()
|
|
2208
|
+
})
|
|
2209
|
+
|
|
2210
|
+
it('does not export spans queued before the client is disabled', async () => {
|
|
2211
|
+
const instance = createMockInstance()
|
|
2212
|
+
const traces = createTraces({}, instance)
|
|
2213
|
+
traces.startSpan('checkout').end()
|
|
2214
|
+
|
|
2215
|
+
instance.isDisabled = true
|
|
2216
|
+
await traces.flush()
|
|
2217
|
+
|
|
2218
|
+
expect(instance._sendTracesBatch).not.toHaveBeenCalled()
|
|
2219
|
+
})
|
|
2220
|
+
|
|
2221
|
+
it('counts spans discarded from the queue when consent is withdrawn', async () => {
|
|
2222
|
+
const instance = createMockInstance()
|
|
2223
|
+
const traces = createTraces({}, instance)
|
|
2224
|
+
traces.startSpan('a').end()
|
|
2225
|
+
traces.startSpan('b').end()
|
|
2226
|
+
|
|
2227
|
+
instance.optedOut = true
|
|
2228
|
+
await traces.flush()
|
|
2229
|
+
|
|
2230
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('2 span(s)'))
|
|
2231
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out'))
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
it('stops draining a backlog when optOut() lands while a batch is in flight', async () => {
|
|
2235
|
+
const instance = createMockInstance()
|
|
2236
|
+
instance._sendTracesBatch = vi.fn(() =>
|
|
2237
|
+
Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') })
|
|
2238
|
+
)
|
|
2239
|
+
const traces = createTraces({ maxExportBatchSize: 2 }, instance)
|
|
2240
|
+
context = { distinctId: 'alice', sessionId: 'session-1' }
|
|
2241
|
+
for (let i = 0; i < 6; i++) {
|
|
2242
|
+
traces.startSpan(`span-${i}`).end()
|
|
2243
|
+
await flushMicrotasks()
|
|
2244
|
+
}
|
|
2245
|
+
expect(instance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2246
|
+
|
|
2247
|
+
instance._sendTracesBatch.mockImplementation(() =>
|
|
2248
|
+
Promise.resolve().then(() => {
|
|
2249
|
+
instance.optedOut = true
|
|
2250
|
+
return { kind: 'ok' as const }
|
|
2251
|
+
})
|
|
2252
|
+
)
|
|
2253
|
+
await traces.flush()
|
|
2254
|
+
|
|
2255
|
+
expect(instance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2256
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('4 span(s)'))
|
|
2257
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the user has opted out'))
|
|
2258
|
+
})
|
|
2259
|
+
|
|
2260
|
+
it('stops draining a backlog when the client is disabled while a batch is in flight', async () => {
|
|
2261
|
+
const instance = createMockInstance()
|
|
2262
|
+
instance._sendTracesBatch = vi.fn(() =>
|
|
2263
|
+
Promise.resolve({ kind: 'retry-later' as const, error: new Error('down') })
|
|
2264
|
+
)
|
|
2265
|
+
const traces = createTraces({ maxExportBatchSize: 2 }, instance)
|
|
2266
|
+
for (let i = 0; i < 6; i++) {
|
|
2267
|
+
traces.startSpan(`span-${i}`).end()
|
|
2268
|
+
await flushMicrotasks()
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
instance._sendTracesBatch.mockImplementation(() =>
|
|
2272
|
+
Promise.resolve().then(() => {
|
|
2273
|
+
instance.isDisabled = true
|
|
2274
|
+
return { kind: 'ok' as const }
|
|
2275
|
+
})
|
|
2276
|
+
)
|
|
2277
|
+
await traces.flush()
|
|
2278
|
+
|
|
2279
|
+
expect(instance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2280
|
+
})
|
|
2281
|
+
})
|
|
2282
|
+
|
|
2283
|
+
describe('flush backoff', () => {
|
|
2284
|
+
it('resumes the depth trigger after a non-retriable drop clears the backlog', async () => {
|
|
2285
|
+
// A 503 burst raises the consecutive-failure count, which disables the
|
|
2286
|
+
// depth trigger. Dropping the poison batch is progress, so the count has to
|
|
2287
|
+
// clear with it or the queue stays on the slow timer while the endpoint is
|
|
2288
|
+
// healthy.
|
|
2289
|
+
mockInstance._sendTracesBatch
|
|
2290
|
+
.mockResolvedValueOnce({ kind: 'retry-later', error: new Error('503') })
|
|
2291
|
+
.mockResolvedValueOnce({ kind: 'fatal', error: new Error('400') })
|
|
2292
|
+
.mockResolvedValue({ kind: 'ok' })
|
|
2293
|
+
|
|
2294
|
+
const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 10_000 })
|
|
2295
|
+
traces.startSpan('poison').end()
|
|
2296
|
+
await flushMicrotasks()
|
|
2297
|
+
await traces.flush()
|
|
2298
|
+
mockInstance._sendTracesBatch.mockClear()
|
|
2299
|
+
|
|
2300
|
+
// Depth trigger only fires again if the failure count was cleared.
|
|
2301
|
+
traces.startSpan('after').end()
|
|
2302
|
+
await flushMicrotasks()
|
|
2303
|
+
|
|
2304
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalled()
|
|
2305
|
+
})
|
|
2306
|
+
|
|
2307
|
+
it('resumes the depth trigger after a too-large single-span drop', async () => {
|
|
2308
|
+
mockInstance._sendTracesBatch
|
|
2309
|
+
.mockResolvedValueOnce({ kind: 'retry-later', error: new Error('503') })
|
|
2310
|
+
.mockResolvedValueOnce({ kind: 'too-large' })
|
|
2311
|
+
.mockResolvedValue({ kind: 'ok' })
|
|
2312
|
+
|
|
2313
|
+
const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 10_000 })
|
|
2314
|
+
traces.startSpan('huge').end()
|
|
2315
|
+
await flushMicrotasks()
|
|
2316
|
+
await traces.flush()
|
|
2317
|
+
mockInstance._sendTracesBatch.mockClear()
|
|
2318
|
+
|
|
2319
|
+
traces.startSpan('after').end()
|
|
2320
|
+
await flushMicrotasks()
|
|
2321
|
+
|
|
2322
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalled()
|
|
2323
|
+
})
|
|
2324
|
+
})
|
|
2325
|
+
|
|
2326
|
+
describe('drop accounting', () => {
|
|
2327
|
+
it('still warns about queue-full drops while the endpoint is failing', async () => {
|
|
2328
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2329
|
+
const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2 })
|
|
2330
|
+
for (let i = 0; i < 10; i++) {
|
|
2331
|
+
traces.startSpan(`span-${i}`).end()
|
|
2332
|
+
await flushMicrotasks()
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
const warnings = logger.warn.mock.calls.map((call: any[]) => call[0])
|
|
2336
|
+
expect(warnings.join(' ')).toContain('queue is full')
|
|
2337
|
+
})
|
|
2338
|
+
|
|
2339
|
+
it('rate-limits the warning instead of one per dropped span', async () => {
|
|
2340
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2341
|
+
const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 10_000 })
|
|
2342
|
+
for (let i = 0; i < 30; i++) {
|
|
2343
|
+
traces.startSpan(`span-${i}`).end()
|
|
2344
|
+
await flushMicrotasks()
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
expect(logger.warn.mock.calls.length).toBeLessThanOrEqual(2)
|
|
2348
|
+
})
|
|
2349
|
+
|
|
2350
|
+
it('surfaces queue-full drops even when every flush pass exits early', async () => {
|
|
2351
|
+
// The retriable branch returns before the drain loop ends, so only the
|
|
2352
|
+
// pass-level `finally` can emit this warning.
|
|
2353
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2354
|
+
const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 1000 })
|
|
2355
|
+
traces.startSpan('queued-a').end()
|
|
2356
|
+
traces.startSpan('queued-b').end()
|
|
2357
|
+
await flushMicrotasks()
|
|
2358
|
+
|
|
2359
|
+
// The first drop opens the rate-limit window itself.
|
|
2360
|
+
traces.startSpan('dropped-first').end()
|
|
2361
|
+
await flushMicrotasks()
|
|
2362
|
+
logger.warn.mockClear()
|
|
2363
|
+
|
|
2364
|
+
// The second lands inside that window, so `_recordDrop` stays quiet and
|
|
2365
|
+
// only the flush pass's own `finally` can report it.
|
|
2366
|
+
traces.startSpan('dropped-second').end()
|
|
2367
|
+
await flushMicrotasks()
|
|
2368
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
2369
|
+
|
|
2370
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2371
|
+
|
|
2372
|
+
expect(logger.warn.mock.calls.map((call: any[]) => call[0]).join(' ')).toContain('queue is full')
|
|
2373
|
+
})
|
|
2374
|
+
|
|
2375
|
+
it('warns again once the flush interval has passed', async () => {
|
|
2376
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2377
|
+
const traces = createTraces({ maxExportBatchSize: 2, maxQueueSize: 2, flushIntervalMs: 1000 })
|
|
2378
|
+
for (let i = 0; i < 4; i++) {
|
|
2379
|
+
traces.startSpan(`first-${i}`).end()
|
|
2380
|
+
await flushMicrotasks()
|
|
2381
|
+
}
|
|
2382
|
+
const afterFirstWindow = logger.warn.mock.calls.length
|
|
2383
|
+
|
|
2384
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2385
|
+
traces.startSpan('later').end()
|
|
2386
|
+
await flushMicrotasks()
|
|
2387
|
+
|
|
2388
|
+
expect(afterFirstWindow).toBe(1)
|
|
2389
|
+
expect(logger.warn.mock.calls.length).toBeGreaterThan(afterFirstWindow)
|
|
2390
|
+
})
|
|
2391
|
+
|
|
2392
|
+
it('warns once per flush with the total, not the first drop', async () => {
|
|
2393
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'fatal', error: new Error('bad key') })
|
|
2394
|
+
const traces = createTraces({ maxExportBatchSize: 5, maxQueueSize: 5 })
|
|
2395
|
+
traces.startSpan('a').end()
|
|
2396
|
+
traces.startSpan('b').end()
|
|
2397
|
+
traces.startSpan('c').end()
|
|
2398
|
+
await traces.flush()
|
|
2399
|
+
|
|
2400
|
+
const warnings = logger.warn.mock.calls.map((call: any[]) => call[0])
|
|
2401
|
+
expect(warnings).toHaveLength(1)
|
|
2402
|
+
expect(warnings[0]).toContain('3 span(s)')
|
|
2403
|
+
expect(warnings[0]).toContain('rejected the batch')
|
|
2404
|
+
})
|
|
2405
|
+
})
|
|
2406
|
+
|
|
2407
|
+
describe('hostile input', () => {
|
|
2408
|
+
it('still exports when a resourceAttributes accessor throws', async () => {
|
|
2409
|
+
const hostile: Record<string, unknown> = {}
|
|
2410
|
+
Object.defineProperty(hostile, 'host.name', {
|
|
2411
|
+
enumerable: true,
|
|
2412
|
+
get() {
|
|
2413
|
+
throw new Error('accessor exploded')
|
|
2414
|
+
},
|
|
2415
|
+
})
|
|
2416
|
+
// This runs before the flush pass's own error handling, so an unguarded
|
|
2417
|
+
// read would rethrow on every flush and export nothing, ever.
|
|
2418
|
+
const traces = createTraces({ resourceAttributes: hostile as never })
|
|
2419
|
+
traces.startSpan('checkout').end()
|
|
2420
|
+
await traces.flush()
|
|
2421
|
+
|
|
2422
|
+
expect(sentSpans().map((span) => span.name)).toEqual(['checkout'])
|
|
2423
|
+
})
|
|
2424
|
+
|
|
2425
|
+
it('bounds a long resource attribute value', async () => {
|
|
2426
|
+
// Resource attributes are caller-supplied like span attributes, and they
|
|
2427
|
+
// ride on every batch rather than on one span.
|
|
2428
|
+
const traces = createTraces({
|
|
2429
|
+
maxAttributeValueLength: 4,
|
|
2430
|
+
resourceAttributes: { 'host.name': 'abcdefgh' } as never,
|
|
2431
|
+
})
|
|
2432
|
+
traces.startSpan('checkout').end()
|
|
2433
|
+
await traces.flush()
|
|
2434
|
+
|
|
2435
|
+
const resource = sentPayloads()[0].resourceSpans[0].resource!.attributes
|
|
2436
|
+
expect(resource.find((attribute) => attribute.key === 'host.name')?.value).toEqual({ stringValue: 'abcd' })
|
|
2437
|
+
})
|
|
2438
|
+
|
|
2439
|
+
it('sends service.name when a resource attribute exhausts the encoder budget', async () => {
|
|
2440
|
+
const traces = createTraces({
|
|
2441
|
+
serviceName: 'checkout-api',
|
|
2442
|
+
resourceAttributes: { payload: Array.from({ length: 10 }, () => Array(1000).fill(1)) } as never,
|
|
2443
|
+
})
|
|
2444
|
+
traces.startSpan('checkout').end()
|
|
2445
|
+
await traces.flush()
|
|
2446
|
+
|
|
2447
|
+
const resource = sentPayloads()[0].resourceSpans[0].resource!.attributes
|
|
2448
|
+
expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'checkout-api' } })
|
|
2449
|
+
expect(resource.map((attribute) => attribute.key)).toEqual(
|
|
2450
|
+
expect.arrayContaining(['telemetry.sdk.name', 'telemetry.sdk.version'])
|
|
2451
|
+
)
|
|
2452
|
+
})
|
|
2453
|
+
|
|
2454
|
+
it('does not throw on a Date-like object with no Date slot', () => {
|
|
2455
|
+
const traces = createTraces()
|
|
2456
|
+
const fakeDate = Object.create(Date.prototype)
|
|
2457
|
+
|
|
2458
|
+
expect(() => traces.startSpan('checkout', { startTime: fakeDate }).end(fakeDate)).not.toThrow()
|
|
2459
|
+
})
|
|
2460
|
+
|
|
2461
|
+
it('does not throw when the parent has a throwing accessor', () => {
|
|
2462
|
+
const traces = createTraces()
|
|
2463
|
+
const hostile = {
|
|
2464
|
+
get traceparent() {
|
|
2465
|
+
throw new Error('accessor exploded')
|
|
2466
|
+
},
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
expect(() => traces.startSpan('checkout', { parent: hostile as any })).not.toThrow()
|
|
2470
|
+
})
|
|
2471
|
+
})
|
|
2472
|
+
|
|
2473
|
+
describe('background flush triggers', () => {
|
|
2474
|
+
it('runs one background drain at a time while the queue stays saturated', async () => {
|
|
2475
|
+
const traces = createTraces({ maxExportBatchSize: 8, maxQueueSize: 64 })
|
|
2476
|
+
let live = 0
|
|
2477
|
+
let peak = 0
|
|
2478
|
+
const drain = traces.flush.bind(traces)
|
|
2479
|
+
vi.spyOn(traces, 'flush').mockImplementation(() => {
|
|
2480
|
+
live++
|
|
2481
|
+
peak = Math.max(peak, live)
|
|
2482
|
+
return drain().finally(() => {
|
|
2483
|
+
live--
|
|
2484
|
+
})
|
|
2485
|
+
})
|
|
2486
|
+
|
|
2487
|
+
for (let i = 0; i < 500; i++) {
|
|
2488
|
+
traces.startSpan(`span-${i}`).end()
|
|
2489
|
+
if (i % 50 === 0) {
|
|
2490
|
+
await flushMicrotasks()
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// A drain per span end would stack a loop per span, each retaining frames.
|
|
2495
|
+
expect(peak).toBeLessThanOrEqual(2)
|
|
2496
|
+
})
|
|
2497
|
+
})
|
|
2498
|
+
|
|
2499
|
+
describe('background flush re-arming', () => {
|
|
2500
|
+
it('leaves a timer behind for a span that ends as a drain finishes', async () => {
|
|
2501
|
+
const traces = createTraces({ maxExportBatchSize: 1, flushIntervalMs: 5000 })
|
|
2502
|
+
traces.startSpan('a').end()
|
|
2503
|
+
// Four microtasks in: the drain has returned but its finally has not run,
|
|
2504
|
+
// so the dedupe guard is still set and the queue was empty when it armed.
|
|
2505
|
+
let chain: Promise<void> = Promise.resolve()
|
|
2506
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
2507
|
+
chain = chain.then(() => undefined)
|
|
2508
|
+
}
|
|
2509
|
+
await chain.then(() => {
|
|
2510
|
+
traces.startSpan('b').end()
|
|
2511
|
+
})
|
|
2512
|
+
await flushMicrotasks()
|
|
2513
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2514
|
+
|
|
2515
|
+
expect(sentSpans().map((span) => span.name)).toEqual(['a', 'b'])
|
|
2516
|
+
})
|
|
2517
|
+
})
|
|
2518
|
+
|
|
2519
|
+
describe('retry backoff', () => {
|
|
2520
|
+
it('caps the retry delay while the endpoint keeps failing', async () => {
|
|
2521
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2522
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2523
|
+
traces.startSpan('a').end()
|
|
2524
|
+
|
|
2525
|
+
const delays: number[] = []
|
|
2526
|
+
// Seven retries: the eighth spends the batch's retry budget and drops it.
|
|
2527
|
+
for (let attempt = 0; attempt < 7; attempt++) {
|
|
2528
|
+
const before = mockInstance._sendTracesBatch.mock.calls.length
|
|
2529
|
+
let waited = 0
|
|
2530
|
+
while (mockInstance._sendTracesBatch.mock.calls.length === before && waited < 120_000) {
|
|
2531
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2532
|
+
waited += 1000
|
|
2533
|
+
}
|
|
2534
|
+
delays.push(waited)
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
expect(Math.max(...delays)).toBeLessThanOrEqual(30_000)
|
|
2538
|
+
expect(delays.slice(-2)).toEqual([30_000, 30_000])
|
|
2539
|
+
})
|
|
2540
|
+
})
|
|
2541
|
+
|
|
2542
|
+
describe('retry budget', () => {
|
|
2543
|
+
it('drops a batch the endpoint keeps refusing and moves to the next one', async () => {
|
|
2544
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2545
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2546
|
+
traces.startSpan('stuck').end()
|
|
2547
|
+
traces.startSpan('fresher').end()
|
|
2548
|
+
|
|
2549
|
+
// Eight retriable failures spend the head batch's budget.
|
|
2550
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
2551
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
const attempted = mockInstance._sendTracesBatch.mock.calls.flatMap((call: any[]) =>
|
|
2555
|
+
call[0].resourceSpans[0].scopeSpans[0].spans.map((span: OtlpSpan) => span.name)
|
|
2556
|
+
)
|
|
2557
|
+
// The stuck span is given up on, and the one behind it gets its turn.
|
|
2558
|
+
expect(attempted).toContain('fresher')
|
|
2559
|
+
expect(logger.warn.mock.calls.map((call: any[]) => call[0]).join(' ')).toContain('8 times in a row')
|
|
2560
|
+
})
|
|
2561
|
+
|
|
2562
|
+
it('does not charge fresh spans to a budget they never spent', async () => {
|
|
2563
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2564
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 512, maxQueueSize: 2048 })
|
|
2565
|
+
traces.startSpan('old').end()
|
|
2566
|
+
while (mockInstance._sendTracesBatch.mock.calls.length < 7) {
|
|
2567
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2568
|
+
}
|
|
2569
|
+
for (let i = 0; i < 20; i++) {
|
|
2570
|
+
traces.startSpan(`fresh-${i}`).end()
|
|
2571
|
+
}
|
|
2572
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2573
|
+
|
|
2574
|
+
const eighth = mockInstance._sendTracesBatch.mock.calls[7][0] as OtlpTracesPayload
|
|
2575
|
+
// The head cannot grow to sweep in spans that have never been retried.
|
|
2576
|
+
expect(eighth.resourceSpans[0].scopeSpans[0].spans.map((span) => span.name)).toEqual(['old'])
|
|
2577
|
+
})
|
|
2578
|
+
|
|
2579
|
+
it('gives the halved batch its own budget after a 413', async () => {
|
|
2580
|
+
let attempt = 0
|
|
2581
|
+
const attempted: string[][] = []
|
|
2582
|
+
mockInstance._sendTracesBatch.mockImplementation(async (payload: OtlpTracesPayload) => {
|
|
2583
|
+
attempted.push(payload.resourceSpans[0].scopeSpans[0].spans.map((span) => span.name))
|
|
2584
|
+
attempt++
|
|
2585
|
+
if (attempt <= 7) {
|
|
2586
|
+
return { kind: 'retry-later', error: new Error('down') }
|
|
2587
|
+
}
|
|
2588
|
+
// The 413 replaces the head batch, so its failure count must not carry over.
|
|
2589
|
+
return attempt === 8 ? { kind: 'too-large' } : { kind: 'retry-later', error: new Error('down') }
|
|
2590
|
+
})
|
|
2591
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 4, maxQueueSize: 32 })
|
|
2592
|
+
for (let i = 0; i < 8; i++) {
|
|
2593
|
+
traces.startSpan(`s${i}`).end()
|
|
2594
|
+
}
|
|
2595
|
+
for (let tick = 0; tick < 6; tick++) {
|
|
2596
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
const halved = attempted.filter((names) => names.join() === 's0,s1')
|
|
2600
|
+
expect(halved.length).toBeGreaterThan(1)
|
|
2601
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
2602
|
+
})
|
|
2603
|
+
|
|
2604
|
+
it('gives a later batch its full budget after a success', async () => {
|
|
2605
|
+
let attempt = 0
|
|
2606
|
+
// Four failures, a success, then four more: seven consecutive failures
|
|
2607
|
+
// would spend the budget, but the success in between must reset it.
|
|
2608
|
+
const succeedsOn = [5, 10]
|
|
2609
|
+
mockInstance._sendTracesBatch.mockImplementation(async () => {
|
|
2610
|
+
attempt++
|
|
2611
|
+
return succeedsOn.includes(attempt) ? { kind: 'ok' } : { kind: 'retry-later', error: new Error('blip') }
|
|
2612
|
+
})
|
|
2613
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2614
|
+
traces.startSpan('a').end()
|
|
2615
|
+
traces.startSpan('b').end()
|
|
2616
|
+
for (let tick = 0; tick < 12; tick++) {
|
|
2617
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
expect([...new Set(sentSpans().map((span) => span.name))]).toEqual(['a', 'b'])
|
|
2621
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
2622
|
+
})
|
|
2623
|
+
})
|
|
2624
|
+
|
|
2625
|
+
describe('drain progress', () => {
|
|
2626
|
+
it('drains a span that arrives while a send is in flight', async () => {
|
|
2627
|
+
// Queue length can't measure progress: one span out and one in leaves it
|
|
2628
|
+
// unchanged, which would read as "no progress" and strand the new span —
|
|
2629
|
+
// and shutdown() then discards it.
|
|
2630
|
+
let onSend = (): void => {}
|
|
2631
|
+
const instance = createMockInstance({
|
|
2632
|
+
_sendTracesBatch: vi.fn(() => {
|
|
2633
|
+
onSend()
|
|
2634
|
+
onSend = (): void => {}
|
|
2635
|
+
return Promise.resolve({ kind: 'ok' as const })
|
|
2636
|
+
}),
|
|
2637
|
+
})
|
|
2638
|
+
const traces = createTraces({ maxExportBatchSize: 10 }, instance)
|
|
2639
|
+
onSend = (): void => traces.startSpan('arrived-mid-flight').end()
|
|
2640
|
+
|
|
2641
|
+
traces.startSpan('first').end()
|
|
2642
|
+
await traces.flush()
|
|
2643
|
+
|
|
2644
|
+
expect(sentSpans(instance).map((s) => s.name)).toEqual(['first', 'arrived-mid-flight'])
|
|
2645
|
+
})
|
|
2646
|
+
|
|
2647
|
+
it('terminates rather than spinning when a batch size of zero slips through', async () => {
|
|
2648
|
+
// Core must not depend on every host clamping its config.
|
|
2649
|
+
const traces = createTraces({ maxExportBatchSize: 0 })
|
|
2650
|
+
traces.startSpan('a').end()
|
|
2651
|
+
|
|
2652
|
+
await traces.flush()
|
|
2653
|
+
|
|
2654
|
+
expect(sentSpans()).toHaveLength(1)
|
|
2655
|
+
})
|
|
2656
|
+
})
|
|
2657
|
+
|
|
2658
|
+
describe('Retry-After', () => {
|
|
2659
|
+
it('waits at least as long as the endpoint asked', async () => {
|
|
2660
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2661
|
+
kind: 'retry-later',
|
|
2662
|
+
error: new Error('throttled'),
|
|
2663
|
+
retryAfterMs: 90_000,
|
|
2664
|
+
})
|
|
2665
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2666
|
+
traces.startSpan('held').end()
|
|
2667
|
+
|
|
2668
|
+
// One attempt, then a wait longer than the 30s exponential cap would give.
|
|
2669
|
+
while (mockInstance._sendTracesBatch.mock.calls.length < 1) {
|
|
2670
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2671
|
+
}
|
|
2672
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
2673
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2674
|
+
|
|
2675
|
+
await vi.advanceTimersByTimeAsync(31_000)
|
|
2676
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2677
|
+
})
|
|
2678
|
+
|
|
2679
|
+
it('keeps its own backoff when the endpoint asks for less', async () => {
|
|
2680
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2681
|
+
kind: 'retry-later',
|
|
2682
|
+
error: new Error('down'),
|
|
2683
|
+
retryAfterMs: 10,
|
|
2684
|
+
})
|
|
2685
|
+
const traces = createTraces({ flushIntervalMs: 5000, maxExportBatchSize: 1 })
|
|
2686
|
+
traces.startSpan('held').end()
|
|
2687
|
+
|
|
2688
|
+
while (mockInstance._sendTracesBatch.mock.calls.length < 1) {
|
|
2689
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2690
|
+
}
|
|
2691
|
+
// A 10ms Retry-After must not turn the retry loop into a hot loop: the
|
|
2692
|
+
// next attempt still waits out the queue's own backoff, not 10ms.
|
|
2693
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2694
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2695
|
+
|
|
2696
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
2697
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2698
|
+
})
|
|
2699
|
+
|
|
2700
|
+
it('waits out Retry-After even when a span ends mid-flush', async () => {
|
|
2701
|
+
// The span that ends while the send is in flight arms a timer at the plain
|
|
2702
|
+
// interval; the 429 then asks for far longer. The earlier timer must not
|
|
2703
|
+
// fire first, or the SDK sends inside the window it was told to skip.
|
|
2704
|
+
mockInstance._sendTracesBatch.mockImplementation(() => {
|
|
2705
|
+
traces.startSpan('arrived mid-flush').end()
|
|
2706
|
+
return Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
|
|
2707
|
+
})
|
|
2708
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
2709
|
+
traces.startSpan('first').end()
|
|
2710
|
+
|
|
2711
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2712
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2713
|
+
|
|
2714
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
2715
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2716
|
+
})
|
|
2717
|
+
|
|
2718
|
+
it('clears Retry-After on an outcome that is not a retry', async () => {
|
|
2719
|
+
// Only a retriable outcome carries a wait. A stale one left set here would
|
|
2720
|
+
// pin every later flush at the server's old window.
|
|
2721
|
+
const outcomes: SendTracesBatchOutcome[] = [
|
|
2722
|
+
{ kind: 'retry-later', error: new Error('throttled'), retryAfterMs: 300_000 },
|
|
2723
|
+
{ kind: 'fatal', error: new Error('400') },
|
|
2724
|
+
]
|
|
2725
|
+
mockInstance._sendTracesBatch.mockImplementation(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' }))
|
|
2726
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2727
|
+
traces.startSpan('first').end()
|
|
2728
|
+
|
|
2729
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2730
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2731
|
+
|
|
2732
|
+
// The wait elapses, the retry lands a 400, and that ends the wait.
|
|
2733
|
+
await vi.advanceTimersByTimeAsync(300_000)
|
|
2734
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2735
|
+
|
|
2736
|
+
traces.startSpan('second').end()
|
|
2737
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
2738
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(3)
|
|
2739
|
+
})
|
|
2740
|
+
|
|
2741
|
+
it('stops honouring a stale Retry-After after a success', async () => {
|
|
2742
|
+
let attempt = 0
|
|
2743
|
+
mockInstance._sendTracesBatch.mockImplementation(() => {
|
|
2744
|
+
attempt++
|
|
2745
|
+
return Promise.resolve(
|
|
2746
|
+
attempt === 1 ? { kind: 'retry-later', error: new Error('throttled'), retryAfterMs: 120_000 } : { kind: 'ok' }
|
|
2747
|
+
)
|
|
2748
|
+
})
|
|
2749
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
2750
|
+
traces.startSpan('first').end()
|
|
2751
|
+
await vi.advanceTimersByTimeAsync(200_000)
|
|
2752
|
+
|
|
2753
|
+
traces.startSpan('second').end()
|
|
2754
|
+
await traces.flush()
|
|
2755
|
+
expect(sentSpans().map((s) => s.name)).toContain('second')
|
|
2756
|
+
|
|
2757
|
+
// The wait is gone, so a later failure falls back to the plain interval.
|
|
2758
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('down') })
|
|
2759
|
+
traces.startSpan('third').end()
|
|
2760
|
+
const before = mockInstance._sendTracesBatch.mock.calls.length
|
|
2761
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
2762
|
+
expect(mockInstance._sendTracesBatch.mock.calls.length).toBeGreaterThan(before)
|
|
2763
|
+
})
|
|
2764
|
+
|
|
2765
|
+
it('still drains on an explicit flush inside the window', async () => {
|
|
2766
|
+
// An explicit flush is a lifecycle or teardown boundary with no later
|
|
2767
|
+
// attempt — on a serverless host the retry timer is unref'd and dies with
|
|
2768
|
+
// the isolate — so the window must not turn it into a no-op.
|
|
2769
|
+
const outcomes: SendTracesBatchOutcome[] = [
|
|
2770
|
+
{ kind: 'retry-later', error: new Error('throttled'), retryAfterMs: 300_000 },
|
|
2771
|
+
]
|
|
2772
|
+
mockInstance._sendTracesBatch.mockImplementation(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' }))
|
|
2773
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2774
|
+
traces.startSpan('first').end()
|
|
2775
|
+
|
|
2776
|
+
await traces.flush()
|
|
2777
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
2778
|
+
|
|
2779
|
+
// The endpoint has recovered, and the caller asked explicitly.
|
|
2780
|
+
await traces.flush()
|
|
2781
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
2782
|
+
expect(sentSpans().map((s) => s.name)).toContain('first')
|
|
2783
|
+
})
|
|
2784
|
+
|
|
2785
|
+
it('does not spend the head batch retry budget inside the window', async () => {
|
|
2786
|
+
// The batch is dropped after MAX_RETRIES_PER_BATCH consecutive failures.
|
|
2787
|
+
// At a host's flush cadence that budget would burn out long before the
|
|
2788
|
+
// window the endpoint asked for, losing the spans with it.
|
|
2789
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2790
|
+
kind: 'retry-later',
|
|
2791
|
+
error: new Error('throttled'),
|
|
2792
|
+
retryAfterMs: 300_000,
|
|
2793
|
+
})
|
|
2794
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2795
|
+
traces.startSpan('first').end()
|
|
2796
|
+
|
|
2797
|
+
for (let i = 0; i < 12; i++) {
|
|
2798
|
+
await traces.flush()
|
|
2799
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Dropping'))
|
|
2803
|
+
expect(mockInstance._sendTracesBatch.mock.calls.length).toBeGreaterThan(1)
|
|
2804
|
+
|
|
2805
|
+
// The budget is deferred, not disabled. Once the window elapses the batch
|
|
2806
|
+
// must retire — a deadline that slid forward on each refusal would keep
|
|
2807
|
+
// the window open forever and strand everything behind the head batch.
|
|
2808
|
+
for (let i = 0; i < 12; i++) {
|
|
2809
|
+
await vi.advanceTimersByTimeAsync(310_000)
|
|
2810
|
+
await traces.flush()
|
|
2811
|
+
}
|
|
2812
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('failed 8 times in a row'))
|
|
2813
|
+
})
|
|
2814
|
+
|
|
2815
|
+
it('charges the budget for a timer-path failure after the wait was served out', async () => {
|
|
2816
|
+
// The exemption is narrow: an attempt made *early*, inside a window that
|
|
2817
|
+
// has not elapsed. A retry the timer fired at the deadline has served the
|
|
2818
|
+
// wait, so it must count — otherwise the budget never advances and a
|
|
2819
|
+
// permanently refused head batch pins everything behind it.
|
|
2820
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2821
|
+
kind: 'retry-later',
|
|
2822
|
+
error: new Error('429'),
|
|
2823
|
+
retryAfterMs: 60_000,
|
|
2824
|
+
})
|
|
2825
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
2826
|
+
traces.startSpan('first').end()
|
|
2827
|
+
|
|
2828
|
+
for (let i = 0; i < 12; i++) {
|
|
2829
|
+
await vi.advanceTimersByTimeAsync(61_000)
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('failed 8 times in a row'))
|
|
2833
|
+
})
|
|
2834
|
+
|
|
2835
|
+
it('does not let a host out-pacing the window stall the retry budget', async () => {
|
|
2836
|
+
// flush() more often than the window is long. Each refusal slides the
|
|
2837
|
+
// deadline, up to five minutes from where the window was installed; the
|
|
2838
|
+
// in-window sends are uncharged, so each window costs one charge and eight
|
|
2839
|
+
// of them retire the head batch rather than letting it hold the queue.
|
|
2840
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2841
|
+
kind: 'retry-later',
|
|
2842
|
+
error: new Error('429'),
|
|
2843
|
+
retryAfterMs: 30_000,
|
|
2844
|
+
})
|
|
2845
|
+
const traces = createTraces({ flushIntervalMs: 10_000, maxQueueSize: 5, maxExportBatchSize: 2 })
|
|
2846
|
+
|
|
2847
|
+
for (let i = 0; i < 500; i++) {
|
|
2848
|
+
traces.startSpan(`span-${i}`).end()
|
|
2849
|
+
await traces.flush()
|
|
2850
|
+
await vi.advanceTimersByTimeAsync(5_000)
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('failed 8 times in a row'))
|
|
2854
|
+
})
|
|
2855
|
+
|
|
2856
|
+
it('still charges the retry budget for failures outside any window', async () => {
|
|
2857
|
+
// The budget must keep working when the endpoint names no wait, or a
|
|
2858
|
+
// permanently failing head batch pins the queue forever. It takes the
|
|
2859
|
+
// eight backoff windows the timer would have waited — roughly three
|
|
2860
|
+
// minutes at this interval — however often the caller drains in between.
|
|
2861
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('503') })
|
|
2862
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2863
|
+
traces.startSpan('first').end()
|
|
2864
|
+
|
|
2865
|
+
for (let i = 0; i < 25; i++) {
|
|
2866
|
+
await traces.flush()
|
|
2867
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Dropping'))
|
|
2871
|
+
})
|
|
2872
|
+
|
|
2873
|
+
it('does not spend the retry budget on a caller draining faster than the backoff', async () => {
|
|
2874
|
+
// A serverless host calls `flush()` per invocation. Charging every refusal
|
|
2875
|
+
// retired the batch in eight calls and no elapsed time at all, so a blip
|
|
2876
|
+
// the timer would have ridden out cost the spans instead of a request.
|
|
2877
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('503') })
|
|
2878
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2879
|
+
traces.startSpan('first').end()
|
|
2880
|
+
|
|
2881
|
+
for (let i = 0; i < 20; i++) {
|
|
2882
|
+
await traces.flush()
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
expect((traces as any)._queue).toHaveLength(1)
|
|
2886
|
+
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Dropping'))
|
|
2887
|
+
})
|
|
2888
|
+
|
|
2889
|
+
it('does not charge a caller-driven flush inside a window a later refusal extended', async () => {
|
|
2890
|
+
// The charge point is set from the window as it stood at the first
|
|
2891
|
+
// refusal. An in-window refusal then extends the window past it, and a
|
|
2892
|
+
// flush between the two deadlines is still inside the endpoint's wait.
|
|
2893
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2894
|
+
kind: 'retry-later',
|
|
2895
|
+
error: new Error('429'),
|
|
2896
|
+
retryAfterMs: 60_000,
|
|
2897
|
+
})
|
|
2898
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2899
|
+
traces.startSpan('first').end()
|
|
2900
|
+
|
|
2901
|
+
await traces.flush()
|
|
2902
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2903
|
+
|
|
2904
|
+
await vi.advanceTimersByTimeAsync(50_000)
|
|
2905
|
+
await traces.flush()
|
|
2906
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
2907
|
+
const sends = mockInstance._sendTracesBatch.mock.calls.length
|
|
2908
|
+
await traces.flush()
|
|
2909
|
+
|
|
2910
|
+
expect(mockInstance._sendTracesBatch.mock.calls.length).toBe(sends + 1)
|
|
2911
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2912
|
+
})
|
|
2913
|
+
|
|
2914
|
+
it('charges once per window however many attempts share it', async () => {
|
|
2915
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('503') })
|
|
2916
|
+
const traces = createTraces({ flushIntervalMs: 10_000 })
|
|
2917
|
+
traces.startSpan('first').end()
|
|
2918
|
+
|
|
2919
|
+
await traces.flush()
|
|
2920
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2921
|
+
|
|
2922
|
+
await traces.flush()
|
|
2923
|
+
await traces.flush()
|
|
2924
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2925
|
+
|
|
2926
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
2927
|
+
await traces.flush()
|
|
2928
|
+
expect((traces as any)._headBatchFailures).toBe(2)
|
|
2929
|
+
})
|
|
2930
|
+
|
|
2931
|
+
it('gives a fresh batch a fresh budget after an opt-out cleared the queue', async () => {
|
|
2932
|
+
// The head batch leaves with the queue, so its budget must leave too:
|
|
2933
|
+
// otherwise the next batch inherits a spent retry count and a charge
|
|
2934
|
+
// deadline still in the future, and its first refusal goes uncharged.
|
|
2935
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('503') })
|
|
2936
|
+
const traces = createTraces({ flushIntervalMs: 10_000, maxExportBatchSize: 1 })
|
|
2937
|
+
traces.startSpan('before').end()
|
|
2938
|
+
await traces.flush()
|
|
2939
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2940
|
+
|
|
2941
|
+
mockInstance.optedOut = true
|
|
2942
|
+
await traces.flush()
|
|
2943
|
+
|
|
2944
|
+
// Asserted here rather than through a later refusal: the charge deadline
|
|
2945
|
+
// the first failure installed is still in the future either way, so the
|
|
2946
|
+
// next refusal is uncharged and the count reads 1 with or without the reset.
|
|
2947
|
+
expect((traces as any)._headBatchFailures).toBe(0)
|
|
2948
|
+
expect((traces as any)._headBatchChargeableAt).toBe(0)
|
|
2949
|
+
})
|
|
2950
|
+
|
|
2951
|
+
it('gives a fresh batch a fresh window after the head is retired', async () => {
|
|
2952
|
+
// The deadline belongs to the batch, not the queue: a new head must be
|
|
2953
|
+
// chargeable straight away or it inherits the last one's served wait.
|
|
2954
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'fatal', error: new Error('400') })
|
|
2955
|
+
const traces = createTraces({ flushIntervalMs: 10_000, maxExportBatchSize: 1 })
|
|
2956
|
+
traces.startSpan('first').end()
|
|
2957
|
+
await traces.flush()
|
|
2958
|
+
|
|
2959
|
+
mockInstance._sendTracesBatch.mockResolvedValue({ kind: 'retry-later', error: new Error('503') })
|
|
2960
|
+
traces.startSpan('second').end()
|
|
2961
|
+
await traces.flush()
|
|
2962
|
+
|
|
2963
|
+
expect((traces as any)._headBatchFailures).toBe(1)
|
|
2964
|
+
})
|
|
2965
|
+
|
|
2966
|
+
it('does not let a backward clock step hold the retry budget open', async () => {
|
|
2967
|
+
// The deadline is wall clock. An NTP correction or a resumed VM would
|
|
2968
|
+
// otherwise keep the window "open" for the size of the step — and with
|
|
2969
|
+
// in-window refusals uncharged, the head batch would never retire.
|
|
2970
|
+
mockInstance._sendTracesBatch.mockResolvedValue({
|
|
2971
|
+
kind: 'retry-later',
|
|
2972
|
+
error: new Error('429'),
|
|
2973
|
+
retryAfterMs: 60_000,
|
|
2974
|
+
})
|
|
2975
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
2976
|
+
traces.startSpan('first').end()
|
|
2977
|
+
await traces.flush()
|
|
2978
|
+
|
|
2979
|
+
// Step the clock back an hour and let simulated time run from there.
|
|
2980
|
+
vi.setSystemTime(Date.now() - 3_600_000)
|
|
2981
|
+
|
|
2982
|
+
// Asserted on the queue, not the drop warning: `_recordDrop` paces that
|
|
2983
|
+
// warning off the same clock and would suppress it here.
|
|
2984
|
+
for (let i = 0; i < 12; i++) {
|
|
2985
|
+
await traces.flush()
|
|
2986
|
+
await vi.advanceTimersByTimeAsync(61_000)
|
|
2987
|
+
}
|
|
2988
|
+
expect((traces as any)._queue).toHaveLength(0)
|
|
2989
|
+
})
|
|
2990
|
+
|
|
2991
|
+
it('keeps the Retry-After window when a batch is refused for size', async () => {
|
|
2992
|
+
// `too-large` is a verdict on the body's size — the SDK's own or a 413 —
|
|
2993
|
+
// so it says nothing about the endpoint's rate limit and must not end the
|
|
2994
|
+
// wait.
|
|
2995
|
+
let first = true
|
|
2996
|
+
mockInstance._sendTracesBatch.mockImplementation(() => {
|
|
2997
|
+
const outcome: SendTracesBatchOutcome = first
|
|
2998
|
+
? { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 }
|
|
2999
|
+
: { kind: 'too-large' }
|
|
3000
|
+
first = false
|
|
3001
|
+
return Promise.resolve(outcome)
|
|
3002
|
+
})
|
|
3003
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
3004
|
+
traces.startSpan('first').end()
|
|
3005
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
3006
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
3007
|
+
|
|
3008
|
+
// Batches of one the endpoint cannot accept: the spans are dropped.
|
|
3009
|
+
traces.startSpan('second').end()
|
|
3010
|
+
await traces.flush()
|
|
3011
|
+
const sends = mockInstance._sendTracesBatch.mock.calls.length
|
|
3012
|
+
|
|
3013
|
+
traces.startSpan('third').end()
|
|
3014
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
3015
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(sends)
|
|
3016
|
+
})
|
|
3017
|
+
|
|
3018
|
+
it('stops the drain when it retires a batch inside the window', async () => {
|
|
3019
|
+
// Retiring the head batch clears the failure counters, so the drain would
|
|
3020
|
+
// otherwise carry straight on to the next batch — inside the wait the very
|
|
3021
|
+
// refusal that retired it had just installed. One extra send still lands at
|
|
3022
|
+
// that instant: `flush()`'s outer loop is deliberately not window-aware.
|
|
3023
|
+
const sentAt: number[] = []
|
|
3024
|
+
mockInstance._sendTracesBatch.mockImplementation(() => {
|
|
3025
|
+
sentAt.push(Date.now())
|
|
3026
|
+
return Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 60_000 })
|
|
3027
|
+
})
|
|
3028
|
+
const traces = createTraces({ flushIntervalMs: 1000, maxExportBatchSize: 1 })
|
|
3029
|
+
for (let i = 0; i < 4; i++) {
|
|
3030
|
+
traces.startSpan(`span-${i}`).end()
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// Every attempt lands after the previous window elapsed, so every refusal
|
|
3034
|
+
// is charged and the eighth retires the head batch.
|
|
3035
|
+
for (let i = 0; i < 12; i++) {
|
|
3036
|
+
await vi.advanceTimersByTimeAsync(61_000)
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
const perInstant = new Map<number, number>()
|
|
3040
|
+
for (const at of sentAt) {
|
|
3041
|
+
perInstant.set(at, (perInstant.get(at) ?? 0) + 1)
|
|
3042
|
+
}
|
|
3043
|
+
expect(Math.max(...perInstant.values())).toBe(2)
|
|
3044
|
+
})
|
|
3045
|
+
|
|
3046
|
+
it('does not install a Retry-After that lands after reset', async () => {
|
|
3047
|
+
// The generation check must precede the window assignment, or a 429 that
|
|
3048
|
+
// settles after teardown pins a wait on the fresh client.
|
|
3049
|
+
let settle: ((outcome: SendTracesBatchOutcome) => void) | undefined
|
|
3050
|
+
mockInstance._sendTracesBatch.mockImplementation(
|
|
3051
|
+
() =>
|
|
3052
|
+
new Promise((resolve) => {
|
|
3053
|
+
settle = resolve
|
|
3054
|
+
})
|
|
3055
|
+
)
|
|
3056
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
3057
|
+
traces.startSpan('before-reset').end()
|
|
3058
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
3059
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
3060
|
+
|
|
3061
|
+
traces.reset()
|
|
3062
|
+
traces.startSpan('after-reset').end()
|
|
3063
|
+
|
|
3064
|
+
settle?.({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
|
|
3065
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
3066
|
+
|
|
3067
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
3068
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
3069
|
+
})
|
|
3070
|
+
|
|
3071
|
+
it('drops a Retry-After wait on reset', async () => {
|
|
3072
|
+
const outcomes: SendTracesBatchOutcome[] = [
|
|
3073
|
+
{ kind: 'retry-later', error: new Error('throttled'), retryAfterMs: 300_000 },
|
|
3074
|
+
]
|
|
3075
|
+
mockInstance._sendTracesBatch.mockImplementation(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' }))
|
|
3076
|
+
const traces = createTraces({ flushIntervalMs: 1000 })
|
|
3077
|
+
traces.startSpan('first').end()
|
|
3078
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
3079
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(1)
|
|
3080
|
+
|
|
3081
|
+
traces.reset()
|
|
3082
|
+
traces.startSpan('second').end()
|
|
3083
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
3084
|
+
expect(mockInstance._sendTracesBatch).toHaveBeenCalledTimes(2)
|
|
3085
|
+
})
|
|
3086
|
+
})
|
|
3087
|
+
|
|
3088
|
+
describe('live span bounds', () => {
|
|
3089
|
+
it('returns an inert handle once maxLiveSpans spans are live', async () => {
|
|
3090
|
+
const traces = createTraces({ maxLiveSpans: 2 })
|
|
3091
|
+
|
|
3092
|
+
traces.startSpan('live-a')
|
|
3093
|
+
traces.startSpan('live-b')
|
|
3094
|
+
const refused = traces.startSpan('refused')
|
|
3095
|
+
refused.end()
|
|
3096
|
+
await traces.flush()
|
|
3097
|
+
|
|
3098
|
+
expect(sentSpans()).toHaveLength(0)
|
|
3099
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('the live-span limit (2) was reached'))
|
|
3100
|
+
})
|
|
3101
|
+
|
|
3102
|
+
it('frees the slot when a span ends', async () => {
|
|
3103
|
+
const traces = createTraces({ maxLiveSpans: 1 })
|
|
3104
|
+
|
|
3105
|
+
traces.startSpan('first').end()
|
|
3106
|
+
traces.startSpan('second').end()
|
|
3107
|
+
await traces.flush()
|
|
3108
|
+
|
|
3109
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['first', 'second'])
|
|
3110
|
+
})
|
|
3111
|
+
|
|
3112
|
+
it('never exports a span evicted for exceeding maxSpanAgeMs', async () => {
|
|
3113
|
+
const traces = createTraces({ maxSpanAgeMs: 60_000 })
|
|
3114
|
+
const leaked = traces.startSpan('leaked')
|
|
3115
|
+
|
|
3116
|
+
await vi.advanceTimersByTimeAsync(61_000)
|
|
3117
|
+
// Eviction is lazy: the next startSpan sweeps.
|
|
3118
|
+
traces.startSpan('later').end()
|
|
3119
|
+
leaked.end()
|
|
3120
|
+
await traces.flush()
|
|
3121
|
+
|
|
3122
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['later'])
|
|
3123
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('still live after 60000ms'))
|
|
3124
|
+
})
|
|
3125
|
+
|
|
3126
|
+
it('returns the slot on age eviction so a leak cannot disable tracing', async () => {
|
|
3127
|
+
const traces = createTraces({ maxLiveSpans: 1, maxSpanAgeMs: 60_000 })
|
|
3128
|
+
traces.startSpan('leaked-forever')
|
|
3129
|
+
|
|
3130
|
+
await vi.advanceTimersByTimeAsync(61_000)
|
|
3131
|
+
traces.startSpan('after-the-leak').end()
|
|
3132
|
+
await traces.flush()
|
|
3133
|
+
|
|
3134
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['after-the-leak'])
|
|
3135
|
+
})
|
|
3136
|
+
|
|
3137
|
+
it('ages from startSpan, not from a caller-supplied startTime', async () => {
|
|
3138
|
+
const traces = createTraces({ maxSpanAgeMs: 60_000 })
|
|
3139
|
+
// Backdated an hour: aging off the supplied time would evict it immediately.
|
|
3140
|
+
const backdated = traces.startSpan('backdated', { startTime: Date.now() - 3_600_000 })
|
|
3141
|
+
|
|
3142
|
+
traces.startSpan('sweep-trigger').end()
|
|
3143
|
+
backdated.end()
|
|
3144
|
+
await traces.flush()
|
|
3145
|
+
|
|
3146
|
+
expect(sentSpans().map((s) => s.name)).toEqual(['sweep-trigger', 'backdated'])
|
|
3147
|
+
})
|
|
3148
|
+
})
|
|
3149
|
+
|
|
3150
|
+
describe('reset', () => {
|
|
3151
|
+
it('abandons an in-flight pass instead of splicing spans it never sent', async () => {
|
|
3152
|
+
let release!: (outcome: SendTracesBatchOutcome) => void
|
|
3153
|
+
const instance = createMockInstance({
|
|
3154
|
+
_sendTracesBatch: vi.fn(
|
|
3155
|
+
() =>
|
|
3156
|
+
new Promise<SendTracesBatchOutcome>((resolve) => {
|
|
3157
|
+
release = resolve
|
|
3158
|
+
})
|
|
3159
|
+
),
|
|
3160
|
+
})
|
|
3161
|
+
const traces = createTraces({ maxExportBatchSize: 10 }, instance)
|
|
3162
|
+
|
|
3163
|
+
traces.startSpan('sent-a').end()
|
|
3164
|
+
traces.startSpan('sent-b').end()
|
|
3165
|
+
const inFlight = traces.flush()
|
|
3166
|
+
await Promise.resolve()
|
|
3167
|
+
|
|
3168
|
+
// shutdown() lost the race and tore the pipeline down.
|
|
3169
|
+
traces.reset()
|
|
3170
|
+
traces.startSpan('after-reset').end()
|
|
3171
|
+
|
|
3172
|
+
release({ kind: 'ok' })
|
|
3173
|
+
await inFlight
|
|
3174
|
+
|
|
3175
|
+
expect((traces as any)._queue.map((r: any) => r.name)).toEqual(['after-reset'])
|
|
3176
|
+
})
|
|
3177
|
+
|
|
3178
|
+
it('clears the queue', async () => {
|
|
3179
|
+
const traces = createTraces()
|
|
3180
|
+
traces.startSpan('a').end()
|
|
3181
|
+
traces.reset()
|
|
3182
|
+
await traces.flush()
|
|
3183
|
+
|
|
3184
|
+
expect(mockInstance._sendTracesBatch).not.toHaveBeenCalled()
|
|
3185
|
+
})
|
|
3186
|
+
})
|
|
3187
|
+
})
|