@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.
Files changed (146) hide show
  1. package/node_modules/@posthog/core/dist/error-tracking/index.d.ts +1 -1
  2. package/node_modules/@posthog/core/dist/error-tracking/index.d.ts.map +1 -1
  3. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
  4. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
  5. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
  6. package/node_modules/@posthog/core/dist/index.d.ts +7 -0
  7. package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
  8. package/node_modules/@posthog/core/dist/index.js +82 -21
  9. package/node_modules/@posthog/core/dist/index.mjs +4 -0
  10. package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
  11. package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
  12. package/node_modules/@posthog/core/dist/logs/index.js +28 -22
  13. package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
  14. package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
  15. package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
  16. package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
  17. package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
  18. package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
  19. package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
  20. package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
  21. package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
  22. package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
  23. package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
  24. package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
  25. package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
  26. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
  27. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
  28. package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
  29. package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
  30. package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
  31. package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
  32. package/node_modules/@posthog/core/dist/traces/config.js +101 -0
  33. package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
  34. package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
  35. package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
  36. package/node_modules/@posthog/core/dist/traces/context.js +53 -0
  37. package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
  38. package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
  39. package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
  40. package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
  41. package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
  42. package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
  43. package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
  44. package/node_modules/@posthog/core/dist/traces/index.js +551 -0
  45. package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
  46. package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
  47. package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
  48. package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
  49. package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
  50. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
  51. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
  52. package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
  53. package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
  54. package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
  55. package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
  56. package/node_modules/@posthog/core/dist/traces/span.js +533 -0
  57. package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
  58. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
  59. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
  60. package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
  61. package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
  62. package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
  63. package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
  64. package/node_modules/@posthog/core/dist/traces/types.js +18 -0
  65. package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
  66. package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
  67. package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
  68. package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
  69. package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
  70. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
  71. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
  72. package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
  73. package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
  74. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
  75. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
  76. package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
  77. package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
  78. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
  79. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
  80. package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
  81. package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
  82. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
  83. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
  84. package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
  85. package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
  86. package/node_modules/@posthog/core/package.json +3 -3
  87. package/node_modules/@posthog/core/src/error-tracking/index.ts +24 -1
  88. package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
  89. package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
  90. package/node_modules/@posthog/core/src/index.ts +18 -0
  91. package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
  92. package/node_modules/@posthog/core/src/logs/index.ts +63 -31
  93. package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
  94. package/node_modules/@posthog/core/src/logs/types.ts +2 -2
  95. package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
  96. package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
  97. package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
  98. package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
  99. package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
  100. package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
  101. package/node_modules/@posthog/core/src/traces/config.ts +142 -0
  102. package/node_modules/@posthog/core/src/traces/context.ts +30 -0
  103. package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
  104. package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
  105. package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
  106. package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
  107. package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
  108. package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
  109. package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
  110. package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
  111. package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
  112. package/node_modules/@posthog/core/src/traces/span.ts +822 -0
  113. package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
  114. package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
  115. package/node_modules/@posthog/core/src/traces/types.ts +140 -0
  116. package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
  117. package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
  118. package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
  119. package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
  120. package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
  121. package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
  122. package/node_modules/@posthog/types/dist/index.d.ts +1 -0
  123. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  124. package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
  125. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  126. package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
  127. package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
  128. package/node_modules/@posthog/types/dist/traces.js +18 -0
  129. package/node_modules/@posthog/types/dist/traces.mjs +1 -0
  130. package/node_modules/@posthog/types/package.json +1 -1
  131. package/node_modules/@posthog/types/src/index.ts +19 -0
  132. package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
  133. package/node_modules/@posthog/types/src/traces.ts +429 -0
  134. package/node_modules/@zuplo/core/package.json +1 -1
  135. package/node_modules/@zuplo/graphql/package.json +1 -1
  136. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  137. package/node_modules/@zuplo/otel/package.json +1 -1
  138. package/node_modules/@zuplo/runtime/out/esm/{chunk-FEB2VFS7.js → chunk-KNAYKBEE.js} +96 -96
  139. package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
  140. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  141. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
  142. package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
  143. package/node_modules/@zuplo/runtime/package.json +1 -1
  144. package/package.json +11 -11
  145. package/node_modules/@zuplo/runtime/out/esm/chunk-FEB2VFS7.js.map +0 -1
  146. /package/node_modules/@zuplo/runtime/out/esm/{chunk-FEB2VFS7.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
+ })