diff --git a/.changeset/browser-os-resource-attributes.md b/.changeset/browser-os-resource-attributes.md new file mode 100644 index 0000000000..9b357f1dff --- /dev/null +++ b/.changeset/browser-os-resource-attributes.md @@ -0,0 +1,6 @@ +--- +'posthog-js': minor +'@posthog/core': patch +--- + +Add `os.name` and `os.version` resource attributes to logs from the browser SDK, overridable via `logs.resourceAttributes` diff --git a/packages/browser/src/__tests__/logs-defaults.test.ts b/packages/browser/src/__tests__/logs-defaults.test.ts index 3a32cd73c3..19fed41d01 100644 --- a/packages/browser/src/__tests__/logs-defaults.test.ts +++ b/packages/browser/src/__tests__/logs-defaults.test.ts @@ -110,4 +110,62 @@ 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 under the name the other PostHog SDKs use', () => { + 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' + ) + + // `detectOS` reads the user agent's "Mac OS X"; posthog-ios reports the + // same OS as "macOS", and one filter has to match both. + expect(resolveLogsConfig(undefined).resourceAttributes).toEqual({ + 'os.name': 'macOS', + 'os.version': '10.15.7', + }) + }) + + it.each([ + ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15', 'iOS'], + ['Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile', 'Android'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0', 'Windows'], + ])('reports %s as os.name %s, matching the native SDKs', (userAgent, expected) => { + setUserAgent(userAgent) + + expect(resolveLogsConfig(undefined).resourceAttributes?.['os.name']).toBe(expected) + }) + + 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/__tests__/posthog-logs.test.ts b/packages/browser/src/__tests__/posthog-logs.test.ts index 7bb5ff5c02..45747f99ef 100644 --- a/packages/browser/src/__tests__/posthog-logs.test.ts +++ b/packages/browser/src/__tests__/posthog-logs.test.ts @@ -764,6 +764,30 @@ describe('posthog-logs', () => { expect(attrsMap['deployment.environment']).toEqual({ stringValue: 'production' }) }) + it('should include the detected OS in OTLP resource attributes', () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0', + configurable: true, + }) + + try { + logs = new PostHogLogs(mockPostHog) + logs.captureLog({ body: 'test' }) + jest.advanceTimersByTime(3000) + + const call = (mockPostHog._send_request as jest.Mock).mock.calls[0][0] + const attrsMap = Object.fromEntries( + call.data.resourceLogs[0].resource.attributes.map((a: any) => [a.key, a.value]) + ) + + expect(attrsMap['os.name']).toEqual({ stringValue: 'Windows' }) + expect(attrsMap['os.version']).toEqual({ stringValue: '10' }) + } finally { + // @ts-expect-error restoring the jsdom prototype getter + delete window.navigator.userAgent + } + }) + it('should allow resourceAttributes to override named fields', () => { ;(mockPostHog.config as any).logs = { ...mockPostHog.config.logs, diff --git a/packages/browser/src/logs-defaults.ts b/packages/browser/src/logs-defaults.ts index 54bb0ed235..0abec83ee9 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, osResourceAttributes } 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,27 @@ 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 osResourceAttributes(osName, osVersion) +} + /** * Resolves the public `logs` config into the shape core `PostHogLogs` consumes. * @@ -31,7 +53,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/index.ts b/packages/core/src/index.ts index 8219875000..1191911e46 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,7 @@ export { getOtlpSeverityText, } from './logs/logs-utils' export { toOtlpAnyValue, toOtlpKeyValueList } from './utils/otlp-any-value' +export { osResourceAttributes } from './utils/otlp-resource' export { PostHogLogs } from './logs' export type { BeforeSendLogFn, 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/utils/otlp-resource.spec.ts b/packages/core/src/utils/otlp-resource.spec.ts new file mode 100644 index 0000000000..a9b6d8891d --- /dev/null +++ b/packages/core/src/utils/otlp-resource.spec.ts @@ -0,0 +1,125 @@ +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 { normalizeOsName, osResourceAttributes } from './otlp-resource' + +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 bothSignals = (partial: object): Record[] => [ + buildResourceAttributes(partial as ResolvedPostHogLogsConfig, 'posthog-node', '1.0.0'), + buildMetricsResourceAttributes(partial as ResolvedPostHogMetricsConfig, '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) + expect(metrics).toEqual(logs) + expect(Object.keys(metrics)).toEqual(Object.keys(logs)) + }) + + it('layers the identity keys over user resource attributes', () => { + for (const attributes of bothSignals(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 bothSignals(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 bothSignals({})) { + expect(attributes).toEqual({ + 'service.name': 'unknown_service', + 'telemetry.sdk.name': 'posthog-node', + 'telemetry.sdk.version': '1.0.0', + }) + } + }) +}) + +describe('osResourceAttributes', () => { + it.each([ + // node:os platform() identifiers rather than os.name values + ['darwin', 'macOS'], + ['win32', 'Windows'], + ['linux', 'Linux'], + ['android', 'Android'], + ['freebsd', 'FreeBSD'], + // detectOS spellings + ['Mac OS X', 'macOS'], + ['iOS', 'iOS'], + ['Android', 'Android'], + ['Windows', 'Windows'], + ['Linux', 'Linux'], + ])('normalizes %s to %s', (raw, expected) => { + expect(normalizeOsName(raw)).toBe(expected) + }) + + it('passes an unmapped name through rather than dropping it', () => { + expect(normalizeOsName('Haiku')).toBe('Haiku') + expect(normalizeOsName('constructor')).toBe('constructor') + }) + + it.each([undefined, ''])('returns undefined for %p', (raw) => { + expect(normalizeOsName(raw)).toBeUndefined() + }) + + it('agrees with the names posthog-ios and posthog-android already send', () => { + // Both SDKs ship these values today; a divergence here splits one filter in two. + expect(normalizeOsName('darwin')).toBe('macOS') + expect(normalizeOsName('Mac OS X')).toBe('macOS') + expect(normalizeOsName('iOS')).toBe('iOS') + expect(normalizeOsName('Android')).toBe('Android') + }) + + it('omits either key rather than emitting it empty', () => { + expect(osResourceAttributes('darwin', undefined)).toEqual({ 'os.name': 'macOS' }) + expect(osResourceAttributes(undefined, '14.0')).toEqual({ 'os.version': '14.0' }) + expect(osResourceAttributes('', '')).toEqual({}) + expect(osResourceAttributes('win32', '10.0.26100')).toEqual({ + 'os.name': 'Windows', + 'os.version': '10.0.26100', + }) + }) +}) diff --git a/packages/core/src/utils/otlp-resource.ts b/packages/core/src/utils/otlp-resource.ts new file mode 100644 index 0000000000..0f19cbe19e --- /dev/null +++ b/packages/core/src/utils/otlp-resource.ts @@ -0,0 +1,89 @@ +/** + * Shape the logs and metrics 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 and metrics 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 { + ...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, + } +} + +/** + * OTLP `os.name` values, keyed by the spellings the JS SDKs detect natively: + * `node:os` `platform()` identifiers and the names `detectOS` reads out of a + * user agent. + * + * OpenTelemetry defines `os.name` as the human-readable OS name; the lowercase + * identifiers (`darwin`, `win32`) are `node:os` `platform()` values, not + * `os.name` values. + * The values match what `posthog-ios` and `posthog-android` send for the + * platforms they cover. + */ +const OS_NAMES: Record = { + // node:os platform() + darwin: 'macOS', + win32: 'Windows', + linux: 'Linux', + android: 'Android', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + sunos: 'SunOS', + aix: 'AIX', + // detectOS + 'Mac OS X': 'macOS', +} + +/** + * Normalizes a natively-detected OS name against the table above. + * Unrecognized names pass through: a wrong-looking value beats dropping an OS + * we have not mapped yet. + * + * @internal Shared within this SDK; not part of the stable public API. + */ +export function normalizeOsName(name: string | undefined): string | undefined { + if (!name) { + return undefined + } + return Object.prototype.hasOwnProperty.call(OS_NAMES, name) ? OS_NAMES[name] : name +} + +/** + * The `os.name` / `os.version` resource attribute pair, with either key omitted + * rather than emitted empty when the host cannot determine it. + * + * @internal Exposed for cross-package use within this SDK; not part of the stable public API. + */ +export function osResourceAttributes(name: string | undefined, version: string | undefined): Record { + const osName = normalizeOsName(name) + return { + ...(osName ? { 'os.name': osName } : {}), + ...(version ? { 'os.version': version } : {}), + } +}