Skip to content

Commit 62f4f4f

Browse files
committed
feat(core): share the OTLP resource builder and emit os.name/os.version
Logs, metrics and spans build resource attributes through one function instead of three copies. Node and browser now contribute the host OS, matching what react-native already sends.
1 parent adc6e4e commit 62f4f4f

15 files changed

Lines changed: 325 additions & 30 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'posthog-js': minor
3+
'posthog-node': minor
4+
'@posthog/core': patch
5+
---
6+
7+
Add `os.name` and `os.version` resource attributes to the logs and spans sent by `posthog-js` and `posthog-node`.

packages/browser/src/__tests__/logs-defaults.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,50 @@ describe('resolveLogsConfig', () => {
110110

111111
expect(resolved.serviceName).toBe('from-named')
112112
})
113+
describe('OS resource attributes', () => {
114+
const setUserAgent = (value: string | undefined): void => {
115+
Object.defineProperty(window.navigator, 'userAgent', { value, configurable: true })
116+
}
117+
118+
afterEach(() => {
119+
// @ts-expect-error restoring the jsdom prototype getter
120+
delete window.navigator.userAgent
121+
})
122+
123+
it('attaches the detected OS', () => {
124+
setUserAgent(
125+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
126+
)
127+
128+
expect(resolveLogsConfig(undefined).resourceAttributes).toEqual({
129+
'os.name': 'Mac OS X',
130+
'os.version': '10.15.7',
131+
})
132+
})
133+
134+
it('lets user resourceAttributes override the detected OS', () => {
135+
setUserAgent(
136+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
137+
)
138+
139+
expect(
140+
resolveLogsConfig({ resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } })
141+
.resourceAttributes
142+
).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' })
143+
})
144+
145+
it('omits a key the user agent cannot supply rather than emitting it empty', () => {
146+
setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0')
147+
148+
expect(resolveLogsConfig(undefined).resourceAttributes).toEqual({ 'os.name': 'Linux' })
149+
})
150+
151+
it('resolves without OS keys when there is no user agent', () => {
152+
setUserAgent(undefined)
153+
154+
expect(resolveLogsConfig({ resourceAttributes: { 'host.name': 'web-01' } }).resourceAttributes).toEqual({
155+
'host.name': 'web-01',
156+
})
157+
})
158+
})
113159
})

packages/browser/src/logs-defaults.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { LogCaptureOptions } from '@posthog/types'
22
import type { ResolvedPostHogLogsConfig } from '@posthog/core'
3-
import { isUndefined } from '@posthog/core'
3+
import { detectOS, isUndefined } from '@posthog/core'
4+
import { navigator } from '@posthog/browser-common/utils/globals'
45

56
const DEFAULT_FLUSH_INTERVAL_MS = 3000
67
const DEFAULT_MAX_BUFFER_SIZE = 100
@@ -9,6 +10,30 @@ const DEFAULT_MAX_LOGS_PER_INTERVAL = 1000
910
const DEFAULT_CONSOLE_MAX_QUEUE_SIZE = 2048
1011
const DEFAULT_MAX_BATCH_RECORDS_PER_POST = 100
1112

13+
/**
14+
* Browser resource attribute defaults. Identifies the visitor's OS so logs can
15+
* be filtered by platform (e.g. "only errors on Windows" in the PostHog UI).
16+
* User-supplied `resourceAttributes` merges last so these stay overridable.
17+
*
18+
* `detectOS` returns empty strings for a user agent it can't place, and there is
19+
* no `navigator` in an SSR or worker-like context — either way the key is
20+
* omitted rather than emitted empty, and resolution never throws.
21+
*/
22+
function defaultResourceAttributes(): Record<string, string> {
23+
let osName = ''
24+
let osVersion = ''
25+
try {
26+
const userAgent = navigator?.userAgent
27+
if (userAgent) {
28+
;[osName, osVersion] = detectOS(userAgent)
29+
}
30+
} catch {}
31+
return {
32+
...(osName ? { 'os.name': osName } : {}),
33+
...(osVersion ? { 'os.version': osVersion } : {}),
34+
}
35+
}
36+
1237
/**
1338
* Resolves the public `logs` config into the shape core `PostHogLogs` consumes.
1439
*
@@ -31,7 +56,7 @@ export function resolveLogsConfig(
3156
? Math.max(maxBufferSize, DEFAULT_CONSOLE_MAX_QUEUE_SIZE)
3257
: Math.max(maxBufferSize, maxLogsPerInterval)
3358
// OTLP keys in `resourceAttributes` take precedence over the named config fields.
34-
const resourceAttributes = config?.resourceAttributes
59+
const resourceAttributes = { ...defaultResourceAttributes(), ...config?.resourceAttributes }
3560
return {
3661
serviceName:
3762
(resourceAttributes?.['service.name'] as string | undefined) ??

packages/core/src/logs/logs-utils.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
1212
import { isNullish, isNumber, isUndefined } from '../utils'
1313
import { sanitizeString, UNSERIALIZABLE_VALUE } from '../utils/json-utils'
1414
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
15+
import { buildOtlpResourceAttributes } from '../utils/otlp-resource'
1516

1617
// ============================================================================
1718
// Severity mapping
@@ -185,14 +186,7 @@ export function buildResourceAttributes(
185186
sdkName: string,
186187
sdkVersion: string
187188
): Record<string, LogAttributeValue> {
188-
return {
189-
...config.resourceAttributes,
190-
'service.name': config.serviceName || 'unknown_service',
191-
...(config.environment && { 'deployment.environment': config.environment }),
192-
...(config.serviceVersion && { 'service.version': config.serviceVersion }),
193-
'telemetry.sdk.name': sdkName,
194-
'telemetry.sdk.version': sdkVersion,
195-
}
189+
return buildOtlpResourceAttributes(config, sdkName, sdkVersion)
196190
}
197191

198192
/**

packages/core/src/metrics/metrics-utils.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MetricAttributeValue, OtlpMetric, OtlpMetricsPayload } from '@posthog/types'
22
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
3+
import { buildOtlpResourceAttributes } from '../utils/otlp-resource'
34
import type { ResolvedPostHogMetricsConfig } from './types'
45

56
/**
@@ -59,14 +60,7 @@ export function buildMetricsResourceAttributes(
5960
scopeName: string,
6061
scopeVersion: string
6162
): Record<string, MetricAttributeValue> {
62-
return {
63-
...config.resourceAttributes,
64-
'service.name': config.serviceName || 'unknown_service',
65-
...(config.environment && { 'deployment.environment': config.environment }),
66-
...(config.serviceVersion && { 'service.version': config.serviceVersion }),
67-
'telemetry.sdk.name': scopeName,
68-
'telemetry.sdk.version': scopeVersion,
69-
}
63+
return buildOtlpResourceAttributes(config, scopeName, scopeVersion)
7064
}
7165

7266
/**

packages/core/src/traces/otlp.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import type { Logger } from '../types'
1111
import type { ResolvedTracesConfig, SpanRecord } from './types'
1212
import { toOtlpKeyValueList } from '../utils/otlp-any-value'
13+
import { buildOtlpResourceAttributes } from '../utils/otlp-resource'
1314

1415
const SPAN_KIND_TO_OTLP: Record<SpanKind, number> = {
1516
internal: 1,
@@ -107,14 +108,7 @@ export function buildTracesResourceAttributes(
107108
sdkName: string,
108109
sdkVersion: string
109110
): SpanAttributes {
110-
return {
111-
...config.resourceAttributes,
112-
'service.name': config.serviceName || 'unknown_service',
113-
...(config.environment && { 'deployment.environment': config.environment }),
114-
...(config.serviceVersion && { 'service.version': config.serviceVersion }),
115-
'telemetry.sdk.name': sdkName,
116-
'telemetry.sdk.version': sdkVersion,
117-
}
111+
return buildOtlpResourceAttributes(config, sdkName, sdkVersion)
118112
}
119113

120114
/**
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 { buildTracesResourceAttributes } from '../traces/otlp'
6+
import type { ResolvedTracesConfig } from '../traces/types'
7+
8+
const shared = {
9+
serviceName: 'checkout',
10+
serviceVersion: '2.1.0',
11+
environment: 'production',
12+
resourceAttributes: { 'host.name': 'web-01' },
13+
}
14+
15+
const conflicting = {
16+
serviceName: 'checkout',
17+
serviceVersion: '2.1.0',
18+
environment: 'production',
19+
resourceAttributes: {
20+
'service.name': 'hijacked',
21+
'service.version': '0.0.0',
22+
'deployment.environment': 'hijacked-env',
23+
'telemetry.sdk.name': 'hijacked-sdk',
24+
'telemetry.sdk.version': '0.0.0',
25+
'host.name': 'web-01',
26+
},
27+
}
28+
29+
const allThree = (partial: object): Record<string, unknown>[] => [
30+
buildResourceAttributes(partial as ResolvedPostHogLogsConfig, 'posthog-node', '1.0.0'),
31+
buildMetricsResourceAttributes(partial as ResolvedPostHogMetricsConfig, 'posthog-node', '1.0.0'),
32+
buildTracesResourceAttributes(partial as ResolvedTracesConfig, 'posthog-node', '1.0.0'),
33+
]
34+
35+
describe('shared OTLP resource attributes', () => {
36+
it.each([
37+
['a fully populated config', shared],
38+
['a config with conflicting user attributes', conflicting],
39+
['an empty config', {}],
40+
])('produces the same attributes for logs, metrics and traces given %s', (_label, config) => {
41+
const [logs, metrics, traces] = allThree(config)
42+
expect(metrics).toEqual(logs)
43+
expect(traces).toEqual(logs)
44+
expect(Object.keys(metrics)).toEqual(Object.keys(logs))
45+
expect(Object.keys(traces)).toEqual(Object.keys(logs))
46+
})
47+
48+
it('layers the identity keys over user resource attributes', () => {
49+
for (const attributes of allThree(conflicting)) {
50+
expect(attributes).toEqual({
51+
'service.name': 'checkout',
52+
'service.version': '2.1.0',
53+
'deployment.environment': 'production',
54+
'telemetry.sdk.name': 'posthog-node',
55+
'telemetry.sdk.version': '1.0.0',
56+
'host.name': 'web-01',
57+
})
58+
}
59+
})
60+
61+
it('keeps user resource attributes that do not collide', () => {
62+
for (const attributes of allThree(shared)) {
63+
expect(attributes).toEqual({
64+
'host.name': 'web-01',
65+
'service.name': 'checkout',
66+
'deployment.environment': 'production',
67+
'service.version': '2.1.0',
68+
'telemetry.sdk.name': 'posthog-node',
69+
'telemetry.sdk.version': '1.0.0',
70+
})
71+
}
72+
})
73+
74+
it('falls back to unknown_service and omits unset optional keys', () => {
75+
for (const attributes of allThree({})) {
76+
expect(attributes).toEqual({
77+
'service.name': 'unknown_service',
78+
'telemetry.sdk.name': 'posthog-node',
79+
'telemetry.sdk.version': '1.0.0',
80+
})
81+
}
82+
})
83+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Shape the logs, metrics and traces 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, metrics and traces 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+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { platform, release } from 'node:os'
2+
import { hostOsResourceAttributes } from '../host-os.node'
3+
4+
jest.mock('node:os', () => ({ platform: jest.fn(), release: jest.fn() }))
5+
6+
const mockPlatform = platform as jest.Mock
7+
const mockRelease = release as jest.Mock
8+
9+
describe('hostOsResourceAttributes', () => {
10+
it('reports the host OS', () => {
11+
mockPlatform.mockReturnValue('linux')
12+
mockRelease.mockReturnValue('6.1.0-27-amd64')
13+
14+
expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' })
15+
})
16+
17+
it('omits a key node:os cannot supply rather than emitting it empty', () => {
18+
mockPlatform.mockReturnValue('linux')
19+
mockRelease.mockReturnValue('')
20+
21+
expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux' })
22+
})
23+
24+
it('returns no attributes when node:os throws', () => {
25+
mockPlatform.mockImplementation(() => {
26+
throw new Error('unsupported')
27+
})
28+
mockRelease.mockImplementation(() => {
29+
throw new Error('unsupported')
30+
})
31+
32+
expect(hostOsResourceAttributes()).toEqual({})
33+
})
34+
})

packages/node/src/__tests__/traces-defaults.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,25 @@ describe('resolveTracesConfig', () => {
7171
it('floors an explicit maxQueueSize at the export batch size', () => {
7272
expect(resolveTracesConfig({ maxExportBatchSize: 512, maxQueueSize: 10 }).maxQueueSize).toBe(512)
7373
})
74+
75+
it('attaches the host resource attributes the entrypoint supplies', () => {
76+
expect(
77+
resolveTracesConfig(undefined, { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }).resourceAttributes
78+
).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' })
79+
})
80+
81+
it('lets user resource attributes override the host ones', () => {
82+
expect(
83+
resolveTracesConfig(
84+
{ resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } },
85+
{ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }
86+
).resourceAttributes
87+
).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' })
88+
})
89+
90+
it('resolves when the entrypoint supplies no host attributes', () => {
91+
expect(resolveTracesConfig({ resourceAttributes: { 'host.name': 'worker-01' } }).resourceAttributes).toEqual({
92+
'host.name': 'worker-01',
93+
})
94+
})
7495
})

0 commit comments

Comments
 (0)