@posthog/core 1.49.1 → 1.50.0
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/dist/featureFlagLocalEvaluation.d.ts +1 -1
- package/dist/featureFlagLocalEvaluation.d.ts.map +1 -1
- package/dist/featureFlagLocalEvaluation.js +149 -10
- package/dist/featureFlagLocalEvaluation.mjs +149 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -31
- package/dist/index.mjs +2 -1
- package/dist/logs/logs-utils.d.ts +1 -3
- package/dist/logs/logs-utils.d.ts.map +1 -1
- package/dist/logs/logs-utils.js +5 -158
- package/dist/logs/logs-utils.mjs +4 -151
- package/dist/metrics/index.js +2 -2
- package/dist/metrics/index.mjs +1 -1
- package/dist/metrics/metrics-utils.js +2 -2
- package/dist/metrics/metrics-utils.mjs +1 -1
- package/dist/surveys/events.d.ts +1 -1
- package/dist/surveys/events.d.ts.map +1 -1
- package/dist/surveys/events.js +2 -2
- package/dist/surveys/events.mjs +2 -2
- package/dist/utils/otlp-any-value.d.ts +5 -0
- package/dist/utils/otlp-any-value.d.ts.map +1 -0
- package/dist/utils/otlp-any-value.js +203 -0
- package/dist/utils/otlp-any-value.mjs +166 -0
- package/dist/utils/user-agent-utils.d.ts +4 -4
- package/dist/utils/user-agent-utils.d.ts.map +1 -1
- package/dist/utils/user-agent-utils.js +16 -0
- package/dist/utils/user-agent-utils.mjs +16 -0
- package/package.json +2 -2
- package/src/featureFlagLocalEvaluation.ts +199 -12
- package/src/index.ts +1 -2
- package/src/logs/logs-utils.spec.ts +2 -350
- package/src/logs/logs-utils.ts +3 -207
- package/src/metrics/index.ts +1 -1
- package/src/metrics/metrics-utils.ts +1 -1
- package/src/surveys/events.spec.ts +41 -0
- package/src/surveys/events.ts +3 -2
- package/src/utils/otlp-any-value.spec.ts +361 -0
- package/src/utils/otlp-any-value.ts +225 -0
- package/src/utils/user-agent-utils.ts +22 -6
package/src/logs/logs-utils.ts
CHANGED
|
@@ -2,8 +2,6 @@ import type {
|
|
|
2
2
|
CaptureLogOptions,
|
|
3
3
|
LogAttributeValue,
|
|
4
4
|
LogSeverityLevel,
|
|
5
|
-
OtlpAnyValue,
|
|
6
|
-
OtlpKeyValue,
|
|
7
5
|
OtlpLogRecord,
|
|
8
6
|
OtlpLogsPayload,
|
|
9
7
|
OtlpSeverityEntry,
|
|
@@ -11,17 +9,9 @@ import type {
|
|
|
11
9
|
} from '@posthog/types'
|
|
12
10
|
import type { Logger } from '../types'
|
|
13
11
|
import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
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'
|
|
12
|
+
import { isNullish, isNumber, isUndefined } from '../utils'
|
|
13
|
+
import { sanitizeString, UNSERIALIZABLE_VALUE } from '../utils/json-utils'
|
|
14
|
+
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
|
|
25
15
|
|
|
26
16
|
// ============================================================================
|
|
27
17
|
// Severity mapping
|
|
@@ -46,200 +36,6 @@ export function getOtlpSeverityNumber(level: LogSeverityLevel): number {
|
|
|
46
36
|
return (OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY).number
|
|
47
37
|
}
|
|
48
38
|
|
|
49
|
-
// ============================================================================
|
|
50
|
-
// OTLP AnyValue conversion
|
|
51
|
-
// ============================================================================
|
|
52
|
-
|
|
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
|
-
|
|
97
|
-
if (isBoolean(value)) {
|
|
98
|
-
return { boolValue: value }
|
|
99
|
-
}
|
|
100
|
-
// typeof, not core's isNumber, which excludes NaN — proto3 JSON distinguishes
|
|
101
|
-
// a non-finite float from an ordinary string.
|
|
102
|
-
if (typeof value === 'number') {
|
|
103
|
-
if (!Number.isFinite(value)) {
|
|
104
|
-
return { stringValue: String(value) }
|
|
105
|
-
}
|
|
106
|
-
if (Number.isInteger(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 }
|
|
125
|
-
}
|
|
126
|
-
return { doubleValue: value }
|
|
127
|
-
}
|
|
128
|
-
if (typeof value === 'string') {
|
|
129
|
-
return { stringValue: sanitizeString(value) }
|
|
130
|
-
}
|
|
131
|
-
// `String(value)` would put a function's source text on the wire.
|
|
132
|
-
if (typeof value === 'function') {
|
|
133
|
-
return { stringValue: FUNCTION_VALUE }
|
|
134
|
-
}
|
|
135
|
-
if (typeof value === 'symbol') {
|
|
136
|
-
return { stringValue: String(value) }
|
|
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
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function encodeKeyValueList(
|
|
211
|
-
attrs: Record<string, LogAttributeValue>,
|
|
212
|
-
logger: Logger | undefined,
|
|
213
|
-
state: EncodeState,
|
|
214
|
-
depth: number
|
|
215
|
-
): OtlpKeyValue[] {
|
|
216
|
-
const result: OtlpKeyValue[] = []
|
|
217
|
-
for (const key in attrs) {
|
|
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)) {
|
|
221
|
-
continue
|
|
222
|
-
}
|
|
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
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return result
|
|
241
|
-
}
|
|
242
|
-
|
|
243
39
|
// ============================================================================
|
|
244
40
|
// OTLP LogRecord construction
|
|
245
41
|
// ============================================================================
|
package/src/metrics/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from '@posthog/types'
|
|
11
11
|
import type { Logger } from '../types'
|
|
12
12
|
import { isArray, safeSetTimeout } from '../utils'
|
|
13
|
-
import { toOtlpKeyValueList } from '../
|
|
13
|
+
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
|
|
14
14
|
import {
|
|
15
15
|
DEFAULT_HISTOGRAM_BOUNDS,
|
|
16
16
|
bucketIndexFor,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MetricAttributeValue, OtlpMetric, OtlpMetricsPayload } from '@posthog/types'
|
|
2
|
-
import { toOtlpKeyValueList } from '../
|
|
2
|
+
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
|
|
3
3
|
import type { ResolvedPostHogMetricsConfig } from './types'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -35,6 +35,47 @@ describe('survey event helpers', () => {
|
|
|
35
35
|
})
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
+
it('prefers question snapshots over the current question text', () => {
|
|
39
|
+
const responses = { [getSurveyResponseKey('q1')]: 5 }
|
|
40
|
+
const questionSnapshots = { q1: 'Rate us (French)' }
|
|
41
|
+
|
|
42
|
+
expect(buildSurveyResponseProperties(responses, survey, questionSnapshots)).toEqual(
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
$survey_questions: [
|
|
45
|
+
{ id: 'q1', question: 'Rate us (French)', response: 5 },
|
|
46
|
+
{ id: 'q2', question: 'Anything else?', response: undefined },
|
|
47
|
+
],
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('falls back to the current question text for a question with no snapshot', () => {
|
|
53
|
+
const questionSnapshots = { q1: 'Rate us (French)' }
|
|
54
|
+
|
|
55
|
+
expect(buildSurveyResponseProperties({}, survey, questionSnapshots)).toEqual(
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
$survey_questions: expect.arrayContaining([{ id: 'q2', question: 'Anything else?', response: undefined }]),
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('falls back to the current question text when the question id is an empty string', () => {
|
|
63
|
+
const surveyWithEmptyId = {
|
|
64
|
+
...survey,
|
|
65
|
+
questions: [{ id: '', question: 'Untitled', originalQuestionIndex: 0 }],
|
|
66
|
+
}
|
|
67
|
+
// A snapshot recorded under the empty-string key must not be picked up for a
|
|
68
|
+
// question whose id is also '' — only an explicit questionSnapshots[''] set by
|
|
69
|
+
// the caller should ever surface here, and there is none in this case.
|
|
70
|
+
const questionSnapshots = {}
|
|
71
|
+
|
|
72
|
+
expect(buildSurveyResponseProperties({}, surveyWithEmptyId, questionSnapshots)).toEqual(
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
$survey_questions: [{ id: '', question: 'Untitled', response: null }],
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
38
79
|
it('copies array response values before returning them', () => {
|
|
39
80
|
const responses = { [getSurveyResponseKey('q1')]: ['a'] }
|
|
40
81
|
|
package/src/surveys/events.ts
CHANGED
|
@@ -28,7 +28,8 @@ export function getSurveyResponseValue(
|
|
|
28
28
|
|
|
29
29
|
export function buildSurveyResponseProperties(
|
|
30
30
|
responses: SurveyResponses = {},
|
|
31
|
-
survey: SurveyForResponses
|
|
31
|
+
survey: SurveyForResponses,
|
|
32
|
+
questionSnapshots?: Record<string, string>
|
|
32
33
|
): Record<string, unknown> {
|
|
33
34
|
const oldFormatResponses: SurveyResponses = {}
|
|
34
35
|
survey.questions.forEach((question: SurveyQuestionForResponses) => {
|
|
@@ -45,7 +46,7 @@ export function buildSurveyResponseProperties(
|
|
|
45
46
|
return {
|
|
46
47
|
$survey_questions: survey.questions.map((question: SurveyQuestionForResponses) => ({
|
|
47
48
|
id: question.id,
|
|
48
|
-
question: question.question,
|
|
49
|
+
question: questionSnapshots?.[question.id ?? ''] ?? question.question,
|
|
49
50
|
response: getSurveyResponseValue(responses, question.id),
|
|
50
51
|
})),
|
|
51
52
|
...responses,
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { LogAttributeValue } from '@posthog/types'
|
|
2
|
+
import { toOtlpAnyValue, toOtlpKeyValueList } from './otlp-any-value'
|
|
3
|
+
|
|
4
|
+
describe('otlp-any-value', () => {
|
|
5
|
+
describe('toOtlpAnyValue', () => {
|
|
6
|
+
it('converts strings', () => {
|
|
7
|
+
expect(toOtlpAnyValue('hello')).toEqual({ stringValue: 'hello' })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('converts integers to decimal strings', () => {
|
|
11
|
+
expect(toOtlpAnyValue(42)).toEqual({ intValue: '42' })
|
|
12
|
+
expect(toOtlpAnyValue(0)).toEqual({ intValue: '0' })
|
|
13
|
+
expect(toOtlpAnyValue(-7)).toEqual({ intValue: '-7' })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Spec: outside int64 it is a stringValue, never an intValue.
|
|
17
|
+
it('converts integers outside int64 to stringValue', () => {
|
|
18
|
+
expect(toOtlpAnyValue(2 ** 63)).toEqual({ stringValue: '9223372036854775808' })
|
|
19
|
+
expect(toOtlpAnyValue(-(2 ** 64))).toEqual({ stringValue: '-18446744073709551616' })
|
|
20
|
+
expect(toOtlpAnyValue(1e21)).toEqual({ stringValue: '1000000000000000000000' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('logs a debug line when an integer falls outside int64', () => {
|
|
24
|
+
const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
|
|
25
|
+
toOtlpAnyValue(2 ** 63, logger as any)
|
|
26
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('outside the int64 range'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps int64 min as intValue', () => {
|
|
30
|
+
// In range, but `String` renders it 192 below int64 min, so the decimal
|
|
31
|
+
// has to come from BigInt.
|
|
32
|
+
expect(toOtlpAnyValue(-(2 ** 63))).toEqual({ intValue: '-9223372036854775808' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps large in-range integers exact', () => {
|
|
36
|
+
expect(toOtlpAnyValue(Number.MAX_SAFE_INTEGER)).toEqual({ intValue: '9007199254740991' })
|
|
37
|
+
// The largest double below 2^63 — no double exists between the two.
|
|
38
|
+
expect(toOtlpAnyValue(9223372036854774784)).toEqual({ intValue: '9223372036854774784' })
|
|
39
|
+
expect(toOtlpAnyValue(2 ** 62)).toEqual({ intValue: '4611686018427387904' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('converts a bigint inside int64 to a stringified intValue', () => {
|
|
43
|
+
// Span attributes accept bigint; a log attribute reaching here is typed
|
|
44
|
+
// out but still encodes correctly.
|
|
45
|
+
expect(toOtlpAnyValue(9007199254740993n as unknown as LogAttributeValue)).toEqual({
|
|
46
|
+
intValue: '9007199254740993',
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('converts a bigint beyond int64 to a string, with a warning', () => {
|
|
51
|
+
const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
|
|
52
|
+
expect(toOtlpAnyValue(18446744073709551616n as unknown as LogAttributeValue, logger as any)).toEqual({
|
|
53
|
+
stringValue: '18446744073709551616',
|
|
54
|
+
})
|
|
55
|
+
expect(logger.debug).toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('converts floats to doubleValue', () => {
|
|
59
|
+
expect(toOtlpAnyValue(3.14)).toEqual({ doubleValue: 3.14 })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('converts booleans', () => {
|
|
63
|
+
expect(toOtlpAnyValue(true)).toEqual({ boolValue: true })
|
|
64
|
+
expect(toOtlpAnyValue(false)).toEqual({ boolValue: false })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// JSON has no representation for non-finite floats; without explicit
|
|
68
|
+
// handling, JSON.stringify silently turns them into `null` and the value
|
|
69
|
+
// is lost server-side.
|
|
70
|
+
it('converts NaN to stringValue', () => {
|
|
71
|
+
expect(toOtlpAnyValue(NaN)).toEqual({ stringValue: 'NaN' })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('converts +Infinity to stringValue', () => {
|
|
75
|
+
expect(toOtlpAnyValue(Infinity)).toEqual({ stringValue: 'Infinity' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('converts -Infinity to stringValue', () => {
|
|
79
|
+
expect(toOtlpAnyValue(-Infinity)).toEqual({ stringValue: '-Infinity' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('converts arrays of strings to arrayValue', () => {
|
|
83
|
+
expect(toOtlpAnyValue(['a', 'b'])).toEqual({
|
|
84
|
+
arrayValue: { values: [{ stringValue: 'a' }, { stringValue: 'b' }] },
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('converts mixed primitive arrays recursively', () => {
|
|
89
|
+
expect(toOtlpAnyValue([1, 'x', true])).toEqual({
|
|
90
|
+
arrayValue: {
|
|
91
|
+
values: [{ intValue: '1' }, { stringValue: 'x' }, { boolValue: true }],
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('converts plain objects to kvlistValue', () => {
|
|
97
|
+
expect(toOtlpAnyValue({ a: 1, b: 'two' })).toEqual({
|
|
98
|
+
kvlistValue: {
|
|
99
|
+
values: [
|
|
100
|
+
{ key: 'a', value: { intValue: '1' } },
|
|
101
|
+
{ key: 'b', value: { stringValue: 'two' } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('converts nested objects recursively', () => {
|
|
108
|
+
expect(toOtlpAnyValue({ outer: { inner: 1 } })).toEqual({
|
|
109
|
+
kvlistValue: {
|
|
110
|
+
values: [
|
|
111
|
+
{
|
|
112
|
+
key: 'outer',
|
|
113
|
+
value: { kvlistValue: { values: [{ key: 'inner', value: { intValue: '1' } }] } },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('drops null and undefined keys inside objects', () => {
|
|
121
|
+
expect(toOtlpAnyValue({ kept: 1, gone: null, alsoGone: undefined })).toEqual({
|
|
122
|
+
kvlistValue: { values: [{ key: 'kept', value: { intValue: '1' } }] },
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Not in LogAttributeValue, but reachable at runtime from untyped callers.
|
|
127
|
+
it('encodes Dates as ISO strings', () => {
|
|
128
|
+
expect(toOtlpAnyValue(new Date('2026-08-20T10:00:00.000Z') as unknown as LogAttributeValue)).toEqual({
|
|
129
|
+
stringValue: '2026-08-20T10:00:00.000Z',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('marks circular references instead of recursing', () => {
|
|
134
|
+
const cyclic: Record<string, unknown> = { name: 'root' }
|
|
135
|
+
cyclic.self = cyclic
|
|
136
|
+
expect(toOtlpAnyValue(cyclic)).toEqual({
|
|
137
|
+
kvlistValue: {
|
|
138
|
+
values: [
|
|
139
|
+
{ key: 'name', value: { stringValue: 'root' } },
|
|
140
|
+
{ key: 'self', value: { stringValue: '[Circular]' } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// An escaping error would surface in the caller's application code.
|
|
147
|
+
it('does not throw on an object nested past the depth cap', () => {
|
|
148
|
+
let deep: Record<string, unknown> = { end: true }
|
|
149
|
+
for (let i = 0; i < 25000; i++) {
|
|
150
|
+
deep = { next: deep }
|
|
151
|
+
}
|
|
152
|
+
expect(() => toOtlpAnyValue(deep)).not.toThrow()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('truncates at exactly 20 levels instead of recursing', () => {
|
|
156
|
+
let deep: Record<string, unknown> = { end: true }
|
|
157
|
+
for (let i = 0; i < 25; i++) {
|
|
158
|
+
deep = { next: deep }
|
|
159
|
+
}
|
|
160
|
+
const encoded = JSON.stringify(toOtlpAnyValue(deep))
|
|
161
|
+
expect(encoded).toContain('[Truncated]')
|
|
162
|
+
expect(encoded.split('"next"').length - 1).toBe(20)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('marks a throwing getter without losing the rest of the object', () => {
|
|
166
|
+
const attrs = {
|
|
167
|
+
ok: 1,
|
|
168
|
+
get bad(): number {
|
|
169
|
+
throw new Error('getter blew up')
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
expect(() => toOtlpKeyValueList(attrs)).not.toThrow()
|
|
173
|
+
expect(toOtlpKeyValueList(attrs)).toEqual([
|
|
174
|
+
{ key: 'ok', value: { intValue: '1' } },
|
|
175
|
+
{ key: 'bad', value: { stringValue: '[Unserializable]' } },
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// for...in walks the prototype chain once own keys are exhausted.
|
|
180
|
+
it('ignores inherited enumerable properties', () => {
|
|
181
|
+
const inherited: Record<string, unknown> = Object.create({ fromPrototype: 'leaked' })
|
|
182
|
+
inherited.own = 1
|
|
183
|
+
expect(toOtlpAnyValue(inherited)).toEqual({
|
|
184
|
+
kvlistValue: { values: [{ key: 'own', value: { intValue: '1' } }] },
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// `String(fn)` would put the function's source text on the wire.
|
|
189
|
+
it('marks function and symbol values instead of stringifying them', () => {
|
|
190
|
+
expect(toOtlpAnyValue({ handler: () => 1, retries: 2 } as unknown as LogAttributeValue)).toEqual({
|
|
191
|
+
kvlistValue: {
|
|
192
|
+
values: [
|
|
193
|
+
{ key: 'handler', value: { stringValue: '[Function]' } },
|
|
194
|
+
{ key: 'retries', value: { intValue: '2' } },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
expect(toOtlpAnyValue({ sym: Symbol('x') } as unknown as LogAttributeValue)).toEqual({
|
|
199
|
+
kvlistValue: { values: [{ key: 'sym', value: { stringValue: 'Symbol(x)' } }] },
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// dayjs, Decimal, ORM documents.
|
|
204
|
+
it('honours toJSON', () => {
|
|
205
|
+
const wrapped = { toJSON: () => ({ amount: 5 }) }
|
|
206
|
+
expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
|
|
207
|
+
kvlistValue: { values: [{ key: 'amount', value: { intValue: '5' } }] },
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('falls back to the plain walk when toJSON throws', () => {
|
|
212
|
+
const wrapped = {
|
|
213
|
+
kept: 1,
|
|
214
|
+
toJSON: () => {
|
|
215
|
+
throw new Error('nope')
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
|
|
219
|
+
kvlistValue: {
|
|
220
|
+
values: [
|
|
221
|
+
{ key: 'kept', value: { intValue: '1' } },
|
|
222
|
+
{ key: 'toJSON', value: { stringValue: '[Function]' } },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// A toJSON returning its own object is a cycle like any other.
|
|
229
|
+
it('marks a cycle that runs through toJSON', () => {
|
|
230
|
+
const cyclic: Record<string, unknown> = {}
|
|
231
|
+
cyclic.toJSON = () => ({ inner: cyclic })
|
|
232
|
+
expect(toOtlpAnyValue(cyclic)).toEqual({
|
|
233
|
+
kvlistValue: { values: [{ key: 'inner', value: { stringValue: '[Circular]' } }] },
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Both `null` and `{}` here are rejected for the whole request; iOS and
|
|
238
|
+
// Android drop them too.
|
|
239
|
+
it('drops holes and nullish elements from arrays', () => {
|
|
240
|
+
// eslint-disable-next-line no-sparse-arrays
|
|
241
|
+
expect(toOtlpAnyValue([1, , 3])).toEqual({
|
|
242
|
+
arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
|
|
243
|
+
})
|
|
244
|
+
expect(toOtlpAnyValue([1, null, undefined, 3])).toEqual({
|
|
245
|
+
arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('stops encoding array items once the node budget is spent', () => {
|
|
250
|
+
const row: Record<string, number> = {}
|
|
251
|
+
for (let i = 0; i < 20; i++) {
|
|
252
|
+
row[`k${i}`] = i
|
|
253
|
+
}
|
|
254
|
+
const wide = Array.from({ length: 1000 }, () => ({ ...row }))
|
|
255
|
+
const values = toOtlpAnyValue(wide).arrayValue!.values
|
|
256
|
+
expect(values[values.length - 1]).toEqual({ stringValue: '[Truncated]' })
|
|
257
|
+
// One marker, not one per unencodable item.
|
|
258
|
+
expect(values.filter((v) => v.stringValue === '[Truncated]')).toHaveLength(1)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('caps a shared object graph instead of expanding it', () => {
|
|
262
|
+
let graph: Record<string, unknown> = { leaf: true }
|
|
263
|
+
for (let i = 0; i < 20; i++) {
|
|
264
|
+
graph = { a: graph, b: graph }
|
|
265
|
+
}
|
|
266
|
+
const encoded = JSON.stringify(toOtlpAnyValue(graph))
|
|
267
|
+
expect(encoded).toContain('[Truncated]')
|
|
268
|
+
expect(encoded.length).toBeLessThan(1_000_000)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('caps a very wide object without inventing an attribute key', () => {
|
|
272
|
+
const wide: Record<string, number> = {}
|
|
273
|
+
for (let i = 0; i < 5000; i++) {
|
|
274
|
+
wide[`k${i}`] = i
|
|
275
|
+
}
|
|
276
|
+
const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
|
|
277
|
+
const values = toOtlpAnyValue(wide, logger as any).kvlistValue!.values
|
|
278
|
+
expect(values).toHaveLength(1000)
|
|
279
|
+
expect(values.every((v) => v.key.startsWith('k'))).toBe(true)
|
|
280
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('truncated'))
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Why the encoder does not delegate to toJsonSafeValue: that maps them to null.
|
|
284
|
+
it('keeps non-finite floats as strings inside nested objects', () => {
|
|
285
|
+
expect(toOtlpAnyValue({ nested: { ratio: NaN } })).toEqual({
|
|
286
|
+
kvlistValue: {
|
|
287
|
+
values: [
|
|
288
|
+
{
|
|
289
|
+
key: 'nested',
|
|
290
|
+
value: { kvlistValue: { values: [{ key: 'ratio', value: { stringValue: 'NaN' } }] } },
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// A lone surrogate survives JSON.stringify as a \uD800 escape, which the
|
|
298
|
+
// server rejects for the whole request.
|
|
299
|
+
it('replaces unpaired surrogates in values and keys', () => {
|
|
300
|
+
expect(toOtlpAnyValue('ok\ud83d')).toEqual({ stringValue: 'ok\ufffd' })
|
|
301
|
+
expect(toOtlpAnyValue({ nested: 'ok\ud83d' })).toEqual({
|
|
302
|
+
kvlistValue: { values: [{ key: 'nested', value: { stringValue: 'ok\ufffd' } }] },
|
|
303
|
+
})
|
|
304
|
+
expect(toOtlpKeyValueList({ 'key\ud83d': 1 })).toEqual([{ key: 'key\ufffd', value: { intValue: '1' } }])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('encodes empty containers with an explicit values array', () => {
|
|
308
|
+
expect(toOtlpAnyValue({})).toEqual({ kvlistValue: { values: [] } })
|
|
309
|
+
expect(toOtlpAnyValue([])).toEqual({ arrayValue: { values: [] } })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('keeps a Date whose toISOString is overridden out of the wire format', () => {
|
|
313
|
+
const broken = new Date('2026-08-20T10:00:00.000Z')
|
|
314
|
+
|
|
315
|
+
;(broken as any).toISOString = () => ({})
|
|
316
|
+
expect(typeof toOtlpAnyValue(broken as unknown as LogAttributeValue).stringValue).toBe('string')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('encodes sibling references to one object twice, not as circular', () => {
|
|
320
|
+
const shared = { id: 1 }
|
|
321
|
+
expect(toOtlpAnyValue({ a: shared, b: shared })).toEqual({
|
|
322
|
+
kvlistValue: {
|
|
323
|
+
values: [
|
|
324
|
+
{ key: 'a', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
|
|
325
|
+
{ key: 'b', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('toOtlpKeyValueList', () => {
|
|
333
|
+
it('converts a record to key-value list', () => {
|
|
334
|
+
expect(
|
|
335
|
+
toOtlpKeyValueList({
|
|
336
|
+
name: 'test',
|
|
337
|
+
count: 5,
|
|
338
|
+
active: true,
|
|
339
|
+
})
|
|
340
|
+
).toEqual([
|
|
341
|
+
{ key: 'name', value: { stringValue: 'test' } },
|
|
342
|
+
{ key: 'count', value: { intValue: '5' } },
|
|
343
|
+
{ key: 'active', value: { boolValue: true } },
|
|
344
|
+
])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('handles empty record', () => {
|
|
348
|
+
expect(toOtlpKeyValueList({})).toEqual([])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('skips null and undefined values', () => {
|
|
352
|
+
expect(
|
|
353
|
+
toOtlpKeyValueList({
|
|
354
|
+
kept: 'yes',
|
|
355
|
+
nullish: null,
|
|
356
|
+
missing: undefined,
|
|
357
|
+
})
|
|
358
|
+
).toEqual([{ key: 'kept', value: { stringValue: 'yes' } }])
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
})
|