Skip to content

Commit 3ad7fa8

Browse files
teeohhemkodiakhq[bot]wrn14897
authored
feat(otel-web): emit browser.language and browser.timezone resource attributes (#267)
* feat(otel-web): emit browser.language and browser.timezone resource attributes 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. * 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). * 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. * fix(deno): import dnt from JSR to avoid deno.land brotli fetch errors The unpinned https://deno.land/x/dnt import pulls ts_morph from deno.land/x, which intermittently fails with 'error: brotli error' on Deno 1.x in CI. The JSR-published dnt resolves its dependencies from jsr.io instead, eliminating the failing fetch. --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Warren Lee <5959690+wrn14897@users.noreply.github.com>
1 parent 6eb530d commit 3ad7fa8

6 files changed

Lines changed: 133 additions & 2 deletions

File tree

.changeset/rum-browser-context.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@hyperdx/otel-web': minor
3+
'@hyperdx/browser': minor
4+
---
5+
6+
Emit approximate locale/region resource attributes from the browser RUM
7+
SDK: `browser.language` (OTel semconv, from `navigator.language`) and
8+
`hyperdx.browser.timezone` (IANA zone from `Intl`). These are honest proxies for
9+
where a user is — they are NOT IP geolocation (the browser can't
10+
determine country without a permission prompt; true geo is derived in the
11+
collector from the client IP). Added before user-provided
12+
`resourceAttributes` so callers can override them.

packages/deno/build-npm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { build, emptyDir } from 'https://deno.land/x/dnt/mod.ts';
1+
import { build, emptyDir } from 'jsr:@deno/dnt@0.41.3';
22

33
async function getJson(filePath: string) {
44
return JSON.parse(await Deno.readTextFile(filePath));

packages/otel-web/.mocharc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"extension": ["ts"],
3-
"spec": "test/nodejs.test.ts",
3+
"spec": ["test/nodejs.test.ts", "test/browserContext.test.ts"],
44
"require": "ts-node/register"
55
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Attributes } from '@opentelemetry/api';
2+
3+
/**
4+
* Approximate locale/region signals read from the browser environment.
5+
* These are HONEST PROXIES for where a user is — they are NOT IP
6+
* geolocation (the browser cannot determine country without a permission
7+
* prompt). True geo (`geo.country.name`, …) is derived in the OTel
8+
* collector from the client IP. See the geoip processor.
9+
*/
10+
export interface BrowserContext {
11+
/** navigator.language, e.g. "en-US" (OTel semconv `browser.language`). */
12+
language?: string;
13+
/** IANA time zone, e.g. "America/New_York" (emitted as `hyperdx.browser.timezone`). */
14+
timeZone?: string;
15+
}
16+
17+
/** Read the browser context from the current environment (best-effort). */
18+
export function resolveBrowserContext(): BrowserContext {
19+
const language =
20+
typeof navigator !== 'undefined' ? navigator.language : undefined;
21+
22+
let timeZone: string | undefined;
23+
try {
24+
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
25+
} catch {
26+
// Intl or the resolved time zone is unavailable in this environment.
27+
timeZone = undefined;
28+
}
29+
30+
return { language, timeZone };
31+
}
32+
33+
/**
34+
* Map a {@link BrowserContext} to resource attributes. `browser.language`
35+
* is an OpenTelemetry semantic-convention attribute; `hyperdx.browser.timezone`
36+
* is a vendor-namespaced custom attribute — OTel semconv has no timezone
37+
* resource attribute, so it is kept out of the reserved `browser.` namespace
38+
* to avoid a future collision. Absent values are omitted so they never
39+
* overwrite a user-provided attribute with an empty string.
40+
*
41+
* The context is injectable so the mapping can be unit-tested without real
42+
* browser globals.
43+
*/
44+
export function getBrowserContextResourceAttributes(
45+
context: BrowserContext = resolveBrowserContext(),
46+
): Attributes {
47+
const attrs: Attributes = {};
48+
if (context.language) {
49+
attrs['browser.language'] = context.language;
50+
}
51+
if (context.timeZone) {
52+
attrs['hyperdx.browser.timezone'] = context.timeZone;
53+
}
54+
return attrs;
55+
}

packages/otel-web/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
} from './SplunkContextManager';
7272
import { resourceFromAttributes } from '@opentelemetry/resources';
7373
import { SDK_INFO } from '@opentelemetry/core';
74+
import { getBrowserContextResourceAttributes } from './browserContext';
7475
import {
7576
ATTR_SERVICE_NAME,
7677
ATTR_TELEMETRY_SDK_NAME,
@@ -547,6 +548,10 @@ export const Rum: RumOtelWebType = {
547548
const pluginDefaults = { ignoreUrls, enabled: false };
548549

549550
const resourceAttrs: Attributes = {
551+
// Approximate locale/region signals (browser.language, hyperdx.browser.timezone).
552+
// Honest proxies, NOT IP geolocation; placed first so user-provided
553+
// resourceAttributes can override them.
554+
...getBrowserContextResourceAttributes(),
550555
// User-provided resource attributes
551556
...(resourceAttributes || {}),
552557
...SDK_INFO,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as assert from 'assert';
2+
3+
import {
4+
getBrowserContextResourceAttributes,
5+
resolveBrowserContext,
6+
} from '../src/browserContext';
7+
8+
describe('browserContext', () => {
9+
it('maps language and timeZone to resource attributes', () => {
10+
assert.deepStrictEqual(
11+
getBrowserContextResourceAttributes({
12+
language: 'en-US',
13+
timeZone: 'America/New_York',
14+
}),
15+
{
16+
'browser.language': 'en-US',
17+
'hyperdx.browser.timezone': 'America/New_York',
18+
},
19+
);
20+
});
21+
22+
it('omits absent values so it never overwrites with empty attributes', () => {
23+
assert.deepStrictEqual(getBrowserContextResourceAttributes({}), {});
24+
assert.deepStrictEqual(
25+
getBrowserContextResourceAttributes({ timeZone: 'UTC' }),
26+
{ 'hyperdx.browser.timezone': 'UTC' },
27+
);
28+
});
29+
30+
it('treats an empty-string language as absent', () => {
31+
assert.deepStrictEqual(
32+
getBrowserContextResourceAttributes({ language: '' }),
33+
{},
34+
);
35+
});
36+
37+
it('resolveBrowserContext reads a non-empty IANA time zone from the environment', () => {
38+
const { timeZone } = resolveBrowserContext();
39+
assert.strictEqual(typeof timeZone, 'string');
40+
assert.ok((timeZone as string).length > 0);
41+
});
42+
43+
it('resolveBrowserContext reads navigator.language when available', () => {
44+
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
45+
Object.defineProperty(globalThis, 'navigator', {
46+
value: { language: 'fr-FR' },
47+
configurable: true,
48+
});
49+
try {
50+
assert.strictEqual(resolveBrowserContext().language, 'fr-FR');
51+
} finally {
52+
if (original) {
53+
Object.defineProperty(globalThis, 'navigator', original);
54+
} else {
55+
delete (globalThis as any).navigator;
56+
}
57+
}
58+
});
59+
});

0 commit comments

Comments
 (0)