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/browser-os-resource-attributes.md
Original file line number Diff line number Diff line change
@@ -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`
58 changes: 58 additions & 0 deletions packages/browser/src/__tests__/logs-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
})
})
24 changes: 24 additions & 0 deletions packages/browser/src/__tests__/posthog-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 24 additions & 2 deletions packages/browser/src/logs-defaults.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string> {
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.
*
Expand All @@ -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) ??
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 2 additions & 8 deletions packages/core/src/logs/logs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -185,14 +186,7 @@ export function buildResourceAttributes(
sdkName: string,
sdkVersion: string
): Record<string, LogAttributeValue> {
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)
}

/**
Expand Down
10 changes: 2 additions & 8 deletions packages/core/src/metrics/metrics-utils.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -59,14 +60,7 @@ export function buildMetricsResourceAttributes(
scopeName: string,
scopeVersion: string
): Record<string, MetricAttributeValue> {
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)
}

/**
Expand Down
125 changes: 125 additions & 0 deletions packages/core/src/utils/otlp-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] => [
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',
})
})
})
Loading