diff --git a/packages/svelte/src/sdk.ts b/packages/svelte/src/sdk.ts index 59108e903151..4857163ec137 100644 --- a/packages/svelte/src/sdk.ts +++ b/packages/svelte/src/sdk.ts @@ -1,5 +1,6 @@ -import { BrowserOptions, init as browserInit, SDK_VERSION } from '@sentry/browser'; - +import { addGlobalEventProcessor, BrowserOptions, init as browserInit, SDK_VERSION } from '@sentry/browser'; +import type { EventProcessor } from '@sentry/types'; +import { getDomElement } from '@sentry/utils'; /** * Inits the Svelte SDK */ @@ -17,4 +18,45 @@ export function init(options: BrowserOptions): void { }; browserInit(options); + + detectAndReportSvelteKit(); +} + +/** + * Adds a global event processor to detect if the SDK is initialized in a SvelteKit frontend, + * in which case we add SvelteKit an event.modules entry to outgoing events. + * SvelteKit detection is performed only once, when the event processor is called for the + * first time. We cannot perform this check upfront (directly when init is called) because + * at this time, the HTML element might not yet be accessible. + */ +export function detectAndReportSvelteKit(): void { + let detectedSvelteKit: boolean | undefined = undefined; + + const svelteKitProcessor: EventProcessor = event => { + if (detectedSvelteKit === undefined) { + detectedSvelteKit = isSvelteKitApp(); + } + if (detectedSvelteKit) { + event.modules = { + svelteKit: 'latest', + ...event.modules, + }; + } + return event; + }; + svelteKitProcessor.id = 'svelteKitProcessor'; + + addGlobalEventProcessor(svelteKitProcessor); +} + +/** + * To actually detect a SvelteKit frontend, we search the DOM for a special + * div that's inserted by SvelteKit when the page is rendered. It's identifyed + * by its id, 'svelte-announcer', and it's used to improve page accessibility. + * This div is not present when only using Svelte without SvelteKit. + * + * @see https://github.com/sveltejs/kit/issues/307 for more information + */ +export function isSvelteKitApp(): boolean { + return getDomElement('div#svelte-announcer') !== null; } diff --git a/packages/svelte/test/sdk.test.ts b/packages/svelte/test/sdk.test.ts index 8ca58bf93bca..8306cb1f3bd9 100644 --- a/packages/svelte/test/sdk.test.ts +++ b/packages/svelte/test/sdk.test.ts @@ -1,8 +1,15 @@ -import { init as browserInitRaw, SDK_VERSION } from '@sentry/browser'; +import { addGlobalEventProcessor, init as browserInitRaw, SDK_VERSION } from '@sentry/browser'; +import { EventProcessor } from '@sentry/types'; -import { init as svelteInit } from '../src/sdk'; +import { detectAndReportSvelteKit, init as svelteInit, isSvelteKitApp } from '../src/sdk'; const browserInit = browserInitRaw as jest.Mock; +const addGlobalEventProcessorFunction = addGlobalEventProcessor as jest.Mock; +let passedEventProcessor: EventProcessor | undefined; +addGlobalEventProcessorFunction.mockImplementation(proc => { + passedEventProcessor = proc; +}); + jest.mock('@sentry/browser'); describe('Initialize Svelte SDk', () => { @@ -29,3 +36,54 @@ describe('Initialize Svelte SDk', () => { expect(browserInit).toHaveBeenCalledWith(expect.objectContaining(expectedMetadata)); }); }); + +describe('detectAndReportSvelteKit()', () => { + const originalHtmlBody = document.body.innerHTML; + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = originalHtmlBody; + passedEventProcessor = undefined; + }); + + it('registers a global event processor', async () => { + detectAndReportSvelteKit(); + + expect(addGlobalEventProcessorFunction).toHaveBeenCalledTimes(1); + expect(passedEventProcessor?.id).toEqual('svelteKitProcessor'); + }); + + it('adds "SvelteKit" as a module to the event, if SvelteKit was detected', () => { + document.body.innerHTML += '
Home
'; + detectAndReportSvelteKit(); + + const processedEvent = passedEventProcessor && passedEventProcessor({} as unknown as any, {}); + + expect(processedEvent).toBeDefined(); + expect(processedEvent).toEqual({ modules: { svelteKit: 'latest' } }); + }); + + it("doesn't add anything to the event, if SvelteKit was not detected", () => { + document.body.innerHTML = ''; + detectAndReportSvelteKit(); + + const processedEvent = passedEventProcessor && passedEventProcessor({} as unknown as any, {}); + + expect(processedEvent).toBeDefined(); + expect(processedEvent).toEqual({}); + }); + + describe('isSvelteKitApp()', () => { + it('returns true if the svelte-announcer div is present', () => { + document.body.innerHTML += '
Home
'; + expect(isSvelteKitApp()).toBe(true); + }); + it('returns false if the svelte-announcer div is not present (but similar elements)', () => { + document.body.innerHTML += '
Home
'; + expect(isSvelteKitApp()).toBe(false); + }); + it('returns false if no div is present', () => { + document.body.innerHTML = ''; + expect(isSvelteKitApp()).toBe(false); + }); + }); +}); diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 27b788aaed85..20ff9effdb0e 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import { Hub } from '@sentry/hub'; import { EventProcessor, Integration, Transaction, TransactionContext } from '@sentry/types'; -import { getGlobalObject, logger, parseBaggageSetMutability } from '@sentry/utils'; +import { getDomElement, getGlobalObject, logger, parseBaggageSetMutability } from '@sentry/utils'; import { startIdleTransaction } from '../hubextensions'; import { DEFAULT_FINAL_TIMEOUT, DEFAULT_IDLE_TIMEOUT } from '../idletransaction'; @@ -294,13 +294,6 @@ export function extractTraceDataFromMetaTags(): Partial | un /** Returns the value of a meta tag */ export function getMetaContent(metaName: string): string | null { - const globalObject = getGlobalObject(); - - // DOM/querySelector is not available in all environments - if (globalObject.document && globalObject.document.querySelector) { - const el = globalObject.document.querySelector(`meta[name=${metaName}]`); - return el ? el.getAttribute('content') : null; - } else { - return null; - } + const metaTag = getDomElement(`meta[name=${metaName}]`); + return metaTag ? metaTag.getAttribute('content') : null; } diff --git a/packages/utils/src/browser.ts b/packages/utils/src/browser.ts index aeb6f9a33296..c11cf22950f8 100644 --- a/packages/utils/src/browser.ts +++ b/packages/utils/src/browser.ts @@ -122,3 +122,21 @@ export function getLocationHref(): string { return ''; } } + +/** + * Gets a DOM element by using document.querySelector. + * + * This wrapper will first check for the existance of the function before + * actually calling it so that we don't have to take care of this check, + * every time we want to access the DOM. + * Reason: DOM/querySelector is not available in all environments + * + * @param selector the selector string passed on to document.querySelector + */ +export function getDomElement(selector: string): Element | null { + const global = getGlobalObject(); + if (global.document && global.document.querySelector) { + return global.document.querySelector(selector); + } + return null; +} diff --git a/packages/utils/test/browser.test.ts b/packages/utils/test/browser.test.ts index 1b7978b75ba6..ca5513cc4f0b 100644 --- a/packages/utils/test/browser.test.ts +++ b/packages/utils/test/browser.test.ts @@ -1,6 +1,6 @@ import { JSDOM } from 'jsdom'; -import { htmlTreeAsString } from '../src/browser'; +import { getDomElement, htmlTreeAsString } from '../src/browser'; beforeAll(() => { const dom = new JSDOM(); @@ -45,3 +45,13 @@ describe('htmlTreeAsString', () => { ); }); }); + +describe('getDomElement', () => { + it('returns the element for a given query selector', () => { + document.head.innerHTML = '
Hello
'; + const el = getDomElement('div#mydiv'); + expect(el).toBeDefined(); + expect(el?.tagName).toEqual('DIV'); + expect(el?.id).toEqual('mydiv'); + }); +});