-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(browser): Include culture context with events #19148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
bd028cf
df8560c
f4339bb
3678b78
94966b7
7a39bb8
99f06d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||||||||||||||
| import type { CultureContext, IntegrationFn } from '@sentry/core'; | ||||||||||||||||||
| import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const INTEGRATION_NAME = 'CultureContext'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const _cultureContextIntegration = (() => { | ||||||||||||||||||
| return { | ||||||||||||||||||
| name: INTEGRATION_NAME, | ||||||||||||||||||
| preprocessEvent(event) { | ||||||||||||||||||
| const culture = getCultureContext(); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (culture) { | ||||||||||||||||||
| event.contexts = { | ||||||||||||||||||
| ...event.contexts, | ||||||||||||||||||
| culture: { ...culture, ...event.contexts?.culture }, | ||||||||||||||||||
| }; | ||||||||||||||||||
| } | ||||||||||||||||||
| }, | ||||||||||||||||||
| }; | ||||||||||||||||||
| }) satisfies IntegrationFn; | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Captures culture context from the browser. | ||||||||||||||||||
| * | ||||||||||||||||||
| * Enabled by default. | ||||||||||||||||||
| * | ||||||||||||||||||
| * @example | ||||||||||||||||||
| * ```js | ||||||||||||||||||
| * import * as Sentry from '@sentry/browser'; | ||||||||||||||||||
| * | ||||||||||||||||||
| * Sentry.init({ | ||||||||||||||||||
| * integrations: [Sentry.cultureContextIntegration()], | ||||||||||||||||||
| * }); | ||||||||||||||||||
| * ``` | ||||||||||||||||||
| */ | ||||||||||||||||||
| export const cultureContextIntegration = defineIntegration(_cultureContextIntegration); | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Returns the culture context from the browser's Intl API. | ||||||||||||||||||
| */ | ||||||||||||||||||
| function getCultureContext(): CultureContext | undefined { | ||||||||||||||||||
| try { | ||||||||||||||||||
| if (typeof (GLOBAL_OBJ as { Intl?: typeof Intl }).Intl === 'undefined') { | ||||||||||||||||||
| return undefined; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const options = Intl.DateTimeFormat().resolvedOptions(); | ||||||||||||||||||
|
|
||||||||||||||||||
| return { | ||||||||||||||||||
| locale: options.locale, | ||||||||||||||||||
| timezone: options.timeZone, | ||||||||||||||||||
| calendar: options.calendar, | ||||||||||||||||||
| }; | ||||||||||||||||||
| } catch { | ||||||||||||||||||
| // Ignore errors | ||||||||||||||||||
|
Comment on lines
+54
to
+57
|
||||||||||||||||||
| calendar: options.calendar, | |
| }; | |
| } catch { | |
| // Ignore errors | |
| }; | |
| } catch { | |
| // Ignore errors | |
| // Ignore errors |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| import type { Event } from '@sentry/core'; | ||
| import * as SentryCore from '@sentry/core'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { cultureContextIntegration } from '../../src/integrations/culturecontext'; | ||
|
|
||
| describe('CultureContext', () => { | ||
| const originalIntl = globalThis.Intl; | ||
|
|
||
| beforeEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| globalThis.Intl = originalIntl; | ||
| }); | ||
|
|
||
| describe('preprocessEvent', () => { | ||
| it('adds culture context with locale and timezone', () => { | ||
| const mockResolvedOptions = vi.fn().mockReturnValue({ | ||
| locale: 'en-US', | ||
| timeZone: 'America/New_York', | ||
| }); | ||
|
|
||
| globalThis.Intl = { | ||
| DateTimeFormat: vi.fn().mockReturnValue({ | ||
| resolvedOptions: mockResolvedOptions, | ||
| }), | ||
| } as unknown as typeof Intl; | ||
|
|
||
| // @ts-expect-error - mockReturnValue is not typed | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: globalThis.Intl, | ||
| } as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = {}; | ||
|
|
||
| integration.preprocessEvent!(event, {}, {} as never); | ||
|
|
||
| expect(event.contexts?.culture).toEqual({ | ||
| locale: 'en-US', | ||
| timezone: 'America/New_York', | ||
| }); | ||
| }); | ||
|
|
||
| it('preserves existing culture context values', () => { | ||
| const mockResolvedOptions = vi.fn().mockReturnValue({ | ||
| locale: 'en-US', | ||
| timeZone: 'America/New_York', | ||
| }); | ||
|
|
||
| globalThis.Intl = { | ||
| DateTimeFormat: vi.fn().mockReturnValue({ | ||
| resolvedOptions: mockResolvedOptions, | ||
| }), | ||
| } as unknown as typeof Intl; | ||
|
|
||
| // @ts-expect-error - mockReturnValue is not typed | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: globalThis.Intl, | ||
| } as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = { | ||
| contexts: { | ||
| culture: { | ||
| calendar: 'gregorian', | ||
| display_name: 'English (United States)', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| integration.preprocessEvent!(event, {}, {} as never); | ||
|
|
||
| expect(event.contexts?.culture).toEqual({ | ||
| locale: 'en-US', | ||
| timezone: 'America/New_York', | ||
| calendar: 'gregorian', | ||
| display_name: 'English (United States)', | ||
| }); | ||
| }); | ||
|
|
||
| it('does not override existing locale and timezone', () => { | ||
| const mockResolvedOptions = vi.fn().mockReturnValue({ | ||
| locale: 'en-US', | ||
| timeZone: 'America/New_York', | ||
| }); | ||
|
|
||
| globalThis.Intl = { | ||
| DateTimeFormat: vi.fn().mockReturnValue({ | ||
| resolvedOptions: mockResolvedOptions, | ||
| }), | ||
| } as unknown as typeof Intl; | ||
|
|
||
| // @ts-expect-error - mockReturnValue is not typed | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: globalThis.Intl, | ||
| } as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = { | ||
| contexts: { | ||
| culture: { | ||
| locale: 'de-DE', | ||
| timezone: 'Europe/Berlin', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| integration.preprocessEvent!(event, {}, {} as never); | ||
|
|
||
| // Existing values should be preserved (not overwritten) | ||
| expect(event.contexts?.culture).toEqual({ | ||
| locale: 'de-DE', | ||
| timezone: 'Europe/Berlin', | ||
| }); | ||
| }); | ||
|
|
||
| it('does not add culture context when Intl is not available', () => { | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: undefined, | ||
| } as unknown as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = {}; | ||
|
|
||
| integration.preprocessEvent!(event, {}, {} as never); | ||
|
|
||
| expect(event.contexts?.culture).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('handles errors gracefully when Intl.DateTimeFormat throws', () => { | ||
| globalThis.Intl = { | ||
| DateTimeFormat: vi.fn().mockImplementation(() => { | ||
| throw new Error('Intl error'); | ||
| }), | ||
| } as unknown as typeof Intl; | ||
|
|
||
| // @ts-expect-error - mockReturnValue is not typed | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: globalThis.Intl, | ||
| } as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = {}; | ||
|
|
||
| // Should not throw | ||
| expect(() => { | ||
| integration.preprocessEvent!(event, {}, {} as never); | ||
| }).not.toThrow(); | ||
|
|
||
| expect(event.contexts?.culture).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('preserves other contexts when adding culture context', () => { | ||
| const mockResolvedOptions = vi.fn().mockReturnValue({ | ||
| locale: 'fr-FR', | ||
| timeZone: 'Europe/Paris', | ||
| }); | ||
|
|
||
| globalThis.Intl = { | ||
| DateTimeFormat: vi.fn().mockReturnValue({ | ||
| resolvedOptions: mockResolvedOptions, | ||
| }), | ||
| } as unknown as typeof Intl; | ||
|
|
||
| // @ts-expect-error - mockReturnValue is not typed | ||
| vi.spyOn(SentryCore, 'GLOBAL_OBJ', 'get').mockReturnValue({ | ||
| Intl: globalThis.Intl, | ||
| } as typeof SentryCore.GLOBAL_OBJ); | ||
|
|
||
| const integration = cultureContextIntegration(); | ||
| const event: Event = { | ||
| contexts: { | ||
| browser: { | ||
| name: 'Chrome', | ||
| version: '100.0', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| integration.preprocessEvent!(event, {}, {} as never); | ||
|
|
||
| expect(event.contexts?.browser).toEqual({ | ||
| name: 'Chrome', | ||
| version: '100.0', | ||
| }); | ||
| expect(event.contexts?.culture).toEqual({ | ||
| locale: 'fr-FR', | ||
| timezone: 'Europe/Paris', | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
Uh oh!
There was an error while loading. Please reload this page.