diff --git a/.changeset/otlp-os-resource-attributes.md b/.changeset/otlp-os-resource-attributes.md new file mode 100644 index 0000000000..9743127a09 --- /dev/null +++ b/.changeset/otlp-os-resource-attributes.md @@ -0,0 +1,6 @@ +--- +'posthog-node': minor +'@posthog/core': patch +--- + +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/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..47257deba7 100644 --- a/packages/core/src/traces/sanitize.ts +++ b/packages/core/src/traces/sanitize.ts @@ -114,8 +114,13 @@ 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, 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 index a9b6d8891d..3ab1ea79de 100644 --- a/packages/core/src/utils/otlp-resource.spec.ts +++ b/packages/core/src/utils/otlp-resource.spec.ts @@ -2,6 +2,8 @@ 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' import { normalizeOsName, osResourceAttributes } from './otlp-resource' const shared = { @@ -25,9 +27,10 @@ const conflicting = { }, } -const bothSignals = (partial: object): Record[] => [ +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', () => { @@ -35,14 +38,16 @@ describe('shared OTLP resource attributes', () => { ['a fully populated config', shared], ['a config with conflicting user attributes', conflicting], ['an empty config', {}], - ])('produces the same attributes for logs and metrics given %s', (_label, config) => { - const [logs, metrics] = bothSignals(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 bothSignals(conflicting)) { + for (const attributes of allThree(conflicting)) { expect(attributes).toEqual({ 'service.name': 'checkout', 'service.version': '2.1.0', @@ -55,7 +60,7 @@ describe('shared OTLP resource attributes', () => { }) it('keeps user resource attributes that do not collide', () => { - for (const attributes of bothSignals(shared)) { + for (const attributes of allThree(shared)) { expect(attributes).toEqual({ 'host.name': 'web-01', 'service.name': 'checkout', @@ -68,7 +73,7 @@ describe('shared OTLP resource attributes', () => { }) it('falls back to unknown_service and omits unset optional keys', () => { - for (const attributes of bothSignals({})) { + for (const attributes of allThree({})) { expect(attributes).toEqual({ 'service.name': 'unknown_service', 'telemetry.sdk.name': 'posthog-node', @@ -86,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'], @@ -97,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 0f19cbe19e..09ac1d9090 100644 --- a/packages/core/src/utils/otlp-resource.ts +++ b/packages/core/src/utils/otlp-resource.ts @@ -1,5 +1,7 @@ +import { assignUserAttributes } from '../traces/sanitize' + /** - * Shape the logs and metrics resolved configs share for resource + * 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. */ @@ -11,7 +13,7 @@ export interface OtlpResourceConfig { } /** - * OTLP resource attributes shared by the logs and metrics envelopes. + * 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 @@ -26,7 +28,10 @@ export function buildOtlpResourceAttributes( sdkVersion: string ): Record { return { - ...config.resourceAttributes, + // 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 }), @@ -47,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', } 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..2fab37eb84 --- /dev/null +++ b/packages/node/src/__tests__/host-os.spec.ts @@ -0,0 +1,45 @@ +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.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' }) + }) + + 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 9c6e2a8770..b767799afa 100644 --- a/packages/node/src/__tests__/traces-defaults.spec.ts +++ b/packages/node/src/__tests__/traces-defaults.spec.ts @@ -87,6 +87,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', () => { @@ -125,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 8034c018dd..dd37b982ee 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -1,7 +1,8 @@ +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' })) @@ -144,6 +145,29 @@ 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: osResourceAttributes(platform(), release())['os.name'] }, + }) + 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 7a9c251855..08cb08b189 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -630,6 +630,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 {} + } + /** * Active-span tracking, built on first use. Lives on the client rather than * on the traces pipeline because a client with tracing off still activates a @@ -653,7 +662,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._spanContextManager, 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..246bd2b429 --- /dev/null +++ b/packages/node/src/host-os.node.ts @@ -0,0 +1,22 @@ +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 + * 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 {} + // 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 9d96d3be5a..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 @@ -55,10 +56,19 @@ 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 { + // 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,