From b21d67d7b6ddf2efb4c663eea219887209b156b2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 16:48:51 -0400 Subject: [PATCH 1/3] 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. --- .changeset/otlp-os-resource-attributes.md | 7 ++ .../src/__tests__/logs-defaults.test.ts | 46 ++++++++++ packages/browser/src/logs-defaults.ts | 29 ++++++- packages/core/src/logs/logs-utils.ts | 10 +-- packages/core/src/metrics/metrics-utils.ts | 10 +-- packages/core/src/traces/otlp.ts | 13 +-- packages/core/src/traces/sanitize.ts | 5 +- packages/core/src/utils/otlp-resource.spec.ts | 83 +++++++++++++++++++ packages/core/src/utils/otlp-resource.ts | 41 +++++++++ packages/node/src/__tests__/host-os.spec.ts | 34 ++++++++ .../src/__tests__/traces-defaults.spec.ts | 21 +++++ packages/node/src/__tests__/traces.spec.ts | 21 +++++ packages/node/src/client.ts | 11 ++- packages/node/src/entrypoints/index.node.ts | 5 ++ packages/node/src/host-os.node.ts | 22 +++++ packages/node/src/traces-defaults.ts | 10 ++- 16 files changed, 334 insertions(+), 34 deletions(-) create mode 100644 .changeset/otlp-os-resource-attributes.md create mode 100644 packages/core/src/utils/otlp-resource.spec.ts create mode 100644 packages/core/src/utils/otlp-resource.ts create mode 100644 packages/node/src/__tests__/host-os.spec.ts create mode 100644 packages/node/src/host-os.node.ts diff --git a/.changeset/otlp-os-resource-attributes.md b/.changeset/otlp-os-resource-attributes.md new file mode 100644 index 0000000000..67b08a0e8f --- /dev/null +++ b/.changeset/otlp-os-resource-attributes.md @@ -0,0 +1,7 @@ +--- +'posthog-js': minor +'posthog-node': minor +'@posthog/core': patch +--- + +Add `os.name` and `os.version` resource attributes to the logs `posthog-js` sends and the spans `posthog-node` sends. diff --git a/packages/browser/src/__tests__/logs-defaults.test.ts b/packages/browser/src/__tests__/logs-defaults.test.ts index 3a32cd73c3..68ca7c032f 100644 --- a/packages/browser/src/__tests__/logs-defaults.test.ts +++ b/packages/browser/src/__tests__/logs-defaults.test.ts @@ -110,4 +110,50 @@ describe('resolveLogsConfig', () => { expect(resolved.serviceName).toBe('from-named') }) + describe('OS resource attributes', () => { + const setUserAgent = (value: string | undefined): void => { + Object.defineProperty(window.navigator, 'userAgent', { value, configurable: true }) + } + + afterEach(() => { + // @ts-expect-error restoring the jsdom prototype getter + delete window.navigator.userAgent + }) + + it('attaches the detected OS', () => { + setUserAgent( + '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' + ) + + expect(resolveLogsConfig(undefined).resourceAttributes).toEqual({ + 'os.name': 'Mac OS X', + 'os.version': '10.15.7', + }) + }) + + it('lets user resourceAttributes override the detected OS', () => { + setUserAgent( + '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' + ) + + expect( + resolveLogsConfig({ resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } }) + .resourceAttributes + ).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' }) + }) + + it('omits a key the user agent cannot supply rather than emitting it empty', () => { + setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0') + + expect(resolveLogsConfig(undefined).resourceAttributes).toEqual({ 'os.name': 'Linux' }) + }) + + it('resolves without OS keys when there is no user agent', () => { + setUserAgent(undefined) + + expect(resolveLogsConfig({ resourceAttributes: { 'host.name': 'web-01' } }).resourceAttributes).toEqual({ + 'host.name': 'web-01', + }) + }) + }) }) diff --git a/packages/browser/src/logs-defaults.ts b/packages/browser/src/logs-defaults.ts index 54bb0ed235..923493ddb2 100644 --- a/packages/browser/src/logs-defaults.ts +++ b/packages/browser/src/logs-defaults.ts @@ -1,6 +1,7 @@ import type { LogCaptureOptions } from '@posthog/types' import type { ResolvedPostHogLogsConfig } from '@posthog/core' -import { isUndefined } from '@posthog/core' +import { detectOS, isUndefined } from '@posthog/core' +import { navigator } from '@posthog/browser-common/utils/globals' const DEFAULT_FLUSH_INTERVAL_MS = 3000 const DEFAULT_MAX_BUFFER_SIZE = 100 @@ -9,6 +10,30 @@ const DEFAULT_MAX_LOGS_PER_INTERVAL = 1000 const DEFAULT_CONSOLE_MAX_QUEUE_SIZE = 2048 const DEFAULT_MAX_BATCH_RECORDS_PER_POST = 100 +/** + * Browser resource attribute defaults. Identifies the visitor's OS so logs can + * be filtered by platform (e.g. "only errors on Windows" in the PostHog UI). + * User-supplied `resourceAttributes` merges last so these stay overridable. + * + * `detectOS` returns empty strings for a user agent it can't place, and there is + * no `navigator` in an SSR or worker-like context — either way the key is + * omitted rather than emitted empty, and resolution never throws. + */ +function defaultResourceAttributes(): Record { + let osName = '' + let osVersion = '' + try { + const userAgent = navigator?.userAgent + if (userAgent) { + ;[osName, osVersion] = detectOS(userAgent) + } + } catch {} + return { + ...(osName ? { 'os.name': osName } : {}), + ...(osVersion ? { 'os.version': osVersion } : {}), + } +} + /** * Resolves the public `logs` config into the shape core `PostHogLogs` consumes. * @@ -31,7 +56,7 @@ export function resolveLogsConfig( ? Math.max(maxBufferSize, DEFAULT_CONSOLE_MAX_QUEUE_SIZE) : Math.max(maxBufferSize, maxLogsPerInterval) // OTLP keys in `resourceAttributes` take precedence over the named config fields. - const resourceAttributes = config?.resourceAttributes + const resourceAttributes = { ...defaultResourceAttributes(), ...config?.resourceAttributes } return { serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? diff --git a/packages/core/src/logs/logs-utils.ts b/packages/core/src/logs/logs-utils.ts index 9bbac89505..4125107e1d 100644 --- a/packages/core/src/logs/logs-utils.ts +++ b/packages/core/src/logs/logs-utils.ts @@ -12,6 +12,7 @@ import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types' import { isNullish, isNumber, isUndefined } from '../utils' import { sanitizeString, UNSERIALIZABLE_VALUE } from '../utils/json-utils' import { toOtlpKeyValueList } from '../utils/otlp-any-value' +import { buildOtlpResourceAttributes } from '../utils/otlp-resource' // ============================================================================ // Severity mapping @@ -185,14 +186,7 @@ export function buildResourceAttributes( sdkName: string, sdkVersion: string ): Record { - return { - ...config.resourceAttributes, - 'service.name': config.serviceName || 'unknown_service', - ...(config.environment && { 'deployment.environment': config.environment }), - ...(config.serviceVersion && { 'service.version': config.serviceVersion }), - 'telemetry.sdk.name': sdkName, - 'telemetry.sdk.version': sdkVersion, - } + return buildOtlpResourceAttributes(config, sdkName, sdkVersion) } /** diff --git a/packages/core/src/metrics/metrics-utils.ts b/packages/core/src/metrics/metrics-utils.ts index 4fb534bbba..5e0cebf638 100644 --- a/packages/core/src/metrics/metrics-utils.ts +++ b/packages/core/src/metrics/metrics-utils.ts @@ -1,5 +1,6 @@ import type { MetricAttributeValue, OtlpMetric, OtlpMetricsPayload } from '@posthog/types' import { toOtlpKeyValueList } from '../utils/otlp-any-value' +import { buildOtlpResourceAttributes } from '../utils/otlp-resource' import type { ResolvedPostHogMetricsConfig } from './types' /** @@ -59,14 +60,7 @@ export function buildMetricsResourceAttributes( scopeName: string, scopeVersion: string ): Record { - return { - ...config.resourceAttributes, - 'service.name': config.serviceName || 'unknown_service', - ...(config.environment && { 'deployment.environment': config.environment }), - ...(config.serviceVersion && { 'service.version': config.serviceVersion }), - 'telemetry.sdk.name': scopeName, - 'telemetry.sdk.version': scopeVersion, - } + return buildOtlpResourceAttributes(config, scopeName, scopeVersion) } /** diff --git a/packages/core/src/traces/otlp.ts b/packages/core/src/traces/otlp.ts index a7fabf8368..75d66721c1 100644 --- a/packages/core/src/traces/otlp.ts +++ b/packages/core/src/traces/otlp.ts @@ -11,7 +11,7 @@ import type { Logger } from '../types' import type { ResolvedTracesConfig, SpanRecord } from './types' import { toOtlpKeyValueList } from '../utils/otlp-any-value' import { UNSERIALIZABLE_VALUE, sanitizeString } from '../utils/json-utils' -import { assignUserAttributes } from './sanitize' +import { buildOtlpResourceAttributes } from '../utils/otlp-resource' const SPAN_KIND_TO_OTLP: Record = { internal: 1, @@ -132,16 +132,7 @@ export function buildTracesResourceAttributes( sdkName: string, sdkVersion: string ): SpanAttributes { - return { - // Read through the shared guard: a throwing accessor here runs on every - // flush, before the pass's own error handling. - ...assignUserAttributes({}, config.resourceAttributes), - 'service.name': config.serviceName || 'unknown_service', - ...(config.environment && { 'deployment.environment': config.environment }), - ...(config.serviceVersion && { 'service.version': config.serviceVersion }), - 'telemetry.sdk.name': sdkName, - 'telemetry.sdk.version': sdkVersion, - } + return buildOtlpResourceAttributes(config, sdkName, sdkVersion) } /** diff --git a/packages/core/src/traces/sanitize.ts b/packages/core/src/traces/sanitize.ts index 1ba5b97bbd..e9a835d2db 100644 --- a/packages/core/src/traces/sanitize.ts +++ b/packages/core/src/traces/sanitize.ts @@ -115,7 +115,10 @@ export function resolveSuppliedTime( * Read key by key rather than spread: a getter over a disposed resource or a * revoked proxy throws on the read itself, before the encoder's guards see it. */ -export function assignUserAttributes>(target: T, source: SpanAttributes | undefined): T { +export function assignUserAttributes>( + target: T, + source: Record | undefined +): T { if (!source) { return target } diff --git a/packages/core/src/utils/otlp-resource.spec.ts b/packages/core/src/utils/otlp-resource.spec.ts new file mode 100644 index 0000000000..6953db7624 --- /dev/null +++ b/packages/core/src/utils/otlp-resource.spec.ts @@ -0,0 +1,83 @@ +import { buildResourceAttributes } from '../logs/logs-utils' +import type { ResolvedPostHogLogsConfig } from '../logs/types' +import { buildMetricsResourceAttributes } from '../metrics/metrics-utils' +import type { ResolvedPostHogMetricsConfig } from '../metrics/types' +import { buildTracesResourceAttributes } from '../traces/otlp' +import type { ResolvedTracesConfig } from '../traces/types' + +const shared = { + serviceName: 'checkout', + serviceVersion: '2.1.0', + environment: 'production', + resourceAttributes: { 'host.name': 'web-01' }, +} + +const conflicting = { + serviceName: 'checkout', + serviceVersion: '2.1.0', + environment: 'production', + resourceAttributes: { + 'service.name': 'hijacked', + 'service.version': '0.0.0', + 'deployment.environment': 'hijacked-env', + 'telemetry.sdk.name': 'hijacked-sdk', + 'telemetry.sdk.version': '0.0.0', + 'host.name': 'web-01', + }, +} + +const allThree = (partial: object): Record[] => [ + buildResourceAttributes(partial as ResolvedPostHogLogsConfig, 'posthog-node', '1.0.0'), + buildMetricsResourceAttributes(partial as ResolvedPostHogMetricsConfig, 'posthog-node', '1.0.0'), + buildTracesResourceAttributes(partial as ResolvedTracesConfig, 'posthog-node', '1.0.0'), +] + +describe('shared OTLP resource attributes', () => { + it.each([ + ['a fully populated config', shared], + ['a config with conflicting user attributes', conflicting], + ['an empty config', {}], + ])('produces the same attributes for logs, metrics and traces given %s', (_label, config) => { + const [logs, metrics, traces] = allThree(config) + expect(metrics).toEqual(logs) + expect(traces).toEqual(logs) + expect(Object.keys(metrics)).toEqual(Object.keys(logs)) + expect(Object.keys(traces)).toEqual(Object.keys(logs)) + }) + + it('layers the identity keys over user resource attributes', () => { + for (const attributes of allThree(conflicting)) { + expect(attributes).toEqual({ + 'service.name': 'checkout', + 'service.version': '2.1.0', + 'deployment.environment': 'production', + 'telemetry.sdk.name': 'posthog-node', + 'telemetry.sdk.version': '1.0.0', + 'host.name': 'web-01', + }) + } + }) + + it('keeps user resource attributes that do not collide', () => { + for (const attributes of allThree(shared)) { + expect(attributes).toEqual({ + 'host.name': 'web-01', + 'service.name': 'checkout', + 'deployment.environment': 'production', + 'service.version': '2.1.0', + 'telemetry.sdk.name': 'posthog-node', + 'telemetry.sdk.version': '1.0.0', + }) + } + }) + + it('falls back to unknown_service and omits unset optional keys', () => { + for (const attributes of allThree({})) { + expect(attributes).toEqual({ + 'service.name': 'unknown_service', + 'telemetry.sdk.name': 'posthog-node', + 'telemetry.sdk.version': '1.0.0', + }) + } + }) +}) diff --git a/packages/core/src/utils/otlp-resource.ts b/packages/core/src/utils/otlp-resource.ts new file mode 100644 index 0000000000..26175a5ecb --- /dev/null +++ b/packages/core/src/utils/otlp-resource.ts @@ -0,0 +1,41 @@ +import { assignUserAttributes } from '../traces/sanitize' + +/** + * Shape the logs, metrics and traces resolved configs share for resource + * attribution. Generic over the attribute value type so each signal keeps its + * own value union. + */ +export interface OtlpResourceConfig { + serviceName?: string + serviceVersion?: string + environment?: string + resourceAttributes?: Record +} + +/** + * OTLP resource attributes shared by the logs, metrics and traces envelopes. + * + * User `resourceAttributes` are spread first, then SDK-controlled keys on top so + * a stray user key can't clobber the ingestion-attribution ones; the dedicated + * `serviceName` / `environment` / `serviceVersion` fields are how you override + * those three. + * + * @internal Shared within this SDK; not part of the stable public API. + */ +export function buildOtlpResourceAttributes( + config: OtlpResourceConfig, + sdkName: string, + sdkVersion: string +): Record { + return { + // Read key by key: a throwing accessor on a user-supplied attribute runs on + // every flush, before the pipeline's own error handling, and would otherwise + // stop the signal exporting entirely. + ...assignUserAttributes>({}, config.resourceAttributes), + 'service.name': config.serviceName || 'unknown_service', + ...(config.environment && { 'deployment.environment': config.environment }), + ...(config.serviceVersion && { 'service.version': config.serviceVersion }), + 'telemetry.sdk.name': sdkName, + 'telemetry.sdk.version': sdkVersion, + } +} diff --git a/packages/node/src/__tests__/host-os.spec.ts b/packages/node/src/__tests__/host-os.spec.ts new file mode 100644 index 0000000000..7c2ea3ee5d --- /dev/null +++ b/packages/node/src/__tests__/host-os.spec.ts @@ -0,0 +1,34 @@ +import { platform, release } from 'node:os' +import { hostOsResourceAttributes } from '../host-os.node' + +jest.mock('node:os', () => ({ platform: jest.fn(), release: jest.fn() })) + +const mockPlatform = platform as jest.Mock +const mockRelease = release as jest.Mock + +describe('hostOsResourceAttributes', () => { + it('reports the host OS', () => { + mockPlatform.mockReturnValue('linux') + mockRelease.mockReturnValue('6.1.0-27-amd64') + + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }) + }) + + it('omits a key node:os cannot supply rather than emitting it empty', () => { + mockPlatform.mockReturnValue('linux') + mockRelease.mockReturnValue('') + + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux' }) + }) + + it('returns no attributes when node:os throws', () => { + mockPlatform.mockImplementation(() => { + throw new Error('unsupported') + }) + mockRelease.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(hostOsResourceAttributes()).toEqual({}) + }) +}) diff --git a/packages/node/src/__tests__/traces-defaults.spec.ts b/packages/node/src/__tests__/traces-defaults.spec.ts index c8b7b5a225..cd8d8cf519 100644 --- a/packages/node/src/__tests__/traces-defaults.spec.ts +++ b/packages/node/src/__tests__/traces-defaults.spec.ts @@ -71,6 +71,27 @@ describe('resolveTracesConfig', () => { it('floors an explicit maxQueueSize at the export batch size', () => { expect(resolveTracesConfig({ maxExportBatchSize: 512, maxQueueSize: 10 }).maxQueueSize).toBe(512) }) + + it('attaches the host resource attributes the entrypoint supplies', () => { + expect( + resolveTracesConfig(undefined, { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }).resourceAttributes + ).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }) + }) + + it('lets user resource attributes override the host ones', () => { + expect( + resolveTracesConfig( + { resourceAttributes: { 'os.name': 'my-os', 'os.version': '1.2.3' } }, + { 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' } + ).resourceAttributes + ).toEqual({ 'os.name': 'my-os', 'os.version': '1.2.3' }) + }) + + it('resolves when the entrypoint supplies no host attributes', () => { + expect(resolveTracesConfig({ resourceAttributes: { 'host.name': 'worker-01' } }).resourceAttributes).toEqual({ + 'host.name': 'worker-01', + }) + }) }) describe('resourceAttributes guarding', () => { diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index 9f4723c6b2..5a19c26900 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -1,3 +1,4 @@ +import { platform, release } from 'node:os' import { PostHog } from '@/entrypoints/index.node' import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types' import { waitForPromises } from './utils' @@ -115,6 +116,26 @@ describe('PostHog traces', () => { value: { stringValue: 'posthog-node' }, }) }) + + it('sends the host OS as resource attributes', async () => { + posthog.startSpan('checkout').end() + await flushTraces() + + const attributes = sentPayloads()[0].resourceSpans[0].resource.attributes + expect(attributes).toContainEqual({ key: 'os.name', value: { stringValue: platform() } }) + expect(attributes).toContainEqual({ key: 'os.version', value: { stringValue: release() } }) + }) + + it('lets configured resourceAttributes override the host OS', async () => { + posthog = createClient({ traces: { serviceName: 'checkout-api', resourceAttributes: { 'os.name': 'my-os' } } }) + posthog.startSpan('checkout').end() + await flushTraces() + + expect(sentPayloads()[0].resourceSpans[0].resource.attributes).toContainEqual({ + key: 'os.name', + value: { stringValue: 'my-os' }, + }) + }) }) describe('span shape', () => { diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 50a2950b72..8d43333ddd 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -628,6 +628,15 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return new SyncSpanContextManager() } + /** + * Runtime-detected OTLP resource attributes for every span. Overridden by the + * Node entrypoint with the host OS; the edge build contributes none, keeping + * `node:os` out of an edge bundle, which cannot resolve it. + */ + protected hostResourceAttributes(): Record { + return {} + } + /** * The traces pipeline, built on first use. Returns `undefined` when the * `traces` client option is absent — tracing is off until configured. @@ -639,7 +648,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen if (!this._traces) { this._traces = new PostHogTraces( this, - resolveTracesConfig(this.options.traces), + resolveTracesConfig(this.options.traces, this.hostResourceAttributes()), this._logger, () => this._tracingContext(), this.initializeSpanContextManager(), diff --git a/packages/node/src/entrypoints/index.node.ts b/packages/node/src/entrypoints/index.node.ts index 96a9fed515..c456c50645 100644 --- a/packages/node/src/entrypoints/index.node.ts +++ b/packages/node/src/entrypoints/index.node.ts @@ -11,6 +11,7 @@ import type { SpanContextManager } from '@posthog/core' import { PostHogContext } from '../extensions/context/context' import { AsyncLocalStorageSpanContextManager } from '../extensions/context/span-context.node' import { gzipCompress } from '../gzip.node' +import { hostOsResourceAttributes } from '../host-os.node' export class PostHog extends PostHogBackendClient { getLibraryId(): string { @@ -29,6 +30,10 @@ export class PostHog extends PostHogBackendClient { return new AsyncLocalStorageSpanContextManager() } + protected override hostResourceAttributes(): Record { + return hostOsResourceAttributes() + } + protected override createErrorPropertiesBuilder(): CoreErrorTracking.ErrorPropertiesBuilder { return new CoreErrorTracking.ErrorPropertiesBuilder( [ diff --git a/packages/node/src/host-os.node.ts b/packages/node/src/host-os.node.ts new file mode 100644 index 0000000000..7cca61b7ec --- /dev/null +++ b/packages/node/src/host-os.node.ts @@ -0,0 +1,22 @@ +import { platform, release } from 'node:os' + +/** + * OTLP `os.name` / `os.version` for the machine running the SDK, so spans can + * be filtered by platform (e.g. "only the Linux workers") in PostHog. + * + * Node-only, like the other `.node` modules: importing `node:os` from a shared + * module would put it in the edge bundle. A failed read omits the key rather + * than throwing out of client construction. + */ +export function hostOsResourceAttributes(): Record { + let osName: string | undefined + let osVersion: string | undefined + try { + osName = platform() + osVersion = release() + } catch {} + return { + ...(osName ? { 'os.name': osName } : {}), + ...(osVersion ? { 'os.version': osVersion } : {}), + } +} diff --git a/packages/node/src/traces-defaults.ts b/packages/node/src/traces-defaults.ts index 2e1be86c67..7a54a28494 100644 --- a/packages/node/src/traces-defaults.ts +++ b/packages/node/src/traces-defaults.ts @@ -46,10 +46,14 @@ function withUsableIdentityKeys(attributes: TracesConfig['resourceAttributes']): /** * Resolves the public `traces` config into the shape core `PostHogTraces` consumes. * OTLP resource attributes take precedence over the named fields, matching the - * logs config — a user who sets `service.name` directly means it. + * logs config. `hostResourceAttributes` are runtime-detected by the entrypoint and + * merge first, so a user-supplied value of the same key wins. */ -export function resolveTracesConfig(config: TracesConfig | undefined): ResolvedTracesConfig { - const resourceAttributes = withUsableIdentityKeys(config?.resourceAttributes) +export function resolveTracesConfig( + config: TracesConfig | undefined, + hostResourceAttributes?: Record +): ResolvedTracesConfig { + const resourceAttributes = { ...hostResourceAttributes, ...withUsableIdentityKeys(config?.resourceAttributes) } const maxExportBatchSize = positiveInteger(config?.maxExportBatchSize, DEFAULT_MAX_EXPORT_BATCH_SIZE) return { serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? config?.serviceName, From 6a343f0739e384b3ee998c1ec932a1109fe23a26 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 14:31:55 -0400 Subject: [PATCH 2/3] fix(node): normalize os.name and guard the resourceAttributes merge --- .changeset/otlp-os-resource-attributes.md | 3 +-- packages/core/src/index.ts | 1 + packages/node/src/__tests__/host-os.spec.ts | 15 +++++++++-- .../src/__tests__/traces-defaults.spec.ts | 26 +++++++++++++++++++ packages/node/src/__tests__/traces.spec.ts | 7 +++-- packages/node/src/host-os.node.ts | 8 +++--- packages/node/src/traces-defaults.ts | 8 +++++- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.changeset/otlp-os-resource-attributes.md b/.changeset/otlp-os-resource-attributes.md index 67b08a0e8f..9743127a09 100644 --- a/.changeset/otlp-os-resource-attributes.md +++ b/.changeset/otlp-os-resource-attributes.md @@ -1,7 +1,6 @@ --- -'posthog-js': minor 'posthog-node': minor '@posthog/core': patch --- -Add `os.name` and `os.version` resource attributes to the logs `posthog-js` sends and the spans `posthog-node` sends. +Add `os.name` and `os.version` resource attributes to the spans `posthog-node` sends, so traces can be filtered by platform. `os.name` is the human-readable name the other PostHog SDKs report (`macOS`, `Windows`, `Linux`) rather than the `node:os` identifier. Either key is omitted when the host cannot supply it, and `traces.resourceAttributes` still overrides both. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e388cd6ddf..f8b93ec090 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,7 @@ export { } from './logs/logs-utils' export { toOtlpAnyValue, toOtlpKeyValueList } from './utils/otlp-any-value' export { osResourceAttributes } from './utils/otlp-resource' +export { assignUserAttributes } from './traces/sanitize' export { PostHogLogs } from './logs' export type { BeforeSendLogFn, diff --git a/packages/node/src/__tests__/host-os.spec.ts b/packages/node/src/__tests__/host-os.spec.ts index 7c2ea3ee5d..2fab37eb84 100644 --- a/packages/node/src/__tests__/host-os.spec.ts +++ b/packages/node/src/__tests__/host-os.spec.ts @@ -11,14 +11,25 @@ describe('hostOsResourceAttributes', () => { mockPlatform.mockReturnValue('linux') mockRelease.mockReturnValue('6.1.0-27-amd64') - expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux', 'os.version': '6.1.0-27-amd64' }) + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'Linux', 'os.version': '6.1.0-27-amd64' }) + }) + + it.each([ + ['darwin', 'macOS'], + ['win32', 'Windows'], + ['freebsd', 'FreeBSD'], + ])('reports %s as the OS name the other SDKs send, not the node:os identifier', (identifier, osName) => { + mockPlatform.mockReturnValue(identifier) + mockRelease.mockReturnValue('1.0.0') + + expect(hostOsResourceAttributes()['os.name']).toBe(osName) }) it('omits a key node:os cannot supply rather than emitting it empty', () => { mockPlatform.mockReturnValue('linux') mockRelease.mockReturnValue('') - expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'linux' }) + expect(hostOsResourceAttributes()).toEqual({ 'os.name': 'Linux' }) }) it('returns no attributes when node:os throws', () => { diff --git a/packages/node/src/__tests__/traces-defaults.spec.ts b/packages/node/src/__tests__/traces-defaults.spec.ts index 8bce0afe44..b767799afa 100644 --- a/packages/node/src/__tests__/traces-defaults.spec.ts +++ b/packages/node/src/__tests__/traces-defaults.spec.ts @@ -146,4 +146,30 @@ describe('resourceAttributes guarding', () => { expect(() => resolveTracesConfig({ resourceAttributes: hostile as never })).not.toThrow() }) + + it('does not throw when a non-identity accessor throws', () => { + const hostile = { 'service.name': 'checkout-api' } + Object.defineProperty(hostile, 'region', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + + expect(() => resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' })).not.toThrow() + }) + + it('keeps the readable attributes when one accessor throws', () => { + const hostile = { region: 'us' } + Object.defineProperty(hostile, 'tenant', { + enumerable: true, + get() { + throw new Error('config getter exploded') + }, + }) + + const resolved = resolveTracesConfig({ resourceAttributes: hostile as never }, { 'os.name': 'Linux' }) + + expect(resolved.resourceAttributes).toMatchObject({ 'os.name': 'Linux', region: 'us' }) + }) }) diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index cb7a045589..dd37b982ee 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -2,7 +2,7 @@ import { platform, release } from 'node:os' import { PostHog } from '@/entrypoints/index.node' import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types' import { waitForPromises } from './utils' -import { isGzipSupported } from '@posthog/core' +import { isGzipSupported, osResourceAttributes } from '@posthog/core' jest.mock('../version', () => ({ version: '1.2.3' })) @@ -151,7 +151,10 @@ describe('PostHog traces', () => { await flushTraces() const attributes = sentPayloads()[0].resourceSpans[0].resource.attributes - expect(attributes).toContainEqual({ key: 'os.name', value: { stringValue: platform() } }) + expect(attributes).toContainEqual({ + key: 'os.name', + value: { stringValue: osResourceAttributes(platform(), release())['os.name'] }, + }) expect(attributes).toContainEqual({ key: 'os.version', value: { stringValue: release() } }) }) diff --git a/packages/node/src/host-os.node.ts b/packages/node/src/host-os.node.ts index 7cca61b7ec..246bd2b429 100644 --- a/packages/node/src/host-os.node.ts +++ b/packages/node/src/host-os.node.ts @@ -1,4 +1,5 @@ import { platform, release } from 'node:os' +import { osResourceAttributes } from '@posthog/core' /** * OTLP `os.name` / `os.version` for the machine running the SDK, so spans can @@ -15,8 +16,7 @@ export function hostOsResourceAttributes(): Record { osName = platform() osVersion = release() } catch {} - return { - ...(osName ? { 'os.name': osName } : {}), - ...(osVersion ? { 'os.version': osVersion } : {}), - } + // Through the shared builder, so a Node span reports the same `os.name` the + // browser, iOS and Android SDKs send rather than the `node:os` identifier. + return osResourceAttributes(osName, osVersion) } diff --git a/packages/node/src/traces-defaults.ts b/packages/node/src/traces-defaults.ts index 50dd37ef8e..f4982505bc 100644 --- a/packages/node/src/traces-defaults.ts +++ b/packages/node/src/traces-defaults.ts @@ -1,3 +1,4 @@ +import { assignUserAttributes } from '@posthog/core' import type { ResolvedTracesConfig, TracesConfig } from '@posthog/core' // OpenTelemetry's BatchSpanProcessor defaults, which sit comfortably under the @@ -62,7 +63,12 @@ export function resolveTracesConfig( config: TracesConfig | undefined, hostResourceAttributes?: Record ): ResolvedTracesConfig { - const resourceAttributes = { ...hostResourceAttributes, ...withUsableIdentityKeys(config?.resourceAttributes) } + // Copied key by key rather than spread: a throwing accessor on a user-supplied + // attribute would otherwise escape the first `startSpan`. + const resourceAttributes = assignUserAttributes( + { ...hostResourceAttributes }, + withUsableIdentityKeys(config?.resourceAttributes) + ) const maxExportBatchSize = positiveInteger(config?.maxExportBatchSize, DEFAULT_MAX_EXPORT_BATCH_SIZE) return { serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? config?.serviceName, From da00b2c1d51a53a594e90d5356b61766ce9b29eb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 17:50:40 -0400 Subject: [PATCH 3/3] fix(traces): map the remaining node:os platforms in os.name Adds haiku, netbsd and cygwin so the table covers all eleven NodeJS.Platform values, and marks assignUserAttributes @internal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DkmCQweddFe4pZLDMYqNoA --- packages/core/src/traces/sanitize.ts | 2 ++ packages/core/src/utils/otlp-resource.spec.ts | 8 +++++++- packages/core/src/utils/otlp-resource.ts | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/core/src/traces/sanitize.ts b/packages/core/src/traces/sanitize.ts index e9a835d2db..47257deba7 100644 --- a/packages/core/src/traces/sanitize.ts +++ b/packages/core/src/traces/sanitize.ts @@ -114,6 +114,8 @@ export function resolveSuppliedTime( * * Read key by key rather than spread: a getter over a disposed resource or a * revoked proxy throws on the read itself, before the encoder's guards see it. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. */ export function assignUserAttributes>( target: T, diff --git a/packages/core/src/utils/otlp-resource.spec.ts b/packages/core/src/utils/otlp-resource.spec.ts index e4dd8b34e0..3ab1ea79de 100644 --- a/packages/core/src/utils/otlp-resource.spec.ts +++ b/packages/core/src/utils/otlp-resource.spec.ts @@ -91,6 +91,12 @@ describe('osResourceAttributes', () => { ['linux', 'Linux'], ['android', 'Android'], ['freebsd', 'FreeBSD'], + ['openbsd', 'OpenBSD'], + ['netbsd', 'NetBSD'], + ['sunos', 'SunOS'], + ['aix', 'AIX'], + ['haiku', 'Haiku'], + ['cygwin', 'Windows'], // detectOS spellings ['Mac OS X', 'macOS'], ['iOS', 'iOS'], @@ -102,7 +108,7 @@ describe('osResourceAttributes', () => { }) it('passes an unmapped name through rather than dropping it', () => { - expect(normalizeOsName('Haiku')).toBe('Haiku') + expect(normalizeOsName('Plan 9')).toBe('Plan 9') expect(normalizeOsName('constructor')).toBe('constructor') }) diff --git a/packages/core/src/utils/otlp-resource.ts b/packages/core/src/utils/otlp-resource.ts index 4f3b733374..09ac1d9090 100644 --- a/packages/core/src/utils/otlp-resource.ts +++ b/packages/core/src/utils/otlp-resource.ts @@ -52,15 +52,19 @@ export function buildOtlpResourceAttributes( * platforms they cover. */ const OS_NAMES: Record = { - // node:os platform() + // node:os platform(), all eleven of them darwin: 'macOS', win32: 'Windows', + // Cygwin is a POSIX layer over Windows, so it belongs under the same filter. + cygwin: 'Windows', linux: 'Linux', android: 'Android', freebsd: 'FreeBSD', openbsd: 'OpenBSD', + netbsd: 'NetBSD', sunos: 'SunOS', aix: 'AIX', + haiku: 'Haiku', // detectOS 'Mac OS X': 'macOS', }