@posthog/core 1.49.2 → 1.50.1
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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -31
- package/dist/index.mjs +4 -2
- 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 +7 -170
- package/dist/logs/logs-utils.mjs +6 -163
- package/dist/metrics/index.js +2 -2
- package/dist/metrics/index.mjs +1 -1
- package/dist/metrics/metrics-utils.d.ts.map +1 -1
- package/dist/metrics/metrics-utils.js +4 -14
- package/dist/metrics/metrics-utils.mjs +3 -13
- 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/otlp-resource.d.ts +38 -0
- package/dist/utils/otlp-resource.d.ts.map +1 -0
- package/dist/utils/otlp-resource.js +81 -0
- package/dist/utils/otlp-resource.mjs +41 -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 +1 -1
- package/src/featureFlagLocalEvaluation.ts +199 -12
- package/src/index.ts +2 -2
- package/src/logs/logs-utils.spec.ts +2 -350
- package/src/logs/logs-utils.ts +5 -215
- package/src/metrics/index.ts +1 -1
- package/src/metrics/metrics-utils.ts +3 -9
- package/src/utils/otlp-any-value.spec.ts +361 -0
- package/src/utils/otlp-any-value.ts +225 -0
- package/src/utils/otlp-resource.spec.ts +125 -0
- package/src/utils/otlp-resource.ts +89 -0
- package/src/utils/user-agent-utils.ts +22 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parsePayload } from './featureFlagUtils'
|
|
2
2
|
import type { FeatureFlagValue, JsonType } from './types'
|
|
3
3
|
|
|
4
|
-
export type FeatureFlagPropertyValue =
|
|
4
|
+
export type FeatureFlagPropertyValue = JsonType
|
|
5
5
|
|
|
6
6
|
export type FeatureFlagProperty = {
|
|
7
7
|
key: string
|
|
@@ -42,6 +42,172 @@ export class InconclusiveMatchError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function isTruthyOrFalsyPropertyValue(value: unknown): boolean {
|
|
46
|
+
if (typeof value === 'boolean') return true
|
|
47
|
+
if (typeof value === 'string') {
|
|
48
|
+
const lowercaseValue = value.toLowerCase()
|
|
49
|
+
return lowercaseValue === 'true' || lowercaseValue === 'false'
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(value)) return false
|
|
52
|
+
for (let index = 0; index < value.length; index++) {
|
|
53
|
+
if (!isTruthyOrFalsyPropertyValue(index in value ? value[index] : null)) return false
|
|
54
|
+
}
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isTruthyPropertyValue(value: unknown): boolean {
|
|
59
|
+
if (typeof value === 'boolean') return value
|
|
60
|
+
if (typeof value === 'string') return value.toLowerCase() === 'true'
|
|
61
|
+
if (!Array.isArray(value)) return false
|
|
62
|
+
for (let index = 0; index < value.length; index++) {
|
|
63
|
+
if (!isTruthyPropertyValue(index in value ? value[index] : null)) return false
|
|
64
|
+
}
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertUnicodeScalarString(value: string): void {
|
|
69
|
+
for (let index = 0; index < value.length; index++) {
|
|
70
|
+
const unit = value.charCodeAt(index)
|
|
71
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
72
|
+
const next = value.charCodeAt(index + 1)
|
|
73
|
+
if (index + 1 >= value.length || next < 0xdc00 || next > 0xdfff) {
|
|
74
|
+
throw new InconclusiveMatchError('Cannot stringify an unpaired surrogate like the flags service')
|
|
75
|
+
}
|
|
76
|
+
index++
|
|
77
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
78
|
+
throw new InconclusiveMatchError('Cannot stringify an unpaired surrogate like the flags service')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertJsonRepresentable(value: unknown, seen: Set<object> = new Set()): void {
|
|
84
|
+
if (value === null || typeof value === 'boolean') return
|
|
85
|
+
if (typeof value === 'string') {
|
|
86
|
+
assertUnicodeScalarString(value)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === 'number') {
|
|
90
|
+
if (!Number.isFinite(value)) {
|
|
91
|
+
throw new InconclusiveMatchError(`Cannot represent non-finite number ${value} like the flags service`)
|
|
92
|
+
}
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
if (seen.has(value)) throw new InconclusiveMatchError('Cannot represent a circular array during local evaluation')
|
|
97
|
+
seen.add(value)
|
|
98
|
+
try {
|
|
99
|
+
for (let index = 0; index < value.length; index++) {
|
|
100
|
+
if (index in value) assertJsonRepresentable(value[index], seen)
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
seen.delete(value)
|
|
104
|
+
}
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (typeof value === 'object') {
|
|
108
|
+
const prototype = Object.getPrototypeOf(value)
|
|
109
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
110
|
+
throw new InconclusiveMatchError('Cannot represent a non-JSON object like the flags service')
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(value)) throw new InconclusiveMatchError('Cannot represent a circular object during local evaluation')
|
|
113
|
+
seen.add(value)
|
|
114
|
+
try {
|
|
115
|
+
for (const key of Object.keys(value)) {
|
|
116
|
+
assertUnicodeScalarString(key)
|
|
117
|
+
assertJsonRepresentable((value as Record<string, unknown>)[key], seen)
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
seen.delete(value)
|
|
121
|
+
}
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
throw new InconclusiveMatchError(`Cannot represent ${typeof value} like the flags service`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function compareJsonObjectKeys(left: string, right: string): number {
|
|
128
|
+
let leftIndex = 0
|
|
129
|
+
let rightIndex = 0
|
|
130
|
+
while (leftIndex < left.length && rightIndex < right.length) {
|
|
131
|
+
const leftUnit = left.charCodeAt(leftIndex)
|
|
132
|
+
const rightUnit = right.charCodeAt(rightIndex)
|
|
133
|
+
const leftIsHighSurrogate = leftUnit >= 0xd800 && leftUnit <= 0xdbff
|
|
134
|
+
const rightIsHighSurrogate = rightUnit >= 0xd800 && rightUnit <= 0xdbff
|
|
135
|
+
const leftNext = leftIsHighSurrogate ? left.charCodeAt(leftIndex + 1) : 0
|
|
136
|
+
const rightNext = rightIsHighSurrogate ? right.charCodeAt(rightIndex + 1) : 0
|
|
137
|
+
|
|
138
|
+
const leftCodePoint = leftIsHighSurrogate ? (leftUnit - 0xd800) * 0x400 + leftNext - 0xdc00 + 0x10000 : leftUnit
|
|
139
|
+
const rightCodePoint = rightIsHighSurrogate
|
|
140
|
+
? (rightUnit - 0xd800) * 0x400 + rightNext - 0xdc00 + 0x10000
|
|
141
|
+
: rightUnit
|
|
142
|
+
if (leftCodePoint !== rightCodePoint) return leftCodePoint - rightCodePoint
|
|
143
|
+
leftIndex += leftIsHighSurrogate ? 2 : 1
|
|
144
|
+
rightIndex += rightIsHighSurrogate ? 2 : 1
|
|
145
|
+
}
|
|
146
|
+
return left.length - right.length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// JavaScript collapses JSON integers and integral floats into Number, including inside composites.
|
|
150
|
+
// These values fall back rather than choosing a spelling that can disagree with the flags service.
|
|
151
|
+
function serializeJsonValue(value: unknown, seen: Set<object> = new Set()): string {
|
|
152
|
+
if (value === null) return 'null'
|
|
153
|
+
if (typeof value === 'string') {
|
|
154
|
+
assertUnicodeScalarString(value)
|
|
155
|
+
return JSON.stringify(value)
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
158
|
+
if (typeof value === 'number') {
|
|
159
|
+
if (!Number.isFinite(value)) {
|
|
160
|
+
throw new InconclusiveMatchError(`Cannot stringify non-finite number ${value} like the flags service`)
|
|
161
|
+
}
|
|
162
|
+
if (Number.isInteger(value)) {
|
|
163
|
+
throw new InconclusiveMatchError(
|
|
164
|
+
`Cannot distinguish integer ${value} from an integral JSON float during local evaluation`
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
return String(value)
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
if (seen.has(value)) throw new InconclusiveMatchError('Cannot stringify a circular array during local evaluation')
|
|
171
|
+
seen.add(value)
|
|
172
|
+
try {
|
|
173
|
+
const items: string[] = []
|
|
174
|
+
for (let index = 0; index < value.length; index++) {
|
|
175
|
+
items.push(index in value ? serializeJsonValue(value[index], seen) : 'null')
|
|
176
|
+
}
|
|
177
|
+
return `[${items.join(',')}]`
|
|
178
|
+
} finally {
|
|
179
|
+
seen.delete(value)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (typeof value === 'object') {
|
|
183
|
+
const prototype = Object.getPrototypeOf(value)
|
|
184
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
185
|
+
throw new InconclusiveMatchError('Cannot stringify a non-JSON object like the flags service')
|
|
186
|
+
}
|
|
187
|
+
if (seen.has(value)) throw new InconclusiveMatchError('Cannot stringify a circular object during local evaluation')
|
|
188
|
+
seen.add(value)
|
|
189
|
+
try {
|
|
190
|
+
const keys = Object.keys(value)
|
|
191
|
+
keys.forEach(assertUnicodeScalarString)
|
|
192
|
+
return `{${keys
|
|
193
|
+
.sort(compareJsonObjectKeys)
|
|
194
|
+
.map((key) => `${JSON.stringify(key)}:${serializeJsonValue((value as Record<string, unknown>)[key], seen)}`)
|
|
195
|
+
.join(',')}}`
|
|
196
|
+
} finally {
|
|
197
|
+
seen.delete(value)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw new InconclusiveMatchError(`Cannot stringify ${typeof value} like the flags service`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function exactMatchString(value: unknown): string {
|
|
204
|
+
if (typeof value === 'string') {
|
|
205
|
+
assertUnicodeScalarString(value)
|
|
206
|
+
return value
|
|
207
|
+
}
|
|
208
|
+
return serializeJsonValue(value)
|
|
209
|
+
}
|
|
210
|
+
|
|
45
211
|
function isValidRegex(regex: string): boolean {
|
|
46
212
|
try {
|
|
47
213
|
new RegExp(regex)
|
|
@@ -51,6 +217,11 @@ function isValidRegex(regex: string): boolean {
|
|
|
51
217
|
}
|
|
52
218
|
}
|
|
53
219
|
|
|
220
|
+
// The flags service deliberately folds only ASCII for substring, prefix, and suffix operators.
|
|
221
|
+
function asciiLowercase(value: unknown): string {
|
|
222
|
+
return String(value).replace(/[A-Z]/g, (character) => character.toLowerCase())
|
|
223
|
+
}
|
|
224
|
+
|
|
54
225
|
type SemverTuple = [number, number, number]
|
|
55
226
|
|
|
56
227
|
function parseSemverNumericIdentifier(
|
|
@@ -190,19 +361,35 @@ export function matchFeatureFlagProperty(
|
|
|
190
361
|
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
|
|
191
362
|
} else if (operator === 'is_not_set') {
|
|
192
363
|
return false
|
|
364
|
+
} else if (operator === 'is_set') {
|
|
365
|
+
return true
|
|
193
366
|
}
|
|
194
367
|
|
|
195
368
|
const overrideValue = propertyValues[key]
|
|
196
|
-
if (overrideValue
|
|
197
|
-
options.warnFunction?.(`Property ${key} cannot have a value of
|
|
369
|
+
if (overrideValue === undefined) {
|
|
370
|
+
options.warnFunction?.(`Property ${key} cannot have a value of undefined with the ${operator} operator`)
|
|
371
|
+
return operator === 'is_not'
|
|
372
|
+
}
|
|
373
|
+
if (
|
|
374
|
+
overrideValue === null &&
|
|
375
|
+
!NULL_VALUES_ALLOWED_OPERATORS.includes(operator) &&
|
|
376
|
+
operator !== 'exact' &&
|
|
377
|
+
operator !== 'is_not'
|
|
378
|
+
) {
|
|
379
|
+
options.warnFunction?.(`Property ${key} cannot have a value of null with the ${operator} operator`)
|
|
198
380
|
return false
|
|
199
381
|
}
|
|
200
382
|
|
|
201
|
-
const computeExactMatch = (target:
|
|
383
|
+
const computeExactMatch = (target: unknown, actual: unknown): boolean => {
|
|
384
|
+
if (isTruthyOrFalsyPropertyValue(target)) {
|
|
385
|
+
assertJsonRepresentable(actual)
|
|
386
|
+
return isTruthyPropertyValue(target) === isTruthyPropertyValue(actual)
|
|
387
|
+
}
|
|
202
388
|
if (Array.isArray(target)) {
|
|
203
|
-
|
|
389
|
+
const actualString = exactMatchString(actual).toLowerCase()
|
|
390
|
+
return target.some((item) => exactMatchString(item).toLowerCase() === actualString)
|
|
204
391
|
}
|
|
205
|
-
return
|
|
392
|
+
return exactMatchString(target).toLowerCase() === exactMatchString(actual).toLowerCase()
|
|
206
393
|
}
|
|
207
394
|
|
|
208
395
|
const compare = (lhs: any, rhs: any, comparisonOperator: string): boolean => {
|
|
@@ -221,17 +408,17 @@ export function matchFeatureFlagProperty(
|
|
|
221
408
|
case 'is_set':
|
|
222
409
|
return true
|
|
223
410
|
case 'icontains':
|
|
224
|
-
return
|
|
411
|
+
return asciiLowercase(overrideValue).includes(asciiLowercase(value))
|
|
225
412
|
case 'not_icontains':
|
|
226
|
-
return !
|
|
413
|
+
return !asciiLowercase(overrideValue).includes(asciiLowercase(value))
|
|
227
414
|
case 'starts_with':
|
|
228
|
-
return
|
|
415
|
+
return asciiLowercase(overrideValue).startsWith(asciiLowercase(value))
|
|
229
416
|
case 'not_starts_with':
|
|
230
|
-
return !
|
|
417
|
+
return !asciiLowercase(overrideValue).startsWith(asciiLowercase(value))
|
|
231
418
|
case 'ends_with':
|
|
232
|
-
return
|
|
419
|
+
return asciiLowercase(overrideValue).endsWith(asciiLowercase(value))
|
|
233
420
|
case 'not_ends_with':
|
|
234
|
-
return !
|
|
421
|
+
return !asciiLowercase(overrideValue).endsWith(asciiLowercase(value))
|
|
235
422
|
case 'regex':
|
|
236
423
|
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
|
|
237
424
|
case 'not_regex':
|
package/src/index.ts
CHANGED
|
@@ -42,9 +42,9 @@ export {
|
|
|
42
42
|
buildResourceAttributes,
|
|
43
43
|
getOtlpSeverityNumber,
|
|
44
44
|
getOtlpSeverityText,
|
|
45
|
-
toOtlpAnyValue,
|
|
46
|
-
toOtlpKeyValueList,
|
|
47
45
|
} from './logs/logs-utils'
|
|
46
|
+
export { toOtlpAnyValue, toOtlpKeyValueList } from './utils/otlp-any-value'
|
|
47
|
+
export { osResourceAttributes } from './utils/otlp-resource'
|
|
48
48
|
export { PostHogLogs } from './logs'
|
|
49
49
|
export type {
|
|
50
50
|
BeforeSendLogFn,
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import type { CaptureLogOptions,
|
|
1
|
+
import type { CaptureLogOptions, LogSeverityLevel } from '@posthog/types'
|
|
2
2
|
import type { LogSdkContext } from './types'
|
|
3
|
-
import {
|
|
4
|
-
buildOtlpLogRecord,
|
|
5
|
-
buildOtlpLogsPayload,
|
|
6
|
-
getOtlpSeverityNumber,
|
|
7
|
-
getOtlpSeverityText,
|
|
8
|
-
toOtlpAnyValue,
|
|
9
|
-
toOtlpKeyValueList,
|
|
10
|
-
} from './logs-utils'
|
|
3
|
+
import { buildOtlpLogRecord, buildOtlpLogsPayload, getOtlpSeverityNumber, getOtlpSeverityText } from './logs-utils'
|
|
11
4
|
|
|
12
5
|
const browserSdkContext: LogSdkContext = {
|
|
13
6
|
distinctId: 'user-123',
|
|
@@ -64,317 +57,6 @@ describe('logs-utils', () => {
|
|
|
64
57
|
})
|
|
65
58
|
})
|
|
66
59
|
|
|
67
|
-
describe('toOtlpAnyValue', () => {
|
|
68
|
-
it('converts strings', () => {
|
|
69
|
-
expect(toOtlpAnyValue('hello')).toEqual({ stringValue: 'hello' })
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('converts integers to decimal strings', () => {
|
|
73
|
-
expect(toOtlpAnyValue(42)).toEqual({ intValue: '42' })
|
|
74
|
-
expect(toOtlpAnyValue(0)).toEqual({ intValue: '0' })
|
|
75
|
-
expect(toOtlpAnyValue(-7)).toEqual({ intValue: '-7' })
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// Spec: outside int64 it is a stringValue, never an intValue.
|
|
79
|
-
it('converts integers outside int64 to stringValue', () => {
|
|
80
|
-
expect(toOtlpAnyValue(2 ** 63)).toEqual({ stringValue: '9223372036854775808' })
|
|
81
|
-
expect(toOtlpAnyValue(-(2 ** 64))).toEqual({ stringValue: '-18446744073709551616' })
|
|
82
|
-
expect(toOtlpAnyValue(1e21)).toEqual({ stringValue: '1000000000000000000000' })
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('logs a debug line when an integer falls outside int64', () => {
|
|
86
|
-
const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
|
|
87
|
-
toOtlpAnyValue(2 ** 63, logger as any)
|
|
88
|
-
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('outside the int64 range'))
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('keeps int64 min as intValue', () => {
|
|
92
|
-
// In range, but `String` renders it 192 below int64 min, so the decimal
|
|
93
|
-
// has to come from BigInt.
|
|
94
|
-
expect(toOtlpAnyValue(-(2 ** 63))).toEqual({ intValue: '-9223372036854775808' })
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('keeps large in-range integers exact', () => {
|
|
98
|
-
expect(toOtlpAnyValue(Number.MAX_SAFE_INTEGER)).toEqual({ intValue: '9007199254740991' })
|
|
99
|
-
// The largest double below 2^63 — no double exists between the two.
|
|
100
|
-
expect(toOtlpAnyValue(9223372036854774784)).toEqual({ intValue: '9223372036854774784' })
|
|
101
|
-
expect(toOtlpAnyValue(2 ** 62)).toEqual({ intValue: '4611686018427387904' })
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('converts floats to doubleValue', () => {
|
|
105
|
-
expect(toOtlpAnyValue(3.14)).toEqual({ doubleValue: 3.14 })
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('converts booleans', () => {
|
|
109
|
-
expect(toOtlpAnyValue(true)).toEqual({ boolValue: true })
|
|
110
|
-
expect(toOtlpAnyValue(false)).toEqual({ boolValue: false })
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// JSON has no representation for non-finite floats; without explicit
|
|
114
|
-
// handling, JSON.stringify silently turns them into `null` and the value
|
|
115
|
-
// is lost server-side.
|
|
116
|
-
it('converts NaN to stringValue', () => {
|
|
117
|
-
expect(toOtlpAnyValue(NaN)).toEqual({ stringValue: 'NaN' })
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('converts +Infinity to stringValue', () => {
|
|
121
|
-
expect(toOtlpAnyValue(Infinity)).toEqual({ stringValue: 'Infinity' })
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('converts -Infinity to stringValue', () => {
|
|
125
|
-
expect(toOtlpAnyValue(-Infinity)).toEqual({ stringValue: '-Infinity' })
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('converts arrays of strings to arrayValue', () => {
|
|
129
|
-
expect(toOtlpAnyValue(['a', 'b'])).toEqual({
|
|
130
|
-
arrayValue: { values: [{ stringValue: 'a' }, { stringValue: 'b' }] },
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('converts mixed primitive arrays recursively', () => {
|
|
135
|
-
expect(toOtlpAnyValue([1, 'x', true])).toEqual({
|
|
136
|
-
arrayValue: {
|
|
137
|
-
values: [{ intValue: '1' }, { stringValue: 'x' }, { boolValue: true }],
|
|
138
|
-
},
|
|
139
|
-
})
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('converts plain objects to kvlistValue', () => {
|
|
143
|
-
expect(toOtlpAnyValue({ a: 1, b: 'two' })).toEqual({
|
|
144
|
-
kvlistValue: {
|
|
145
|
-
values: [
|
|
146
|
-
{ key: 'a', value: { intValue: '1' } },
|
|
147
|
-
{ key: 'b', value: { stringValue: 'two' } },
|
|
148
|
-
],
|
|
149
|
-
},
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('converts nested objects recursively', () => {
|
|
154
|
-
expect(toOtlpAnyValue({ outer: { inner: 1 } })).toEqual({
|
|
155
|
-
kvlistValue: {
|
|
156
|
-
values: [
|
|
157
|
-
{
|
|
158
|
-
key: 'outer',
|
|
159
|
-
value: { kvlistValue: { values: [{ key: 'inner', value: { intValue: '1' } }] } },
|
|
160
|
-
},
|
|
161
|
-
],
|
|
162
|
-
},
|
|
163
|
-
})
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('drops null and undefined keys inside objects', () => {
|
|
167
|
-
expect(toOtlpAnyValue({ kept: 1, gone: null, alsoGone: undefined })).toEqual({
|
|
168
|
-
kvlistValue: { values: [{ key: 'kept', value: { intValue: '1' } }] },
|
|
169
|
-
})
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// Not in LogAttributeValue, but reachable at runtime from untyped callers.
|
|
173
|
-
it('encodes Dates as ISO strings', () => {
|
|
174
|
-
expect(toOtlpAnyValue(new Date('2026-08-20T10:00:00.000Z') as unknown as LogAttributeValue)).toEqual({
|
|
175
|
-
stringValue: '2026-08-20T10:00:00.000Z',
|
|
176
|
-
})
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('marks circular references instead of recursing', () => {
|
|
180
|
-
const cyclic: Record<string, unknown> = { name: 'root' }
|
|
181
|
-
cyclic.self = cyclic
|
|
182
|
-
expect(toOtlpAnyValue(cyclic)).toEqual({
|
|
183
|
-
kvlistValue: {
|
|
184
|
-
values: [
|
|
185
|
-
{ key: 'name', value: { stringValue: 'root' } },
|
|
186
|
-
{ key: 'self', value: { stringValue: '[Circular]' } },
|
|
187
|
-
],
|
|
188
|
-
},
|
|
189
|
-
})
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
// An escaping error would surface in the caller's application code.
|
|
193
|
-
it('does not throw on an object nested past the depth cap', () => {
|
|
194
|
-
let deep: Record<string, unknown> = { end: true }
|
|
195
|
-
for (let i = 0; i < 25000; i++) {
|
|
196
|
-
deep = { next: deep }
|
|
197
|
-
}
|
|
198
|
-
expect(() => toOtlpAnyValue(deep)).not.toThrow()
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
it('truncates at exactly 20 levels instead of recursing', () => {
|
|
202
|
-
let deep: Record<string, unknown> = { end: true }
|
|
203
|
-
for (let i = 0; i < 25; i++) {
|
|
204
|
-
deep = { next: deep }
|
|
205
|
-
}
|
|
206
|
-
const encoded = JSON.stringify(toOtlpAnyValue(deep))
|
|
207
|
-
expect(encoded).toContain('[Truncated]')
|
|
208
|
-
expect(encoded.split('"next"').length - 1).toBe(20)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('marks a throwing getter without losing the rest of the object', () => {
|
|
212
|
-
const attrs = {
|
|
213
|
-
ok: 1,
|
|
214
|
-
get bad(): number {
|
|
215
|
-
throw new Error('getter blew up')
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
expect(() => toOtlpKeyValueList(attrs)).not.toThrow()
|
|
219
|
-
expect(toOtlpKeyValueList(attrs)).toEqual([
|
|
220
|
-
{ key: 'ok', value: { intValue: '1' } },
|
|
221
|
-
{ key: 'bad', value: { stringValue: '[Unserializable]' } },
|
|
222
|
-
])
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// for...in walks the prototype chain once own keys are exhausted.
|
|
226
|
-
it('ignores inherited enumerable properties', () => {
|
|
227
|
-
const inherited: Record<string, unknown> = Object.create({ fromPrototype: 'leaked' })
|
|
228
|
-
inherited.own = 1
|
|
229
|
-
expect(toOtlpAnyValue(inherited)).toEqual({
|
|
230
|
-
kvlistValue: { values: [{ key: 'own', value: { intValue: '1' } }] },
|
|
231
|
-
})
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
// `String(fn)` would put the function's source text on the wire.
|
|
235
|
-
it('marks function and symbol values instead of stringifying them', () => {
|
|
236
|
-
expect(toOtlpAnyValue({ handler: () => 1, retries: 2 } as unknown as LogAttributeValue)).toEqual({
|
|
237
|
-
kvlistValue: {
|
|
238
|
-
values: [
|
|
239
|
-
{ key: 'handler', value: { stringValue: '[Function]' } },
|
|
240
|
-
{ key: 'retries', value: { intValue: '2' } },
|
|
241
|
-
],
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
expect(toOtlpAnyValue({ sym: Symbol('x') } as unknown as LogAttributeValue)).toEqual({
|
|
245
|
-
kvlistValue: { values: [{ key: 'sym', value: { stringValue: 'Symbol(x)' } }] },
|
|
246
|
-
})
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
// dayjs, Decimal, ORM documents.
|
|
250
|
-
it('honours toJSON', () => {
|
|
251
|
-
const wrapped = { toJSON: () => ({ amount: 5 }) }
|
|
252
|
-
expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
|
|
253
|
-
kvlistValue: { values: [{ key: 'amount', value: { intValue: '5' } }] },
|
|
254
|
-
})
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it('falls back to the plain walk when toJSON throws', () => {
|
|
258
|
-
const wrapped = {
|
|
259
|
-
kept: 1,
|
|
260
|
-
toJSON: () => {
|
|
261
|
-
throw new Error('nope')
|
|
262
|
-
},
|
|
263
|
-
}
|
|
264
|
-
expect(toOtlpAnyValue(wrapped as unknown as LogAttributeValue)).toEqual({
|
|
265
|
-
kvlistValue: {
|
|
266
|
-
values: [
|
|
267
|
-
{ key: 'kept', value: { intValue: '1' } },
|
|
268
|
-
{ key: 'toJSON', value: { stringValue: '[Function]' } },
|
|
269
|
-
],
|
|
270
|
-
},
|
|
271
|
-
})
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
// A toJSON returning its own object is a cycle like any other.
|
|
275
|
-
it('marks a cycle that runs through toJSON', () => {
|
|
276
|
-
const cyclic: Record<string, unknown> = {}
|
|
277
|
-
cyclic.toJSON = () => ({ inner: cyclic })
|
|
278
|
-
expect(toOtlpAnyValue(cyclic)).toEqual({
|
|
279
|
-
kvlistValue: { values: [{ key: 'inner', value: { stringValue: '[Circular]' } }] },
|
|
280
|
-
})
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
// Both `null` and `{}` here are rejected for the whole request; iOS and
|
|
284
|
-
// Android drop them too.
|
|
285
|
-
it('drops holes and nullish elements from arrays', () => {
|
|
286
|
-
// eslint-disable-next-line no-sparse-arrays
|
|
287
|
-
expect(toOtlpAnyValue([1, , 3])).toEqual({
|
|
288
|
-
arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
|
|
289
|
-
})
|
|
290
|
-
expect(toOtlpAnyValue([1, null, undefined, 3])).toEqual({
|
|
291
|
-
arrayValue: { values: [{ intValue: '1' }, { intValue: '3' }] },
|
|
292
|
-
})
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
it('stops encoding array items once the node budget is spent', () => {
|
|
296
|
-
const row: Record<string, number> = {}
|
|
297
|
-
for (let i = 0; i < 20; i++) {
|
|
298
|
-
row[`k${i}`] = i
|
|
299
|
-
}
|
|
300
|
-
const wide = Array.from({ length: 1000 }, () => ({ ...row }))
|
|
301
|
-
const values = toOtlpAnyValue(wide).arrayValue!.values
|
|
302
|
-
expect(values[values.length - 1]).toEqual({ stringValue: '[Truncated]' })
|
|
303
|
-
// One marker, not one per unencodable item.
|
|
304
|
-
expect(values.filter((v) => v.stringValue === '[Truncated]')).toHaveLength(1)
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
it('caps a shared object graph instead of expanding it', () => {
|
|
308
|
-
let graph: Record<string, unknown> = { leaf: true }
|
|
309
|
-
for (let i = 0; i < 20; i++) {
|
|
310
|
-
graph = { a: graph, b: graph }
|
|
311
|
-
}
|
|
312
|
-
const encoded = JSON.stringify(toOtlpAnyValue(graph))
|
|
313
|
-
expect(encoded).toContain('[Truncated]')
|
|
314
|
-
expect(encoded.length).toBeLessThan(1_000_000)
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
it('caps a very wide object without inventing an attribute key', () => {
|
|
318
|
-
const wide: Record<string, number> = {}
|
|
319
|
-
for (let i = 0; i < 5000; i++) {
|
|
320
|
-
wide[`k${i}`] = i
|
|
321
|
-
}
|
|
322
|
-
const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), critical: jest.fn() }
|
|
323
|
-
const values = toOtlpAnyValue(wide, logger as any).kvlistValue!.values
|
|
324
|
-
expect(values).toHaveLength(1000)
|
|
325
|
-
expect(values.every((v) => v.key.startsWith('k'))).toBe(true)
|
|
326
|
-
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('truncated'))
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
// Why the encoder does not delegate to toJsonSafeValue: that maps them to null.
|
|
330
|
-
it('keeps non-finite floats as strings inside nested objects', () => {
|
|
331
|
-
expect(toOtlpAnyValue({ nested: { ratio: NaN } })).toEqual({
|
|
332
|
-
kvlistValue: {
|
|
333
|
-
values: [
|
|
334
|
-
{
|
|
335
|
-
key: 'nested',
|
|
336
|
-
value: { kvlistValue: { values: [{ key: 'ratio', value: { stringValue: 'NaN' } }] } },
|
|
337
|
-
},
|
|
338
|
-
],
|
|
339
|
-
},
|
|
340
|
-
})
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
// A lone surrogate survives JSON.stringify as a \uD800 escape, which the
|
|
344
|
-
// server rejects for the whole request.
|
|
345
|
-
it('replaces unpaired surrogates in values and keys', () => {
|
|
346
|
-
expect(toOtlpAnyValue('ok\ud83d')).toEqual({ stringValue: 'ok\ufffd' })
|
|
347
|
-
expect(toOtlpAnyValue({ nested: 'ok\ud83d' })).toEqual({
|
|
348
|
-
kvlistValue: { values: [{ key: 'nested', value: { stringValue: 'ok\ufffd' } }] },
|
|
349
|
-
})
|
|
350
|
-
expect(toOtlpKeyValueList({ 'key\ud83d': 1 })).toEqual([{ key: 'key\ufffd', value: { intValue: '1' } }])
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('encodes empty containers with an explicit values array', () => {
|
|
354
|
-
expect(toOtlpAnyValue({})).toEqual({ kvlistValue: { values: [] } })
|
|
355
|
-
expect(toOtlpAnyValue([])).toEqual({ arrayValue: { values: [] } })
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
it('keeps a Date whose toISOString is overridden out of the wire format', () => {
|
|
359
|
-
const broken = new Date('2026-08-20T10:00:00.000Z')
|
|
360
|
-
|
|
361
|
-
;(broken as any).toISOString = () => ({})
|
|
362
|
-
expect(typeof toOtlpAnyValue(broken as unknown as LogAttributeValue).stringValue).toBe('string')
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('encodes sibling references to one object twice, not as circular', () => {
|
|
366
|
-
const shared = { id: 1 }
|
|
367
|
-
expect(toOtlpAnyValue({ a: shared, b: shared })).toEqual({
|
|
368
|
-
kvlistValue: {
|
|
369
|
-
values: [
|
|
370
|
-
{ key: 'a', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
|
|
371
|
-
{ key: 'b', value: { kvlistValue: { values: [{ key: 'id', value: { intValue: '1' } }] } } },
|
|
372
|
-
],
|
|
373
|
-
},
|
|
374
|
-
})
|
|
375
|
-
})
|
|
376
|
-
})
|
|
377
|
-
|
|
378
60
|
describe('buildOtlpLogRecord attribute reads', () => {
|
|
379
61
|
// Reading `options.attributes` happens before the encoder's per-key guard.
|
|
380
62
|
it('marks an attribute whose getter throws without dropping the record', () => {
|
|
@@ -425,36 +107,6 @@ describe('logs-utils', () => {
|
|
|
425
107
|
})
|
|
426
108
|
})
|
|
427
109
|
|
|
428
|
-
describe('toOtlpKeyValueList', () => {
|
|
429
|
-
it('converts a record to key-value list', () => {
|
|
430
|
-
expect(
|
|
431
|
-
toOtlpKeyValueList({
|
|
432
|
-
name: 'test',
|
|
433
|
-
count: 5,
|
|
434
|
-
active: true,
|
|
435
|
-
})
|
|
436
|
-
).toEqual([
|
|
437
|
-
{ key: 'name', value: { stringValue: 'test' } },
|
|
438
|
-
{ key: 'count', value: { intValue: '5' } },
|
|
439
|
-
{ key: 'active', value: { boolValue: true } },
|
|
440
|
-
])
|
|
441
|
-
})
|
|
442
|
-
|
|
443
|
-
it('handles empty record', () => {
|
|
444
|
-
expect(toOtlpKeyValueList({})).toEqual([])
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
it('skips null and undefined values', () => {
|
|
448
|
-
expect(
|
|
449
|
-
toOtlpKeyValueList({
|
|
450
|
-
kept: 'yes',
|
|
451
|
-
nullish: null,
|
|
452
|
-
missing: undefined,
|
|
453
|
-
})
|
|
454
|
-
).toEqual([{ key: 'kept', value: { stringValue: 'yes' } }])
|
|
455
|
-
})
|
|
456
|
-
})
|
|
457
|
-
|
|
458
110
|
describe('buildOtlpLogRecord', () => {
|
|
459
111
|
it('builds a minimal log record', () => {
|
|
460
112
|
const record = buildOtlpLogRecord({ body: 'hello world' }, minimalSdkContext)
|