From 5e9916ab0daceb101b82cdda05724b941bbc8d8a Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 5 Jun 2026 12:53:59 -0400 Subject: [PATCH 1/3] feat(otel-web): emit browser.language and browser.timezone resource attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add approximate locale/region signals to the browser RUM resource: - browser.language (OTel semconv) from navigator.language, e.g. "en-US" - browser.timezone (IANA zone) from Intl, e.g. "America/New_York" These are honest proxies for a user's region, NOT IP geolocation — the browser cannot determine country without a permission prompt; true geo (geo.country.name, …) is derived in the collector from the client IP. The resolver is split out (browserContext.ts) with the context injectable so the attribute mapping is unit-tested without real browser globals, and omits absent values so it never overwrites a user attribute with an empty string. Wired into resourceAttrs before the user-provided resourceAttributes so callers can override. --- .changeset/rum-browser-context.md | 12 +++++ packages/otel-web/.mocharc.json | 2 +- packages/otel-web/src/browserContext.ts | 53 +++++++++++++++++++ packages/otel-web/src/index.ts | 5 ++ packages/otel-web/test/browserContext.test.ts | 35 ++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 .changeset/rum-browser-context.md create mode 100644 packages/otel-web/src/browserContext.ts create mode 100644 packages/otel-web/test/browserContext.test.ts diff --git a/.changeset/rum-browser-context.md b/.changeset/rum-browser-context.md new file mode 100644 index 00000000..d758fdc0 --- /dev/null +++ b/.changeset/rum-browser-context.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/otel-web': minor +'@hyperdx/browser': minor +--- + +Emit approximate locale/region resource attributes from the browser RUM +SDK: `browser.language` (OTel semconv, from `navigator.language`) and +`browser.timezone` (IANA zone from `Intl`). These are honest proxies for +where a user is — they are NOT IP geolocation (the browser can't +determine country without a permission prompt; true geo is derived in the +collector from the client IP). Added before user-provided +`resourceAttributes` so callers can override them. diff --git a/packages/otel-web/.mocharc.json b/packages/otel-web/.mocharc.json index 94d9fdec..2f6a4620 100644 --- a/packages/otel-web/.mocharc.json +++ b/packages/otel-web/.mocharc.json @@ -1,5 +1,5 @@ { "extension": ["ts"], - "spec": "test/nodejs.test.ts", + "spec": ["test/nodejs.test.ts", "test/browserContext.test.ts"], "require": "ts-node/register" } diff --git a/packages/otel-web/src/browserContext.ts b/packages/otel-web/src/browserContext.ts new file mode 100644 index 00000000..1d809ce2 --- /dev/null +++ b/packages/otel-web/src/browserContext.ts @@ -0,0 +1,53 @@ +import type { Attributes } from '@opentelemetry/api'; + +/** + * Approximate locale/region signals read from the browser environment. + * These are HONEST PROXIES for where a user is — they are NOT IP + * geolocation (the browser cannot determine country without a permission + * prompt). True geo (`geo.country.name`, …) is derived in the OTel + * collector from the client IP. See the geoip processor. + */ +export interface BrowserContext { + /** navigator.language, e.g. "en-US" (OTel semconv `browser.language`). */ + language?: string; + /** IANA time zone, e.g. "America/New_York". */ + timeZone?: string; +} + +/** Read the browser context from the current environment (best-effort). */ +export function resolveBrowserContext(): BrowserContext { + const language = + typeof navigator !== 'undefined' ? navigator.language : undefined; + + let timeZone: string | undefined; + try { + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + // Intl or the resolved time zone is unavailable in this environment. + timeZone = undefined; + } + + return { language, timeZone }; +} + +/** + * Map a {@link BrowserContext} to resource attributes. `browser.language` + * is an OpenTelemetry semantic-convention attribute; `browser.timezone` is + * a custom companion in the same namespace. Absent values are omitted so + * they never overwrite a user-provided attribute with an empty string. + * + * The context is injectable so the mapping can be unit-tested without real + * browser globals. + */ +export function getBrowserContextResourceAttributes( + context: BrowserContext = resolveBrowserContext(), +): Attributes { + const attrs: Attributes = {}; + if (context.language) { + attrs['browser.language'] = context.language; + } + if (context.timeZone) { + attrs['browser.timezone'] = context.timeZone; + } + return attrs; +} diff --git a/packages/otel-web/src/index.ts b/packages/otel-web/src/index.ts index 7d26b384..7bc5ac62 100644 --- a/packages/otel-web/src/index.ts +++ b/packages/otel-web/src/index.ts @@ -71,6 +71,7 @@ import { } from './SplunkContextManager'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { SDK_INFO } from '@opentelemetry/core'; +import { getBrowserContextResourceAttributes } from './browserContext'; import { ATTR_SERVICE_NAME, ATTR_TELEMETRY_SDK_NAME, @@ -547,6 +548,10 @@ export const Rum: RumOtelWebType = { const pluginDefaults = { ignoreUrls, enabled: false }; const resourceAttrs: Attributes = { + // Approximate locale/region signals (browser.language, browser.timezone). + // Honest proxies, NOT IP geolocation; placed first so user-provided + // resourceAttributes can override them. + ...getBrowserContextResourceAttributes(), // User-provided resource attributes ...(resourceAttributes || {}), ...SDK_INFO, diff --git a/packages/otel-web/test/browserContext.test.ts b/packages/otel-web/test/browserContext.test.ts new file mode 100644 index 00000000..d4a5ddc7 --- /dev/null +++ b/packages/otel-web/test/browserContext.test.ts @@ -0,0 +1,35 @@ +import * as assert from 'assert'; + +import { + getBrowserContextResourceAttributes, + resolveBrowserContext, +} from '../src/browserContext'; + +describe('browserContext', () => { + it('maps language and timeZone to resource attributes', () => { + assert.deepStrictEqual( + getBrowserContextResourceAttributes({ + language: 'en-US', + timeZone: 'America/New_York', + }), + { + 'browser.language': 'en-US', + 'browser.timezone': 'America/New_York', + }, + ); + }); + + it('omits absent values so it never overwrites with empty attributes', () => { + assert.deepStrictEqual(getBrowserContextResourceAttributes({}), {}); + assert.deepStrictEqual( + getBrowserContextResourceAttributes({ timeZone: 'UTC' }), + { 'browser.timezone': 'UTC' }, + ); + }); + + it('resolveBrowserContext reads a non-empty IANA time zone from the environment', () => { + const { timeZone } = resolveBrowserContext(); + assert.strictEqual(typeof timeZone, 'string'); + assert.ok((timeZone as string).length > 0); + }); +}); From 67f7e07b069d77928965a67a0f1daf44fd822cac Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 5 Jun 2026 15:01:01 -0400 Subject: [PATCH 2/3] test(otel-web): cover navigator.language and empty-string language branches Adds two unit tests flagged in review of the RUM browser-context change: - getBrowserContextResourceAttributes({ language: '' }) -> {} confirms an empty-string language is treated as absent. - resolveBrowserContext() reads navigator.language: mock navigator with a fixed 'fr-FR' locale so the truthful branch is exercised deterministically, independent of the host's locale (Node 22 defines navigator.language). --- packages/otel-web/test/browserContext.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/otel-web/test/browserContext.test.ts b/packages/otel-web/test/browserContext.test.ts index d4a5ddc7..36a7a51c 100644 --- a/packages/otel-web/test/browserContext.test.ts +++ b/packages/otel-web/test/browserContext.test.ts @@ -27,9 +27,33 @@ describe('browserContext', () => { ); }); + it('treats an empty-string language as absent', () => { + assert.deepStrictEqual( + getBrowserContextResourceAttributes({ language: '' }), + {}, + ); + }); + it('resolveBrowserContext reads a non-empty IANA time zone from the environment', () => { const { timeZone } = resolveBrowserContext(); assert.strictEqual(typeof timeZone, 'string'); assert.ok((timeZone as string).length > 0); }); + + it('resolveBrowserContext reads navigator.language when available', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'fr-FR' }, + configurable: true, + }); + try { + assert.strictEqual(resolveBrowserContext().language, 'fr-FR'); + } finally { + if (original) { + Object.defineProperty(globalThis, 'navigator', original); + } else { + delete (globalThis as any).navigator; + } + } + }); }); From 7c0b5821cbc40c021efc107162b830997b0e6d04 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 11 Jun 2026 16:08:40 -0400 Subject: [PATCH 3/3] refactor(otel-web): namespace the custom timezone attr as hyperdx.browser.timezone Per review: the OTel browser resource semconv has no timezone attribute, so the custom one was squatting in the reserved `browser.` namespace. Move it to the vendor namespace `hyperdx.browser.timezone` to avoid a future collision. `browser.language` is unchanged (it is the spec attribute). Updates the emit site, docstring, changeset, and the two existing test assertions. --- .changeset/rum-browser-context.md | 2 +- packages/otel-web/src/browserContext.ts | 12 +++++++----- packages/otel-web/src/index.ts | 2 +- packages/otel-web/test/browserContext.test.ts | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/rum-browser-context.md b/.changeset/rum-browser-context.md index d758fdc0..e96bc9d6 100644 --- a/.changeset/rum-browser-context.md +++ b/.changeset/rum-browser-context.md @@ -5,7 +5,7 @@ Emit approximate locale/region resource attributes from the browser RUM SDK: `browser.language` (OTel semconv, from `navigator.language`) and -`browser.timezone` (IANA zone from `Intl`). These are honest proxies for +`hyperdx.browser.timezone` (IANA zone from `Intl`). These are honest proxies for where a user is — they are NOT IP geolocation (the browser can't determine country without a permission prompt; true geo is derived in the collector from the client IP). Added before user-provided diff --git a/packages/otel-web/src/browserContext.ts b/packages/otel-web/src/browserContext.ts index 1d809ce2..d3c3b81c 100644 --- a/packages/otel-web/src/browserContext.ts +++ b/packages/otel-web/src/browserContext.ts @@ -10,7 +10,7 @@ import type { Attributes } from '@opentelemetry/api'; export interface BrowserContext { /** navigator.language, e.g. "en-US" (OTel semconv `browser.language`). */ language?: string; - /** IANA time zone, e.g. "America/New_York". */ + /** IANA time zone, e.g. "America/New_York" (emitted as `hyperdx.browser.timezone`). */ timeZone?: string; } @@ -32,9 +32,11 @@ export function resolveBrowserContext(): BrowserContext { /** * Map a {@link BrowserContext} to resource attributes. `browser.language` - * is an OpenTelemetry semantic-convention attribute; `browser.timezone` is - * a custom companion in the same namespace. Absent values are omitted so - * they never overwrite a user-provided attribute with an empty string. + * is an OpenTelemetry semantic-convention attribute; `hyperdx.browser.timezone` + * is a vendor-namespaced custom attribute — OTel semconv has no timezone + * resource attribute, so it is kept out of the reserved `browser.` namespace + * to avoid a future collision. Absent values are omitted so they never + * overwrite a user-provided attribute with an empty string. * * The context is injectable so the mapping can be unit-tested without real * browser globals. @@ -47,7 +49,7 @@ export function getBrowserContextResourceAttributes( attrs['browser.language'] = context.language; } if (context.timeZone) { - attrs['browser.timezone'] = context.timeZone; + attrs['hyperdx.browser.timezone'] = context.timeZone; } return attrs; } diff --git a/packages/otel-web/src/index.ts b/packages/otel-web/src/index.ts index 7bc5ac62..953afafc 100644 --- a/packages/otel-web/src/index.ts +++ b/packages/otel-web/src/index.ts @@ -548,7 +548,7 @@ export const Rum: RumOtelWebType = { const pluginDefaults = { ignoreUrls, enabled: false }; const resourceAttrs: Attributes = { - // Approximate locale/region signals (browser.language, browser.timezone). + // Approximate locale/region signals (browser.language, hyperdx.browser.timezone). // Honest proxies, NOT IP geolocation; placed first so user-provided // resourceAttributes can override them. ...getBrowserContextResourceAttributes(), diff --git a/packages/otel-web/test/browserContext.test.ts b/packages/otel-web/test/browserContext.test.ts index 36a7a51c..290084f7 100644 --- a/packages/otel-web/test/browserContext.test.ts +++ b/packages/otel-web/test/browserContext.test.ts @@ -14,7 +14,7 @@ describe('browserContext', () => { }), { 'browser.language': 'en-US', - 'browser.timezone': 'America/New_York', + 'hyperdx.browser.timezone': 'America/New_York', }, ); }); @@ -23,7 +23,7 @@ describe('browserContext', () => { assert.deepStrictEqual(getBrowserContextResourceAttributes({}), {}); assert.deepStrictEqual( getBrowserContextResourceAttributes({ timeZone: 'UTC' }), - { 'browser.timezone': 'UTC' }, + { 'hyperdx.browser.timezone': 'UTC' }, ); });