@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The OTLP `AnyValue` encoder, shared by the logs, metrics and traces senders.
|
|
2
|
+
//
|
|
3
|
+
// Every value here comes from application code, so the encoder's job is to
|
|
4
|
+
// produce a payload the ingestion service accepts no matter what it is handed.
|
|
5
|
+
// A value the server refuses doesn't fail on its own — it 400s the whole
|
|
6
|
+
// request, taking every other record in the batch with it.
|
|
7
|
+
|
|
8
|
+
import type { OtlpAnyValue, OtlpKeyValue } from '@posthog/types'
|
|
9
|
+
import type { Logger } from '../types'
|
|
10
|
+
import { isArray, isBoolean, isNull, isNullish, isUndefined } from './type-utils'
|
|
11
|
+
import {
|
|
12
|
+
CIRCULAR_VALUE,
|
|
13
|
+
FUNCTION_VALUE,
|
|
14
|
+
MAX_JSON_SAFE_VALUE_DEPTH,
|
|
15
|
+
MAX_JSON_SAFE_VALUE_ITEMS,
|
|
16
|
+
MAX_JSON_SAFE_VALUE_NODES,
|
|
17
|
+
sanitizeString,
|
|
18
|
+
TRUNCATED_VALUE,
|
|
19
|
+
UNSERIALIZABLE_VALUE,
|
|
20
|
+
} from './json-utils'
|
|
21
|
+
|
|
22
|
+
// 2^63 — one past int64 max.
|
|
23
|
+
const INT64_RANGE_LIMIT = 9223372036854775808
|
|
24
|
+
|
|
25
|
+
// The same bound for the bigint branch. A decimal string rather than a `n`
|
|
26
|
+
// literal: this module reaches the browser bundle, which compiles to ES5, and
|
|
27
|
+
// a bigint literal there is a syntax error rather than a runtime fallback.
|
|
28
|
+
const INT64_RANGE_LIMIT_DECIMAL = '9223372036854775808'
|
|
29
|
+
|
|
30
|
+
const propertyIsEnumerable = Object.prototype.propertyIsEnumerable
|
|
31
|
+
|
|
32
|
+
interface EncodeState {
|
|
33
|
+
/** Containers on the current path, so a back-reference becomes a marker. */
|
|
34
|
+
ancestors: WeakSet<object>
|
|
35
|
+
remainingNodes: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function newState(): EncodeState {
|
|
39
|
+
return { ancestors: new WeakSet(), remainingNodes: MAX_JSON_SAFE_VALUE_NODES }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toOtlpAnyValue(value: unknown, logger?: Logger): OtlpAnyValue {
|
|
43
|
+
try {
|
|
44
|
+
return encodeAnyValue(value, logger, newState(), 0)
|
|
45
|
+
} catch {
|
|
46
|
+
// Runs inside `captureLog`, the metrics flush and span encoding: an error
|
|
47
|
+
// escaping here surfaces in the caller's own code.
|
|
48
|
+
return { stringValue: UNSERIALIZABLE_VALUE }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toOtlpKeyValueList(attrs: Record<string, unknown>, logger?: Logger): OtlpKeyValue[] {
|
|
53
|
+
try {
|
|
54
|
+
return encodeKeyValueList(attrs, logger, newState(), 0)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function encodeBigInt(value: bigint, logger: Logger | undefined): OtlpAnyValue {
|
|
61
|
+
const decimal = value.toString()
|
|
62
|
+
const limit = BigInt(INT64_RANGE_LIMIT_DECIMAL)
|
|
63
|
+
if (value >= limit || value < -limit) {
|
|
64
|
+
logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`)
|
|
65
|
+
return { stringValue: decimal }
|
|
66
|
+
}
|
|
67
|
+
return { intValue: decimal }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function encodeAnyValue(value: unknown, logger: Logger | undefined, state: EncodeState, depth: number): OtlpAnyValue {
|
|
71
|
+
if (state.remainingNodes <= 0) {
|
|
72
|
+
return { stringValue: TRUNCATED_VALUE }
|
|
73
|
+
}
|
|
74
|
+
state.remainingNodes--
|
|
75
|
+
|
|
76
|
+
if (isBoolean(value)) {
|
|
77
|
+
return { boolValue: value }
|
|
78
|
+
}
|
|
79
|
+
// Reaching this branch proves BigInt exists, so the limit can be built here
|
|
80
|
+
// rather than at module load.
|
|
81
|
+
if (typeof value === 'bigint') {
|
|
82
|
+
return encodeBigInt(value, logger)
|
|
83
|
+
}
|
|
84
|
+
// typeof, not core's isNumber, which excludes NaN — proto3 JSON distinguishes
|
|
85
|
+
// a non-finite float from an ordinary string.
|
|
86
|
+
if (typeof value === 'number') {
|
|
87
|
+
if (!Number.isFinite(value)) {
|
|
88
|
+
return { stringValue: String(value) }
|
|
89
|
+
}
|
|
90
|
+
if (Number.isInteger(value)) {
|
|
91
|
+
if (Number.isSafeInteger(value)) {
|
|
92
|
+
return { intValue: String(value) }
|
|
93
|
+
}
|
|
94
|
+
// Past MAX_SAFE_INTEGER only BigInt gives the double's exact decimal:
|
|
95
|
+
// `String(-(2**63))` lands 192 below int64 min, outside the field it is
|
|
96
|
+
// about to be parsed into. Without BigInt the value rides as a string,
|
|
97
|
+
// which is never range-checked.
|
|
98
|
+
if (typeof BigInt === 'undefined') {
|
|
99
|
+
return { stringValue: String(value) }
|
|
100
|
+
}
|
|
101
|
+
const decimal = BigInt(value).toString()
|
|
102
|
+
if (value >= INT64_RANGE_LIMIT || value < -INT64_RANGE_LIMIT) {
|
|
103
|
+
// An out-of-range intValue 400s the whole logs request; on the metrics
|
|
104
|
+
// path it is swallowed server-side and the metric just disappears.
|
|
105
|
+
logger?.debug(`Attribute ${decimal} is outside the int64 range; encoding it as a string`)
|
|
106
|
+
return { stringValue: decimal }
|
|
107
|
+
}
|
|
108
|
+
return { intValue: decimal }
|
|
109
|
+
}
|
|
110
|
+
return { doubleValue: value }
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
return { stringValue: sanitizeString(value) }
|
|
114
|
+
}
|
|
115
|
+
// `String(value)` would put a function's source text on the wire.
|
|
116
|
+
if (typeof value === 'function') {
|
|
117
|
+
return { stringValue: FUNCTION_VALUE }
|
|
118
|
+
}
|
|
119
|
+
if (typeof value === 'symbol') {
|
|
120
|
+
return { stringValue: String(value) }
|
|
121
|
+
}
|
|
122
|
+
if (typeof value === 'object' && value !== null) {
|
|
123
|
+
if (state.ancestors.has(value)) {
|
|
124
|
+
return { stringValue: CIRCULAR_VALUE }
|
|
125
|
+
}
|
|
126
|
+
if (depth >= MAX_JSON_SAFE_VALUE_DEPTH) {
|
|
127
|
+
return { stringValue: TRUNCATED_VALUE }
|
|
128
|
+
}
|
|
129
|
+
if (value instanceof Date) {
|
|
130
|
+
const time = value.getTime()
|
|
131
|
+
const iso = Number.isFinite(time) ? value.toISOString() : String(value)
|
|
132
|
+
// An overridden toISOString can return a non-string, which the server
|
|
133
|
+
// refuses for the whole request.
|
|
134
|
+
return { stringValue: typeof iso === 'string' ? sanitizeString(iso) : String(iso) }
|
|
135
|
+
}
|
|
136
|
+
// Registered before the toJSON probe: a toJSON returning a structure that
|
|
137
|
+
// references its own object is a cycle like any other.
|
|
138
|
+
state.ancestors.add(value)
|
|
139
|
+
try {
|
|
140
|
+
// The representation a value defines for itself — dayjs, Decimal, an ORM
|
|
141
|
+
// document, and a cross-realm Date that fails the `instanceof` above.
|
|
142
|
+
try {
|
|
143
|
+
const toJSON = (value as { toJSON?: unknown }).toJSON
|
|
144
|
+
if (typeof toJSON === 'function') {
|
|
145
|
+
return encodeAnyValue(toJSON.call(value), logger, state, depth + 1)
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// A throwing toJSON falls through to the plain walk.
|
|
149
|
+
}
|
|
150
|
+
if (isArray(value)) {
|
|
151
|
+
return { arrayValue: { values: encodeArrayValues(value, logger, state, depth + 1) } }
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kvlistValue: {
|
|
155
|
+
values: encodeKeyValueList(value as Record<string, unknown>, logger, state, depth + 1),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
// Siblings that reference the same object are duplication, not a cycle.
|
|
160
|
+
state.ancestors.delete(value)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { stringValue: sanitizeString(String(value)) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function encodeArrayValues(
|
|
167
|
+
values: unknown[],
|
|
168
|
+
logger: Logger | undefined,
|
|
169
|
+
state: EncodeState,
|
|
170
|
+
depth: number
|
|
171
|
+
): OtlpAnyValue[] {
|
|
172
|
+
const result: OtlpAnyValue[] = []
|
|
173
|
+
const itemCount = Math.min(values.length, MAX_JSON_SAFE_VALUE_ITEMS)
|
|
174
|
+
let index = 0
|
|
175
|
+
for (; index < itemCount && state.remainingNodes > 0; index++) {
|
|
176
|
+
try {
|
|
177
|
+
const element = index in values ? values[index] : undefined
|
|
178
|
+
// Dropped: proto3 JSON has no null AnyValue, and both `null` and `{}` here
|
|
179
|
+
// are rejected for the whole request.
|
|
180
|
+
if (isNullish(element)) {
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
result.push(encodeAnyValue(element, logger, state, depth))
|
|
184
|
+
} catch {
|
|
185
|
+
result.push({ stringValue: UNSERIALIZABLE_VALUE })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (values.length > index) {
|
|
189
|
+
result.push({ stringValue: TRUNCATED_VALUE })
|
|
190
|
+
}
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function encodeKeyValueList(
|
|
195
|
+
attrs: Record<string, unknown>,
|
|
196
|
+
logger: Logger | undefined,
|
|
197
|
+
state: EncodeState,
|
|
198
|
+
depth: number
|
|
199
|
+
): OtlpKeyValue[] {
|
|
200
|
+
const result: OtlpKeyValue[] = []
|
|
201
|
+
for (const key in attrs) {
|
|
202
|
+
// for...in walks the prototype chain once own keys are exhausted. Skipped
|
|
203
|
+
// rather than broken out of: a proxy can yield keys in any order.
|
|
204
|
+
if (!propertyIsEnumerable.call(attrs, key)) {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (result.length >= MAX_JSON_SAFE_VALUE_ITEMS || state.remainingNodes <= 0) {
|
|
208
|
+
// Reported rather than written into the attributes: a synthetic key would
|
|
209
|
+
// land in the user's own namespace and could collide with a real one.
|
|
210
|
+
logger?.debug('Attributes truncated: the value exceeds the OTLP encoder budget')
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const value = attrs[key]
|
|
215
|
+
if (isNull(value) || isUndefined(value)) {
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
result.push({ key: sanitizeString(key), value: encodeAnyValue(value, logger, state, depth) })
|
|
219
|
+
} catch {
|
|
220
|
+
// A getter that throws costs its own key, not the whole record.
|
|
221
|
+
result.push({ key: sanitizeString(key), value: { stringValue: UNSERIALIZABLE_VALUE } })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result
|
|
225
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { buildResourceAttributes } from '../logs/logs-utils'
|
|
2
|
+
import type { ResolvedPostHogLogsConfig } from '../logs/types'
|
|
3
|
+
import { buildMetricsResourceAttributes } from '../metrics/metrics-utils'
|
|
4
|
+
import type { ResolvedPostHogMetricsConfig } from '../metrics/types'
|
|
5
|
+
import { normalizeOsName, osResourceAttributes } from './otlp-resource'
|
|
6
|
+
|
|
7
|
+
const shared = {
|
|
8
|
+
serviceName: 'checkout',
|
|
9
|
+
serviceVersion: '2.1.0',
|
|
10
|
+
environment: 'production',
|
|
11
|
+
resourceAttributes: { 'host.name': 'web-01' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const conflicting = {
|
|
15
|
+
serviceName: 'checkout',
|
|
16
|
+
serviceVersion: '2.1.0',
|
|
17
|
+
environment: 'production',
|
|
18
|
+
resourceAttributes: {
|
|
19
|
+
'service.name': 'hijacked',
|
|
20
|
+
'service.version': '0.0.0',
|
|
21
|
+
'deployment.environment': 'hijacked-env',
|
|
22
|
+
'telemetry.sdk.name': 'hijacked-sdk',
|
|
23
|
+
'telemetry.sdk.version': '0.0.0',
|
|
24
|
+
'host.name': 'web-01',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bothSignals = (partial: object): Record<string, unknown>[] => [
|
|
29
|
+
buildResourceAttributes(partial as ResolvedPostHogLogsConfig, 'posthog-node', '1.0.0'),
|
|
30
|
+
buildMetricsResourceAttributes(partial as ResolvedPostHogMetricsConfig, 'posthog-node', '1.0.0'),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe('shared OTLP resource attributes', () => {
|
|
34
|
+
it.each([
|
|
35
|
+
['a fully populated config', shared],
|
|
36
|
+
['a config with conflicting user attributes', conflicting],
|
|
37
|
+
['an empty config', {}],
|
|
38
|
+
])('produces the same attributes for logs and metrics given %s', (_label, config) => {
|
|
39
|
+
const [logs, metrics] = bothSignals(config)
|
|
40
|
+
expect(metrics).toEqual(logs)
|
|
41
|
+
expect(Object.keys(metrics)).toEqual(Object.keys(logs))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('layers the identity keys over user resource attributes', () => {
|
|
45
|
+
for (const attributes of bothSignals(conflicting)) {
|
|
46
|
+
expect(attributes).toEqual({
|
|
47
|
+
'service.name': 'checkout',
|
|
48
|
+
'service.version': '2.1.0',
|
|
49
|
+
'deployment.environment': 'production',
|
|
50
|
+
'telemetry.sdk.name': 'posthog-node',
|
|
51
|
+
'telemetry.sdk.version': '1.0.0',
|
|
52
|
+
'host.name': 'web-01',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps user resource attributes that do not collide', () => {
|
|
58
|
+
for (const attributes of bothSignals(shared)) {
|
|
59
|
+
expect(attributes).toEqual({
|
|
60
|
+
'host.name': 'web-01',
|
|
61
|
+
'service.name': 'checkout',
|
|
62
|
+
'deployment.environment': 'production',
|
|
63
|
+
'service.version': '2.1.0',
|
|
64
|
+
'telemetry.sdk.name': 'posthog-node',
|
|
65
|
+
'telemetry.sdk.version': '1.0.0',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to unknown_service and omits unset optional keys', () => {
|
|
71
|
+
for (const attributes of bothSignals({})) {
|
|
72
|
+
expect(attributes).toEqual({
|
|
73
|
+
'service.name': 'unknown_service',
|
|
74
|
+
'telemetry.sdk.name': 'posthog-node',
|
|
75
|
+
'telemetry.sdk.version': '1.0.0',
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('osResourceAttributes', () => {
|
|
82
|
+
it.each([
|
|
83
|
+
// node:os platform() identifiers rather than os.name values
|
|
84
|
+
['darwin', 'macOS'],
|
|
85
|
+
['win32', 'Windows'],
|
|
86
|
+
['linux', 'Linux'],
|
|
87
|
+
['android', 'Android'],
|
|
88
|
+
['freebsd', 'FreeBSD'],
|
|
89
|
+
// detectOS spellings
|
|
90
|
+
['Mac OS X', 'macOS'],
|
|
91
|
+
['iOS', 'iOS'],
|
|
92
|
+
['Android', 'Android'],
|
|
93
|
+
['Windows', 'Windows'],
|
|
94
|
+
['Linux', 'Linux'],
|
|
95
|
+
])('normalizes %s to %s', (raw, expected) => {
|
|
96
|
+
expect(normalizeOsName(raw)).toBe(expected)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('passes an unmapped name through rather than dropping it', () => {
|
|
100
|
+
expect(normalizeOsName('Haiku')).toBe('Haiku')
|
|
101
|
+
expect(normalizeOsName('constructor')).toBe('constructor')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it.each([undefined, ''])('returns undefined for %p', (raw) => {
|
|
105
|
+
expect(normalizeOsName(raw)).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('agrees with the names posthog-ios and posthog-android already send', () => {
|
|
109
|
+
// Both SDKs ship these values today; a divergence here splits one filter in two.
|
|
110
|
+
expect(normalizeOsName('darwin')).toBe('macOS')
|
|
111
|
+
expect(normalizeOsName('Mac OS X')).toBe('macOS')
|
|
112
|
+
expect(normalizeOsName('iOS')).toBe('iOS')
|
|
113
|
+
expect(normalizeOsName('Android')).toBe('Android')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('omits either key rather than emitting it empty', () => {
|
|
117
|
+
expect(osResourceAttributes('darwin', undefined)).toEqual({ 'os.name': 'macOS' })
|
|
118
|
+
expect(osResourceAttributes(undefined, '14.0')).toEqual({ 'os.version': '14.0' })
|
|
119
|
+
expect(osResourceAttributes('', '')).toEqual({})
|
|
120
|
+
expect(osResourceAttributes('win32', '10.0.26100')).toEqual({
|
|
121
|
+
'os.name': 'Windows',
|
|
122
|
+
'os.version': '10.0.26100',
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape the logs and metrics resolved configs share for resource
|
|
3
|
+
* attribution. Generic over the attribute value type so each signal keeps its
|
|
4
|
+
* own value union.
|
|
5
|
+
*/
|
|
6
|
+
export interface OtlpResourceConfig<TAttributeValue> {
|
|
7
|
+
serviceName?: string
|
|
8
|
+
serviceVersion?: string
|
|
9
|
+
environment?: string
|
|
10
|
+
resourceAttributes?: Record<string, TAttributeValue>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OTLP resource attributes shared by the logs and metrics envelopes.
|
|
15
|
+
*
|
|
16
|
+
* User `resourceAttributes` are spread first, then SDK-controlled keys on top so
|
|
17
|
+
* a stray user key can't clobber the ingestion-attribution ones; the dedicated
|
|
18
|
+
* `serviceName` / `environment` / `serviceVersion` fields are how you override
|
|
19
|
+
* those three.
|
|
20
|
+
*
|
|
21
|
+
* @internal Shared within this SDK; not part of the stable public API.
|
|
22
|
+
*/
|
|
23
|
+
export function buildOtlpResourceAttributes<TAttributeValue>(
|
|
24
|
+
config: OtlpResourceConfig<TAttributeValue>,
|
|
25
|
+
sdkName: string,
|
|
26
|
+
sdkVersion: string
|
|
27
|
+
): Record<string, TAttributeValue | string> {
|
|
28
|
+
return {
|
|
29
|
+
...config.resourceAttributes,
|
|
30
|
+
'service.name': config.serviceName || 'unknown_service',
|
|
31
|
+
...(config.environment && { 'deployment.environment': config.environment }),
|
|
32
|
+
...(config.serviceVersion && { 'service.version': config.serviceVersion }),
|
|
33
|
+
'telemetry.sdk.name': sdkName,
|
|
34
|
+
'telemetry.sdk.version': sdkVersion,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* OTLP `os.name` values, keyed by the spellings the JS SDKs detect natively:
|
|
40
|
+
* `node:os` `platform()` identifiers and the names `detectOS` reads out of a
|
|
41
|
+
* user agent.
|
|
42
|
+
*
|
|
43
|
+
* OpenTelemetry defines `os.name` as the human-readable OS name; the lowercase
|
|
44
|
+
* identifiers (`darwin`, `win32`) are `node:os` `platform()` values, not
|
|
45
|
+
* `os.name` values.
|
|
46
|
+
* The values match what `posthog-ios` and `posthog-android` send for the
|
|
47
|
+
* platforms they cover.
|
|
48
|
+
*/
|
|
49
|
+
const OS_NAMES: Record<string, string> = {
|
|
50
|
+
// node:os platform()
|
|
51
|
+
darwin: 'macOS',
|
|
52
|
+
win32: 'Windows',
|
|
53
|
+
linux: 'Linux',
|
|
54
|
+
android: 'Android',
|
|
55
|
+
freebsd: 'FreeBSD',
|
|
56
|
+
openbsd: 'OpenBSD',
|
|
57
|
+
sunos: 'SunOS',
|
|
58
|
+
aix: 'AIX',
|
|
59
|
+
// detectOS
|
|
60
|
+
'Mac OS X': 'macOS',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalizes a natively-detected OS name against the table above.
|
|
65
|
+
* Unrecognized names pass through: a wrong-looking value beats dropping an OS
|
|
66
|
+
* we have not mapped yet.
|
|
67
|
+
*
|
|
68
|
+
* @internal Shared within this SDK; not part of the stable public API.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeOsName(name: string | undefined): string | undefined {
|
|
71
|
+
if (!name) {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
return Object.prototype.hasOwnProperty.call(OS_NAMES, name) ? OS_NAMES[name] : name
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `os.name` / `os.version` resource attribute pair, with either key omitted
|
|
79
|
+
* rather than emitted empty when the host cannot determine it.
|
|
80
|
+
*
|
|
81
|
+
* @internal Exposed for cross-package use within this SDK; not part of the stable public API.
|
|
82
|
+
*/
|
|
83
|
+
export function osResourceAttributes(name: string | undefined, version: string | undefined): Record<string, string> {
|
|
84
|
+
const osName = normalizeOsName(name)
|
|
85
|
+
return {
|
|
86
|
+
...(osName ? { 'os.name': osName } : {}),
|
|
87
|
+
...(version ? { 'os.version': version } : {}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -57,10 +57,16 @@ const DUCKDUCKGO = 'DuckDuckGo'
|
|
|
57
57
|
const PALE_MOON = 'Pale Moon'
|
|
58
58
|
const WATERFOX = 'Waterfox'
|
|
59
59
|
const BRAVE = 'Brave'
|
|
60
|
+
const CLAUDE = 'Claude'
|
|
61
|
+
const CODEX = 'Codex'
|
|
62
|
+
const CHATGPT = 'ChatGPT'
|
|
60
63
|
const GOOGLE_SEARCH_APP = 'Google Search App'
|
|
61
64
|
|
|
62
65
|
const BROWSER_VERSION_REGEX_SUFFIX = '(\\d+(\\.\\d+)?)'
|
|
63
66
|
const DEFAULT_BROWSER_VERSION_REGEX = new RegExp('Version/' + BROWSER_VERSION_REGEX_SUFFIX)
|
|
67
|
+
const AI_APP_VERSION_REGEX = new RegExp(
|
|
68
|
+
'(' + CLAUDE + '|' + CODEX + '|' + CHATGPT + ')\\/' + BROWSER_VERSION_REGEX_SUFFIX
|
|
69
|
+
)
|
|
64
70
|
|
|
65
71
|
/**
|
|
66
72
|
* Hints from sources outside the User-Agent string. These let us identify Brave
|
|
@@ -82,10 +88,10 @@ function browserFromHints(hints: BrowserDetectionHints | undefined): string | nu
|
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
85
|
-
* Opt-in tweaks to UA-string detection.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
91
|
+
* Opt-in tweaks to UA-string detection. Turning one on reattributes browsers
|
|
92
|
+
* that were previously reported as something else, so the host SDK gates
|
|
93
|
+
* shifts big enough to move users' metrics behind its `2026-05-30` config
|
|
94
|
+
* defaults. Smaller reattributions ship unconditionally in `detectBrowser`.
|
|
89
95
|
*/
|
|
90
96
|
export interface BrowserDetectionOptions {
|
|
91
97
|
// Surface the Google Search App as its own browser via its `GSA/` UA marker
|
|
@@ -181,8 +187,9 @@ export const detectBrowser = function (
|
|
|
181
187
|
} else if (includes(user_agent, EDGE) || includes(user_agent, 'Edg/')) {
|
|
182
188
|
return MICROSOFT_EDGE
|
|
183
189
|
}
|
|
184
|
-
// Chromium forks that DO stamp themselves into the
|
|
185
|
-
// checked before Chrome because their UA also contains
|
|
190
|
+
// Chromium forks and Chromium-based apps that DO stamp themselves into the
|
|
191
|
+
// UA. These must be checked before Chrome because their UA also contains
|
|
192
|
+
// `Chrome/`.
|
|
186
193
|
else if (includes(user_agent, VIVALDI + '/')) {
|
|
187
194
|
return VIVALDI
|
|
188
195
|
} else if (includes(user_agent, 'YaBrowser/')) {
|
|
@@ -191,6 +198,12 @@ export const detectBrowser = function (
|
|
|
191
198
|
return WHALE
|
|
192
199
|
} else if (includes(user_agent, DUCKDUCKGO + '/') || includes(user_agent, 'Ddg/')) {
|
|
193
200
|
return DUCKDUCKGO
|
|
201
|
+
} else if (includes(user_agent, CLAUDE + '/')) {
|
|
202
|
+
return CLAUDE
|
|
203
|
+
} else if (includes(user_agent, CODEX + '/')) {
|
|
204
|
+
return CODEX
|
|
205
|
+
} else if (includes(user_agent, CHATGPT + '/')) {
|
|
206
|
+
return CHATGPT
|
|
194
207
|
} else if (includes(user_agent, 'FBIOS')) {
|
|
195
208
|
return FACEBOOK + ' ' + MOBILE
|
|
196
209
|
} else if (includes(user_agent, 'UCWEB') || includes(user_agent, 'UCBrowser')) {
|
|
@@ -257,6 +270,9 @@ const versionRegexes: Record<string, RegExp[]> = {
|
|
|
257
270
|
// Brave don't, which is why hint-based Brave detection returns a null
|
|
258
271
|
// version: we have no UA marker to parse.
|
|
259
272
|
[BRAVE]: [new RegExp(BRAVE + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)],
|
|
273
|
+
[CLAUDE]: [AI_APP_VERSION_REGEX],
|
|
274
|
+
[CODEX]: [AI_APP_VERSION_REGEX],
|
|
275
|
+
[CHATGPT]: [AI_APP_VERSION_REGEX],
|
|
260
276
|
// DuckDuckGo on iOS uses `Ddg/`, on Android/desktop preview it uses `DuckDuckGo/`.
|
|
261
277
|
[DUCKDUCKGO]: [new RegExp('(DuckDuckGo|Ddg)\\/' + BROWSER_VERSION_REGEX_SUFFIX)],
|
|
262
278
|
[PALE_MOON]: [new RegExp('PaleMoon\\/' + BROWSER_VERSION_REGEX_SUFFIX)],
|