@zuplo/cli 7.4.4 → 7.4.5

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 (61) hide show
  1. package/dist/common/settings.d.ts +1 -0
  2. package/dist/common/settings.d.ts.map +1 -1
  3. package/dist/common/settings.js +3 -0
  4. package/dist/common/settings.js.map +1 -1
  5. package/dist/deploy/classify-build-probe.d.ts +14 -0
  6. package/dist/deploy/classify-build-probe.d.ts.map +1 -0
  7. package/dist/deploy/classify-build-probe.js +35 -0
  8. package/dist/deploy/classify-build-probe.js.map +1 -0
  9. package/dist/deploy/classify-build-probe.test.d.ts +2 -0
  10. package/dist/deploy/classify-build-probe.test.d.ts.map +1 -0
  11. package/dist/deploy/classify-build-probe.test.js +51 -0
  12. package/dist/deploy/classify-build-probe.test.js.map +1 -0
  13. package/dist/deploy/handler.js +20 -5
  14. package/dist/deploy/handler.js.map +1 -1
  15. package/dist/deploy/poll-deployment.d.ts +8 -4
  16. package/dist/deploy/poll-deployment.d.ts.map +1 -1
  17. package/dist/deploy/poll-deployment.js +32 -4
  18. package/dist/deploy/poll-deployment.js.map +1 -1
  19. package/dist/deploy/poll-deployment.test.d.ts +2 -0
  20. package/dist/deploy/poll-deployment.test.d.ts.map +1 -0
  21. package/dist/deploy/poll-deployment.test.js +158 -0
  22. package/dist/deploy/poll-deployment.test.js.map +1 -0
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/node_modules/@posthog/core/dist/logs/index.js +1 -1
  25. package/node_modules/@posthog/core/dist/logs/index.mjs +1 -1
  26. package/node_modules/@posthog/core/dist/logs/logs-utils.d.ts +4 -13
  27. package/node_modules/@posthog/core/dist/logs/logs-utils.d.ts.map +1 -1
  28. package/node_modules/@posthog/core/dist/logs/logs-utils.js +160 -26
  29. package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +160 -26
  30. package/node_modules/@posthog/core/dist/metrics/index.js +1 -1
  31. package/node_modules/@posthog/core/dist/metrics/index.mjs +1 -1
  32. package/node_modules/@posthog/core/dist/posthog-core.d.ts.map +1 -1
  33. package/node_modules/@posthog/core/dist/posthog-core.js +12 -7
  34. package/node_modules/@posthog/core/dist/posthog-core.mjs +12 -7
  35. package/node_modules/@posthog/core/dist/utils/index.d.ts +1 -1
  36. package/node_modules/@posthog/core/dist/utils/index.d.ts.map +1 -1
  37. package/node_modules/@posthog/core/dist/utils/index.js +14 -24
  38. package/node_modules/@posthog/core/dist/utils/index.mjs +2 -2
  39. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +15 -0
  40. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
  41. package/node_modules/@posthog/core/dist/utils/json-utils.js +24 -0
  42. package/node_modules/@posthog/core/dist/utils/json-utils.mjs +1 -1
  43. package/node_modules/@posthog/core/package.json +2 -2
  44. package/node_modules/@posthog/core/src/logs/index.ts +1 -1
  45. package/node_modules/@posthog/core/src/logs/logs-utils.spec.ts +314 -9
  46. package/node_modules/@posthog/core/src/logs/logs-utils.ts +215 -27
  47. package/node_modules/@posthog/core/src/metrics/index.spec.ts +15 -1
  48. package/node_modules/@posthog/core/src/metrics/index.ts +1 -1
  49. package/node_modules/@posthog/core/src/posthog-core.ts +19 -12
  50. package/node_modules/@posthog/core/src/utils/index.ts +3 -1
  51. package/node_modules/@posthog/core/src/utils/json-utils.ts +15 -8
  52. package/node_modules/@posthog/types/dist/capture-log.d.ts +10 -1
  53. package/node_modules/@posthog/types/dist/capture-log.d.ts.map +1 -1
  54. package/node_modules/@posthog/types/package.json +1 -1
  55. package/node_modules/@posthog/types/src/capture-log.ts +8 -1
  56. package/node_modules/@zuplo/core/package.json +1 -1
  57. package/node_modules/@zuplo/graphql/package.json +1 -1
  58. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  59. package/node_modules/@zuplo/otel/package.json +1 -1
  60. package/node_modules/@zuplo/runtime/package.json +1 -1
  61. package/package.json +11 -11
@@ -1,4 +1,4 @@
1
- import type { CaptureLogOptions, LogSeverityLevel } from '@posthog/types'
1
+ import type { CaptureLogOptions, LogAttributeValue, LogSeverityLevel } from '@posthog/types'
2
2
  import type { LogSdkContext } from './types'
3
3
  import {
4
4
  buildOtlpLogRecord,
@@ -69,10 +69,36 @@ describe('logs-utils', () => {
69
69
  expect(toOtlpAnyValue('hello')).toEqual({ stringValue: 'hello' })
70
70
  })
71
71
 
72
- it('converts integers', () => {
73
- expect(toOtlpAnyValue(42)).toEqual({ intValue: 42 })
74
- expect(toOtlpAnyValue(0)).toEqual({ intValue: 0 })
75
- expect(toOtlpAnyValue(-7)).toEqual({ intValue: -7 })
72
+ it('converts integers to decimal strings', () => {
73
+ expect(toOtlpAnyValue(42)).toEqual({ intValue: '42' })
74
+ expect(toOtlpAnyValue(0)).toEqual({ intValue: '0' })
75
+ expect(toOtlpAnyValue(-7)).toEqual({ intValue: '-7' })
76
+ })
77
+
78
+ // Spec: outside int64 it is a stringValue, never an intValue.
79
+ it('converts integers outside int64 to stringValue', () => {
80
+ expect(toOtlpAnyValue(2 ** 63)).toEqual({ stringValue: '9223372036854775808' })
81
+ expect(toOtlpAnyValue(-(2 ** 64))).toEqual({ stringValue: '-18446744073709551616' })
82
+ expect(toOtlpAnyValue(1e21)).toEqual({ stringValue: '1000000000000000000000' })
83
+ })
84
+
85
+ it('logs a debug line when an integer falls outside int64', () => {
86
+ const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
87
+ toOtlpAnyValue(2 ** 63, logger as any)
88
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('outside the int64 range'))
89
+ })
90
+
91
+ it('keeps int64 min as intValue', () => {
92
+ // In range, but `String` renders it 192 below int64 min, so the decimal
93
+ // has to come from BigInt.
94
+ expect(toOtlpAnyValue(-(2 ** 63))).toEqual({ intValue: '-9223372036854775808' })
95
+ })
96
+
97
+ it('keeps large in-range integers exact', () => {
98
+ expect(toOtlpAnyValue(Number.MAX_SAFE_INTEGER)).toEqual({ intValue: '9007199254740991' })
99
+ // The largest double below 2^63 — no double exists between the two.
100
+ expect(toOtlpAnyValue(9223372036854774784)).toEqual({ intValue: '9223372036854774784' })
101
+ expect(toOtlpAnyValue(2 ** 62)).toEqual({ intValue: '4611686018427387904' })
76
102
  })
77
103
 
78
104
  it('converts floats to doubleValue', () => {
@@ -108,16 +134,295 @@ describe('logs-utils', () => {
108
134
  it('converts mixed primitive arrays recursively', () => {
109
135
  expect(toOtlpAnyValue([1, 'x', true])).toEqual({
110
136
  arrayValue: {
111
- values: [{ intValue: 1 }, { stringValue: 'x' }, { boolValue: true }],
137
+ values: [{ intValue: '1' }, { stringValue: 'x' }, { boolValue: true }],
112
138
  },
113
139
  })
114
140
  })
115
141
 
116
- it('JSON-stringifies plain objects', () => {
142
+ it('converts plain objects to kvlistValue', () => {
117
143
  expect(toOtlpAnyValue({ a: 1, b: 'two' })).toEqual({
118
- stringValue: '{"a":1,"b":"two"}',
144
+ kvlistValue: {
145
+ values: [
146
+ { key: 'a', value: { intValue: '1' } },
147
+ { key: 'b', value: { stringValue: 'two' } },
148
+ ],
149
+ },
150
+ })
151
+ })
152
+
153
+ it('converts nested objects recursively', () => {
154
+ expect(toOtlpAnyValue({ outer: { inner: 1 } })).toEqual({
155
+ kvlistValue: {
156
+ values: [
157
+ {
158
+ key: 'outer',
159
+ value: { kvlistValue: { values: [{ key: 'inner', value: { intValue: '1' } }] } },
160
+ },
161
+ ],
162
+ },
163
+ })
164
+ })
165
+
166
+ it('drops null and undefined keys inside objects', () => {
167
+ expect(toOtlpAnyValue({ kept: 1, gone: null, alsoGone: undefined })).toEqual({
168
+ kvlistValue: { values: [{ key: 'kept', value: { intValue: '1' } }] },
169
+ })
170
+ })
171
+
172
+ // Not in LogAttributeValue, but reachable at runtime from untyped callers.
173
+ it('encodes Dates as ISO strings', () => {
174
+ expect(toOtlpAnyValue(new Date('2026-08-20T10:00:00.000Z') as unknown as LogAttributeValue)).toEqual({
175
+ stringValue: '2026-08-20T10:00:00.000Z',
176
+ })
177
+ })
178
+
179
+ it('marks circular references instead of recursing', () => {
180
+ const cyclic: Record<string, unknown> = { name: 'root' }
181
+ cyclic.self = cyclic
182
+ expect(toOtlpAnyValue(cyclic)).toEqual({
183
+ kvlistValue: {
184
+ values: [
185
+ { key: 'name', value: { stringValue: 'root' } },
186
+ { key: 'self', value: { stringValue: '[Circular]' } },
187
+ ],
188
+ },
189
+ })
190
+ })
191
+
192
+ // An escaping error would surface in the caller's application code.
193
+ it('does not throw on an object nested past the depth cap', () => {
194
+ let deep: Record<string, unknown> = { end: true }
195
+ for (let i = 0; i < 25000; i++) {
196
+ deep = { next: deep }
197
+ }
198
+ expect(() => toOtlpAnyValue(deep)).not.toThrow()
199
+ })
200
+
201
+ it('truncates at exactly 20 levels instead of recursing', () => {
202
+ let deep: Record<string, unknown> = { end: true }
203
+ for (let i = 0; i < 25; i++) {
204
+ deep = { next: deep }
205
+ }
206
+ const encoded = JSON.stringify(toOtlpAnyValue(deep))
207
+ expect(encoded).toContain('[Truncated]')
208
+ expect(encoded.split('"next"').length - 1).toBe(20)
209
+ })
210
+
211
+ it('marks a throwing getter without losing the rest of the object', () => {
212
+ const attrs = {
213
+ ok: 1,
214
+ get bad(): number {
215
+ throw new Error('getter blew up')
216
+ },
217
+ }
218
+ expect(() => toOtlpKeyValueList(attrs)).not.toThrow()
219
+ expect(toOtlpKeyValueList(attrs)).toEqual([
220
+ { key: 'ok', value: { intValue: '1' } },
221
+ { key: 'bad', value: { stringValue: '[Unserializable]' } },
222
+ ])
223
+ })
224
+
225
+ // for...in walks the prototype chain once own keys are exhausted.
226
+ it('ignores inherited enumerable properties', () => {
227
+ const inherited: Record<string, unknown> = Object.create({ fromPrototype: 'leaked' })
228
+ inherited.own = 1
229
+ expect(toOtlpAnyValue(inherited)).toEqual({
230
+ kvlistValue: { values: [{ key: 'own', value: { intValue: '1' } }] },
119
231
  })
120
232
  })
233
+
234
+ // `String(fn)` would put the function's source text on the wire.
235
+ it('marks function and symbol values instead of stringifying them', () => {
236
+ expect(toOtlpAnyValue({ handler: () => 1, retries: 2 } as unknown as LogAttributeValue)).toEqual({
237
+ kvlistValue: {
238
+ values: [
239
+ { key: 'handler', value: { stringValue: '[Function]' } },
240
+ { key: 'retries', value: { intValue: '2' } },
241
+ ],
242
+ },
243
+ })
244
+ expect(toOtlpAnyValue({ sym: Symbol('x') } as unknown as LogAttributeValue)).toEqual({
245
+ kvlistValue: { values: [{ key: 'sym', value: { stringValue: 'Symbol(x)' } }] },
246
+ })
247
+ })
248
+
249
+ // dayjs, Decimal, ORM documents.
250
+ it('honours toJSON', () => {
251
+ const wrapped = { toJSON: () => ({ amount: 5 }) }
252
+ expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
253
+ kvlistValue: { values: [{ key: 'amount', value: { intValue: '5' } }] },
254
+ })
255
+ })
256
+
257
+ it('falls back to the plain walk when toJSON throws', () => {
258
+ const wrapped = {
259
+ kept: 1,
260
+ toJSON: () => {
261
+ throw new Error('nope')
262
+ },
263
+ }
264
+ expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
265
+ kvlistValue: {
266
+ values: [
267
+ { key: 'kept', value: { intValue: '1' } },
268
+ { key: 'toJSON', value: { stringValue: '[Function]' } },
269
+ ],
270
+ },
271
+ })
272
+ })
273
+
274
+ // A toJSON returning its own object is a cycle like any other.
275
+ it('marks a cycle that runs through toJSON', () => {
276
+ const cyclic: Record<string, unknown> = {}
277
+ cyclic.toJSON = () => ({ inner: cyclic })
278
+ expect(toOtlpAnyValue(cyclic)).toEqual({
279
+ kvlistValue: { values: [{ key: 'inner', value: { stringValue: '[Circular]' } }] },
280
+ })
281
+ })
282
+
283
+ // Both `null` and `{}` here are rejected for the whole request; iOS and
284
+ // Android drop them too.
285
+ it('drops holes and nullish elements from arrays', () => {
286
+ // eslint-disable-next-line no-sparse-arrays
287
+ expect(toOtlpAnyValue([1, , 3])).toEqual({
288
+ arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
289
+ })
290
+ expect(toOtlpAnyValue([1, null, undefined, 3])).toEqual({
291
+ arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
292
+ })
293
+ })
294
+
295
+ it('stops encoding array items once the node budget is spent', () => {
296
+ const row: Record<string, number> = {}
297
+ for (let i = 0; i < 20; i++) {
298
+ row[`k${i}`] = i
299
+ }
300
+ const wide = Array.from({ length: 1000 }, () => ({ ...row }))
301
+ const values = toOtlpAnyValue(wide).arrayValue!.values
302
+ expect(values[values.length - 1]).toEqual({ stringValue: '[Truncated]' })
303
+ // One marker, not one per unencodable item.
304
+ expect(values.filter((v) => v.stringValue === '[Truncated]')).toHaveLength(1)
305
+ })
306
+
307
+ it('caps a shared object graph instead of expanding it', () => {
308
+ let graph: Record<string, unknown> = { leaf: true }
309
+ for (let i = 0; i < 20; i++) {
310
+ graph = { a: graph, b: graph }
311
+ }
312
+ const encoded = JSON.stringify(toOtlpAnyValue(graph))
313
+ expect(encoded).toContain('[Truncated]')
314
+ expect(encoded.length).toBeLessThan(1_000_000)
315
+ })
316
+
317
+ it('caps a very wide object without inventing an attribute key', () => {
318
+ const wide: Record<string, number> = {}
319
+ for (let i = 0; i < 5000; i++) {
320
+ wide[`k${i}`] = i
321
+ }
322
+ const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
323
+ const values = toOtlpAnyValue(wide, logger as any).kvlistValue!.values
324
+ expect(values).toHaveLength(1000)
325
+ expect(values.every((v) => v.key.startsWith('k'))).toBe(true)
326
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('truncated'))
327
+ })
328
+
329
+ // Why the encoder does not delegate to toJsonSafeValue: that maps them to null.
330
+ it('keeps non-finite floats as strings inside nested objects', () => {
331
+ expect(toOtlpAnyValue({ nested: { ratio: NaN } })).toEqual({
332
+ kvlistValue: {
333
+ values: [
334
+ {
335
+ key: 'nested',
336
+ value: { kvlistValue: { values: [{ key: 'ratio', value: { stringValue: 'NaN' } }] } },
337
+ },
338
+ ],
339
+ },
340
+ })
341
+ })
342
+
343
+ // A lone surrogate survives JSON.stringify as a \uD800 escape, which the
344
+ // server rejects for the whole request.
345
+ it('replaces unpaired surrogates in values and keys', () => {
346
+ expect(toOtlpAnyValue('ok\ud83d')).toEqual({ stringValue: 'ok\ufffd' })
347
+ expect(toOtlpAnyValue({ nested: 'ok\ud83d' })).toEqual({
348
+ kvlistValue: { values: [{ key: 'nested', value: { stringValue: 'ok\ufffd' } }] },
349
+ })
350
+ expect(toOtlpKeyValueList({ 'key\ud83d': 1 })).toEqual([{ key: 'key\ufffd', value: { intValue: '1' } }])
351
+ })
352
+
353
+ it('encodes empty containers with an explicit values array', () => {
354
+ expect(toOtlpAnyValue({})).toEqual({ kvlistValue: { values: [] } })
355
+ expect(toOtlpAnyValue([])).toEqual({ arrayValue: { values: [] } })
356
+ })
357
+
358
+ it('keeps a Date whose toISOString is overridden out of the wire format', () => {
359
+ const broken = new Date('2026-08-20T10:00:00.000Z')
360
+
361
+ ;(broken as any).toISOString = () => ({})
362
+ expect(typeof toOtlpAnyValue(broken as unknown as LogAttributeValue).stringValue).toBe('string')
363
+ })
364
+
365
+ it('encodes sibling references to one object twice, not as circular', () => {
366
+ const shared = { id: 1 }
367
+ expect(toOtlpAnyValue({ a: shared, b: shared })).toEqual({
368
+ kvlistValue: {
369
+ values: [
370
+ { key: 'a', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
371
+ { key: 'b', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
372
+ ],
373
+ },
374
+ })
375
+ })
376
+ })
377
+
378
+ describe('buildOtlpLogRecord attribute reads', () => {
379
+ // Reading `options.attributes` happens before the encoder's per-key guard.
380
+ it('marks an attribute whose getter throws without dropping the record', () => {
381
+ const attributes = {
382
+ ok: 1,
383
+ get bad(): number {
384
+ throw new Error('disposed store')
385
+ },
386
+ }
387
+ const record = buildOtlpLogRecord({ body: 'x', attributes }, {})
388
+ expect(record.attributes).toContainEqual({ key: 'ok', value: { intValue: '1' } })
389
+ expect(record.attributes).toContainEqual({ key: 'bad', value: { stringValue: '[Unserializable]' } })
390
+ })
391
+
392
+ // Plain assignment hits the prototype setter and the attribute vanishes.
393
+ it('keeps an attribute literally named __proto__', () => {
394
+ const attributes = JSON.parse('{"__proto__": {"a": 1}, "normal": "n"}')
395
+ const record = buildOtlpLogRecord({ body: 'x', attributes }, {})
396
+ expect(record.attributes.map((a) => a.key)).toContain('__proto__')
397
+ })
398
+
399
+ it('coerces a non-string body', () => {
400
+ // A non-string stringValue is refused for the whole request.
401
+ const record = buildOtlpLogRecord({ body: 12345 as unknown as string }, {})
402
+ expect(record.body).toEqual({ stringValue: '12345' })
403
+ })
404
+
405
+ // `String()` throws on these, and buildOtlpLogRecord is called straight from
406
+ // captureLog — beforeSend can also hand back any body.
407
+ it('marks a body that cannot be coerced to a string', () => {
408
+ const throwing = {
409
+ toString() {
410
+ throw new Error('boom')
411
+ },
412
+ } as unknown as string
413
+ expect(() => buildOtlpLogRecord({ body: throwing }, {})).not.toThrow()
414
+ expect(buildOtlpLogRecord({ body: throwing }, {}).body).toEqual({ stringValue: '[Unserializable]' })
415
+ expect(buildOtlpLogRecord({ body: Object.create(null) }, {}).body).toEqual({
416
+ stringValue: '[Unserializable]',
417
+ })
418
+ })
419
+
420
+ it('keeps the record when the attributes object itself cannot be read', () => {
421
+ const revocable = Proxy.revocable({ a: 1 }, {})
422
+ revocable.revoke()
423
+
424
+ expect(buildOtlpLogRecord({ body: 'x', attributes: revocable.proxy }, {}).body).toEqual({ stringValue: 'x' })
425
+ })
121
426
  })
122
427
 
123
428
  describe('toOtlpKeyValueList', () => {
@@ -130,7 +435,7 @@ describe('logs-utils', () => {
130
435
  })
131
436
  ).toEqual([
132
437
  { key: 'name', value: { stringValue: 'test' } },
133
- { key: 'count', value: { intValue: 5 } },
438
+ { key: 'count', value: { intValue: '5' } },
134
439
  { key: 'active', value: { boolValue: true } },
135
440
  ])
136
441
  })
@@ -9,8 +9,19 @@ import type {
9
9
  OtlpSeverityEntry,
10
10
  OtlpSeverityText,
11
11
  } from '@posthog/types'
12
+ import type { Logger } from '../types'
12
13
  import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
13
14
  import { isArray, isBoolean, isNull, isNullish, isUndefined } from '../utils'
15
+ import {
16
+ CIRCULAR_VALUE,
17
+ FUNCTION_VALUE,
18
+ MAX_JSON_SAFE_VALUE_DEPTH,
19
+ MAX_JSON_SAFE_VALUE_ITEMS,
20
+ MAX_JSON_SAFE_VALUE_NODES,
21
+ sanitizeString,
22
+ TRUNCATED_VALUE,
23
+ UNSERIALIZABLE_VALUE,
24
+ } from '../utils/json-utils'
14
25
 
15
26
  // ============================================================================
16
27
  // Severity mapping
@@ -39,49 +50,192 @@ export function getOtlpSeverityNumber(level: LogSeverityLevel): number {
39
50
  // OTLP AnyValue conversion
40
51
  // ============================================================================
41
52
 
42
- export function toOtlpAnyValue(value: LogAttributeValue): OtlpAnyValue {
53
+ // 2^63 one past int64 max.
54
+ const INT64_RANGE_LIMIT = 9223372036854775808
55
+
56
+ const propertyIsEnumerable = Object.prototype.propertyIsEnumerable
57
+
58
+ interface EncodeState {
59
+ /** Containers on the current path, so a back-reference becomes a marker. */
60
+ ancestors: WeakSet<object>
61
+ remainingNodes: number
62
+ }
63
+
64
+ function newState(): EncodeState {
65
+ return { ancestors: new WeakSet(), remainingNodes: MAX_JSON_SAFE_VALUE_NODES }
66
+ }
67
+
68
+ export function toOtlpAnyValue(value: LogAttributeValue, logger?: Logger): OtlpAnyValue {
69
+ try {
70
+ return encodeAnyValue(value, logger, newState(), 0)
71
+ } catch {
72
+ // Runs inside `captureLog` and the metrics flush: an error escaping here
73
+ // surfaces in the caller's own code.
74
+ return { stringValue: UNSERIALIZABLE_VALUE }
75
+ }
76
+ }
77
+
78
+ export function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>, logger?: Logger): OtlpKeyValue[] {
79
+ try {
80
+ return encodeKeyValueList(attrs, logger, newState(), 0)
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ function encodeAnyValue(
87
+ value: LogAttributeValue,
88
+ logger: Logger | undefined,
89
+ state: EncodeState,
90
+ depth: number
91
+ ): OtlpAnyValue {
92
+ if (state.remainingNodes <= 0) {
93
+ return { stringValue: TRUNCATED_VALUE }
94
+ }
95
+ state.remainingNodes--
96
+
43
97
  if (isBoolean(value)) {
44
98
  return { boolValue: value }
45
99
  }
46
- // NOTE: typeof check (not core's isNumber) so NaN is included. core's
47
- // isNumber explicitly excludes NaN via the `x === x` guard, which would
48
- // route NaN through the JSON.stringify branch below — JSON has no
49
- // representation for non-finite floats and JSON.stringify turns them into
50
- // `null`, losing the value server-side. proto3 JSON mapping (which OTLP/HTTP
51
- // rides) requires the literal strings; we encode them as stringValue to keep
52
- // the human-readable signal regardless of which downstream parser sees them.
100
+ // typeof, not core's isNumber, which excludes NaN proto3 JSON distinguishes
101
+ // a non-finite float from an ordinary string.
53
102
  if (typeof value === 'number') {
54
103
  if (!Number.isFinite(value)) {
55
104
  return { stringValue: String(value) }
56
105
  }
57
106
  if (Number.isInteger(value)) {
58
- return { intValue: value }
107
+ if (Number.isSafeInteger(value)) {
108
+ return { intValue: String(value) }
109
+ }
110
+ // Past MAX_SAFE_INTEGER only BigInt gives the double's exact decimal:
111
+ // `String(-(2**63))` lands 192 below int64 min, outside the field it is
112
+ // about to be parsed into. Without BigInt the value rides as a string,
113
+ // which is never range-checked.
114
+ if (typeof BigInt === 'undefined') {
115
+ return { stringValue: String(value) }
116
+ }
117
+ const decimal = BigInt(value).toString()
118
+ if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) {
119
+ // An out-of-range intValue 400s the whole logs request; on the metrics
120
+ // path it is swallowed server-side and the metric just disappears.
121
+ logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`)
122
+ return { stringValue: decimal }
123
+ }
124
+ return { intValue: decimal }
59
125
  }
60
126
  return { doubleValue: value }
61
127
  }
62
128
  if (typeof value === 'string') {
63
- return { stringValue: value }
129
+ return { stringValue: sanitizeString(value) }
64
130
  }
65
- if (isArray(value)) {
66
- return { arrayValue: { values: value.map((v) => toOtlpAnyValue(v as LogAttributeValue)) } }
131
+ // `String(value)` would put a function's source text on the wire.
132
+ if (typeof value === 'function') {
133
+ return { stringValue: FUNCTION_VALUE }
67
134
  }
68
- // Objects fall back to JSON. OTLP supports kvlistValue but the encoder
69
- // stays flat for simplicity.
70
- try {
71
- return { stringValue: JSON.stringify(value) }
72
- } catch {
135
+ if (typeof value === 'symbol') {
73
136
  return { stringValue: String(value) }
74
137
  }
138
+ if (typeof value === 'object' && value !== null) {
139
+ if (state.ancestors.has(value)) {
140
+ return { stringValue: CIRCULAR_VALUE }
141
+ }
142
+ if (depth >= MAX_JSON_SAFE_VALUE_DEPTH) {
143
+ return { stringValue: TRUNCATED_VALUE }
144
+ }
145
+ if (value instanceof Date) {
146
+ const time = value.getTime()
147
+ const iso = Number.isFinite(time) ? value.toISOString() : String(value)
148
+ // An overridden toISOString can return a non-string, which the server
149
+ // refuses for the whole request.
150
+ return { stringValue: typeof iso === 'string' ? sanitizeString(iso) : String(iso) }
151
+ }
152
+ // Registered before the toJSON probe: a toJSON returning a structure that
153
+ // references its own object is a cycle like any other.
154
+ state.ancestors.add(value)
155
+ try {
156
+ // The representation a value defines for itself — dayjs, Decimal, an ORM
157
+ // document, and a cross-realm Date that fails the `instanceof` above.
158
+ try {
159
+ const toJSON = (value as { toJSON?: unknown }).toJSON
160
+ if (typeof toJSON === 'function') {
161
+ return encodeAnyValue(toJSON.call(value) as LogAttributeValue, logger, state, depth + 1)
162
+ }
163
+ } catch {
164
+ // A throwing toJSON falls through to the plain walk.
165
+ }
166
+ if (isArray(value)) {
167
+ return { arrayValue: { values: encodeArrayValues(value, logger, state, depth + 1) } }
168
+ }
169
+ return {
170
+ kvlistValue: {
171
+ values: encodeKeyValueList(value as Record<string, LogAttributeValue>, logger, state, depth + 1),
172
+ },
173
+ }
174
+ } finally {
175
+ // Siblings that reference the same object are duplication, not a cycle.
176
+ state.ancestors.delete(value)
177
+ }
178
+ }
179
+ return { stringValue: sanitizeString(String(value)) }
180
+ }
181
+
182
+ function encodeArrayValues(
183
+ values: unknown[],
184
+ logger: Logger | undefined,
185
+ state: EncodeState,
186
+ depth: number
187
+ ): OtlpAnyValue[] {
188
+ const result: OtlpAnyValue[] = []
189
+ const itemCount = Math.min(values.length, MAX_JSON_SAFE_VALUE_ITEMS)
190
+ let index = 0
191
+ for (; index < itemCount && state.remainingNodes > 0; index++) {
192
+ try {
193
+ const element = index in values ? values[index] : undefined
194
+ // Dropped, as iOS and Android do: proto3 JSON has no null AnyValue, and
195
+ // both `null` and `{}` here are rejected for the whole request.
196
+ if (isNullish(element)) {
197
+ continue
198
+ }
199
+ result.push(encodeAnyValue(element as LogAttributeValue, logger, state, depth))
200
+ } catch {
201
+ result.push({ stringValue: UNSERIALIZABLE_VALUE })
202
+ }
203
+ }
204
+ if (values.length > index) {
205
+ result.push({ stringValue: TRUNCATED_VALUE })
206
+ }
207
+ return result
75
208
  }
76
209
 
77
- export function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>): OtlpKeyValue[] {
210
+ function encodeKeyValueList(
211
+ attrs: Record<string, LogAttributeValue>,
212
+ logger: Logger | undefined,
213
+ state: EncodeState,
214
+ depth: number
215
+ ): OtlpKeyValue[] {
78
216
  const result: OtlpKeyValue[] = []
79
217
  for (const key in attrs) {
80
- const value = attrs[key]
81
- if (isNull(value) || isUndefined(value)) {
218
+ // for...in walks the prototype chain once own keys are exhausted. Skipped
219
+ // rather than broken out of: a proxy can yield keys in any order.
220
+ if (!propertyIsEnumerable.call(attrs, key)) {
82
221
  continue
83
222
  }
84
- result.push({ key, value: toOtlpAnyValue(value) })
223
+ if (result.length >= MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) {
224
+ // Reported rather than written into the attributes: a synthetic key would
225
+ // land in the user's own namespace and could collide with a real one.
226
+ logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget')
227
+ break
228
+ }
229
+ try {
230
+ const value = attrs[key]
231
+ if (isNull(value) || isUndefined(value)) {
232
+ continue
233
+ }
234
+ result.push({ key: sanitizeString(key), value: encodeAnyValue(value, logger, state, depth) })
235
+ } catch {
236
+ // A getter that throws costs its own key, not the whole record.
237
+ result.push({ key: sanitizeString(key), value: { stringValue: UNSERIALIZABLE_VALUE } })
238
+ }
85
239
  }
86
240
  return result
87
241
  }
@@ -111,7 +265,22 @@ function timestampToUnixNano(): string {
111
265
  *
112
266
  * User-provided `options.attributes` always wins on conflicts.
113
267
  */
114
- export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSdkContext): OtlpLogRecord {
268
+ // `body` is only typed as a string. An untyped caller — or a `beforeSend` that
269
+ // rewrites it — can pass anything, and `String()` throws on a value whose
270
+ // `toString` does, or on a null-prototype object.
271
+ function encodeBody(body: string): string {
272
+ try {
273
+ return sanitizeString(String(body))
274
+ } catch {
275
+ return UNSERIALIZABLE_VALUE
276
+ }
277
+ }
278
+
279
+ export function buildOtlpLogRecord(
280
+ options: CaptureLogOptions,
281
+ sdkContext: LogSdkContext,
282
+ logger?: Logger
283
+ ): OtlpLogRecord {
115
284
  const level: LogSeverityLevel = options.level || 'info'
116
285
  const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY
117
286
  const now = timestampToUnixNano()
@@ -146,9 +315,28 @@ export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSd
146
315
  autoAttributes.feature_flags = sdkContext.activeFeatureFlags
147
316
  }
148
317
 
149
- const mergedAttributes = {
150
- ...autoAttributes,
151
- ...(options.attributes || {}),
318
+ // Read key by key rather than spreading: a getter over a disposed store or a
319
+ // revoked proxy throws on the read itself, before the encoder's guards see it.
320
+ const mergedAttributes: Record<string, LogAttributeValue> = { ...autoAttributes }
321
+ const userAttributes = options.attributes
322
+ if (userAttributes) {
323
+ let keys: string[] = []
324
+ try {
325
+ keys = Object.keys(userAttributes)
326
+ } catch {
327
+ keys = []
328
+ }
329
+ for (const key of keys) {
330
+ let value: LogAttributeValue
331
+ try {
332
+ value = userAttributes[key]
333
+ } catch {
334
+ value = UNSERIALIZABLE_VALUE
335
+ }
336
+ // defineProperty, not assignment: `attributes['__proto__'] = v` hits the
337
+ // prototype setter and the attribute vanishes.
338
+ Object.defineProperty(mergedAttributes, key, { value, enumerable: true, writable: true, configurable: true })
339
+ }
152
340
  }
153
341
 
154
342
  const record: OtlpLogRecord = {
@@ -156,8 +344,8 @@ export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSd
156
344
  observedTimeUnixNano: now,
157
345
  severityNumber,
158
346
  severityText,
159
- body: { stringValue: options.body },
160
- attributes: toOtlpKeyValueList(mergedAttributes),
347
+ body: { stringValue: encodeBody(options.body) },
348
+ attributes: toOtlpKeyValueList(mergedAttributes, logger),
161
349
  }
162
350
 
163
351
  if (options.trace_id) {
@@ -169,10 +169,24 @@ describe('PostHogMetrics', () => {
169
169
 
170
170
  const attrs = sentMetrics()[0].sum!.dataPoints[0].attributes
171
171
  expect(attrs).toContainEqual({ key: 'route', value: { stringValue: '/home' } })
172
- expect(attrs).toContainEqual({ key: 'retries', value: { intValue: 2 } })
172
+ expect(attrs).toContainEqual({ key: 'retries', value: { intValue: '2' } })
173
173
  expect(attrs).toContainEqual({ key: 'cached', value: { boolValue: true } })
174
174
  })
175
175
 
176
+ // The encoder is shared with logs, so the debug line it emits on the
177
+ // out-of-range path only reaches anyone if metrics passes its logger down.
178
+ it('encodes an out-of-int64 attribute as a string and reports it through the logger', async () => {
179
+ const metrics = createMetrics()
180
+ metrics.count('api_calls', 1, { attributes: { huge: 2 ** 63 } as any })
181
+ await metrics.flush()
182
+
183
+ const attrs = sentMetrics()[0].sum!.dataPoints[0].attributes
184
+ expect(attrs).toContainEqual({ key: 'huge', value: { stringValue: '9223372036854775808' } })
185
+ expect((logger.debug as jest.Mock).mock.calls.some((c) => String(c[0]).includes('outside the int64 range'))).toBe(
186
+ true
187
+ )
188
+ })
189
+
176
190
  it('stamps delta data points with a window: startTimeUnixNano <= timeUnixNano, both nano strings', async () => {
177
191
  const metrics = createMetrics()
178
192
  metrics.count('orders_created', 1)