@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.
- package/node_modules/@posthog/core/dist/error-tracking/index.d.ts +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
- package/node_modules/@posthog/core/dist/index.d.ts +7 -0
- package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/index.js +82 -21
- package/node_modules/@posthog/core/dist/index.mjs +4 -0
- package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
- package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/logs/index.js +28 -22
- package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
- package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
- package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
- package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
- package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
- package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
- package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
- package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
- package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
- package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/config.js +101 -0
- package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/context.js +53 -0
- package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
- package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/index.js +551 -0
- package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
- package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/span.js +533 -0
- package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/types.js +18 -0
- package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
- package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
- package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
- package/node_modules/@posthog/core/package.json +3 -3
- package/node_modules/@posthog/core/src/error-tracking/index.ts +24 -1
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
- package/node_modules/@posthog/core/src/index.ts +18 -0
- package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
- package/node_modules/@posthog/core/src/logs/index.ts +63 -31
- package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
- package/node_modules/@posthog/core/src/logs/types.ts +2 -2
- package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
- package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
- package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
- package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
- package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
- package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
- package/node_modules/@posthog/core/src/traces/config.ts +142 -0
- package/node_modules/@posthog/core/src/traces/context.ts +30 -0
- package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
- package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
- package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
- package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
- package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
- package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
- package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
- package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
- package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
- package/node_modules/@posthog/core/src/traces/span.ts +822 -0
- package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
- package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
- package/node_modules/@posthog/core/src/traces/types.ts +140 -0
- package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
- package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
- package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
- package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
- package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
- package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
- package/node_modules/@posthog/types/dist/index.d.ts +1 -0
- package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
- package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
- package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
- package/node_modules/@posthog/types/dist/traces.js +18 -0
- package/node_modules/@posthog/types/dist/traces.mjs +1 -0
- package/node_modules/@posthog/types/package.json +1 -1
- package/node_modules/@posthog/types/src/index.ts +19 -0
- package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
- package/node_modules/@posthog/types/src/traces.ts +429 -0
- package/node_modules/@zuplo/core/package.json +1 -1
- package/node_modules/@zuplo/graphql/package.json +1 -1
- package/node_modules/@zuplo/openapi-tools/package.json +1 -1
- package/node_modules/@zuplo/otel/package.json +1 -1
- package/node_modules/@zuplo/runtime/out/esm/{chunk-FEB2VFS7.js → chunk-KNAYKBEE.js} +96 -96
- package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
- package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
- package/node_modules/@zuplo/runtime/package.json +1 -1
- package/package.json +11 -11
- package/node_modules/@zuplo/runtime/out/esm/chunk-FEB2VFS7.js.map +0 -1
- /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
|
+
}
|