Skip to content

feat(browser): Add lazyLoadIntegration utility #11339

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

Merged
merged 5 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/browser';
import { httpClientIntegration } from '@sentry/browser';

window.Sentry = {
...Sentry,
// This would be done by the CDN bundle otherwise
httpClientIntegration: httpClientIntegration,
};

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
window._testLazyLoadIntegration = async function run() {
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');

window.Sentry.getClient()?.addIntegration(integration());

window._integrationLoaded = true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';

sentryTest('it bails if the integration is already loaded', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.goto(url);

const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
expect(hasIntegration).toBe(false);

const scriptTagsBefore = await page.evaluate('document.querySelectorAll("script").length');

await page.evaluate('window._testLazyLoadIntegration()');
await page.waitForFunction('window._integrationLoaded');

const scriptTagsAfter = await page.evaluate('document.querySelectorAll("script").length');

const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
expect(hasIntegration2).toBe(true);

expect(scriptTagsAfter).toBe(scriptTagsBefore);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/browser';

window.Sentry = {
...Sentry,
// Ensure this is _not_ set
httpClientIntegration: undefined,
};

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
window._testLazyLoadIntegration = async function run() {
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');

window.Sentry.getClient()?.addIntegration(integration());

window._integrationLoaded = true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';

sentryTest('it allows to lazy load an integration', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.goto(url);

const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
expect(hasIntegration).toBe(false);

const scriptTagsBefore = await page.evaluate<number>('document.querySelectorAll("script").length');

await page.evaluate('window._testLazyLoadIntegration()');
await page.waitForFunction('window._integrationLoaded');

const scriptTagsAfter = await page.evaluate<number>('document.querySelectorAll("script").length');

const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
expect(hasIntegration2).toBe(true);

expect(scriptTagsAfter).toBe(scriptTagsBefore + 1);
});
5 changes: 4 additions & 1 deletion packages/browser/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export type BrowserOptions = Options<BrowserTransportOptions> &
*/
export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> &
BrowserClientReplayOptions &
BrowserClientProfilingOptions;
BrowserClientProfilingOptions & {
/** If configured, this URL will be used as base URL for lazy loading integration. */
cdnBaseUrl?: string;
};

/**
* The Sentry Browser SDK Client.
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ export { globalHandlersIntegration } from './integrations/globalhandlers';
export { httpContextIntegration } from './integrations/httpcontext';
export { linkedErrorsIntegration } from './integrations/linkederrors';
export { browserApiErrorsIntegration } from './integrations/browserapierrors';

export { lazyLoadIntegration } from './utils/lazyLoadIntegration';
77 changes: 77 additions & 0 deletions packages/browser/src/utils/lazyLoadIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { SDK_VERSION, getClient } from '@sentry/core';
import type { IntegrationFn } from '@sentry/types';
import type { BrowserClient } from '../client';
import { WINDOW } from '../helpers';

// This is a map of integration function method to bundle file name.
const LazyLoadableIntegrations = {
replayIntegration: 'replay',
replayCanvasIntegration: 'replay-canvas',
feedbackIntegration: 'feedback',
captureConsoleIntegration: 'captureconsole',
contextLinesIntegration: 'contextlines',
linkedErrorsIntegration: 'linkederrors',
debugIntegration: 'debug',
dedupeIntegration: 'dedupe',
extraErrorDataIntegration: 'extraerrordata',
httpClientIntegration: 'httpclient',
reportingObserverIntegration: 'reportingobserver',
rewriteFramesIntegration: 'rewriteframes',
sessionTimingIntegration: 'sessiontiming',
} as const;

const WindowWithMaybeIntegration = WINDOW as {
Sentry?: Partial<Record<keyof typeof LazyLoadableIntegrations, IntegrationFn>>;
};

/**
* Lazy load an integration from the CDN.
* Rejects if the integration cannot be loaded.
*/
export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise<IntegrationFn> {
const bundle = LazyLoadableIntegrations[name];

if (!bundle || !WindowWithMaybeIntegration.Sentry) {
throw new Error(`Cannot lazy load integration: ${name}`);
}

// Bail if the integration already exists
const existing = WindowWithMaybeIntegration.Sentry[name];
if (typeof existing === 'function') {
return existing;
}

const url = getScriptURL(bundle);
const script = WINDOW.document.createElement('script');
script.src = url;
script.crossOrigin = 'anonymous';

const waitForLoad = new Promise<void>((resolve, reject) => {
script.addEventListener('load', () => resolve());
script.addEventListener('error', reject);
});

WINDOW.document.body.appendChild(script);

try {
await waitForLoad;
} catch {
throw new Error(`Error when loading integration: ${name}`);
}

const integrationFn = WindowWithMaybeIntegration.Sentry[name];

if (typeof integrationFn !== 'function') {
throw new Error(`Could not load integration: ${name}`);
}

return integrationFn;
}

function getScriptURL(bundle: string): string {
const client = getClient<BrowserClient>();
const options = client && client.getOptions();
const baseURL = (options && options.cdnBaseUrl) || 'https://browser.sentry-cdn.com';

return new URL(`/${SDK_VERSION}/${bundle}.min.js`, baseURL).toString();
}
83 changes: 83 additions & 0 deletions packages/browser/test/unit/utils/lazyLoadIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { TextDecoder, TextEncoder } from 'util';
import { SDK_VERSION, lazyLoadIntegration } from '../../../src';
import * as Sentry from '../../../src';
const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true;
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll)
const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true;

import { JSDOM } from 'jsdom';

const globalDocument = global.document;
const globalWindow = global.window;
const globalLocation = global.location;

describe('lazyLoadIntegration', () => {
beforeEach(() => {
const dom = new JSDOM('<body></body>', {
runScripts: 'dangerously',
resources: 'usable',
});

global.document = dom.window.document;
// @ts-expect-error need to override global document
global.window = dom.window;
global.location = dom.window.location;
// @ts-expect-error For testing sake
global.Sentry = undefined;
});

// Reset back to previous values
afterEach(() => {
global.document = globalDocument;
global.window = globalWindow;
global.location = globalLocation;
});

afterAll(() => {
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
patchedEncoder && delete global.window.TextEncoder;
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
patchedDecoder && delete global.window.TextDecoder;
});

test('it rejects invalid name', async () => {
// @ts-expect-error For testing sake - otherwise this bails out anyhow
global.Sentry = Sentry;

// @ts-expect-error we want to test this
await expect(() => lazyLoadIntegration('invalid!!!')).rejects.toThrow('Cannot lazy load integration: invalid!!!');
});

test('it rejects without global Sentry variable', async () => {
await expect(() => lazyLoadIntegration('httpClientIntegration')).rejects.toThrow(
'Cannot lazy load integration: httpClientIntegration',
);
});

test('it does not inject a script tag if integration already exists', async () => {
// @ts-expect-error For testing sake
global.Sentry = Sentry;

const integration = await lazyLoadIntegration('httpClientIntegration');

expect(integration).toBe(Sentry.httpClientIntegration);
expect(global.document.querySelectorAll('script')).toHaveLength(0);
});

test('it injects a script tag if integration is not yet loaded xxx', async () => {
// @ts-expect-error For testing sake
global.Sentry = {
...Sentry,
httpClientIntegration: undefined,
};

// We do not await here, as this this does not seem to work with JSDOM :(
// We have browser integration tests to check that this actually works
void lazyLoadIntegration('httpClientIntegration');

expect(global.document.querySelectorAll('script')).toHaveLength(1);
expect(global.document.querySelector('script')?.src).toEqual(
`https://browser.sentry-cdn.com/${SDK_VERSION}/httpclient.min.js`,
);
});
});