Skip to content
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
31 changes: 30 additions & 1 deletion packages/nextjs/src/index.client.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
import { configureScope, init as reactInit } from '@sentry/react';
import { Integrations } from '@sentry/tracing';

import { nextRouterInstrumentation } from './performance/client';
import { MetadataBuilder } from './utils/metadataBuilder';
import { NextjsOptions } from './utils/nextjsOptions';
import { addIntegration, UserIntegrations } from './utils/userIntegrations';

export * from '@sentry/react';
export { nextRouterInstrumentation } from './performance/client';

const { BrowserTracing } = Integrations;

/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
export function init(options: NextjsOptions): void {
const metadataBuilder = new MetadataBuilder(options, ['nextjs', 'react']);
metadataBuilder.addSdkMetadata();
options.environment = options.environment || process.env.NODE_ENV;
reactInit(options);

// Only add BrowserTracing if a tracesSampleRate or tracesSampler is set
const integrations =
options.tracesSampleRate === undefined && options.tracesSampler === undefined
? options.integrations
: createClientIntegrations(options.integrations);

reactInit({
...options,
integrations,
});
configureScope(scope => {
scope.setTag('runtime', 'browser');
});
}

const defaultBrowserTracingIntegration = new BrowserTracing({
routingInstrumentation: nextRouterInstrumentation,
});

function createClientIntegrations(integrations?: UserIntegrations): UserIntegrations {
if (integrations) {
return addIntegration(defaultBrowserTracingIntegration, integrations, {
BrowserTracing: { keyPath: 'options.routingInstrumentation', value: nextRouterInstrumentation },
});
} else {
return [defaultBrowserTracingIntegration];
}
}
66 changes: 59 additions & 7 deletions packages/nextjs/src/utils/userIntegrations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import { Integration } from '@sentry/types';

export type UserFunctionIntegrations = (integrations: Integration[]) => Integration[];
type UserIntegrations = Integration[] | UserFunctionIntegrations;
export type UserIntegrations = Integration[] | UserFunctionIntegrations;

type Options = {
[integrationName: string]:
| {
keyPath: string;
value: unknown;
}
| undefined;
};

/**
* Recursively traverses an object to update an existing nested key.
* Note: The provided key path must include existing properties,
* the function will not create objects while traversing.
*
* @param obj An object to update
* @param value The value to update the nested key with
* @param keyPath The path to the key to update ex. fizz.buzz.foo
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setNestedKey(obj: Record<string, any>, keyPath: string, value: unknown): void {
// Ex. foo.bar.zoop will extract foo and bar.zoop
const match = keyPath.match(/([a-z]+)\.(.*)/i);
if (match === null) {
obj[keyPath] = value;
} else {
setNestedKey(obj[match[1]], match[2], value);
}
}

/**
* Retrieves the patched integrations with the provided integration.
Expand All @@ -12,18 +41,40 @@ type UserIntegrations = Integration[] | UserFunctionIntegrations;
*
* @param integration The integration to patch, if necessary.
* @param userIntegrations Integrations defined by the user.
* @param options options to update for a particular integration
* @returns Final integrations, patched if necessary.
*/
export function addIntegration(integration: Integration, userIntegrations: UserIntegrations): UserIntegrations {
export function addIntegration(
integration: Integration,
userIntegrations: UserIntegrations,
options: Options = {},
): UserIntegrations {
if (Array.isArray(userIntegrations)) {
return addIntegrationToArray(integration, userIntegrations);
return addIntegrationToArray(integration, userIntegrations, options);
} else {
return addIntegrationToFunction(integration, userIntegrations);
return addIntegrationToFunction(integration, userIntegrations, options);
}
}

function addIntegrationToArray(integration: Integration, userIntegrations: Integration[]): Integration[] {
if (userIntegrations.map(int => int.name).includes(integration.name)) {
function addIntegrationToArray(
integration: Integration,
userIntegrations: Integration[],
options: Options,
): Integration[] {
let includesName = false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let x = 0; x < userIntegrations.length; x++) {
if (userIntegrations[x].name === integration.name) {
includesName = true;
}

const op = options[userIntegrations[x].name];
if (op) {
setNestedKey(userIntegrations[x], op.keyPath, op.value);
}
}

if (includesName) {
return userIntegrations;
}
return [...userIntegrations, integration];
Expand All @@ -32,10 +83,11 @@ function addIntegrationToArray(integration: Integration, userIntegrations: Integ
function addIntegrationToFunction(
integration: Integration,
userIntegrationsFunc: UserFunctionIntegrations,
options: Options,
): UserFunctionIntegrations {
const wrapper: UserFunctionIntegrations = defaultIntegrations => {
const userFinalIntegrations = userIntegrationsFunc(defaultIntegrations);
return addIntegrationToArray(integration, userFinalIntegrations);
return addIntegrationToArray(integration, userFinalIntegrations, options);
};
return wrapper;
}
137 changes: 137 additions & 0 deletions packages/nextjs/test/index.client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Integrations as TracingIntegrations } from '@sentry/tracing';
import { Integration } from '@sentry/types';

import { init, Integrations, nextRouterInstrumentation, Scope } from '../src/index.client';
import { NextjsOptions } from '../src/utils/nextjsOptions';

const { BrowserTracing } = TracingIntegrations;

const mockInit = jest.fn();
let configureScopeCallback: (scope: Scope) => void = () => undefined;

jest.mock('@sentry/react', () => {
const actual = jest.requireActual('@sentry/react');
return {
...actual,
init: (options: NextjsOptions) => {
mockInit(options);
},
configureScope: (callback: (scope: Scope) => void) => {
configureScopeCallback = callback;
},
};
});

describe('Client init()', () => {
afterEach(() => {
mockInit.mockClear();
configureScopeCallback = () => undefined;
});

it('inits the React SDK', () => {
expect(mockInit).toHaveBeenCalledTimes(0);
init({});
expect(mockInit).toHaveBeenCalledTimes(1);
expect(mockInit).toHaveBeenLastCalledWith({
_metadata: {
sdk: {
name: 'sentry.javascript.nextjs',
version: expect.any(String),
packages: expect.any(Array),
},
},
environment: 'test',
integrations: undefined,
});
});

it('sets runtime on scope', () => {
const mockScope = new Scope();
init({});
configureScopeCallback(mockScope);
// @ts-ignore need access to protected _tags attribute
expect(mockScope._tags).toEqual({ runtime: 'browser' });
});

describe('integrations', () => {
it('does not add BrowserTracing integration by default if tracesSampleRate is not set', () => {
init({});

const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
expect(reactInitOptions.integrations).toBeUndefined();
});

it('adds BrowserTracing integration by default if tracesSampleRate is set', () => {
init({ tracesSampleRate: 1.0 });

const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
expect(reactInitOptions.integrations).toHaveLength(1);

const integrations = reactInitOptions.integrations as Integration[];
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
// eslint-disable-next-line @typescript-eslint/unbound-method
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
nextRouterInstrumentation,
);
});

it('adds BrowserTracing integration by default if tracesSampler is set', () => {
init({ tracesSampler: () => true });

const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
expect(reactInitOptions.integrations).toHaveLength(1);

const integrations = reactInitOptions.integrations as Integration[];
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
// eslint-disable-next-line @typescript-eslint/unbound-method
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
nextRouterInstrumentation,
);
});

it('supports passing integration through options', () => {
init({ tracesSampleRate: 1.0, integrations: [new Integrations.Breadcrumbs({ console: false })] });
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
expect(reactInitOptions.integrations).toHaveLength(2);

const integrations = reactInitOptions.integrations as Integration[];
expect(integrations).toEqual([expect.any(Integrations.Breadcrumbs), expect.any(BrowserTracing)]);
});

it('uses custom BrowserTracing with array option with nextRouterInstrumentation', () => {
init({
tracesSampleRate: 1.0,
integrations: [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
});

const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
expect(reactInitOptions.integrations).toHaveLength(1);
const integrations = reactInitOptions.integrations as Integration[];
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
expect.objectContaining({
idleTimeout: 5000,
startTransactionOnLocationChange: false,
routingInstrumentation: nextRouterInstrumentation,
}),
);
});

it('uses custom BrowserTracing with function option with nextRouterInstrumentation', () => {
init({
tracesSampleRate: 1.0,
integrations: () => [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
});

const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
const integrationFunc = reactInitOptions.integrations as () => Integration[];
const integrations = integrationFunc();
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
expect.objectContaining({
idleTimeout: 5000,
startTransactionOnLocationChange: false,
routingInstrumentation: nextRouterInstrumentation,
}),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RewriteFrames } from '@sentry/integrations';
import { Integration } from '@sentry/types';

import { addIntegration, UserFunctionIntegrations } from '../src/utils/userIntegrations';
import { addIntegration, UserFunctionIntegrations } from '../../src/utils/userIntegrations';

const testIntegration = new RewriteFrames();

Expand Down