@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,69 @@
1
+ import { PostHogTraces } from './index'
2
+ import { SyncSpanContextManager } from './context'
3
+ import type { ResolvedTracesConfig } from './types'
4
+ import type { Logger } from '../types'
5
+
6
+ // Live-span accounting keeps an id and a timestamp per span, never the span, so a
7
+ // handle the caller drops stays collectable. This probe is what stops a later
8
+ // change from turning that accounting into a registry of span objects.
9
+ const gc = (globalThis as { gc?: () => void }).gc
10
+
11
+ // `--expose-gc` is set by the `test:unit` script. A runner that invokes vitest
12
+ // directly has no `gc`, and a probe that cannot force a collection proves nothing.
13
+ const itWithGc = gc ? it : process.env.CI ? it : it.skip
14
+
15
+ describe('live spans', () => {
16
+ const config: ResolvedTracesConfig = {
17
+ serviceName: 'svc',
18
+ flushIntervalMs: 5000,
19
+ maxExportBatchSize: 512,
20
+ maxQueueSize: 2048,
21
+ maxLiveSpans: 10000,
22
+ maxSpanAgeMs: 3600000,
23
+ beforeSpanSend: [],
24
+ maxAttributesPerSpan: 128,
25
+ maxEventsPerSpan: 128,
26
+ maxAttributesPerEvent: 128,
27
+ maxAttributeValueLength: 8192,
28
+ }
29
+
30
+ const createTraces = (): PostHogTraces =>
31
+ new PostHogTraces(
32
+ {
33
+ isDisabled: false,
34
+ optedOut: false,
35
+ getLibraryId: () => 'posthog-core',
36
+ getLibraryVersion: () => '0.0.0',
37
+ _sendTracesBatch: async () => ({ kind: 'ok' }),
38
+ },
39
+ config,
40
+ { debug: vi.fn(), warn: vi.fn() } as unknown as Logger,
41
+ () => ({}),
42
+ new SyncSpanContextManager()
43
+ )
44
+
45
+ itWithGc('does not retain spans that never end', async () => {
46
+ if (!gc) {
47
+ throw new Error('Run this suite with NODE_OPTIONS=--expose-gc; see packages/core test:unit')
48
+ }
49
+ vi.useRealTimers()
50
+ try {
51
+ const traces = createTraces()
52
+ // Started in their own frame so the handles are unreachable once it returns.
53
+ const refs = ((): WeakRef<object>[] =>
54
+ Array.from({ length: 1000 }, (_unused, i) => new WeakRef(traces.startSpan(`leaked-${i}`) as object)))()
55
+
56
+ await new Promise((resolve) => setTimeout(resolve, 50))
57
+ gc()
58
+ await new Promise((resolve) => setTimeout(resolve, 50))
59
+ gc()
60
+
61
+ // A threshold, not zero: collection timing is not guaranteed, but a registry
62
+ // holding every span would keep all 1000 alive.
63
+ const alive = refs.filter((ref) => ref.deref() !== undefined).length
64
+ expect(alive).toBeLessThan(100)
65
+ } finally {
66
+ vi.useFakeTimers()
67
+ }
68
+ })
69
+ })
@@ -0,0 +1,368 @@
1
+ import {
2
+ buildOtlpSpan,
3
+ buildOtlpTracesPayload,
4
+ buildTracesResourceAttributes,
5
+ msToUnixNanoString,
6
+ spanKindToOtlp,
7
+ } from './otlp'
8
+ import type { OtlpSpan, ResolvedTracesConfig, SpanRecord } from './types'
9
+
10
+ const record = (overrides: Partial<SpanRecord> = {}): SpanRecord => ({
11
+ traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
12
+ spanId: '00f067aa0ba902b7',
13
+ name: 'checkout',
14
+ kind: 'internal',
15
+ traceFlags: '01',
16
+ parentIsRemote: false,
17
+ attributes: {},
18
+ events: [],
19
+ startTime: 1_700_000_000_000,
20
+ endTime: 1_700_000_000_080,
21
+ ...overrides,
22
+ })
23
+
24
+ describe('OTLP span encoding', () => {
25
+ describe('msToUnixNanoString', () => {
26
+ it('encodes milliseconds as a nanosecond string', () => {
27
+ expect(msToUnixNanoString(1_700_000_000_000)).toBe('1700000000000000000')
28
+ })
29
+
30
+ it('keeps sub-millisecond precision', () => {
31
+ expect(msToUnixNanoString(1_700_000_000_000.5)).toBe('1700000000000500000')
32
+ })
33
+
34
+ it('stays exact beyond Number.MAX_SAFE_INTEGER', () => {
35
+ // The whole point of string concatenation over `ms * 1e6`, which would
36
+ // silently lose precision at this magnitude.
37
+ const encoded = msToUnixNanoString(1_700_000_000_123)
38
+ expect(encoded).toBe('1700000000123000000')
39
+ expect(Number(encoded)).toBeGreaterThan(Number.MAX_SAFE_INTEGER)
40
+ })
41
+
42
+ it.each([0.9999999, 1.9999999, 999.9999999])(
43
+ 'carries a rounded-up fraction into the next millisecond for %p',
44
+ (ms) => {
45
+ // Without the carry the padded fraction gains a seventh digit, producing
46
+ // a malformed timestamp that 400s the whole request. Unreachable from a
47
+ // real clock — float64 quantization at epoch-ms magnitude keeps the
48
+ // fraction well below the carry — but reachable via a caller-supplied
49
+ // `startTime`, which the validity check accepts anywhere in [0, MAX].
50
+ const encoded = msToUnixNanoString(ms)
51
+ expect(encoded).toHaveLength(String(Math.round(ms)).length + 6)
52
+ expect(encoded).toMatch(/^\d+$/)
53
+ }
54
+ )
55
+ })
56
+
57
+ describe('spanKindToOtlp', () => {
58
+ it.each([
59
+ ['internal', 1],
60
+ ['server', 2],
61
+ ['client', 3],
62
+ ['producer', 4],
63
+ ['consumer', 5],
64
+ ] as const)('maps %s to %i', (kind, expected) => {
65
+ expect(spanKindToOtlp(kind)).toBe(expected)
66
+ })
67
+
68
+ it('defaults to internal', () => {
69
+ expect(spanKindToOtlp(undefined)).toBe(1)
70
+ })
71
+ })
72
+
73
+ describe('buildOtlpSpan', () => {
74
+ it('builds the minimal shape', () => {
75
+ expect(buildOtlpSpan(record())).toEqual({
76
+ traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
77
+ spanId: '00f067aa0ba902b7',
78
+ name: 'checkout',
79
+ kind: 1,
80
+ startTimeUnixNano: '1700000000000000000',
81
+ endTimeUnixNano: '1700000000080000000',
82
+ flags: 0x101,
83
+ })
84
+ })
85
+
86
+ it('omits status when it was never set', () => {
87
+ expect(buildOtlpSpan(record())).not.toHaveProperty('status')
88
+ })
89
+
90
+ it('encodes ok and error status codes', () => {
91
+ expect(buildOtlpSpan(record({ status: { code: 'ok' } })).status).toEqual({ code: 1 })
92
+ expect(buildOtlpSpan(record({ status: { code: 'error', message: 'boom' } })).status).toEqual({
93
+ code: 2,
94
+ message: 'boom',
95
+ })
96
+ })
97
+
98
+ it('includes parent, tracestate, attributes and events when present', () => {
99
+ const span = buildOtlpSpan(
100
+ record({
101
+ parentSpanId: 'b7ad6b7169203331',
102
+ traceState: 'vendor=abc',
103
+ attributes: { plan: 'pro' },
104
+ events: [{ name: 'cache miss', timestamp: 1_700_000_000_040 }],
105
+ })
106
+ )
107
+ expect(span.parentSpanId).toBe('b7ad6b7169203331')
108
+ expect(span.traceState).toBe('vendor=abc')
109
+ expect(span.attributes).toEqual([{ key: 'plan', value: { stringValue: 'pro' } }])
110
+ expect(span.events).toEqual([{ name: 'cache miss', timeUnixNano: '1700000000040000000' }])
111
+ })
112
+
113
+ it('carries an event attribute drop as the proto counter', () => {
114
+ const span = buildOtlpSpan(
115
+ record({
116
+ events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: 3 }],
117
+ })
118
+ )
119
+ expect(span.events?.[0]).toMatchObject({ name: 'query', droppedAttributesCount: 3 })
120
+ })
121
+
122
+ it.each([
123
+ ['none were dropped', 0],
124
+ ['a hook wrote a negative', -2],
125
+ ['a hook wrote a non-number', 'lots' as unknown as number],
126
+ ])('omits the event drop counter when %s', (_label, dropped) => {
127
+ // Coerced like the span's own counters: a non-integer here is refused for
128
+ // the whole request, taking unrelated spans with it.
129
+ const span = buildOtlpSpan(
130
+ record({
131
+ events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: dropped }],
132
+ })
133
+ )
134
+ expect(span.events?.[0].droppedAttributesCount).toBeUndefined()
135
+ })
136
+
137
+ it('sets the sampled bit and marks a root span as known-not-remote', () => {
138
+ // A root span has no parent context to be remote, which the OTel Go and
139
+ // Java exporters also report as known-not-remote rather than unknown.
140
+ expect(buildOtlpSpan(record()).flags).toBe(0x101)
141
+ })
142
+
143
+ it('marks a local parent as known-not-remote', () => {
144
+ expect(buildOtlpSpan(record({ parentSpanId: 'b7ad6b7169203331' })).flags).toBe(0x101)
145
+ })
146
+
147
+ it('marks a parent that arrived as a header as remote', () => {
148
+ expect(buildOtlpSpan(record({ parentSpanId: 'b7ad6b7169203331', parentIsRemote: true })).flags).toBe(0x301)
149
+ })
150
+
151
+ it('propagates an inbound sampled-out flag rather than overriding it', () => {
152
+ // The span is still recorded and exported; what the wire says is the
153
+ // decision the head sampler made.
154
+ expect(buildOtlpSpan(record({ traceFlags: '00', parentIsRemote: true })).flags).toBe(0x300)
155
+ })
156
+
157
+ it('falls back to sampled when the flags byte is unusable', () => {
158
+ expect(buildOtlpSpan(record({ traceFlags: 'zz' })).flags).toBe(0x101)
159
+ })
160
+ })
161
+
162
+ describe('buildTracesResourceAttributes', () => {
163
+ const config = (partial: Partial<ResolvedTracesConfig> = {}): ResolvedTracesConfig => ({
164
+ flushIntervalMs: 5000,
165
+ maxExportBatchSize: 512,
166
+ maxQueueSize: 2048,
167
+ beforeSpanSend: [],
168
+ maxAttributesPerSpan: 128,
169
+ maxEventsPerSpan: 128,
170
+ maxAttributesPerEvent: 128,
171
+ maxAttributeValueLength: 8192,
172
+ maxLiveSpans: 10000,
173
+ maxSpanAgeMs: 3600000,
174
+ ...partial,
175
+ })
176
+
177
+ it('always emits service.name', () => {
178
+ // The server reads service_name only from this attribute and stores an
179
+ // empty string when it's missing, leaving spans unattributable.
180
+ expect(buildTracesResourceAttributes(config(), 'posthog-node', '1.0.0')['service.name']).toBe('unknown_service')
181
+ })
182
+
183
+ it('uses the configured service name', () => {
184
+ expect(buildTracesResourceAttributes(config({ serviceName: 'checkout' }), 'posthog-node', '1.0.0')).toMatchObject(
185
+ {
186
+ 'service.name': 'checkout',
187
+ }
188
+ )
189
+ })
190
+
191
+ it('includes environment and version only when set', () => {
192
+ const attributes = buildTracesResourceAttributes(
193
+ config({ environment: 'production', serviceVersion: '2.1.0' }),
194
+ 'posthog-node',
195
+ '1.0.0'
196
+ )
197
+ expect(attributes['deployment.environment']).toBe('production')
198
+ expect(attributes['service.version']).toBe('2.1.0')
199
+ expect(buildTracesResourceAttributes(config(), 'posthog-node', '1.0.0')).not.toHaveProperty(
200
+ 'deployment.environment'
201
+ )
202
+ })
203
+
204
+ it('protects SDK identity keys from user resource attributes', () => {
205
+ const attributes = buildTracesResourceAttributes(
206
+ config({ resourceAttributes: { 'telemetry.sdk.name': 'custom', 'host.name': 'web-01' } }),
207
+ 'posthog-node',
208
+ '1.0.0'
209
+ )
210
+ expect(attributes['telemetry.sdk.name']).toBe('posthog-node')
211
+ expect(attributes['host.name']).toBe('web-01')
212
+ })
213
+ })
214
+
215
+ describe('buildOtlpTracesPayload', () => {
216
+ it('produces one resource, one scope, N spans', () => {
217
+ const spans = [buildOtlpSpan(record()), buildOtlpSpan(record({ name: 'other' }))]
218
+ const payload = buildOtlpTracesPayload(spans, { 'service.name': 'checkout' }, 'posthog-node', '1.0.0')
219
+
220
+ expect(payload.resourceSpans).toHaveLength(1)
221
+ expect(payload.resourceSpans[0].scopeSpans).toHaveLength(1)
222
+ expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2)
223
+ expect(payload.resourceSpans[0].scopeSpans[0].scope).toEqual({ name: 'posthog-node', version: '1.0.0' })
224
+ expect(payload.resourceSpans[0].resource.attributes).toEqual([
225
+ { key: 'service.name', value: { stringValue: 'checkout' } },
226
+ ])
227
+ })
228
+ })
229
+
230
+ describe('golden wire fixture', () => {
231
+ it('matches the shape the ingestion service accepts', () => {
232
+ // Pinned against the OTLP/JSON encoding the capture-logs service's own
233
+ // trace fixtures use: hex ids, string nanosecond timestamps, integer kind
234
+ // and status enums, and stringified int64 attribute values.
235
+ const payload = buildOtlpTracesPayload(
236
+ [
237
+ buildOtlpSpan(
238
+ record({
239
+ parentSpanId: 'b7ad6b7169203331',
240
+ name: 'GET /users/:id',
241
+ kind: 'server',
242
+ status: { code: 'error', message: 'boom' },
243
+ attributes: {
244
+ posthogDistinctId: 'user-123',
245
+ sessionId: 'session-123',
246
+ 'http.status_code': 500,
247
+ 'http.duration_ratio': 0.25,
248
+ cached: false,
249
+ },
250
+ events: [
251
+ {
252
+ name: 'exception',
253
+ timestamp: 1_700_000_000_040,
254
+ attributes: { 'exception.type': 'TypeError', 'exception.message': 'boom' },
255
+ },
256
+ ],
257
+ })
258
+ ),
259
+ ],
260
+ { 'service.name': 'checkout-api', 'telemetry.sdk.name': 'posthog-node' },
261
+ 'posthog-node',
262
+ '1.0.0'
263
+ )
264
+
265
+ expect(payload).toEqual({
266
+ resourceSpans: [
267
+ {
268
+ resource: {
269
+ attributes: [
270
+ { key: 'service.name', value: { stringValue: 'checkout-api' } },
271
+ { key: 'telemetry.sdk.name', value: { stringValue: 'posthog-node' } },
272
+ ],
273
+ },
274
+ scopeSpans: [
275
+ {
276
+ scope: { name: 'posthog-node', version: '1.0.0' },
277
+ spans: [
278
+ {
279
+ traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
280
+ spanId: '00f067aa0ba902b7',
281
+ parentSpanId: 'b7ad6b7169203331',
282
+ name: 'GET /users/:id',
283
+ kind: 2,
284
+ startTimeUnixNano: '1700000000000000000',
285
+ endTimeUnixNano: '1700000000080000000',
286
+ flags: 0x101,
287
+ attributes: [
288
+ { key: 'posthogDistinctId', value: { stringValue: 'user-123' } },
289
+ { key: 'sessionId', value: { stringValue: 'session-123' } },
290
+ { key: 'http.status_code', value: { intValue: '500' } },
291
+ { key: 'http.duration_ratio', value: { doubleValue: 0.25 } },
292
+ { key: 'cached', value: { boolValue: false } },
293
+ ],
294
+ events: [
295
+ {
296
+ name: 'exception',
297
+ timeUnixNano: '1700000000040000000',
298
+ attributes: [
299
+ { key: 'exception.type', value: { stringValue: 'TypeError' } },
300
+ { key: 'exception.message', value: { stringValue: 'boom' } },
301
+ ],
302
+ },
303
+ ],
304
+ status: { code: 2, message: 'boom' },
305
+ },
306
+ ],
307
+ },
308
+ ],
309
+ },
310
+ ],
311
+ })
312
+ })
313
+ })
314
+ })
315
+
316
+ describe('wire string safety', () => {
317
+ const lone = 'value \u{1F680}'.slice(0, 7)
318
+
319
+ const encode = (record: Partial<SpanRecord> = {}): OtlpSpan =>
320
+ buildOtlpSpan({
321
+ traceId: 'a'.repeat(32),
322
+ spanId: 'b'.repeat(16),
323
+ name: 'checkout',
324
+ kind: 'internal',
325
+ attributes: {},
326
+ events: [],
327
+ startTime: 1,
328
+ endTime: 2,
329
+ ...record,
330
+ } as SpanRecord)
331
+
332
+ it('replaces a lone surrogate in every free-text field', () => {
333
+ const span = encode({
334
+ name: lone,
335
+ traceState: `vendor=${lone}`,
336
+ status: { code: 'error', message: lone },
337
+ events: [{ name: lone, timestamp: 1 }],
338
+ })
339
+
340
+ expect(span.name).not.toContain('\ud83d')
341
+ expect(span.events?.[0].name).not.toContain('\ud83d')
342
+ expect(span.status?.message).not.toContain('\ud83d')
343
+ expect(span.traceState).not.toContain('\ud83d')
344
+ })
345
+
346
+ it('keeps the span when a status message cannot be stringified', () => {
347
+ const hostile = {
348
+ toString() {
349
+ throw new Error('toString exploded')
350
+ },
351
+ }
352
+ const span = encode({ status: { code: 'error', message: hostile as unknown as string } })
353
+
354
+ expect(span.name).toBe('checkout')
355
+ expect(span.status?.message).toBe('[Unserializable]')
356
+ })
357
+
358
+ it('coerces a non-string status message', () => {
359
+ const span = encode({ status: { code: 'error', message: 500 as unknown as string } })
360
+
361
+ expect(span.status?.message).toBe('500')
362
+ })
363
+
364
+ it('falls back to internal for a prototype key passed as a kind', () => {
365
+ expect(spanKindToOtlp('__proto__' as never)).toBe(1)
366
+ expect(spanKindToOtlp('toString' as never)).toBe(1)
367
+ })
368
+ })
@@ -0,0 +1,191 @@
1
+ import type {
2
+ OtlpSpan,
3
+ OtlpSpanEvent,
4
+ OtlpSpanKeyValue,
5
+ OtlpTracesPayload,
6
+ SpanAttributes,
7
+ SpanKind,
8
+ SpanStatusCode,
9
+ } from '@posthog/types'
10
+ import type { Logger } from '../types'
11
+ import type { ResolvedTracesConfig, SpanRecord } from './types'
12
+ import { nonNegativeCount } from './span'
13
+ import { toOtlpKeyValueList } from '../utils/otlp-any-value'
14
+ import { UNSERIALIZABLE_VALUE, sanitizeString } from '../utils/json-utils'
15
+ import { buildOtlpResourceAttributes, toOtlpResourceKeyValueList } from '../utils/otlp-resource'
16
+
17
+ const SPAN_KIND_TO_OTLP: Record<SpanKind, number> = {
18
+ internal: 1,
19
+ server: 2,
20
+ client: 3,
21
+ producer: 4,
22
+ consumer: 5,
23
+ }
24
+
25
+ const SPAN_STATUS_TO_OTLP: Record<SpanStatusCode, number> = {
26
+ ok: 1,
27
+ error: 2,
28
+ }
29
+
30
+ /** W3C trace flags live in the low byte; the sampled bit is `0x01`. */
31
+ const TRACE_FLAGS_SAMPLED = 0x01
32
+ // OTel's span flags, above the W3C byte: one bit says the parent's remoteness is
33
+ // known, the other says it is remote. Both unset reads as "unknown", which this
34
+ // SDK never has to say — a string parent is remote, a handle parent is local, and
35
+ // a root span's parent context is empty, which is not remote. Setting the known
36
+ // bit on a root span is what the OTel Go and Java exporters do too.
37
+ const SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE = 0x100
38
+ const SPAN_FLAGS_CONTEXT_IS_REMOTE = 0x200
39
+
40
+ /** The `flags` field for a span: its W3C trace-flags byte, plus OTel's parent-remoteness bits. */
41
+ function spanFlags(record: SpanRecord): number {
42
+ const traceFlags = parseInt(record.traceFlags, 16)
43
+ const w3c = Number.isFinite(traceFlags) ? traceFlags & 0xff : TRACE_FLAGS_SAMPLED
44
+ return w3c | SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE | (record.parentIsRemote ? SPAN_FLAGS_CONTEXT_IS_REMOTE : 0)
45
+ }
46
+
47
+ /**
48
+ * Every free-text string this encoder puts on the wire. A lone surrogate survives
49
+ * `JSON.stringify` as a `\uD800` escape that strict parsers refuse, and the
50
+ * ingestion service refuses the whole request rather than the one row — so the
51
+ * safe path has to be the default, not the exception.
52
+ */
53
+ function wireString(value: unknown): string {
54
+ if (typeof value === 'string') {
55
+ return sanitizeString(value)
56
+ }
57
+ try {
58
+ return sanitizeString(String(value))
59
+ } catch {
60
+ // A hostile `toString` costs its own field, not the whole span.
61
+ return UNSERIALIZABLE_VALUE
62
+ }
63
+ }
64
+
65
+ export function spanKindToOtlp(kind: SpanKind | undefined): number {
66
+ // `hasOwnProperty`, not a plain lookup: `kind: '__proto__'` from an untyped
67
+ // caller otherwise resolves to `Object.prototype` and ships `"kind":{}`.
68
+ if (kind && Object.prototype.hasOwnProperty.call(SPAN_KIND_TO_OTLP, kind)) {
69
+ return SPAN_KIND_TO_OTLP[kind]
70
+ }
71
+ return SPAN_KIND_TO_OTLP.internal
72
+ }
73
+
74
+ /**
75
+ * Converts a millisecond epoch to the unix-nanosecond string OTLP expects.
76
+ * Concatenation rather than multiplication: `Date.now() * 1e6` exceeds
77
+ * `Number.MAX_SAFE_INTEGER`.
78
+ */
79
+ export function msToUnixNanoString(ms: number): string {
80
+ let whole = Math.floor(ms)
81
+ let fractionalNanos = Math.round((ms - whole) * 1e6)
82
+ // Rounding can carry into the next millisecond. Without this the padded
83
+ // fraction gains a seventh digit and the concatenated timestamp is malformed,
84
+ // which 400s the entire request — the exact failure client-side validity
85
+ // exists to prevent.
86
+ if (fractionalNanos >= 1e6) {
87
+ whole += 1
88
+ fractionalNanos = 0
89
+ }
90
+ return String(whole) + String(fractionalNanos).padStart(6, '0')
91
+ }
92
+
93
+ function toOtlpEvent(event: SpanRecord['events'][number], logger?: Logger): OtlpSpanEvent {
94
+ const encoded: OtlpSpanEvent = {
95
+ name: wireString(event.name),
96
+ timeUnixNano: msToUnixNanoString(event.timestamp),
97
+ }
98
+ if (event.attributes) {
99
+ const attributes = toOtlpKeyValueList(event.attributes, logger)
100
+ if (attributes.length) {
101
+ encoded.attributes = attributes
102
+ }
103
+ }
104
+ const dropped = nonNegativeCount(event.droppedAttributesCount)
105
+ if (dropped) {
106
+ encoded.droppedAttributesCount = dropped
107
+ }
108
+ return encoded
109
+ }
110
+
111
+ export function buildOtlpSpan(record: SpanRecord, logger?: Logger): OtlpSpan {
112
+ const span: OtlpSpan = {
113
+ traceId: record.traceId,
114
+ spanId: record.spanId,
115
+ name: wireString(record.name),
116
+ kind: spanKindToOtlp(record.kind),
117
+ startTimeUnixNano: msToUnixNanoString(record.startTime),
118
+ endTimeUnixNano: msToUnixNanoString(record.endTime),
119
+ flags: spanFlags(record),
120
+ }
121
+ if (record.parentSpanId) {
122
+ span.parentSpanId = record.parentSpanId
123
+ }
124
+ if (record.traceState) {
125
+ span.traceState = wireString(record.traceState)
126
+ }
127
+ const attributes = toOtlpKeyValueList(record.attributes, logger)
128
+ if (attributes.length) {
129
+ span.attributes = attributes
130
+ }
131
+ if (record.events.length) {
132
+ span.events = record.events.map((event) => toOtlpEvent(event, logger))
133
+ }
134
+ // Coerced: a `beforeSpanSend` hook can write anything onto the record, and a
135
+ // non-integer here is refused for the whole request.
136
+ const droppedAttributes = nonNegativeCount(record.droppedAttributesCount)
137
+ if (droppedAttributes) {
138
+ span.droppedAttributesCount = droppedAttributes
139
+ }
140
+ const droppedEvents = nonNegativeCount(record.droppedEventsCount)
141
+ if (droppedEvents) {
142
+ span.droppedEventsCount = droppedEvents
143
+ }
144
+ if (record.status) {
145
+ span.status = {
146
+ code: SPAN_STATUS_TO_OTLP[record.status.code],
147
+ ...(record.status.message && { message: wireString(record.status.message) }),
148
+ }
149
+ }
150
+ return span
151
+ }
152
+
153
+ /**
154
+ * OTLP resource attributes for every batch. User `resourceAttributes` are spread
155
+ * first, then SDK-controlled identity keys on top so a stray key can't clobber
156
+ * them. `service.name` is always emitted: the server reads `service_name` only
157
+ * from that attribute, and spans are unattributable without it.
158
+ */
159
+ export function buildTracesResourceAttributes(
160
+ config: ResolvedTracesConfig,
161
+ sdkName: string,
162
+ sdkVersion: string
163
+ ): SpanAttributes {
164
+ return buildOtlpResourceAttributes(config, sdkName, sdkVersion)
165
+ }
166
+
167
+ /**
168
+ * Wraps spans in the OTLP `resourceSpans` envelope: one resource, one scope, N
169
+ * spans per batch. The server flattens the scope to `{name}@{version}`.
170
+ */
171
+ export function buildOtlpTracesPayload(
172
+ spans: OtlpSpan[],
173
+ resourceAttributes: SpanAttributes,
174
+ scopeName: string,
175
+ scopeVersion: string,
176
+ logger?: Logger
177
+ ): OtlpTracesPayload {
178
+ return {
179
+ resourceSpans: [
180
+ {
181
+ resource: { attributes: toOtlpResourceKeyValueList(resourceAttributes, logger) },
182
+ scopeSpans: [
183
+ {
184
+ scope: { name: scopeName, version: scopeVersion },
185
+ spans,
186
+ },
187
+ ],
188
+ },
189
+ ],
190
+ }
191
+ }