@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.
Files changed (143) hide show
  1. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
  2. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
  3. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
  4. package/node_modules/@posthog/core/dist/index.d.ts +7 -0
  5. package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
  6. package/node_modules/@posthog/core/dist/index.js +82 -21
  7. package/node_modules/@posthog/core/dist/index.mjs +4 -0
  8. package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
  9. package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
  10. package/node_modules/@posthog/core/dist/logs/index.js +28 -22
  11. package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
  12. package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
  13. package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
  14. package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
  15. package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
  16. package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
  17. package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
  18. package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
  19. package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
  20. package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
  21. package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
  22. package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
  23. package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
  24. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
  25. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
  26. package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
  27. package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
  28. package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
  29. package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
  30. package/node_modules/@posthog/core/dist/traces/config.js +101 -0
  31. package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
  32. package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
  33. package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
  34. package/node_modules/@posthog/core/dist/traces/context.js +53 -0
  35. package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
  36. package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
  37. package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
  38. package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
  39. package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
  40. package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
  41. package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
  42. package/node_modules/@posthog/core/dist/traces/index.js +551 -0
  43. package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
  44. package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
  45. package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
  46. package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
  47. package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
  48. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
  49. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
  50. package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
  51. package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
  52. package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
  53. package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
  54. package/node_modules/@posthog/core/dist/traces/span.js +533 -0
  55. package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
  56. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
  57. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
  58. package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
  59. package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
  60. package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
  61. package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
  62. package/node_modules/@posthog/core/dist/traces/types.js +18 -0
  63. package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
  64. package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
  65. package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
  66. package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
  67. package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
  68. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
  69. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
  70. package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
  71. package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
  72. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
  73. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
  74. package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
  75. package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
  76. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
  77. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
  78. package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
  79. package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
  80. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
  81. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
  82. package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
  83. package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
  84. package/node_modules/@posthog/core/package.json +3 -3
  85. package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
  86. package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
  87. package/node_modules/@posthog/core/src/index.ts +18 -0
  88. package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
  89. package/node_modules/@posthog/core/src/logs/index.ts +63 -31
  90. package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
  91. package/node_modules/@posthog/core/src/logs/types.ts +2 -2
  92. package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
  93. package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
  94. package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
  95. package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
  96. package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
  97. package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
  98. package/node_modules/@posthog/core/src/traces/config.ts +142 -0
  99. package/node_modules/@posthog/core/src/traces/context.ts +30 -0
  100. package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
  101. package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
  102. package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
  103. package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
  104. package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
  105. package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
  106. package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
  107. package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
  108. package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
  109. package/node_modules/@posthog/core/src/traces/span.ts +822 -0
  110. package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
  111. package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
  112. package/node_modules/@posthog/core/src/traces/types.ts +140 -0
  113. package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
  114. package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
  115. package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
  116. package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
  117. package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
  118. package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
  119. package/node_modules/@posthog/types/dist/index.d.ts +1 -0
  120. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  121. package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
  122. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  123. package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
  124. package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
  125. package/node_modules/@posthog/types/dist/traces.js +18 -0
  126. package/node_modules/@posthog/types/dist/traces.mjs +1 -0
  127. package/node_modules/@posthog/types/package.json +1 -1
  128. package/node_modules/@posthog/types/src/index.ts +19 -0
  129. package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
  130. package/node_modules/@posthog/types/src/traces.ts +429 -0
  131. package/node_modules/@zuplo/core/package.json +1 -1
  132. package/node_modules/@zuplo/graphql/package.json +1 -1
  133. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  134. package/node_modules/@zuplo/otel/package.json +1 -1
  135. package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js → chunk-KNAYKBEE.js} +80 -80
  136. package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
  137. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  138. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
  139. package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
  140. package/node_modules/@zuplo/runtime/package.json +1 -1
  141. package/package.json +11 -11
  142. package/node_modules/@zuplo/runtime/out/esm/chunk-DRQ5CMVC.js.map +0 -1
  143. /package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js.LEGAL.txt → chunk-KNAYKBEE.js.LEGAL.txt} +0 -0
@@ -0,0 +1,271 @@
1
+ import { createMockLogger } from '@/testing'
2
+ import { resolveTracesConfig } from './config'
3
+
4
+ describe('resolveTracesConfig', () => {
5
+ it.each([
6
+ ['zero', 0],
7
+ ['negative', -1],
8
+ ['not a number', NaN],
9
+ // Floored, these read as 1, 2 and 3 — caps an order of magnitude below what
10
+ // the caller wrote, applied silently.
11
+ ['a fraction', 1.5],
12
+ ['a large fraction', 200.5],
13
+ ['infinity', Infinity],
14
+ ])('falls back to the default per-span caps when given %s', (_label, value) => {
15
+ const resolved = resolveTracesConfig({
16
+ maxAttributesPerSpan: value,
17
+ maxEventsPerSpan: value,
18
+ maxAttributeValueLength: value,
19
+ })
20
+ expect(resolved.maxAttributesPerSpan).toBe(128)
21
+ expect(resolved.maxEventsPerSpan).toBe(128)
22
+ expect(resolved.maxAttributeValueLength).toBe(8192)
23
+ })
24
+
25
+ it('honours explicit per-span caps', () => {
26
+ // Without this the resolver can ignore maxEventsPerSpan entirely and every
27
+ // other test still passes, because they all assert the default.
28
+ expect(resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7 })).toMatchObject({
29
+ maxAttributesPerSpan: 5,
30
+ maxEventsPerSpan: 7,
31
+ })
32
+ })
33
+
34
+ it('keeps the per-event attribute cap fixed', () => {
35
+ // The cap is internal, so an untyped caller naming it gets the default.
36
+ expect(resolveTracesConfig({ maxAttributesPerEvent: 9 } as any).maxAttributesPerEvent).toBe(128)
37
+ })
38
+
39
+ it('applies the documented defaults', () => {
40
+ expect(resolveTracesConfig(undefined)).toMatchObject({
41
+ flushIntervalMs: 5000,
42
+ maxExportBatchSize: 512,
43
+ maxQueueSize: 2048,
44
+ maxAttributesPerSpan: 128,
45
+ maxEventsPerSpan: 128,
46
+ maxAttributesPerEvent: 128,
47
+ maxAttributeValueLength: 8192,
48
+ maxLiveSpans: 10_000,
49
+ maxSpanAgeMs: 3_600_000,
50
+ })
51
+ })
52
+
53
+ it('honours an explicit attribute value bound', () => {
54
+ expect(resolveTracesConfig({ maxAttributeValueLength: 256 }).maxAttributeValueLength).toBe(256)
55
+ })
56
+
57
+ it('honours explicit live-span bounds', () => {
58
+ expect(resolveTracesConfig({ maxLiveSpans: 50, maxSpanAgeMs: 30_000 })).toMatchObject({
59
+ maxLiveSpans: 50,
60
+ maxSpanAgeMs: 30_000,
61
+ })
62
+ })
63
+
64
+ it.each([0, -1, Number.NaN])('falls back to the defaults for unusable live-span bounds (%p)', (value) => {
65
+ expect(resolveTracesConfig({ maxLiveSpans: value, maxSpanAgeMs: value })).toMatchObject({
66
+ maxLiveSpans: 10_000,
67
+ maxSpanAgeMs: 3_600_000,
68
+ })
69
+ })
70
+
71
+ it('leaves serviceName unset so core supplies unknown_service', () => {
72
+ expect(resolveTracesConfig({}).serviceName).toBeUndefined()
73
+ })
74
+
75
+ it('honours explicit values', () => {
76
+ expect(
77
+ resolveTracesConfig({ serviceName: 'checkout', flushIntervalMs: 1000, maxExportBatchSize: 50 })
78
+ ).toMatchObject({
79
+ serviceName: 'checkout',
80
+ flushIntervalMs: 1000,
81
+ maxExportBatchSize: 50,
82
+ })
83
+ })
84
+
85
+ it('lets OTLP resource attributes override the named fields', () => {
86
+ const resolved = resolveTracesConfig({
87
+ serviceName: 'named',
88
+ serviceVersion: '1.0.0',
89
+ environment: 'staging',
90
+ resourceAttributes: {
91
+ 'service.name': 'from-attributes',
92
+ 'service.version': '2.0.0',
93
+ 'deployment.environment': 'production',
94
+ },
95
+ })
96
+
97
+ expect(resolved.serviceName).toBe('from-attributes')
98
+ expect(resolved.serviceVersion).toBe('2.0.0')
99
+ expect(resolved.environment).toBe('production')
100
+ })
101
+
102
+ it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])(
103
+ 'falls back to the default for an unusable maxExportBatchSize (%p)',
104
+ (value) => {
105
+ // A non-positive batch size reaches an export loop that cannot make
106
+ // progress with it, so it spins forever posting empty batches.
107
+ expect(resolveTracesConfig({ maxExportBatchSize: value }).maxExportBatchSize).toBe(512)
108
+ }
109
+ )
110
+
111
+ it('takes the default for a fractional batch size rather than flooring it', () => {
112
+ // Every numeric knob resolves the same way, so a fraction is a value the
113
+ // caller did not mean rather than one to round down behind their back.
114
+ expect(resolveTracesConfig({ maxExportBatchSize: 10.9 }).maxExportBatchSize).toBe(512)
115
+ })
116
+
117
+ it.each([0, -1, 1.5, Number.NaN])('falls back to the default for an unusable flushIntervalMs (%p)', (value) => {
118
+ expect(resolveTracesConfig({ flushIntervalMs: value }).flushIntervalMs).toBe(5000)
119
+ })
120
+
121
+ it.each([0.5, 10_000.5])('falls back to the defaults for fractional live-span bounds (%p)', (value) => {
122
+ expect(resolveTracesConfig({ maxLiveSpans: value, maxSpanAgeMs: value })).toMatchObject({
123
+ maxLiveSpans: 10_000,
124
+ maxSpanAgeMs: 3_600_000,
125
+ })
126
+ })
127
+
128
+ it('keeps the queue at least as large as the export batch', () => {
129
+ // A queue smaller than the flush trigger would stop the depth-based flush
130
+ // from ever firing.
131
+ expect(resolveTracesConfig({ maxExportBatchSize: 5000 }).maxQueueSize).toBe(5000)
132
+ expect(resolveTracesConfig({ maxExportBatchSize: 10 }).maxQueueSize).toBe(2048)
133
+ })
134
+
135
+ it('honours an explicit maxQueueSize', () => {
136
+ expect(resolveTracesConfig({ maxQueueSize: 100_000 }).maxQueueSize).toBe(100_000)
137
+ })
138
+
139
+ it('floors an explicit maxQueueSize at the export batch size', () => {
140
+ expect(resolveTracesConfig({ maxExportBatchSize: 512, maxQueueSize: 10 }).maxQueueSize).toBe(512)
141
+ })
142
+
143
+ it('attaches the host resource attributes the entrypoint supplies', () => {
144
+ expect(
145
+ resolveTracesConfig(undefined, { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }).resourceAttributes
146
+ ).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' })
147
+ })
148
+
149
+ it('lets user resource attributes override the host ones', () => {
150
+ expect(
151
+ resolveTracesConfig(
152
+ { resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } },
153
+ { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }
154
+ ).resourceAttributes
155
+ ).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' })
156
+ })
157
+
158
+ it('resolves when the entrypoint supplies no host attributes', () => {
159
+ expect(resolveTracesConfig({ resourceAttributes: { 'host.name': 'worker-01' } }).resourceAttributes).toEqual({
160
+ 'host.name': 'worker-01',
161
+ })
162
+ })
163
+ })
164
+
165
+ describe('resourceAttributes guarding', () => {
166
+ it('ignores a non-object value', () => {
167
+ const { resourceAttributes } = resolveTracesConfig({ resourceAttributes: 'oops' as never })
168
+
169
+ expect(Object.keys(resourceAttributes ?? {})).toEqual([])
170
+ })
171
+
172
+ it('ignores an array, which would otherwise spread as numeric keys', () => {
173
+ const { resourceAttributes } = resolveTracesConfig({
174
+ resourceAttributes: [{ key: 'service.name' }] as never,
175
+ })
176
+
177
+ expect(Object.keys(resourceAttributes ?? {})).not.toContain('0')
178
+ })
179
+
180
+ it('drops an identity key that is not a string', () => {
181
+ const resolved = resolveTracesConfig({
182
+ serviceName: 'checkout-api',
183
+ resourceAttributes: { 'service.name': 12345 as never, region: 'us' },
184
+ })
185
+
186
+ expect(resolved.serviceName).toBe('checkout-api')
187
+ expect(resolved.resourceAttributes).toEqual({ region: 'us' })
188
+ })
189
+
190
+ it('ignores a beforeSpanSend entry that is not a function', () => {
191
+ // A plain-JS caller passing the wrong shape would otherwise have every span
192
+ // dropped by a hook that throws on call, with tracing silently off.
193
+ const scrub = (span: any): any => span
194
+ const resolved = resolveTracesConfig({ beforeSpanSend: ['not a function', scrub] as never })
195
+
196
+ expect(resolved.beforeSpanSend).toEqual([scrub])
197
+ })
198
+
199
+ it('warns about a dropped hook, since the redaction it was configured for is gone', () => {
200
+ const logger = createMockLogger()
201
+ const scrub = (span: any): any => span
202
+
203
+ resolveTracesConfig({ beforeSpanSend: ['not a function', scrub] as never }, undefined, logger)
204
+
205
+ // `critical`, not `warn`: every other level is gated behind `debug: true`.
206
+ expect(logger.critical).toHaveBeenCalledWith(expect.stringContaining('ignoring 1 of 2 entries'))
207
+ })
208
+
209
+ it('stays quiet for a conditionally disabled hook', () => {
210
+ // `[featureEnabled && scrub]` is ordinary JS; nothing was configured to
211
+ // redact, so claiming redaction is broken would be false alarm.
212
+ const logger = createMockLogger()
213
+
214
+ const resolved = resolveTracesConfig({ beforeSpanSend: [false, null, undefined] as never }, undefined, logger)
215
+
216
+ expect(resolved.beforeSpanSend).toEqual([])
217
+ expect(logger.critical).not.toHaveBeenCalled()
218
+ })
219
+
220
+ it('stays quiet when every hook is callable', () => {
221
+ const logger = createMockLogger()
222
+
223
+ resolveTracesConfig({ beforeSpanSend: [(span: any): any => span] }, undefined, logger)
224
+
225
+ expect(logger.critical).not.toHaveBeenCalled()
226
+ })
227
+
228
+ it('resolves to no hooks when beforeSpanSend is the wrong type entirely', () => {
229
+ const resolved = resolveTracesConfig({ beforeSpanSend: { scrub: true } as never })
230
+
231
+ expect(resolved.beforeSpanSend).toEqual([])
232
+ })
233
+
234
+ it('does not throw when an identity accessor throws', () => {
235
+ const hostile = {}
236
+ Object.defineProperty(hostile, 'service.name', {
237
+ enumerable: true,
238
+ get() {
239
+ throw new Error('config getter exploded')
240
+ },
241
+ })
242
+
243
+ expect(() => resolveTracesConfig({ resourceAttributes: hostile as never })).not.toThrow()
244
+ })
245
+
246
+ it('does not throw when a non-identity accessor throws', () => {
247
+ const hostile = { 'service.name': 'checkout-api' }
248
+ Object.defineProperty(hostile, 'region', {
249
+ enumerable: true,
250
+ get() {
251
+ throw new Error('config getter exploded')
252
+ },
253
+ })
254
+
255
+ expect(() => resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' })).not.toThrow()
256
+ })
257
+
258
+ it('keeps the readable attributes when one accessor throws', () => {
259
+ const hostile = { region: 'us' }
260
+ Object.defineProperty(hostile, 'tenant', {
261
+ enumerable: true,
262
+ get() {
263
+ throw new Error('config getter exploded')
264
+ },
265
+ })
266
+
267
+ const resolved = resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' })
268
+
269
+ expect(resolved.resourceAttributes).toMatchObject({ 'os.name': 'Linux', region: 'us' })
270
+ })
271
+ })
@@ -0,0 +1,142 @@
1
+ import { assignUserAttributes } from '../utils/json-utils'
2
+ import type { ResolvedTracesConfig } from './types'
3
+ import type { BeforeSpanSendFn, TracesConfig } from '@posthog/types'
4
+ import type { Logger } from '../types'
5
+
6
+ // OpenTelemetry's BatchSpanProcessor defaults, which sit comfortably under the
7
+ // server's request body cap.
8
+ const DEFAULT_FLUSH_INTERVAL_MS = 5000
9
+ const DEFAULT_MAX_EXPORT_BATCH_SIZE = 512
10
+ const DEFAULT_MAX_QUEUE_SIZE = 2048
11
+ // OpenTelemetry's per-span defaults.
12
+ const DEFAULT_MAX_ATTRIBUTES_PER_SPAN = 128
13
+ const DEFAULT_MAX_EVENTS_PER_SPAN = 128
14
+ // OpenTelemetry's per-event limit, which is the same number. Fixed rather than
15
+ // configurable: `maxAttributesPerSpan` and `maxEventsPerSpan` already give a
16
+ // caller room to shape a span, and this one only has to stop an event holding
17
+ // an unbounded bag.
18
+ const DEFAULT_MAX_ATTRIBUTES_PER_EVENT = 128
19
+ // OpenTelemetry leaves the value length unlimited, which is what lets one
20
+ // multi-MB attribute make a span too large for the endpoint to accept — and an
21
+ // oversized span is dropped whole. 8 KB bounds a single string: it holds a deep
22
+ // stack trace and any realistic header, query string or payload excerpt, and
23
+ // keeps a span's own attributes under 1 MB at the attribute cap. A span's total
24
+ // size is the product of these caps; the body limit itself is enforced at the
25
+ // batch boundary, which is where the whole payload can be weighed at once.
26
+ const DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH = 8192
27
+
28
+ // Live-span bounds. A server can legitimately hold thousands of spans open at
29
+ // once, and refusing a legitimate span is worse than tolerating a leak, so the
30
+ // count bound sits well above realistic concurrency — affordable because live
31
+ // accounting is an id and a timestamp per span, not the span. The age bound is
32
+ // an hour: production traces routinely run past ten minutes, and a span still
33
+ // open after an hour is a leak rather than slow work.
34
+ const DEFAULT_MAX_LIVE_SPANS = 10_000
35
+ const DEFAULT_MAX_SPAN_AGE_MS = 3_600_000
36
+
37
+ /**
38
+ * Coerces a caller-supplied positive-integer option. `0`, a negative, or `NaN`
39
+ * reaching the export loop would stall it.
40
+ *
41
+ * A fraction takes the default rather than being floored: these are documented
42
+ * as positive integers, and silently reading `maxAttributesPerSpan: 1.5` as `1`
43
+ * caps a span an order of magnitude below what the caller wrote.
44
+ */
45
+ function positiveInteger(value: number | undefined, fallback: number): number {
46
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback
47
+ }
48
+
49
+ const IDENTITY_KEYS = ['service.name', 'service.version', 'deployment.environment'] as const
50
+
51
+ /**
52
+ * Drops a non-string identity key rather than letting it through: the resolver
53
+ * would ignore it, and it would still reach the wire as an int and leave the
54
+ * spans unattributable.
55
+ */
56
+ function withUsableIdentityKeys(attributes: TracesConfig['resourceAttributes']): TracesConfig['resourceAttributes'] {
57
+ // A primitive or array would otherwise be spread into attributes keyed "0", "1", "2".
58
+ if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
59
+ return undefined
60
+ }
61
+ try {
62
+ if (IDENTITY_KEYS.every((key) => !(key in attributes) || typeof attributes[key] === 'string')) {
63
+ return attributes
64
+ }
65
+ const usable = { ...attributes }
66
+ for (const key of IDENTITY_KEYS) {
67
+ if (key in usable && typeof usable[key] !== 'string') {
68
+ delete usable[key]
69
+ }
70
+ }
71
+ return usable
72
+ } catch {
73
+ // A throwing accessor on the config object must not escape `startSpan`.
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Keeps only the callable hooks. Anything else is dropped rather than called: an
80
+ * untyped caller passing the wrong shape would otherwise have every span dropped
81
+ * by a hook that throws, leaving tracing silently off.
82
+ *
83
+ * Reported at `critical` rather than thrown on: a constructor that throws takes
84
+ * the application down, but every other log level is gated behind `debug: true`,
85
+ * and an inert redaction hook ships the values it was meant to remove.
86
+ */
87
+ function resolveBeforeSpanSend(beforeSpanSend: TracesConfig['beforeSpanSend'], logger?: Logger): BeforeSpanSendFn[] {
88
+ if (!beforeSpanSend) {
89
+ return []
90
+ }
91
+ // `[featureEnabled && scrub]` is ordinary JS, and a caller who wrote it did not
92
+ // configure a hook at all — only a value that was meant to be one is worth
93
+ // shouting about.
94
+ const supplied = [beforeSpanSend].flat().filter((hook) => Boolean(hook))
95
+ const hooks = supplied.filter((hook): hook is BeforeSpanSendFn => typeof hook === 'function')
96
+ if (hooks.length !== supplied.length) {
97
+ logger?.critical(
98
+ `beforeSpanSend: ignoring ${supplied.length - hooks.length} of ${supplied.length} entries that are not functions. ` +
99
+ 'Spans export without them, so whatever they were redacting is not redacted.'
100
+ )
101
+ }
102
+ return hooks
103
+ }
104
+
105
+ /**
106
+ * Resolves the public `traces` config into the shape core `PostHogTraces` consumes.
107
+ * OTLP resource attributes take precedence over the named fields, matching the
108
+ * logs config. `hostResourceAttributes` are runtime-detected by the entrypoint and
109
+ * merge first, so a user-supplied value of the same key wins.
110
+ *
111
+ * @internal Exposed for cross-package use within this SDK; not part of the stable public API.
112
+ */
113
+ export function resolveTracesConfig(
114
+ config: TracesConfig | undefined,
115
+ hostResourceAttributes?: Record<string, string>,
116
+ logger?: Logger
117
+ ): ResolvedTracesConfig {
118
+ // Copied key by key rather than spread: a throwing accessor on a user-supplied
119
+ // attribute would otherwise escape the first `startSpan`.
120
+ const resourceAttributes = assignUserAttributes(
121
+ { ...hostResourceAttributes },
122
+ withUsableIdentityKeys(config?.resourceAttributes)
123
+ )
124
+ const maxExportBatchSize = positiveInteger(config?.maxExportBatchSize, DEFAULT_MAX_EXPORT_BATCH_SIZE)
125
+ return {
126
+ serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? config?.serviceName,
127
+ serviceVersion: (resourceAttributes?.['service.version'] as string | undefined) ?? config?.serviceVersion,
128
+ environment: (resourceAttributes?.['deployment.environment'] as string | undefined) ?? config?.environment,
129
+ resourceAttributes,
130
+ beforeSpanSend: resolveBeforeSpanSend(config?.beforeSpanSend, logger),
131
+ maxAttributesPerSpan: positiveInteger(config?.maxAttributesPerSpan, DEFAULT_MAX_ATTRIBUTES_PER_SPAN),
132
+ maxEventsPerSpan: positiveInteger(config?.maxEventsPerSpan, DEFAULT_MAX_EVENTS_PER_SPAN),
133
+ maxAttributesPerEvent: DEFAULT_MAX_ATTRIBUTES_PER_EVENT,
134
+ maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH),
135
+ flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS),
136
+ maxExportBatchSize,
137
+ // Never below the flush trigger, or the depth-based flush could never fire.
138
+ maxQueueSize: Math.max(positiveInteger(config?.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE), maxExportBatchSize),
139
+ maxLiveSpans: positiveInteger(config?.maxLiveSpans, DEFAULT_MAX_LIVE_SPANS),
140
+ maxSpanAgeMs: positiveInteger(config?.maxSpanAgeMs, DEFAULT_MAX_SPAN_AGE_MS),
141
+ }
142
+ }
@@ -0,0 +1,30 @@
1
+ import type { Span } from '@posthog/types'
2
+ import type { SpanContextManager } from './types'
3
+
4
+ /**
5
+ * Synchronous active-span tracking: restores the previous active span when the
6
+ * callback returns, which for an async callback means when it returns its
7
+ * promise — so spans started after an `await` won't see it as active.
8
+ *
9
+ * The fallback for runtimes with no ambient async context; Node injects an
10
+ * `AsyncLocalStorage`-backed manager instead. `parent` is the escape hatch.
11
+ *
12
+ * @internal Exposed for cross-package use within this SDK; not part of the stable public API.
13
+ */
14
+ export class SyncSpanContextManager implements SpanContextManager {
15
+ private _active: Span | undefined
16
+
17
+ active(): Span | undefined {
18
+ return this._active
19
+ }
20
+
21
+ with<T>(span: Span, fn: () => T): T {
22
+ const previous = this._active
23
+ this._active = span
24
+ try {
25
+ return fn()
26
+ } finally {
27
+ this._active = previous
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,115 @@
1
+ import { getRandomBytes, isValidSpanId, isValidTraceId, newSpanId, newTraceId } from './ids'
2
+
3
+ describe('trace and span ids', () => {
4
+ describe('newTraceId', () => {
5
+ it('is 32 lowercase hex characters', () => {
6
+ for (let i = 0; i < 50; i++) {
7
+ expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
8
+ }
9
+ })
10
+
11
+ it('is never all zeros', () => {
12
+ for (let i = 0; i < 50; i++) {
13
+ expect(newTraceId()).not.toBe('0'.repeat(32))
14
+ }
15
+ })
16
+
17
+ it('does not repeat', () => {
18
+ const ids = new Set(Array.from({ length: 200 }, newTraceId))
19
+ expect(ids.size).toBe(200)
20
+ })
21
+ })
22
+
23
+ describe('newSpanId', () => {
24
+ it('is 16 lowercase hex characters', () => {
25
+ for (let i = 0; i < 50; i++) {
26
+ expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/)
27
+ }
28
+ })
29
+
30
+ it('does not repeat', () => {
31
+ const ids = new Set(Array.from({ length: 200 }, newSpanId))
32
+ expect(ids.size).toBe(200)
33
+ })
34
+ })
35
+
36
+ describe('getRandomBytes', () => {
37
+ it('returns the requested length', () => {
38
+ expect(getRandomBytes(8)).toHaveLength(8)
39
+ expect(getRandomBytes(16)).toHaveLength(16)
40
+ })
41
+
42
+ it('falls back to Math.random when crypto is unavailable', () => {
43
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
44
+ // React Native has no global crypto without a polyfill — the fallback path
45
+ // is what keeps span ids working there.
46
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
47
+ try {
48
+ expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
49
+ expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/)
50
+ } finally {
51
+ if (original) {
52
+ Object.defineProperty(globalThis, 'crypto', original)
53
+ }
54
+ }
55
+ })
56
+
57
+ it('falls back when getRandomValues throws', () => {
58
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
59
+ Object.defineProperty(globalThis, 'crypto', {
60
+ value: {
61
+ getRandomValues: () => {
62
+ throw new Error('not allowed')
63
+ },
64
+ },
65
+ configurable: true,
66
+ })
67
+ try {
68
+ expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/)
69
+ } finally {
70
+ if (original) {
71
+ Object.defineProperty(globalThis, 'crypto', original)
72
+ }
73
+ }
74
+ })
75
+
76
+ it('never emits an all-zero id even when the random source is broken', () => {
77
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
78
+ Object.defineProperty(globalThis, 'crypto', {
79
+ value: { getRandomValues: (array: Uint8Array) => array.fill(0) },
80
+ configurable: true,
81
+ })
82
+ try {
83
+ // The server zeroes ids it can't use, so an all-zero id would be stored
84
+ // and silently orphaned rather than rejected.
85
+ expect(newTraceId()).not.toBe('0'.repeat(32))
86
+ expect(newSpanId()).not.toBe('0'.repeat(16))
87
+ } finally {
88
+ if (original) {
89
+ Object.defineProperty(globalThis, 'crypto', original)
90
+ }
91
+ }
92
+ })
93
+ })
94
+
95
+ describe('validation', () => {
96
+ it.each([
97
+ ['a valid trace id', '4bf92f3577b34da6a3ce929d0e0e4736', true],
98
+ ['an all-zero trace id', '0'.repeat(32), false],
99
+ ['a short trace id', 'abc', false],
100
+ ['uppercase hex', '4BF92F3577B34DA6A3CE929D0E0E4736', false],
101
+ ['a non-hex string', 'zzf92f3577b34da6a3ce929d0e0e4736', false],
102
+ ['a non-string', 12345, false],
103
+ ])('isValidTraceId rejects/accepts %s', (_name, value, expected) => {
104
+ expect(isValidTraceId(value)).toBe(expected)
105
+ })
106
+
107
+ it.each([
108
+ ['a valid span id', '00f067aa0ba902b7', true],
109
+ ['an all-zero span id', '0'.repeat(16), false],
110
+ ['a trace-length id', '4bf92f3577b34da6a3ce929d0e0e4736', false],
111
+ ])('isValidSpanId rejects/accepts %s', (_name, value, expected) => {
112
+ expect(isValidSpanId(value)).toBe(expected)
113
+ })
114
+ })
115
+ })
@@ -0,0 +1,75 @@
1
+ // W3C Trace Context identifier generation. Trace ids are 16 bytes, span ids 8,
2
+ // both lowercase hex on the JSON wire. The ingestion service *zeroes* ids that
3
+ // aren't exactly the right length rather than rejecting them, silently orphaning
4
+ // the span — so length is load-bearing and every id is validated before it ships.
5
+
6
+ const TRACE_ID_BYTES = 16
7
+ const SPAN_ID_BYTES = 8
8
+
9
+ const TRACE_ID_HEX = TRACE_ID_BYTES * 2
10
+ const SPAN_ID_HEX = SPAN_ID_BYTES * 2
11
+
12
+ const INVALID_TRACE_ID = '0'.repeat(TRACE_ID_HEX)
13
+ const INVALID_SPAN_ID = '0'.repeat(SPAN_ID_HEX)
14
+
15
+ const HEX_RE = /^[0-9a-f]+$/
16
+
17
+ type CryptoLike = { getRandomValues?: (array: Uint8Array) => Uint8Array }
18
+
19
+ /**
20
+ * Random bytes from the platform's CSPRNG, falling back to `Math.random`.
21
+ *
22
+ * The fallback exists for React Native, which has no global `crypto` without a
23
+ * polyfill. Trace ids need collision resistance, not unpredictability.
24
+ */
25
+ export function getRandomBytes(byteLength: number): Uint8Array {
26
+ const bytes = new Uint8Array(byteLength)
27
+ const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto
28
+ if (cryptoLike && typeof cryptoLike.getRandomValues === 'function') {
29
+ try {
30
+ cryptoLike.getRandomValues(bytes)
31
+ return bytes
32
+ } catch {
33
+ // A locked-down `crypto` throws; fall through to the `Math.random` path.
34
+ }
35
+ }
36
+ for (let i = 0; i < byteLength; i++) {
37
+ bytes[i] = Math.floor(Math.random() * 256)
38
+ }
39
+ return bytes
40
+ }
41
+
42
+ function bytesToHex(bytes: Uint8Array): string {
43
+ let hex = ''
44
+ for (let i = 0; i < bytes.length; i++) {
45
+ hex += bytes[i].toString(16).padStart(2, '0')
46
+ }
47
+ return hex
48
+ }
49
+
50
+ function randomHexId(byteLength: number): string {
51
+ const hex = bytesToHex(getRandomBytes(byteLength))
52
+ // An all-zero id is invalid per W3C and the server treats one as absent, so the
53
+ // span would be stored and silently orphaned. Only reachable from a broken source.
54
+ return /[^0]/.test(hex) ? hex : hex.slice(0, -1) + '1'
55
+ }
56
+
57
+ export function newTraceId(): string {
58
+ return randomHexId(TRACE_ID_BYTES)
59
+ }
60
+
61
+ export function newSpanId(): string {
62
+ return randomHexId(SPAN_ID_BYTES)
63
+ }
64
+
65
+ function isValidHexId(value: unknown, length: number, invalid: string): value is string {
66
+ return typeof value === 'string' && value.length === length && value !== invalid && HEX_RE.test(value)
67
+ }
68
+
69
+ export function isValidTraceId(value: unknown): value is string {
70
+ return isValidHexId(value, TRACE_ID_HEX, INVALID_TRACE_ID)
71
+ }
72
+
73
+ export function isValidSpanId(value: unknown): value is string {
74
+ return isValidHexId(value, SPAN_ID_HEX, INVALID_SPAN_ID)
75
+ }