Skip to content

Commit 9c8f0d3

Browse files
authored
feat(browser): Add lazyLoadIntegration utility (#11339)
This can be used to lazy load a pluggable integration. Usage: ```js async function getOrLazyLoadFeedback() { const existing = Sentry.getFeedback(); // check if it has already been installed if (existing) { return existing; } try { const feedbackIntegration = await Sentry.lazyLoadIntegration('feedbackIntegration'); client.addIntegration(feedbackIntegration()); } catch(error) { // this can error, we need to handle this! } } ``` Closes #10905
1 parent f284116 commit 9c8f0d3

File tree

10 files changed

+253
-1
lines changed

10 files changed

+253
-1
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { httpClientIntegration } from '@sentry/browser';
3+
4+
window.Sentry = {
5+
...Sentry,
6+
// This would be done by the CDN bundle otherwise
7+
httpClientIntegration: httpClientIntegration,
8+
};
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
integrations: [],
13+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
window._testLazyLoadIntegration = async function run() {
2+
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');
3+
4+
window.Sentry.getClient()?.addIntegration(integration());
5+
6+
window._integrationLoaded = true;
7+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('it bails if the integration is already loaded', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
await page.goto(url);
9+
10+
const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
11+
expect(hasIntegration).toBe(false);
12+
13+
const scriptTagsBefore = await page.evaluate('document.querySelectorAll("script").length');
14+
15+
await page.evaluate('window._testLazyLoadIntegration()');
16+
await page.waitForFunction('window._integrationLoaded');
17+
18+
const scriptTagsAfter = await page.evaluate('document.querySelectorAll("script").length');
19+
20+
const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
21+
expect(hasIntegration2).toBe(true);
22+
23+
expect(scriptTagsAfter).toBe(scriptTagsBefore);
24+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = {
4+
...Sentry,
5+
// Ensure this is _not_ set
6+
httpClientIntegration: undefined,
7+
};
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
integrations: [],
12+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
window._testLazyLoadIntegration = async function run() {
2+
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');
3+
4+
window.Sentry.getClient()?.addIntegration(integration());
5+
6+
window._integrationLoaded = true;
7+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('it allows to lazy load an integration', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
await page.goto(url);
9+
10+
const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
11+
expect(hasIntegration).toBe(false);
12+
13+
const scriptTagsBefore = await page.evaluate<number>('document.querySelectorAll("script").length');
14+
15+
await page.evaluate('window._testLazyLoadIntegration()');
16+
await page.waitForFunction('window._integrationLoaded');
17+
18+
const scriptTagsAfter = await page.evaluate<number>('document.querySelectorAll("script").length');
19+
20+
const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
21+
expect(hasIntegration2).toBe(true);
22+
23+
expect(scriptTagsAfter).toBe(scriptTagsBefore + 1);
24+
});

packages/browser/src/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export type BrowserOptions = Options<BrowserTransportOptions> &
3434
*/
3535
export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> &
3636
BrowserClientReplayOptions &
37-
BrowserClientProfilingOptions;
37+
BrowserClientProfilingOptions & {
38+
/** If configured, this URL will be used as base URL for lazy loading integration. */
39+
cdnBaseUrl?: string;
40+
};
3841

3942
/**
4043
* The Sentry Browser SDK Client.

packages/browser/src/exports.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,5 @@ export { globalHandlersIntegration } from './integrations/globalhandlers';
9595
export { httpContextIntegration } from './integrations/httpcontext';
9696
export { linkedErrorsIntegration } from './integrations/linkederrors';
9797
export { browserApiErrorsIntegration } from './integrations/browserapierrors';
98+
99+
export { lazyLoadIntegration } from './utils/lazyLoadIntegration';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { SDK_VERSION, getClient } from '@sentry/core';
2+
import type { IntegrationFn } from '@sentry/types';
3+
import type { BrowserClient } from '../client';
4+
import { WINDOW } from '../helpers';
5+
6+
// This is a map of integration function method to bundle file name.
7+
const LazyLoadableIntegrations = {
8+
replayIntegration: 'replay',
9+
replayCanvasIntegration: 'replay-canvas',
10+
feedbackIntegration: 'feedback',
11+
captureConsoleIntegration: 'captureconsole',
12+
contextLinesIntegration: 'contextlines',
13+
linkedErrorsIntegration: 'linkederrors',
14+
debugIntegration: 'debug',
15+
dedupeIntegration: 'dedupe',
16+
extraErrorDataIntegration: 'extraerrordata',
17+
httpClientIntegration: 'httpclient',
18+
reportingObserverIntegration: 'reportingobserver',
19+
rewriteFramesIntegration: 'rewriteframes',
20+
sessionTimingIntegration: 'sessiontiming',
21+
} as const;
22+
23+
const WindowWithMaybeIntegration = WINDOW as {
24+
Sentry?: Partial<Record<keyof typeof LazyLoadableIntegrations, IntegrationFn>>;
25+
};
26+
27+
/**
28+
* Lazy load an integration from the CDN.
29+
* Rejects if the integration cannot be loaded.
30+
*/
31+
export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise<IntegrationFn> {
32+
const bundle = LazyLoadableIntegrations[name];
33+
34+
if (!bundle || !WindowWithMaybeIntegration.Sentry) {
35+
throw new Error(`Cannot lazy load integration: ${name}`);
36+
}
37+
38+
// Bail if the integration already exists
39+
const existing = WindowWithMaybeIntegration.Sentry[name];
40+
if (typeof existing === 'function') {
41+
return existing;
42+
}
43+
44+
const url = getScriptURL(bundle);
45+
const script = WINDOW.document.createElement('script');
46+
script.src = url;
47+
script.crossOrigin = 'anonymous';
48+
49+
const waitForLoad = new Promise<void>((resolve, reject) => {
50+
script.addEventListener('load', () => resolve());
51+
script.addEventListener('error', reject);
52+
});
53+
54+
WINDOW.document.body.appendChild(script);
55+
56+
try {
57+
await waitForLoad;
58+
} catch {
59+
throw new Error(`Error when loading integration: ${name}`);
60+
}
61+
62+
const integrationFn = WindowWithMaybeIntegration.Sentry[name];
63+
64+
if (typeof integrationFn !== 'function') {
65+
throw new Error(`Could not load integration: ${name}`);
66+
}
67+
68+
return integrationFn;
69+
}
70+
71+
function getScriptURL(bundle: string): string {
72+
const client = getClient<BrowserClient>();
73+
const options = client && client.getOptions();
74+
const baseURL = (options && options.cdnBaseUrl) || 'https://browser.sentry-cdn.com';
75+
76+
return new URL(`/${SDK_VERSION}/${bundle}.min.js`, baseURL).toString();
77+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { TextDecoder, TextEncoder } from 'util';
2+
import { SDK_VERSION, lazyLoadIntegration } from '../../../src';
3+
import * as Sentry from '../../../src';
4+
const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true;
5+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll)
6+
const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true;
7+
8+
import { JSDOM } from 'jsdom';
9+
10+
const globalDocument = global.document;
11+
const globalWindow = global.window;
12+
const globalLocation = global.location;
13+
14+
describe('lazyLoadIntegration', () => {
15+
beforeEach(() => {
16+
const dom = new JSDOM('<body></body>', {
17+
runScripts: 'dangerously',
18+
resources: 'usable',
19+
});
20+
21+
global.document = dom.window.document;
22+
// @ts-expect-error need to override global document
23+
global.window = dom.window;
24+
global.location = dom.window.location;
25+
// @ts-expect-error For testing sake
26+
global.Sentry = undefined;
27+
});
28+
29+
// Reset back to previous values
30+
afterEach(() => {
31+
global.document = globalDocument;
32+
global.window = globalWindow;
33+
global.location = globalLocation;
34+
});
35+
36+
afterAll(() => {
37+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
38+
patchedEncoder && delete global.window.TextEncoder;
39+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
40+
patchedDecoder && delete global.window.TextDecoder;
41+
});
42+
43+
test('it rejects invalid name', async () => {
44+
// @ts-expect-error For testing sake - otherwise this bails out anyhow
45+
global.Sentry = Sentry;
46+
47+
// @ts-expect-error we want to test this
48+
await expect(() => lazyLoadIntegration('invalid!!!')).rejects.toThrow('Cannot lazy load integration: invalid!!!');
49+
});
50+
51+
test('it rejects without global Sentry variable', async () => {
52+
await expect(() => lazyLoadIntegration('httpClientIntegration')).rejects.toThrow(
53+
'Cannot lazy load integration: httpClientIntegration',
54+
);
55+
});
56+
57+
test('it does not inject a script tag if integration already exists', async () => {
58+
// @ts-expect-error For testing sake
59+
global.Sentry = Sentry;
60+
61+
const integration = await lazyLoadIntegration('httpClientIntegration');
62+
63+
expect(integration).toBe(Sentry.httpClientIntegration);
64+
expect(global.document.querySelectorAll('script')).toHaveLength(0);
65+
});
66+
67+
test('it injects a script tag if integration is not yet loaded xxx', async () => {
68+
// @ts-expect-error For testing sake
69+
global.Sentry = {
70+
...Sentry,
71+
httpClientIntegration: undefined,
72+
};
73+
74+
// We do not await here, as this this does not seem to work with JSDOM :(
75+
// We have browser integration tests to check that this actually works
76+
void lazyLoadIntegration('httpClientIntegration');
77+
78+
expect(global.document.querySelectorAll('script')).toHaveLength(1);
79+
expect(global.document.querySelector('script')?.src).toEqual(
80+
`https://browser.sentry-cdn.com/${SDK_VERSION}/httpclient.min.js`,
81+
);
82+
});
83+
});

0 commit comments

Comments
 (0)