@zuplo/cli 7.7.8 → 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/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/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-DRQ5CMVC.js → chunk-KNAYKBEE.js} +80 -80
- 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-DRQ5CMVC.js.map +0 -1
- /package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js.LEGAL.txt → chunk-KNAYKBEE.js.LEGAL.txt} +0 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { NOOP_SPAN, PostHogSpan, describeError, truncateAttributeValue } from './span'
|
|
2
|
+
import { buildOtlpSpan } from './otlp'
|
|
3
|
+
import { resolveTracesConfig } from './config'
|
|
4
|
+
import type { SpanInit } from './span'
|
|
5
|
+
import type { SpanRecord } from './types'
|
|
6
|
+
import type { Logger } from '../types'
|
|
7
|
+
import { createMockLogger } from '@/testing'
|
|
8
|
+
import { MAX_JSON_SAFE_VALUE_ITEMS, MAX_JSON_SAFE_VALUE_NODES } from '../utils/json-utils'
|
|
9
|
+
|
|
10
|
+
const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736'
|
|
11
|
+
const SPAN_ID = '00f067aa0ba902b7'
|
|
12
|
+
|
|
13
|
+
describe('PostHogSpan', () => {
|
|
14
|
+
let ended: SpanRecord[]
|
|
15
|
+
let logger: Logger
|
|
16
|
+
|
|
17
|
+
const createSpan = (init: Partial<SpanInit> = {}): PostHogSpan =>
|
|
18
|
+
new PostHogSpan(
|
|
19
|
+
{
|
|
20
|
+
traceId: TRACE_ID,
|
|
21
|
+
spanId: SPAN_ID,
|
|
22
|
+
name: 'checkout',
|
|
23
|
+
kind: 'internal',
|
|
24
|
+
attributes: {},
|
|
25
|
+
startTime: Date.now(),
|
|
26
|
+
backdated: false,
|
|
27
|
+
autoAttributeKeys: [],
|
|
28
|
+
maxAttributes: 128,
|
|
29
|
+
maxEvents: 128,
|
|
30
|
+
maxAttributesPerEvent: 128,
|
|
31
|
+
maxAttributeValueLength: 8192,
|
|
32
|
+
...init,
|
|
33
|
+
},
|
|
34
|
+
(record) => ended.push(record),
|
|
35
|
+
logger
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
ended = []
|
|
40
|
+
logger = createMockLogger()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('monotonic clock', () => {
|
|
44
|
+
const withMonotonic = (readings: number[], run: () => void): void => {
|
|
45
|
+
const original = (globalThis as any).performance
|
|
46
|
+
let index = 0
|
|
47
|
+
;(globalThis as any).performance = { now: () => readings[Math.min(index++, readings.length - 1)] }
|
|
48
|
+
try {
|
|
49
|
+
run()
|
|
50
|
+
} finally {
|
|
51
|
+
;(globalThis as any).performance = original
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('measures duration against the monotonic reading, not the wall clock', () => {
|
|
56
|
+
const start = Date.now()
|
|
57
|
+
withMonotonic([1000, 1025], () => {
|
|
58
|
+
const span = createSpan({ startTime: start })
|
|
59
|
+
vi.spyOn(Date, 'now').mockReturnValue(start - 60_000)
|
|
60
|
+
span.end()
|
|
61
|
+
})
|
|
62
|
+
vi.spyOn(Date, 'now').mockRestore()
|
|
63
|
+
|
|
64
|
+
expect(ended[0].endTime - ended[0].startTime).toBe(25)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('never reports a negative duration when the monotonic source goes backwards', () => {
|
|
68
|
+
const start = Date.now()
|
|
69
|
+
withMonotonic([1000, 900], () => {
|
|
70
|
+
createSpan({ startTime: start }).end()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(ended[0].endTime).toBe(ended[0].startTime)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('places an event inside the span window', () => {
|
|
77
|
+
const start = Date.now()
|
|
78
|
+
withMonotonic([1000, 1010, 1040], () => {
|
|
79
|
+
const span = createSpan({ startTime: start })
|
|
80
|
+
span.addEvent('cache-miss')
|
|
81
|
+
span.end()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const [record] = ended
|
|
85
|
+
expect(record.events[0].timestamp).toBeGreaterThanOrEqual(record.startTime)
|
|
86
|
+
expect(record.events[0].timestamp).toBeLessThanOrEqual(record.endTime)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses the wall clock for a backdated span', () => {
|
|
90
|
+
withMonotonic([1000, 9999], () => {
|
|
91
|
+
createSpan({ startTime: Date.now() - 5000, backdated: true }).end()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(ended[0].endTime - ended[0].startTime).toBeGreaterThanOrEqual(5000)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('produces exactly one record on end', () => {
|
|
99
|
+
createSpan().end()
|
|
100
|
+
expect(ended).toHaveLength(1)
|
|
101
|
+
expect(ended[0].name).toBe('checkout')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('is idempotent on end', () => {
|
|
105
|
+
const span = createSpan()
|
|
106
|
+
span.end()
|
|
107
|
+
span.end()
|
|
108
|
+
expect(ended).toHaveLength(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('ignores operations after end', () => {
|
|
112
|
+
const span = createSpan()
|
|
113
|
+
span.end()
|
|
114
|
+
span.setAttribute('k', 'v')
|
|
115
|
+
span.updateName('renamed')
|
|
116
|
+
span.addEvent('late')
|
|
117
|
+
|
|
118
|
+
expect(ended[0].attributes).not.toHaveProperty('k')
|
|
119
|
+
expect(ended[0].name).toBe('checkout')
|
|
120
|
+
expect(ended[0].events).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('chains mutators', () => {
|
|
124
|
+
const span = createSpan()
|
|
125
|
+
span.setAttribute('a', 1).setAttributes({ b: 2 }).setStatus('ok').updateName('renamed')
|
|
126
|
+
span.end()
|
|
127
|
+
|
|
128
|
+
expect(ended[0].attributes).toEqual({ a: 1, b: 2 })
|
|
129
|
+
expect(ended[0].name).toBe('renamed')
|
|
130
|
+
expect(ended[0].status).toEqual({ code: 'ok' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('replaces the name up until end', () => {
|
|
134
|
+
// A route template is often only knowable after routing resolves, and the
|
|
135
|
+
// product aggregates by (service, name) — so renaming has to be possible.
|
|
136
|
+
const span = createSpan({ name: 'HTTP request' })
|
|
137
|
+
span.updateName('GET /users/:id')
|
|
138
|
+
span.end()
|
|
139
|
+
expect(ended[0].name).toBe('GET /users/:id')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('replaces an empty name rather than dropping the span', () => {
|
|
143
|
+
const span = createSpan()
|
|
144
|
+
span.updateName(' ')
|
|
145
|
+
span.end()
|
|
146
|
+
expect(ended[0].name).toBe('unknown')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('applies last-write-wins to status', () => {
|
|
150
|
+
const span = createSpan()
|
|
151
|
+
span.setStatus('ok')
|
|
152
|
+
span.setStatus('error', 'boom')
|
|
153
|
+
span.end()
|
|
154
|
+
expect(ended[0].status).toEqual({ code: 'error', message: 'boom' })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('omits status when never set', () => {
|
|
158
|
+
createSpan().end()
|
|
159
|
+
expect(ended[0].status).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('ignores an unrecognized status along with its message', () => {
|
|
163
|
+
const rejected = createSpan()
|
|
164
|
+
expect((rejected as any).setStatus('OK', 'all good')).toBe(rejected)
|
|
165
|
+
rejected.end()
|
|
166
|
+
|
|
167
|
+
const corrected = createSpan()
|
|
168
|
+
;(corrected as any).setStatus('OK', 'all good')
|
|
169
|
+
corrected.setStatus('error', 'boom')
|
|
170
|
+
corrected.end()
|
|
171
|
+
|
|
172
|
+
expect(ended[0].status).toBeUndefined()
|
|
173
|
+
expect(ended[1].status).toEqual({ code: 'error', message: 'boom' })
|
|
174
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('OK'))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('event cap', () => {
|
|
178
|
+
const fillEvents = (span: PostHogSpan, count: number): void => {
|
|
179
|
+
for (let i = 0; i < count; i++) {
|
|
180
|
+
span.addEvent(`step-${i}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it('drops an exception event on a span that has filled its events', () => {
|
|
185
|
+
// The cap is absolute, so an exception arriving last is dropped like any
|
|
186
|
+
// other event. The span keeps its `error` status and `droppedEventsCount`
|
|
187
|
+
// reports the loss, which is what makes the case findable in production.
|
|
188
|
+
const span = createSpan({ maxEvents: 2 })
|
|
189
|
+
fillEvents(span, 2)
|
|
190
|
+
span.recordException(new Error('boom'))
|
|
191
|
+
span.end()
|
|
192
|
+
|
|
193
|
+
expect(ended[0].events.map((event) => event.name)).toEqual(['step-0', 'step-1'])
|
|
194
|
+
expect(ended[0].status).toEqual({ code: 'error', message: 'boom' })
|
|
195
|
+
expect(ended[0].droppedEventsCount).toBe(1)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('records an exception like any other event while the cap has room', () => {
|
|
199
|
+
const span = createSpan({ maxEvents: 128 })
|
|
200
|
+
for (let i = 0; i < 20; i++) {
|
|
201
|
+
span.recordException(new Error(`boom-${i}`))
|
|
202
|
+
}
|
|
203
|
+
span.end()
|
|
204
|
+
|
|
205
|
+
expect(ended[0].events).toHaveLength(20)
|
|
206
|
+
expect(ended[0].droppedEventsCount).toBeUndefined()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not let an exception-named event bypass the cap', () => {
|
|
210
|
+
// The cap counts events, not names: nothing about the name `exception`
|
|
211
|
+
// buys a slot, whoever wrote it.
|
|
212
|
+
const span = createSpan({ maxEvents: 2 })
|
|
213
|
+
for (let i = 0; i < 7; i++) {
|
|
214
|
+
span.addEvent('exception', { mine: i })
|
|
215
|
+
}
|
|
216
|
+
span.end()
|
|
217
|
+
|
|
218
|
+
expect(ended[0].events).toHaveLength(2)
|
|
219
|
+
expect(ended[0].droppedEventsCount).toBe(5)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('drops ordinary events past the cap', () => {
|
|
223
|
+
const span = createSpan({ maxEvents: 2 })
|
|
224
|
+
fillEvents(span, 5)
|
|
225
|
+
span.end()
|
|
226
|
+
|
|
227
|
+
expect(ended[0].events.map((event) => event.name)).toEqual(['step-0', 'step-1'])
|
|
228
|
+
expect(ended[0].droppedEventsCount).toBe(3)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('event attribute cap', () => {
|
|
233
|
+
it('keeps the first attributes and reports the rest as dropped', () => {
|
|
234
|
+
const span = createSpan({ maxAttributesPerEvent: 2 })
|
|
235
|
+
span.addEvent('query', { a: 1, b: 2, c: 3, d: 4 })
|
|
236
|
+
span.end()
|
|
237
|
+
|
|
238
|
+
expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2 })
|
|
239
|
+
expect(ended[0].events[0].droppedAttributesCount).toBe(2)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('leaves the count off an event that lost nothing', () => {
|
|
243
|
+
const span = createSpan({ maxAttributesPerEvent: 2 })
|
|
244
|
+
span.addEvent('query', { a: 1, b: 2 })
|
|
245
|
+
span.end()
|
|
246
|
+
|
|
247
|
+
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('bounds an exception event like any other', () => {
|
|
251
|
+
// The SDK's own `exception.*` attributes are width the caller sees too, so
|
|
252
|
+
// they spend the cap rather than being exempt from it.
|
|
253
|
+
const span = createSpan({ maxAttributesPerEvent: 1 })
|
|
254
|
+
span.recordException(new Error('boom'))
|
|
255
|
+
span.end()
|
|
256
|
+
|
|
257
|
+
expect(Object.keys(ended[0].events[0].attributes ?? {})).toEqual(['exception.type'])
|
|
258
|
+
expect(ended[0].events[0].droppedAttributesCount).toBe(2)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('does not read a value past the cap', () => {
|
|
262
|
+
// The cap is spent before the value is bounded, so a wide bag does not pay
|
|
263
|
+
// for getters on entries that are about to be dropped.
|
|
264
|
+
const read: string[] = []
|
|
265
|
+
const watched: any = {}
|
|
266
|
+
for (const key of ['a', 'b', 'c']) {
|
|
267
|
+
Object.defineProperty(watched, key, {
|
|
268
|
+
enumerable: true,
|
|
269
|
+
get() {
|
|
270
|
+
read.push(key)
|
|
271
|
+
return key
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const span = createSpan({ maxAttributesPerEvent: 2 })
|
|
277
|
+
span.addEvent('query', watched)
|
|
278
|
+
span.end()
|
|
279
|
+
|
|
280
|
+
expect(read).toEqual(['a', 'b'])
|
|
281
|
+
expect(ended[0].events[0].droppedAttributesCount).toBe(1)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('does not let a nullish value spend a slot', () => {
|
|
285
|
+
// The encoder drops these, so a caller who blanked a value rather than
|
|
286
|
+
// omitting the key must not cost the event a real attribute. Same rule the
|
|
287
|
+
// span half of the cap already follows.
|
|
288
|
+
const span = createSpan({ maxAttributesPerEvent: 2 })
|
|
289
|
+
span.addEvent('query', { blanked: undefined, cleared: null, real: 1, second: 2 })
|
|
290
|
+
span.end()
|
|
291
|
+
|
|
292
|
+
expect(ended[0].events[0].attributes).toEqual({ real: 1, second: 2 })
|
|
293
|
+
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('survives an attribute bag whose own keys cannot be read', () => {
|
|
297
|
+
const hostile = new Proxy(
|
|
298
|
+
{},
|
|
299
|
+
{
|
|
300
|
+
ownKeys() {
|
|
301
|
+
throw new Error('ownKeys exploded')
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
const span = createSpan()
|
|
306
|
+
expect(() => span.addEvent('query', hostile)).not.toThrow()
|
|
307
|
+
expect(() => span.end()).not.toThrow()
|
|
308
|
+
|
|
309
|
+
expect(ended[0].events[0].attributes).toEqual({})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('drops the attribute past the shipped default and nothing before it', () => {
|
|
313
|
+
// Ties the default the SDK actually ships to the behaviour at its boundary:
|
|
314
|
+
// the other cases here pick small caps, so neither half moves the other.
|
|
315
|
+
const limit = resolveTracesConfig(undefined).maxAttributesPerEvent
|
|
316
|
+
const atLimit = Object.fromEntries(Array.from({ length: limit }, (_, index) => [`k${index}`, index]))
|
|
317
|
+
|
|
318
|
+
const span = createSpan({ maxAttributesPerEvent: limit })
|
|
319
|
+
span.addEvent('at-limit', atLimit)
|
|
320
|
+
span.addEvent('over-limit', { ...atLimit, extra: 1 })
|
|
321
|
+
span.end()
|
|
322
|
+
|
|
323
|
+
expect(ended[0].events[0].attributes).toEqual(atLimit)
|
|
324
|
+
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
|
|
325
|
+
expect(ended[0].events[1].attributes).toEqual(atLimit)
|
|
326
|
+
expect(ended[0].events[1].droppedAttributesCount).toBe(1)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('clamps a hook-written drop count to what the wire field holds', () => {
|
|
330
|
+
// The count is a uint32 on the wire, and a value over it is refused for the
|
|
331
|
+
// whole request rather than the one span that carried it.
|
|
332
|
+
const span = createSpan()
|
|
333
|
+
span.addEvent('query', { a: 1 })
|
|
334
|
+
span.end()
|
|
335
|
+
ended[0].events[0].droppedAttributesCount = Number.MAX_SAFE_INTEGER
|
|
336
|
+
|
|
337
|
+
expect(buildOtlpSpan(ended[0], logger).events?.[0].droppedAttributesCount).toBe(0xffff_ffff)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('counts the span attribute cap separately from an event cap', () => {
|
|
341
|
+
// maxAttributesPerSpan does not reach inside events, which is the gap this
|
|
342
|
+
// cap closes: a span at its own cap can still carry full-width events.
|
|
343
|
+
const span = createSpan({ maxAttributes: 1, maxAttributesPerEvent: 3 })
|
|
344
|
+
span.setAttributes({ kept: 1, dropped: 2 })
|
|
345
|
+
span.addEvent('query', { a: 1, b: 2, c: 3 })
|
|
346
|
+
span.end()
|
|
347
|
+
|
|
348
|
+
expect(ended[0].attributes).toEqual({ kept: 1 })
|
|
349
|
+
expect(ended[0].droppedAttributesCount).toBe(1)
|
|
350
|
+
expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2, c: 3 })
|
|
351
|
+
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
describe('attribute store hygiene', () => {
|
|
356
|
+
it('does not copy a polluted Object.prototype key into the span', () => {
|
|
357
|
+
;(Object.prototype as any).polluted = 'yes'
|
|
358
|
+
try {
|
|
359
|
+
const span = createSpan({ attributes: { real: 1 } })
|
|
360
|
+
span.end()
|
|
361
|
+
|
|
362
|
+
expect(Object.keys(ended[0].attributes)).toEqual(['real'])
|
|
363
|
+
} finally {
|
|
364
|
+
delete (Object.prototype as any).polluted
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('poison attributes', () => {
|
|
370
|
+
const withThrowingGetter = (): any => {
|
|
371
|
+
const attributes: any = { ok: 1 }
|
|
372
|
+
Object.defineProperty(attributes, 'boom', {
|
|
373
|
+
enumerable: true,
|
|
374
|
+
get() {
|
|
375
|
+
throw new Error('getter exploded')
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
return attributes
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
it('marks only the throwing key on setAttributes', () => {
|
|
382
|
+
const span = createSpan()
|
|
383
|
+
expect(() => span.setAttributes(withThrowingGetter())).not.toThrow()
|
|
384
|
+
span.end()
|
|
385
|
+
|
|
386
|
+
expect(ended[0].attributes).toEqual({ ok: 1, boom: '[Unserializable]' })
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('marks only the throwing key on addEvent', () => {
|
|
390
|
+
const span = createSpan()
|
|
391
|
+
expect(() => span.addEvent('checkout.step', withThrowingGetter())).not.toThrow()
|
|
392
|
+
span.end()
|
|
393
|
+
|
|
394
|
+
expect(ended[0].events[0].attributes).toEqual({ ok: 1, boom: '[Unserializable]' })
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('keeps a __proto__ key on setAttribute', () => {
|
|
398
|
+
const span = createSpan()
|
|
399
|
+
span.setAttribute('__proto__', 'polluted')
|
|
400
|
+
span.end()
|
|
401
|
+
|
|
402
|
+
expect(Object.keys(ended[0].attributes)).toContain('__proto__')
|
|
403
|
+
expect(Object.getPrototypeOf(ended[0].attributes)).toBe(Object.prototype)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('copies only own enumerable keys on setAttributes', () => {
|
|
407
|
+
const span = createSpan()
|
|
408
|
+
span.setAttributes(Object.create({ inherited: 'proto' }, { own: { value: 'yes', enumerable: true } }))
|
|
409
|
+
span.end()
|
|
410
|
+
|
|
411
|
+
expect(ended[0].attributes).toEqual({ own: 'yes' })
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
describe('recordException', () => {
|
|
416
|
+
it('sets error status and attaches an exception event without ending', () => {
|
|
417
|
+
const span = createSpan()
|
|
418
|
+
span.recordException(new TypeError('boom'))
|
|
419
|
+
|
|
420
|
+
expect(ended).toHaveLength(0)
|
|
421
|
+
|
|
422
|
+
span.end()
|
|
423
|
+
expect(ended[0].status).toEqual({ code: 'error', message: 'boom' })
|
|
424
|
+
expect(ended[0].events).toEqual([
|
|
425
|
+
expect.objectContaining({
|
|
426
|
+
name: 'exception',
|
|
427
|
+
attributes: expect.objectContaining({ 'exception.type': 'TypeError', 'exception.message': 'boom' }),
|
|
428
|
+
}),
|
|
429
|
+
])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('attaches the stack as exception.stacktrace', () => {
|
|
433
|
+
const span = createSpan()
|
|
434
|
+
span.recordException(new TypeError('boom'))
|
|
435
|
+
span.end()
|
|
436
|
+
|
|
437
|
+
const stack = ended[0].events[0].attributes?.['exception.stacktrace']
|
|
438
|
+
expect(stack).toEqual(expect.stringContaining('TypeError: boom'))
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('bounds the stack by maxAttributeValueLength', () => {
|
|
442
|
+
const span = createSpan({ maxAttributeValueLength: 40 })
|
|
443
|
+
const error = new Error('boom')
|
|
444
|
+
error.stack = `Error: boom\n${' at somewhere deep\n'.repeat(500)}`
|
|
445
|
+
|
|
446
|
+
span.recordException(error)
|
|
447
|
+
span.end()
|
|
448
|
+
|
|
449
|
+
expect(ended[0].events[0].attributes?.['exception.stacktrace']).toHaveLength(40)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('records an exception with no stack without inventing one', () => {
|
|
453
|
+
const span = createSpan()
|
|
454
|
+
span.recordException('just a string')
|
|
455
|
+
span.end()
|
|
456
|
+
|
|
457
|
+
expect(ended[0].events[0].attributes).not.toHaveProperty('exception.stacktrace')
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
describe('maxAttributeValueLength', () => {
|
|
462
|
+
it('truncates a long string attribute without counting it as dropped', () => {
|
|
463
|
+
const span = createSpan({ maxAttributeValueLength: 10 })
|
|
464
|
+
span.setAttribute('payload', 'x'.repeat(5000))
|
|
465
|
+
span.end()
|
|
466
|
+
|
|
467
|
+
expect(ended[0].attributes.payload).toBe('xxxxxxxxxx')
|
|
468
|
+
// The count is for whole entries; a trimmed value is still exported.
|
|
469
|
+
expect(ended[0].droppedAttributesCount).toBeUndefined()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('truncates the strings inside an array attribute, and leaves other types alone', () => {
|
|
473
|
+
const span = createSpan({ maxAttributeValueLength: 3 })
|
|
474
|
+
span.setAttributes({ tags: ['abcdef', 'ab'], count: 1234567, flag: true })
|
|
475
|
+
span.end()
|
|
476
|
+
|
|
477
|
+
expect(ended[0].attributes).toMatchObject({ tags: ['abc', 'ab'], count: 1234567, flag: true })
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('truncates strings nested inside an object value', () => {
|
|
481
|
+
// `setAttribute('payload', { body: res.body })` is the natural way to attach
|
|
482
|
+
// a response, and an unbounded one is what pushes a span past the endpoint.
|
|
483
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
484
|
+
span.setAttribute('payload', { body: 'abcdefgh', status: 200 })
|
|
485
|
+
span.end()
|
|
486
|
+
|
|
487
|
+
expect(ended[0].attributes.payload).toEqual({ body: 'abcd', status: 200 })
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('truncates strings nested inside an array value', () => {
|
|
491
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
492
|
+
span.setAttribute('rows', [{ body: 'abcdefgh' }, ['abcdefgh']])
|
|
493
|
+
span.end()
|
|
494
|
+
|
|
495
|
+
expect(ended[0].attributes.rows).toEqual([{ body: 'abcd' }, ['abcd']])
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('bounds the work a shared subtree costs, not just a cyclic one', () => {
|
|
499
|
+
// Siblings pointing at one object are re-walked once per path, so this
|
|
500
|
+
// reaches the same leaves ten million times. Only the node budget stops it.
|
|
501
|
+
const leaf: string[] = []
|
|
502
|
+
for (let i = 0; i < 1000; i++) {
|
|
503
|
+
leaf.push('x'.repeat(2000))
|
|
504
|
+
}
|
|
505
|
+
const mid = Array.from({ length: 1000 }, () => leaf)
|
|
506
|
+
const shared = Array.from({ length: 10 }, () => mid)
|
|
507
|
+
|
|
508
|
+
const bounded = truncateAttributeValue(shared, 8)
|
|
509
|
+
|
|
510
|
+
// Counting what the walk shortened, not what the result can reach: past
|
|
511
|
+
// the budget the original is handed back by reference.
|
|
512
|
+
const cap = MAX_JSON_SAFE_VALUE_NODES * 2
|
|
513
|
+
let shortened = 0
|
|
514
|
+
const stack: unknown[] = [bounded]
|
|
515
|
+
while (stack.length && shortened <= cap) {
|
|
516
|
+
const value = stack.pop()
|
|
517
|
+
if (typeof value === 'string') {
|
|
518
|
+
if (value.length === 8) {
|
|
519
|
+
shortened++
|
|
520
|
+
}
|
|
521
|
+
} else if (Array.isArray(value) && value !== leaf && value !== mid) {
|
|
522
|
+
stack.push(...value)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
expect(shortened).toBeLessThanOrEqual(cap)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('terminates on a self-referencing value', () => {
|
|
529
|
+
const cyclic: any = { body: 'abcdefgh' }
|
|
530
|
+
cyclic.self = cyclic
|
|
531
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
532
|
+
|
|
533
|
+
expect(() => {
|
|
534
|
+
span.setAttribute('payload', cyclic)
|
|
535
|
+
span.end()
|
|
536
|
+
}).not.toThrow()
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('charges a throwing accessor to its own key, still bounding its siblings', () => {
|
|
540
|
+
// A lazy ORM relation next to a large field is the shape that matters: if
|
|
541
|
+
// the throw abandons the whole walk, the large field ships at full length.
|
|
542
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
543
|
+
const hostile = {
|
|
544
|
+
ok: 'abcdefgh',
|
|
545
|
+
get boom() {
|
|
546
|
+
throw new Error('getter exploded')
|
|
547
|
+
},
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
expect(() => {
|
|
551
|
+
span.setAttribute('payload', hostile as any)
|
|
552
|
+
span.end()
|
|
553
|
+
}).not.toThrow()
|
|
554
|
+
|
|
555
|
+
const payload = ended[0].attributes.payload as Record<string, unknown>
|
|
556
|
+
expect(payload.ok).toBe('abcd')
|
|
557
|
+
expect(payload.boom).toBe('[Unserializable]')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('does not walk into a value whose toJSON redacts it', () => {
|
|
561
|
+
// Copying the object's own keys would hand the encoder a plain object it
|
|
562
|
+
// no longer recognises as self-describing, putting the internals of a
|
|
563
|
+
// value that redacts itself on the wire.
|
|
564
|
+
class Redacted {
|
|
565
|
+
constructor(public secret: string) {}
|
|
566
|
+
toJSON(): null {
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const span = createSpan({ maxAttributeValueLength: 10 })
|
|
571
|
+
|
|
572
|
+
span.setAttribute('payload', { inner: new Redacted('S'.repeat(50)) } as any)
|
|
573
|
+
span.end()
|
|
574
|
+
|
|
575
|
+
// The string the encoder builds from the same `null`, so the wire is
|
|
576
|
+
// unchanged, and the secret is nowhere in what the span kept.
|
|
577
|
+
expect((ended[0].attributes.payload as any).inner).toBe('null')
|
|
578
|
+
expect(JSON.stringify(ended[0].attributes)).not.toContain('S')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('materializes a toJSON that resolves to nothing, so a second call cannot answer differently', () => {
|
|
582
|
+
// Keeping the object itself left the encoder to probe toJSON again. A
|
|
583
|
+
// serializer that answered `null` here could answer with a megabyte
|
|
584
|
+
// there, past the bound entirely.
|
|
585
|
+
let calls = 0
|
|
586
|
+
const stateful = {
|
|
587
|
+
toJSON: () => {
|
|
588
|
+
calls++
|
|
589
|
+
return calls === 1 ? null : 'x'.repeat(100)
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
593
|
+
|
|
594
|
+
span.setAttribute('doc', stateful as any)
|
|
595
|
+
span.end()
|
|
596
|
+
|
|
597
|
+
expect(calls).toBe(1)
|
|
598
|
+
expect(ended[0].attributes.doc).toBe('null')
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('keeps a stateful toJSON bounded through to the encoded span', () => {
|
|
602
|
+
// End to end, because the second call is the encoder's: what the span
|
|
603
|
+
// stored has to leave it nothing to call.
|
|
604
|
+
let calls = 0
|
|
605
|
+
const stateful = {
|
|
606
|
+
toJSON: () => {
|
|
607
|
+
calls++
|
|
608
|
+
return calls === 1 ? null : 'x'.repeat(100)
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
612
|
+
|
|
613
|
+
span.setAttribute('doc', stateful as any)
|
|
614
|
+
span.end()
|
|
615
|
+
const encoded = buildOtlpSpan(ended[0])
|
|
616
|
+
|
|
617
|
+
expect(calls).toBe(1)
|
|
618
|
+
expect(encoded.attributes?.find((attribute) => attribute.key === 'doc')).toEqual({
|
|
619
|
+
key: 'doc',
|
|
620
|
+
value: { stringValue: 'null' },
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it('describes a toJSON resolving to undefined the way the encoder would', () => {
|
|
625
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
626
|
+
|
|
627
|
+
span.setAttribute('ghost', { toJSON: () => undefined } as any)
|
|
628
|
+
span.end()
|
|
629
|
+
|
|
630
|
+
// Not trimmed to the bound: this is the SDK's own marker, like
|
|
631
|
+
// `[Circular]`, and `unde` reads as nothing at all.
|
|
632
|
+
expect(ended[0].attributes.ghost).toBe('undefined')
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('bounds event attributes too', () => {
|
|
636
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
637
|
+
span.addEvent('cache.miss', { key: 'abcdefgh' })
|
|
638
|
+
span.end()
|
|
639
|
+
|
|
640
|
+
expect(ended[0].events[0].attributes).toEqual({ key: 'abcd' })
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('walks a value that points at itself twice only once', () => {
|
|
644
|
+
// Depth alone is not a bound. Two back-references at each level cost
|
|
645
|
+
// 2 ** 20 visits, which is a quarter-second inside the caller's own
|
|
646
|
+
// `setAttribute` call, and a third reference is minutes.
|
|
647
|
+
let reads = 0
|
|
648
|
+
const cyclic: any = {
|
|
649
|
+
get body() {
|
|
650
|
+
reads++
|
|
651
|
+
return 'abcdefgh'
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
cyclic.self1 = cyclic
|
|
655
|
+
cyclic.self2 = cyclic
|
|
656
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
657
|
+
|
|
658
|
+
span.setAttribute('payload', cyclic)
|
|
659
|
+
span.end()
|
|
660
|
+
|
|
661
|
+
expect(reads).toBe(1)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('bounds the nodes an acyclic value costs, not just its depth', () => {
|
|
665
|
+
// Siblings sharing a subtree are not a cycle, so the ancestor set does not
|
|
666
|
+
// catch them: 3 ** 12 visits without a node budget.
|
|
667
|
+
let reads = 0
|
|
668
|
+
let level: any = {
|
|
669
|
+
get body() {
|
|
670
|
+
reads++
|
|
671
|
+
return 'abcdefgh'
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
for (let i = 0; i < 12; i++) {
|
|
675
|
+
level = { a: level, b: level, c: level }
|
|
676
|
+
}
|
|
677
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
678
|
+
|
|
679
|
+
span.setAttribute('payload', level)
|
|
680
|
+
span.end()
|
|
681
|
+
|
|
682
|
+
expect(reads).toBeLessThanOrEqual(10_000)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('bounds the value a toJSON produces, which is what the encoder puts on the wire', () => {
|
|
686
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
687
|
+
|
|
688
|
+
span.setAttribute('doc', { toJSON: () => 'abcdefgh' } as any)
|
|
689
|
+
span.end()
|
|
690
|
+
|
|
691
|
+
expect(ended[0].attributes.doc).toBe('abcd')
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('resolves toJSON exactly once, so a second call cannot dodge the bound', () => {
|
|
695
|
+
// Returning the original object when nothing needed shortening left the
|
|
696
|
+
// encoder to call toJSON again — a value that answered differently the
|
|
697
|
+
// second time reached the wire unbounded.
|
|
698
|
+
let calls = 0
|
|
699
|
+
const doc = {
|
|
700
|
+
toJSON: () => {
|
|
701
|
+
calls++
|
|
702
|
+
return calls === 1 ? 'ab' : 'x'.repeat(4000)
|
|
703
|
+
},
|
|
704
|
+
}
|
|
705
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
706
|
+
|
|
707
|
+
span.setAttribute('doc', doc as any)
|
|
708
|
+
span.end()
|
|
709
|
+
|
|
710
|
+
expect(calls).toBe(1)
|
|
711
|
+
expect(ended[0].attributes.doc).toBe('ab')
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it('bounds a string that follows a large collection of nulls', () => {
|
|
715
|
+
// The encoder drops a nullish value without spending its budget, so a walk
|
|
716
|
+
// that charges for one runs out first and leaves the string after it
|
|
717
|
+
// unbounded on both sides — a 2 MB value under a bound of 8.
|
|
718
|
+
const span = createSpan({ maxAttributeValueLength: 8 })
|
|
719
|
+
|
|
720
|
+
span.setAttribute('payload', {
|
|
721
|
+
rows: Array.from({ length: 400 }, () =>
|
|
722
|
+
Object.fromEntries(Array.from({ length: 50 }, (_unused, index) => [`c${index}`, null]))
|
|
723
|
+
),
|
|
724
|
+
html: 'X'.repeat(50000),
|
|
725
|
+
})
|
|
726
|
+
span.end()
|
|
727
|
+
|
|
728
|
+
expect((ended[0].attributes.payload as any).html).toHaveLength(8)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('bounds a string that follows a large collection', () => {
|
|
732
|
+
// The traversal budget is spent on containers, not leaves: a big array
|
|
733
|
+
// used to exhaust it and leave every later string at full length.
|
|
734
|
+
const span = createSpan({ maxAttributeValueLength: 8 })
|
|
735
|
+
|
|
736
|
+
span.setAttribute('payload', {
|
|
737
|
+
rows: Array.from({ length: 20000 }, (_, index) => index),
|
|
738
|
+
html: 'X'.repeat(50000),
|
|
739
|
+
})
|
|
740
|
+
span.end()
|
|
741
|
+
|
|
742
|
+
expect((ended[0].attributes.payload as any).html).toHaveLength(8)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('keeps a nested __proto__ key as an ordinary entry', () => {
|
|
746
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
747
|
+
|
|
748
|
+
span.setAttribute('payload', JSON.parse('{"__proto__": {"body": "abcdefgh"}}'))
|
|
749
|
+
span.end()
|
|
750
|
+
|
|
751
|
+
const payload = ended[0].attributes.payload as Record<string, unknown>
|
|
752
|
+
expect(Object.keys(payload)).toEqual(['__proto__'])
|
|
753
|
+
expect(Object.getOwnPropertyDescriptor(payload, '__proto__')?.value).toEqual({ body: 'abcd' })
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('bounds an event name the SDK records like any other', () => {
|
|
757
|
+
const span = createSpan({ maxAttributeValueLength: 8, maxEvents: 4 })
|
|
758
|
+
|
|
759
|
+
span.recordException(new Error('boom'))
|
|
760
|
+
span.end()
|
|
761
|
+
|
|
762
|
+
expect(ended[0].events[0].name).toBe('exceptio')
|
|
763
|
+
expect(ended[0].events[0].attributes?.['exception.type']).toBe('Error')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('bounds a span name and an event name, like a status message', () => {
|
|
767
|
+
// A name built from a URL is caller-controlled, and one large enough takes
|
|
768
|
+
// the span past the ingestion body limit.
|
|
769
|
+
const span = createSpan({ maxAttributeValueLength: 12 })
|
|
770
|
+
|
|
771
|
+
span.updateName('abcdefghijklmnop')
|
|
772
|
+
span.addEvent('abcdefghijklmnop')
|
|
773
|
+
span.end()
|
|
774
|
+
|
|
775
|
+
expect(ended[0].name).toBe('abcdefghijkl')
|
|
776
|
+
expect(ended[0].events[0].name).toBe('abcdefghijkl')
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it('bounds a status message', () => {
|
|
780
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
781
|
+
|
|
782
|
+
span.setStatus('error', 'abcdefgh')
|
|
783
|
+
span.end()
|
|
784
|
+
|
|
785
|
+
expect(ended[0].status).toEqual({ code: 'error', message: 'abcd' })
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it('bounds the status message recordException sets, like the event attribute', () => {
|
|
789
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
790
|
+
|
|
791
|
+
span.recordException(new Error('abcdefgh'))
|
|
792
|
+
span.end()
|
|
793
|
+
|
|
794
|
+
expect(ended[0].status?.message).toBe('abcd')
|
|
795
|
+
expect(ended[0].events[0].attributes?.['exception.message']).toBe('abcd')
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('replaces a back-reference with the marker rather than the value itself', () => {
|
|
799
|
+
// Handing the raw ancestor back left it inside a copied parent, where the
|
|
800
|
+
// encoder's own cycle detection no longer recognised it and walked one
|
|
801
|
+
// more level of its strings at full length.
|
|
802
|
+
const cyclic: any = { body: 'abcdefgh' }
|
|
803
|
+
cyclic.self = cyclic
|
|
804
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
805
|
+
|
|
806
|
+
span.setAttribute('payload', cyclic)
|
|
807
|
+
span.end()
|
|
808
|
+
|
|
809
|
+
expect(ended[0].attributes.payload).toEqual({ body: 'abcd', self: '[Circular]' })
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('still bounds an array whose accessor past the encoder cap throws', () => {
|
|
813
|
+
// `slice()` read the whole array to copy it, so one throwing accessor
|
|
814
|
+
// beyond the encoder's cap cost every item in range its bound.
|
|
815
|
+
const rows: unknown[] = ['abcdefgh']
|
|
816
|
+
for (let index = 1; index < MAX_JSON_SAFE_VALUE_ITEMS + 200; index++) {
|
|
817
|
+
rows.push('x')
|
|
818
|
+
}
|
|
819
|
+
Object.defineProperty(rows, MAX_JSON_SAFE_VALUE_ITEMS + 100, {
|
|
820
|
+
get: () => {
|
|
821
|
+
throw new Error('lazy relation')
|
|
822
|
+
},
|
|
823
|
+
enumerable: true,
|
|
824
|
+
configurable: true,
|
|
825
|
+
})
|
|
826
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
827
|
+
|
|
828
|
+
span.setAttribute('rows', rows as any)
|
|
829
|
+
span.end()
|
|
830
|
+
|
|
831
|
+
expect((ended[0].attributes.rows as unknown[])[0]).toBe('abcd')
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
it('stops reading keys where the encoder stops emitting them', () => {
|
|
835
|
+
// Every key was read even though the encoder emits at most the cap, so a
|
|
836
|
+
// wide object charged `setAttribute` for getters that never ship.
|
|
837
|
+
let reads = 0
|
|
838
|
+
const wide: Record<string, unknown> = {}
|
|
839
|
+
for (let index = 0; index < MAX_JSON_SAFE_VALUE_ITEMS * 2; index++) {
|
|
840
|
+
Object.defineProperty(wide, `k${index}`, {
|
|
841
|
+
get: () => {
|
|
842
|
+
reads++
|
|
843
|
+
return 'v'
|
|
844
|
+
},
|
|
845
|
+
enumerable: true,
|
|
846
|
+
configurable: true,
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
850
|
+
|
|
851
|
+
span.setAttribute('payload', wide)
|
|
852
|
+
span.end()
|
|
853
|
+
|
|
854
|
+
expect(reads).toBe(MAX_JSON_SAFE_VALUE_ITEMS)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('copies a nested value the caller goes on to mutate', () => {
|
|
858
|
+
// A value that needed no truncation was attached as it came, so the span
|
|
859
|
+
// held caller-owned state and shipped whatever it was changed to.
|
|
860
|
+
const nested = { body: 'ok' }
|
|
861
|
+
const span = createSpan({ maxAttributeValueLength: 4 })
|
|
862
|
+
|
|
863
|
+
span.setAttribute('payload', { nested })
|
|
864
|
+
nested.body = 'abcdefgh'
|
|
865
|
+
span.end()
|
|
866
|
+
|
|
867
|
+
expect(ended[0].attributes.payload).toEqual({ nested: { body: 'ok' } })
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
it('leaves a Date whole rather than truncating its timestamp', () => {
|
|
871
|
+
// The encoder emits a Date from its own branch ahead of any `toJSON`, so
|
|
872
|
+
// bounding it here shipped a cut-off timestamp instead of a shorter one.
|
|
873
|
+
const span = createSpan({ maxAttributeValueLength: 10 })
|
|
874
|
+
|
|
875
|
+
span.setAttribute('when', new Date('2020-01-02T03:04:05.000Z') as never)
|
|
876
|
+
span.end()
|
|
877
|
+
|
|
878
|
+
expect(ended[0].attributes.when).toEqual(new Date('2020-01-02T03:04:05.000Z'))
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('bounds an SDK-attached value, which is exempt from the count cap only', () => {
|
|
882
|
+
const span = createSpan({
|
|
883
|
+
maxAttributeValueLength: 4,
|
|
884
|
+
attributes: { posthogDistinctId: 'user-12345' },
|
|
885
|
+
autoAttributeKeys: ['posthogDistinctId'],
|
|
886
|
+
})
|
|
887
|
+
span.setAttribute('posthogDistinctId', 'user-12345')
|
|
888
|
+
span.end()
|
|
889
|
+
|
|
890
|
+
expect(ended[0].attributes.posthogDistinctId).toBe('user')
|
|
891
|
+
})
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
describe('timestamps', () => {
|
|
895
|
+
it('records an end at or after the start', () => {
|
|
896
|
+
const span = createSpan()
|
|
897
|
+
span.end()
|
|
898
|
+
expect(ended[0].endTime).toBeGreaterThanOrEqual(ended[0].startTime)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('honours an explicit end time', () => {
|
|
902
|
+
const start = 1_700_000_000_000
|
|
903
|
+
const span = createSpan({ startTime: start, backdated: true })
|
|
904
|
+
span.end(start + 5000)
|
|
905
|
+
expect(ended[0].endTime).toBe(start + 5000)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('accepts a Date as an end time', () => {
|
|
909
|
+
const start = 1_700_000_000_000
|
|
910
|
+
const span = createSpan({ startTime: start, backdated: true })
|
|
911
|
+
span.end(new Date(start + 1000))
|
|
912
|
+
expect(ended[0].endTime).toBe(start + 1000)
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('corrects an end before the start to a zero duration', () => {
|
|
916
|
+
const start = 1_700_000_000_000
|
|
917
|
+
const span = createSpan({ startTime: start, backdated: true })
|
|
918
|
+
span.end(start - 5000)
|
|
919
|
+
expect(ended[0].endTime).toBe(start)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('falls back to the derived end for an out-of-range end time', () => {
|
|
923
|
+
const start = 1_700_000_000_000
|
|
924
|
+
const span = createSpan({ startTime: start, backdated: true })
|
|
925
|
+
span.end(Number.MAX_SAFE_INTEGER)
|
|
926
|
+
expect(ended[0].endTime).toBeGreaterThanOrEqual(start)
|
|
927
|
+
expect(ended[0].endTime).toBeLessThan(9_223_372_036_854)
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
it('keeps event timestamps inside the span window', () => {
|
|
931
|
+
const span = createSpan()
|
|
932
|
+
span.addEvent('cache miss')
|
|
933
|
+
span.end()
|
|
934
|
+
|
|
935
|
+
const [event] = ended[0].events
|
|
936
|
+
expect(event.timestamp).toBeGreaterThanOrEqual(ended[0].startTime)
|
|
937
|
+
expect(event.timestamp).toBeLessThanOrEqual(ended[0].endTime)
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
it('honours an explicit event timestamp', () => {
|
|
941
|
+
const start = 1_700_000_000_000
|
|
942
|
+
const span = createSpan({ startTime: start, backdated: true })
|
|
943
|
+
span.addEvent('cache miss', undefined, start + 40)
|
|
944
|
+
span.end(start + 80)
|
|
945
|
+
expect(ended[0].events[0].timestamp).toBe(start + 40)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('snapshots event attributes so a reused object cannot mutate them', () => {
|
|
949
|
+
const span = createSpan()
|
|
950
|
+
const reused = { attempt: 1 }
|
|
951
|
+
span.addEvent('retry', reused)
|
|
952
|
+
reused.attempt = 2
|
|
953
|
+
span.addEvent('retry', reused)
|
|
954
|
+
span.end()
|
|
955
|
+
|
|
956
|
+
expect(ended[0].events.map((event) => event.attributes)).toEqual([{ attempt: 1 }, { attempt: 2 }])
|
|
957
|
+
})
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
describe('context propagation', () => {
|
|
961
|
+
it('produces a sampled traceparent', () => {
|
|
962
|
+
expect(createSpan().traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('returns null tracestate when it has none', () => {
|
|
966
|
+
expect(createSpan().tracestate()).toBeNull()
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
it('returns the tracestate it was created with', () => {
|
|
970
|
+
expect(createSpan({ traceState: 'vendor=abc' }).tracestate()).toBe('vendor=abc')
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
it('propagates the trace flags it was started with', () => {
|
|
974
|
+
expect(createSpan({ traceFlags: '00' }).traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-00`)
|
|
975
|
+
expect(createSpan().traceparent()).toBe(`00-${TRACE_ID}-${SPAN_ID}-01`)
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
it('hands a child the flags it propagates, so the whole chain agrees', () => {
|
|
979
|
+
expect(createSpan({ traceFlags: '00' }).childContext().traceFlags).toBe('00')
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('exposes a child context carrying its own span id as the parent', () => {
|
|
983
|
+
expect(createSpan({ traceState: 'vendor=abc' }).childContext()).toEqual({
|
|
984
|
+
traceId: TRACE_ID,
|
|
985
|
+
parentSpanId: SPAN_ID,
|
|
986
|
+
traceState: 'vendor=abc',
|
|
987
|
+
traceFlags: '01',
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
})
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
describe('NoopSpan', () => {
|
|
994
|
+
it('supports the full surface without throwing', () => {
|
|
995
|
+
expect(() => {
|
|
996
|
+
NOOP_SPAN.setAttribute('a', 1)
|
|
997
|
+
.setAttributes({ b: 2 })
|
|
998
|
+
.addEvent('x')
|
|
999
|
+
.setStatus('error', 'boom')
|
|
1000
|
+
.recordException(new Error('boom'))
|
|
1001
|
+
.updateName('renamed')
|
|
1002
|
+
.end()
|
|
1003
|
+
}).not.toThrow()
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('never produces a well-formed traceparent', () => {
|
|
1007
|
+
// An id that was never recorded must not propagate to another service.
|
|
1008
|
+
expect(NOOP_SPAN.traceparent()).toBeNull()
|
|
1009
|
+
expect(NOOP_SPAN.tracestate()).toBeNull()
|
|
1010
|
+
})
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
describe('attribute store', () => {
|
|
1014
|
+
it('hands out a record whose attributes behave like an ordinary object', () => {
|
|
1015
|
+
const ended: SpanRecord[] = []
|
|
1016
|
+
const span = new PostHogSpan(
|
|
1017
|
+
{
|
|
1018
|
+
traceId: TRACE_ID,
|
|
1019
|
+
spanId: SPAN_ID,
|
|
1020
|
+
name: 'checkout',
|
|
1021
|
+
kind: 'internal',
|
|
1022
|
+
attributes: { plan: 'pro' },
|
|
1023
|
+
startTime: Date.now(),
|
|
1024
|
+
backdated: false,
|
|
1025
|
+
autoAttributeKeys: [],
|
|
1026
|
+
maxAttributes: 128,
|
|
1027
|
+
maxEvents: 128,
|
|
1028
|
+
maxAttributesPerEvent: 128,
|
|
1029
|
+
maxAttributeValueLength: 8192,
|
|
1030
|
+
},
|
|
1031
|
+
(record) => ended.push(record)
|
|
1032
|
+
)
|
|
1033
|
+
span.end()
|
|
1034
|
+
|
|
1035
|
+
const { attributes } = ended[0]
|
|
1036
|
+
expect(Object.getPrototypeOf(attributes)).toBe(Object.prototype)
|
|
1037
|
+
expect(Object.prototype.hasOwnProperty.call(attributes, 'plan')).toBe(true)
|
|
1038
|
+
expect(() => JSON.stringify(attributes)).not.toThrow()
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
it('keeps a parsed __proto__ key as an ordinary attribute', () => {
|
|
1042
|
+
const ended: SpanRecord[] = []
|
|
1043
|
+
const span = new PostHogSpan(
|
|
1044
|
+
{
|
|
1045
|
+
traceId: TRACE_ID,
|
|
1046
|
+
spanId: SPAN_ID,
|
|
1047
|
+
name: 'checkout',
|
|
1048
|
+
kind: 'internal',
|
|
1049
|
+
attributes: JSON.parse('{"__proto__": {"leaked": 1}, "orderId": "abc"}'),
|
|
1050
|
+
startTime: Date.now(),
|
|
1051
|
+
backdated: false,
|
|
1052
|
+
autoAttributeKeys: [],
|
|
1053
|
+
maxAttributes: 128,
|
|
1054
|
+
maxEvents: 128,
|
|
1055
|
+
maxAttributesPerEvent: 128,
|
|
1056
|
+
maxAttributeValueLength: 8192,
|
|
1057
|
+
},
|
|
1058
|
+
(record) => ended.push(record)
|
|
1059
|
+
)
|
|
1060
|
+
span.end()
|
|
1061
|
+
|
|
1062
|
+
const keys: string[] = []
|
|
1063
|
+
for (const key in ended[0].attributes) {
|
|
1064
|
+
keys.push(key)
|
|
1065
|
+
}
|
|
1066
|
+
expect(keys).not.toContain('leaked')
|
|
1067
|
+
})
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
describe('describeError', () => {
|
|
1071
|
+
it.each([
|
|
1072
|
+
['an Error', new Error('boom'), { type: 'Error', message: 'boom' }],
|
|
1073
|
+
['a TypeError', new TypeError('bad type'), { type: 'TypeError', message: 'bad type' }],
|
|
1074
|
+
['a string', 'just a string', { type: 'string', message: 'just a string' }],
|
|
1075
|
+
['an object with a message', { name: 'CustomError', message: 'oops' }, { type: 'CustomError', message: 'oops' }],
|
|
1076
|
+
['an object without a name', { message: 'oops' }, { type: 'Object', message: 'oops' }],
|
|
1077
|
+
])('describes %s', (_name, error, expected) => {
|
|
1078
|
+
expect(describeError(error)).toMatchObject(expected)
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
it('carries the stack where the thrown value has one, and nothing where it does not', () => {
|
|
1082
|
+
expect(describeError(new Error('boom')).stack).toEqual(expect.stringContaining('Error: boom'))
|
|
1083
|
+
expect(describeError('just a string').stack).toBeUndefined()
|
|
1084
|
+
expect(describeError({ message: 'oops' }).stack).toBeUndefined()
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('survives a throwing stack accessor', () => {
|
|
1088
|
+
const hostile = {
|
|
1089
|
+
message: 'oops',
|
|
1090
|
+
get stack() {
|
|
1091
|
+
throw new Error('nope')
|
|
1092
|
+
},
|
|
1093
|
+
}
|
|
1094
|
+
expect(describeError(hostile)).toEqual({ type: 'Object', message: 'oops' })
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('describes a thrown primitive', () => {
|
|
1098
|
+
// Anything can be thrown in JS, so a non-Error must still produce a usable
|
|
1099
|
+
// exception event rather than being dropped.
|
|
1100
|
+
expect(describeError(42)).toEqual({ type: 'number', message: '42' })
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
it('survives a value whose toString throws', () => {
|
|
1104
|
+
const hostile = {
|
|
1105
|
+
message: 123,
|
|
1106
|
+
toString() {
|
|
1107
|
+
throw new Error('boom from toString')
|
|
1108
|
+
},
|
|
1109
|
+
}
|
|
1110
|
+
expect(() => describeError(hostile)).not.toThrow()
|
|
1111
|
+
expect(describeError(hostile)).toEqual({ type: 'object', message: '' })
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
it('survives a value whose message getter throws', () => {
|
|
1115
|
+
const hostile = {
|
|
1116
|
+
get message(): string {
|
|
1117
|
+
throw new Error('boom from getter')
|
|
1118
|
+
},
|
|
1119
|
+
}
|
|
1120
|
+
expect(() => describeError(hostile)).not.toThrow()
|
|
1121
|
+
})
|
|
1122
|
+
})
|