Skip to content

Commit bd028cf

Browse files
committed
feat(browser): Include culture context with events
1 parent 3fa7a86 commit bd028cf

4 files changed

Lines changed: 255 additions & 0 deletions

File tree

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export type { Span, FeatureFlagsIntegration } from '@sentry/core';
7575
export { makeBrowserOfflineTransport } from './transports/offline';
7676
export { browserProfilingIntegration } from './profiling/integration';
7777
export { spotlightBrowserIntegration } from './integrations/spotlight';
78+
export { cultureContextIntegration } from './integrations/culturecontext';
7879
export { browserSessionIntegration } from './integrations/browsersession';
7980
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
8081
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { CultureContext, IntegrationFn } from '@sentry/core';
2+
import { defineIntegration, GLOBAL_OBJ } from '@sentry/core';
3+
4+
const INTEGRATION_NAME = 'CultureContext';
5+
6+
const _cultureContextIntegration = (() => {
7+
return {
8+
name: INTEGRATION_NAME,
9+
preprocessEvent(event) {
10+
const culture = getCultureContext();
11+
12+
if (culture) {
13+
event.contexts = {
14+
...event.contexts,
15+
culture: { ...culture, ...event.contexts?.culture },
16+
};
17+
}
18+
},
19+
};
20+
}) satisfies IntegrationFn;
21+
22+
/**
23+
* Captures culture context from the browser.
24+
*
25+
* Enabled by default.
26+
*
27+
* @example
28+
* ```js
29+
* import * as Sentry from '@sentry/browser';
30+
*
31+
* Sentry.init({
32+
* integrations: [Sentry.cultureContextIntegration()],
33+
* });
34+
* ```
35+
*/
36+
export const cultureContextIntegration = defineIntegration(_cultureContextIntegration);
37+
38+
/**
39+
* Returns the culture context from the browser's Intl API.
40+
*/
41+
function getCultureContext(): CultureContext | undefined {
42+
try {
43+
if (typeof (GLOBAL_OBJ as { Intl?: typeof Intl }).Intl === 'undefined') {
44+
return undefined;
45+
}
46+
47+
const options = Intl.DateTimeFormat().resolvedOptions();
48+
49+
return {
50+
locale: options.locale,
51+
timezone: options.timeZone,
52+
calendar: options.calendar,
53+
};
54+
} catch {
55+
// Ignore errors
56+
return undefined;
57+
}
58+
}

packages/browser/src/sdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { BrowserClientOptions, BrowserOptions } from './client';
1212
import { BrowserClient } from './client';
1313
import { breadcrumbsIntegration } from './integrations/breadcrumbs';
1414
import { browserApiErrorsIntegration } from './integrations/browserapierrors';
15+
import { cultureContextIntegration } from './integrations/culturecontext';
1516
import { browserSessionIntegration } from './integrations/browsersession';
1617
import { globalHandlersIntegration } from './integrations/globalhandlers';
1718
import { httpContextIntegration } from './integrations/httpcontext';
@@ -39,6 +40,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
3940
linkedErrorsIntegration(),
4041
dedupeIntegration(),
4142
httpContextIntegration(),
43+
cultureContextIntegration(),
4244
browserSessionIntegration(),
4345
];
4446
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { Event } from '@sentry/core';
2+
import * as SentryCore from '@sentry/core';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { cultureContextIntegration } from '../../src/integrations/culturecontext';
5+
6+
describe('CultureContext', () => {
7+
const originalIntl = globalThis.Intl;
8+
9+
beforeEach(() => {
10+
vi.restoreAllMocks();
11+
});
12+
13+
afterEach(() => {
14+
globalThis.Intl = originalIntl;
15+
});
16+
17+
describe('preprocessEvent', () => {
18+
it('adds culture context with locale and timezone', () => {
19+
const mockResolvedOptions = vi.fn().mockReturnValue({
20+
locale: 'en-US',
21+
timeZone: 'America/New_York',
22+
});
23+
24+
globalThis.Intl = {
25+
DateTimeFormat: vi.fn().mockReturnValue({
26+
resolvedOptions: mockResolvedOptions,
27+
}),
28+
} as unknown as typeof Intl;
29+
30+
// @ts-expect-error - mockReturnValue is not typed
31+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
32+
Intl: globalThis.Intl,
33+
} as typeof SentryCore.GLOBAL_OBJ);
34+
35+
const integration = cultureContextIntegration();
36+
const event: Event = {};
37+
38+
integration.preprocessEvent!(event, {}, {} as never);
39+
40+
expect(event.contexts?.culture).toEqual({
41+
locale: 'en-US',
42+
timezone: 'America/New_York',
43+
});
44+
});
45+
46+
it('preserves existing culture context values', () => {
47+
const mockResolvedOptions = vi.fn().mockReturnValue({
48+
locale: 'en-US',
49+
timeZone: 'America/New_York',
50+
});
51+
52+
globalThis.Intl = {
53+
DateTimeFormat: vi.fn().mockReturnValue({
54+
resolvedOptions: mockResolvedOptions,
55+
}),
56+
} as unknown as typeof Intl;
57+
58+
// @ts-expect-error - mockReturnValue is not typed
59+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
60+
Intl: globalThis.Intl,
61+
} as typeof SentryCore.GLOBAL_OBJ);
62+
63+
const integration = cultureContextIntegration();
64+
const event: Event = {
65+
contexts: {
66+
culture: {
67+
calendar: 'gregorian',
68+
display_name: 'English (United States)',
69+
},
70+
},
71+
};
72+
73+
integration.preprocessEvent!(event, {}, {} as never);
74+
75+
expect(event.contexts?.culture).toEqual({
76+
locale: 'en-US',
77+
timezone: 'America/New_York',
78+
calendar: 'gregorian',
79+
display_name: 'English (United States)',
80+
});
81+
});
82+
83+
it('does not override existing locale and timezone', () => {
84+
const mockResolvedOptions = vi.fn().mockReturnValue({
85+
locale: 'en-US',
86+
timeZone: 'America/New_York',
87+
});
88+
89+
globalThis.Intl = {
90+
DateTimeFormat: vi.fn().mockReturnValue({
91+
resolvedOptions: mockResolvedOptions,
92+
}),
93+
} as unknown as typeof Intl;
94+
95+
// @ts-expect-error - mockReturnValue is not typed
96+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
97+
Intl: globalThis.Intl,
98+
} as typeof SentryCore.GLOBAL_OBJ);
99+
100+
const integration = cultureContextIntegration();
101+
const event: Event = {
102+
contexts: {
103+
culture: {
104+
locale: 'de-DE',
105+
timezone: 'Europe/Berlin',
106+
},
107+
},
108+
};
109+
110+
integration.preprocessEvent!(event, {}, {} as never);
111+
112+
// Existing values should be preserved (not overwritten)
113+
expect(event.contexts?.culture).toEqual({
114+
locale: 'de-DE',
115+
timezone: 'Europe/Berlin',
116+
});
117+
});
118+
119+
it('does not add culture context when Intl is not available', () => {
120+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
121+
Intl: undefined,
122+
} as unknown as typeof SentryCore.GLOBAL_OBJ);
123+
124+
const integration = cultureContextIntegration();
125+
const event: Event = {};
126+
127+
integration.preprocessEvent!(event, {}, {} as never);
128+
129+
expect(event.contexts?.culture).toBeUndefined();
130+
});
131+
132+
it('handles errors gracefully when Intl.DateTimeFormat throws', () => {
133+
globalThis.Intl = {
134+
DateTimeFormat: vi.fn().mockImplementation(() => {
135+
throw new Error('Intl error');
136+
}),
137+
} as unknown as typeof Intl;
138+
139+
// @ts-expect-error - mockReturnValue is not typed
140+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
141+
Intl: globalThis.Intl,
142+
} as typeof SentryCore.GLOBAL_OBJ);
143+
144+
const integration = cultureContextIntegration();
145+
const event: Event = {};
146+
147+
// Should not throw
148+
expect(() => {
149+
integration.preprocessEvent!(event, {}, {} as never);
150+
}).not.toThrow();
151+
152+
expect(event.contexts?.culture).toBeUndefined();
153+
});
154+
155+
it('preserves other contexts when adding culture context', () => {
156+
const mockResolvedOptions = vi.fn().mockReturnValue({
157+
locale: 'fr-FR',
158+
timeZone: 'Europe/Paris',
159+
});
160+
161+
globalThis.Intl = {
162+
DateTimeFormat: vi.fn().mockReturnValue({
163+
resolvedOptions: mockResolvedOptions,
164+
}),
165+
} as unknown as typeof Intl;
166+
167+
// @ts-expect-error - mockReturnValue is not typed
168+
vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({
169+
Intl: globalThis.Intl,
170+
} as typeof SentryCore.GLOBAL_OBJ);
171+
172+
const integration = cultureContextIntegration();
173+
const event: Event = {
174+
contexts: {
175+
browser: {
176+
name: 'Chrome',
177+
version: '100.0',
178+
},
179+
},
180+
};
181+
182+
integration.preprocessEvent!(event, {}, {} as never);
183+
184+
expect(event.contexts?.browser).toEqual({
185+
name: 'Chrome',
186+
version: '100.0',
187+
});
188+
expect(event.contexts?.culture).toEqual({
189+
locale: 'fr-FR',
190+
timezone: 'Europe/Paris',
191+
});
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)