Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/otlp-os-resource-attributes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
turnipdabeets marked this conversation as resolved.
export { PostHogLogs } from './logs'
export type {
BeforeSendLogFn,
Expand Down
13 changes: 2 additions & 11 deletions packages/core/src/traces/otlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpanKind, number> = {
internal: 1,
Expand Down Expand Up @@ -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<SpanAttributes>({}, 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)
}

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/traces/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Record<string, any>>(target: T, source: SpanAttributes | undefined): T {
export function assignUserAttributes<T extends Record<string, any>>(
target: T,
source: Record<string, unknown> | undefined
): T {
if (!source) {
return target
}
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/utils/otlp-resource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -25,24 +27,27 @@ const conflicting = {
},
}

const bothSignals = (partial: object): Record<string, unknown>[] => [
const allThree = (partial: object): Record<string, unknown>[] => [
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 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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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'],
Expand All @@ -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')
})

Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/utils/otlp-resource.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -11,7 +13,7 @@ export interface OtlpResourceConfig<TAttributeValue> {
}

/**
* 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
Expand All @@ -26,7 +28,10 @@ export function buildOtlpResourceAttributes<TAttributeValue>(
sdkVersion: string
): Record<string, TAttributeValue | string> {
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<Record<string, TAttributeValue>>({}, config.resourceAttributes),
'service.name': config.serviceName || 'unknown_service',
...(config.environment && { 'deployment.environment': config.environment }),
...(config.serviceVersion && { 'service.version': config.serviceVersion }),
Expand All @@ -47,15 +52,19 @@ export function buildOtlpResourceAttributes<TAttributeValue>(
* platforms they cover.
*/
const OS_NAMES: Record<string, string> = {
// 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',
}
Expand Down
45 changes: 45 additions & 0 deletions packages/node/src/__tests__/host-os.spec.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
47 changes: 47 additions & 0 deletions packages/node/src/__tests__/traces-defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' })
})
})
26 changes: 25 additions & 1 deletion packages/node/src/__tests__/traces.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }))

Expand Down Expand Up @@ -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', () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/node/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/node/src/entrypoints/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,6 +30,10 @@ export class PostHog extends PostHogBackendClient {
return new AsyncLocalStorageSpanContextManager()
}

protected override hostResourceAttributes(): Record<string, string> {
return hostOsResourceAttributes()
}

protected override createErrorPropertiesBuilder(): CoreErrorTracking.ErrorPropertiesBuilder {
return new CoreErrorTracking.ErrorPropertiesBuilder(
[
Expand Down
Loading