Skip to content

Commit 435ebdd

Browse files
committed
feat(node): emit os.name and os.version on spans
Reads the host OS from node:os in a node-only module so the edge bundle keeps resolving no builtins, and normalizes os.name to the names posthog-ios and posthog-android already send.
1 parent dd4d089 commit 435ebdd

8 files changed

Lines changed: 174 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-node': minor
3+
---
4+
5+
Spans sent by `posthog-node` now carry `os.name` and `os.version` resource attributes, so traces can be filtered by the host operating system. `os.name` uses the same values `posthog-ios` and `posthog-android` send (`macOS`, `Windows`, `Linux`), and `os.version` is the kernel release. Both keys are overridable via `traces.resourceAttributes`, and neither is emitted on edge runtimes, which expose no OS.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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.each([
18+
['darwin', 'macOS'],
19+
['win32', 'Windows'],
20+
['linux', 'Linux'],
21+
['freebsd', 'FreeBSD'],
22+
])('reports %s under the name the other PostHog SDKs use, %s', (identifier, expected) => {
23+
// posthog-ios and posthog-android already send these names; `platform()`
24+
// returns os.type identifiers, which would be a second spelling.
25+
mockPlatform.mockReturnValue(identifier)
26+
mockRelease.mockReturnValue('1.0.0')
27+
28+
expect(hostOsResourceAttributes()['os.name']).toBe(expected)
29+
})
30+
31+
it('passes an unmapped platform through rather than dropping it', () => {
32+
mockPlatform.mockReturnValue('haiku')
33+
mockRelease.mockReturnValue('1.0.0')
34+
35+
expect(hostOsResourceAttributes()['os.name']).toBe('haiku')
36+
})
37+
38+
it('omits a key node:os cannot supply rather than emitting it empty', () => {
39+
mockPlatform.mockReturnValue('linux')
40+
mockRelease.mockReturnValue('')
41+
42+
expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'Linux' })
43+
})
44+
45+
it('returns no attributes when node:os throws', () => {
46+
mockPlatform.mockImplementation(() => {
47+
throw new Error('unsupported')
48+
})
49+
mockRelease.mockImplementation(() => {
50+
throw new Error('unsupported')
51+
})
52+
53+
expect(hostOsResourceAttributes()).toEqual({})
54+
})
55+
})

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
})

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hostOsResourceAttributes } from '../host-os.node'
12
import { PostHog } from '@/entrypoints/index.node'
23
import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types'
34
import { waitForPromises } from './utils'
@@ -114,6 +115,30 @@ describe('PostHog traces', () => {
114115
value: { stringValue: 'posthog-node' },
115116
})
116117
})
118+
119+
it('sends the host OS as resource attributes', async () => {
120+
posthog.startSpan('checkout').end()
121+
await flushTraces()
122+
123+
// Compared against the resolver rather than `platform()` directly: the point
124+
// is that the node entrypoint's override reaches the wire, not what this
125+
// machine runs.
126+
const { 'os.name': osName, 'os.version': osVersion } = hostOsResourceAttributes()
127+
const attributes = sentPayloads()[0].resourceSpans[0].resource.attributes
128+
expect(attributes).toContainEqual({ key: 'os.name', value: { stringValue: osName } })
129+
expect(attributes).toContainEqual({ key: 'os.version', value: { stringValue: osVersion } })
130+
})
131+
132+
it('lets configured resourceAttributes override the host OS', async () => {
133+
posthog = createClient({ traces: { serviceName: 'checkout-api', resourceAttributes: { 'os.name': 'my-os' } } })
134+
posthog.startSpan('checkout').end()
135+
await flushTraces()
136+
137+
expect(sentPayloads()[0].resourceSpans[0].resource.attributes).toContainEqual({
138+
key: 'os.name',
139+
value: { stringValue: 'my-os' },
140+
})
141+
})
117142
})
118143

119144
describe('span shape', () => {

packages/node/src/client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,15 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
628628
return new SyncSpanContextManager()
629629
}
630630

631+
/**
632+
* Runtime-detected OTLP resource attributes for every span. Overridden by the
633+
* Node entrypoint with the host OS; the edge build contributes none, keeping
634+
* `node:os` out of an edge bundle, which cannot resolve it.
635+
*/
636+
protected hostResourceAttributes(): Record<string, string> {
637+
return {}
638+
}
639+
631640
/**
632641
* The traces pipeline, built on first use. Returns `undefined` when the
633642
* `traces` client option is absent — tracing is off until configured.
@@ -639,7 +648,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
639648
if (!this._traces) {
640649
this._traces = new PostHogTraces(
641650
this,
642-
resolveTracesConfig(this.options.traces),
651+
resolveTracesConfig(this.options.traces, this.hostResourceAttributes()),
643652
this._logger,
644653
() => this._tracingContext(),
645654
this.initializeSpanContextManager()

packages/node/src/entrypoints/index.node.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { SpanContextManager } from '@posthog/core'
1111
import { PostHogContext } from '../extensions/context/context'
1212
import { AsyncLocalStorageSpanContextManager } from '../extensions/context/span-context.node'
1313
import { gzipCompress } from '../gzip.node'
14+
import { hostOsResourceAttributes } from '../host-os.node'
1415

1516
export class PostHog extends PostHogBackendClient {
1617
getLibraryId(): string {
@@ -29,6 +30,10 @@ export class PostHog extends PostHogBackendClient {
2930
return new AsyncLocalStorageSpanContextManager()
3031
}
3132

33+
protected override hostResourceAttributes(): Record<string, string> {
34+
return hostOsResourceAttributes()
35+
}
36+
3237
protected override createErrorPropertiesBuilder(): CoreErrorTracking.ErrorPropertiesBuilder {
3338
return new CoreErrorTracking.ErrorPropertiesBuilder(
3439
[

packages/node/src/host-os.node.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { platform, release } from 'node:os'
2+
3+
/**
4+
* OTLP `os.name` values for the `node:os` platform identifiers.
5+
*
6+
* OpenTelemetry defines `os.name` as the human-readable OS name; the identifiers
7+
* `platform()` returns (`darwin`, `win32`) belong to `os.type`, a different
8+
* attribute. These are the names `posthog-ios` and `posthog-android` already
9+
* send, so one `os.name` filter spans every PostHog SDK.
10+
*/
11+
const OS_NAMES: Record<string, string> = {
12+
darwin: 'macOS',
13+
win32: 'Windows',
14+
linux: 'Linux',
15+
android: 'Android',
16+
freebsd: 'FreeBSD',
17+
openbsd: 'OpenBSD',
18+
sunos: 'SunOS',
19+
aix: 'AIX',
20+
}
21+
22+
/**
23+
* OTLP `os.name` / `os.version` for the machine running the SDK, so spans can
24+
* be filtered by platform (e.g. "only the Linux workers") in PostHog.
25+
*
26+
* Node-only, like the other `.node` modules: importing `node:os` from a shared
27+
* module would put it in the edge bundle. A failed read omits the key rather
28+
* than throwing out of client construction.
29+
*
30+
* `os.version` is the kernel release (`25.6.0` on macOS 26, `6.1.0-27-amd64` on
31+
* Debian 12), not the marketing version — Node exposes no product-version API,
32+
* and `os.version()` returns a full banner string rather than a version.
33+
*/
34+
export function hostOsResourceAttributes(): Record<string, string> {
35+
let osName: string | undefined
36+
let osVersion: string | undefined
37+
try {
38+
// An unmapped platform passes through: a raw identifier beats no OS at all.
39+
osName = OS_NAMES[platform()] ?? platform()
40+
osVersion = release()
41+
} catch {}
42+
return {
43+
...(osName ? { 'os.name': osName } : {}),
44+
...(osVersion ? { 'os.version': osVersion } : {}),
45+
}
46+
}

packages/node/src/traces-defaults.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ function positiveInteger(value: number | undefined, fallback: number): number {
2222
/**
2323
* Resolves the public `traces` config into the shape core `PostHogTraces` consumes.
2424
* OTLP resource attributes take precedence over the named fields, matching the
25-
* logs config — a user who sets `service.name` directly means it.
25+
* logs config. `hostResourceAttributes` are runtime-detected by the entrypoint and
26+
* merge first, so a user-supplied value of the same key wins.
2627
*/
27-
export function resolveTracesConfig(config: TracesConfig | undefined): ResolvedTracesConfig {
28-
const resourceAttributes = config?.resourceAttributes
28+
export function resolveTracesConfig(
29+
config: TracesConfig | undefined,
30+
hostResourceAttributes?: Record<string, string>
31+
): ResolvedTracesConfig {
32+
const resourceAttributes = { ...hostResourceAttributes, ...config?.resourceAttributes }
2933
const maxExportBatchSize = positiveInteger(config?.maxExportBatchSize, DEFAULT_MAX_EXPORT_BATCH_SIZE)
3034
return {
3135
serviceName: stringAttribute(resourceAttributes?.['service.name']) ?? config?.serviceName,

0 commit comments

Comments
 (0)