diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29a0a10d095e..b54ff29db772 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,41 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+
+## 7.52.0
+
+### Important Next.js SDK changes:
+
+This release adds support Vercel Cron Jobs in the Next.js SDK.
+The SDK will automatically create [Sentry Cron Monitors](https://docs.sentry.io/product/crons/) for your [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs) configured via `vercel.json` when deployed on Vercel.
+
+You can opt out of this functionality by setting the `automaticVercelMonitors` option to `false`:
+
+```js
+// next.config.js
+const nextConfig = {
+ sentry: {
+ automaticVercelMonitors: false,
+ },
+};
+```
+
+(Note: Sentry Cron Monitoring is currently in beta and subject to change. Help us make it better by letting us know what you think. Respond on [GitHub](https://github.com/getsentry/sentry/discussions/42283) or write to us at crons-feedback@sentry.io)
+
+- feat(nextjs): Add API method to wrap API routes with crons instrumentation (#8084)
+- feat(nextjs): Add automatic monitors for Vercel Cron Jobs (#8088)
+
+### Other changes
+
+- feat(replay): Capture keyboard presses for special characters (#8051)
+- fix(build): Don't mangle away global debug ID map (#8096)
+- fix(core): Return checkin id from client (#8116)
+- fix(core): Use last error for `ignoreErrors` check (#8089)
+- fix(docs): Change to `addTracingExtensions` was not documented in MIGRATION.md (#8101)
+- fix(replay): Check relative URLs correctly (#8024)
+- fix(tracing-internal): Avoid classifying protocol-relative URLs as same-origin urls (#8114)
+- ref: Hoist `createCheckinEnvelope` to core package (#8082)
+
## 7.51.2
- fix(nextjs): Continue traces in data fetchers when there is an already active transaction on the hub (#8073)
diff --git a/MIGRATION.md b/MIGRATION.md
index 53aa05d9207d..77cb959525a8 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -4,6 +4,34 @@
The `timestampWithMs` util is deprecated in favor of using `timestampInSeconds`.
+## `addTracingExtensions` replaces `addExtensionMethods` (since 7.46.0)
+
+Since the deprecation of `@sentry/tracing`, tracing extensions are now added by calling `addTracingExtensions` which is
+exported from all framework SDKs.
+
+```js
+// Before
+import * as Sentry from "@sentry/browser";
+import { addExtensionMethods } from "@sentry/tracing";
+
+Sentry.init({
+ dsn: '__DSN__',
+ tracesSampleRate: 1.0,
+});
+
+addExtensionMethods()
+
+// After
+import * as Sentry from "@sentry/browser";
+
+Sentry.init({
+ dsn: '__DSN__',
+ tracesSampleRate: 1.0,
+});
+
+Sentry.addTracingExtensions();
+```
+
## Remove requirement for `@sentry/tracing` package (since 7.46.0)
With `7.46.0` you no longer require the `@sentry/tracing` package to use tracing and performance monitoring with the Sentry JavaScript SDKs. The `@sentry/tracing` package will be removed in a future major release, but can still be used in the meantime.
diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js
index 15be2bb2764d..2323cd2dda7f 100644
--- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js
+++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js
@@ -5,7 +5,7 @@ window.Replay = new Sentry.Replay({
flushMinDelay: 200,
flushMaxDelay: 200,
- networkDetailAllowUrls: ['http://localhost:7654/foo'],
+ networkDetailAllowUrls: ['http://localhost:7654/foo', 'http://sentry-test.io/foo'],
networkCaptureBodies: true,
});
diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts
index b5c81b4b1b6c..f526c60d6fb1 100644
--- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts
+++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts
@@ -249,6 +249,85 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br
]);
});
+sentryTest('captures text request body when matching relative URL', async ({ getLocalTestUrl, page, browserName }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined;
+
+ await page.route('**/foo', route => {
+ return route.fulfill({
+ status: 200,
+ });
+ });
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const requestPromise = waitForErrorRequest(page);
+ const replayRequestPromise1 = waitForReplayRequest(page, 0);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ await page.evaluate(() => {
+ /* eslint-disable */
+ fetch('/foo', {
+ method: 'POST',
+ body: 'input body',
+ }).then(() => {
+ // @ts-ignore Sentry is a global
+ Sentry.captureException('test error');
+ });
+ /* eslint-enable */
+ });
+
+ const request = await requestPromise;
+ const eventData = envelopeRequestParser(request);
+
+ expect(eventData.exception?.values).toHaveLength(1);
+
+ expect(eventData?.breadcrumbs?.length).toBe(1);
+ expect(eventData!.breadcrumbs![0]).toEqual({
+ timestamp: expect.any(Number),
+ category: 'fetch',
+ type: 'http',
+ data: {
+ method: 'POST',
+ request_body_size: 10,
+ status_code: 200,
+ url: '/foo',
+ },
+ });
+
+ const replayReq1 = await replayRequestPromise1;
+ const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
+ expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([
+ {
+ data: {
+ method: 'POST',
+ statusCode: 200,
+ request: {
+ size: 10,
+ headers: {},
+ body: 'input body',
+ },
+ response: additionalHeaders ? { headers: additionalHeaders } : undefined,
+ },
+ description: '/foo',
+ endTimestamp: expect.any(Number),
+ op: 'resource.fetch',
+ startTimestamp: expect.any(Number),
+ },
+ ]);
+});
+
sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js
index 15be2bb2764d..2323cd2dda7f 100644
--- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js
+++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js
@@ -5,7 +5,7 @@ window.Replay = new Sentry.Replay({
flushMinDelay: 200,
flushMaxDelay: 200,
- networkDetailAllowUrls: ['http://localhost:7654/foo'],
+ networkDetailAllowUrls: ['http://localhost:7654/foo', 'http://sentry-test.io/foo'],
networkCaptureBodies: true,
});
diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts
index 0d8b8579939e..6fc19f18f9c7 100644
--- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts
+++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts
@@ -255,6 +255,87 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br
]);
});
+sentryTest('captures text request body when matching relative URL', async ({ getLocalTestUrl, page, browserName }) => {
+ // These are a bit flaky on non-chromium browsers
+ if (shouldSkipReplayTest() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+
+ await page.route('**/foo', route => {
+ return route.fulfill({
+ status: 200,
+ });
+ });
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const requestPromise = waitForErrorRequest(page);
+ const replayRequestPromise1 = waitForReplayRequest(page, 0);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ void page.evaluate(() => {
+ /* eslint-disable */
+ const xhr = new XMLHttpRequest();
+
+ xhr.open('POST', '/foo');
+ xhr.send('input body');
+
+ xhr.addEventListener('readystatechange', function () {
+ if (xhr.readyState === 4) {
+ // @ts-ignore Sentry is a global
+ setTimeout(() => Sentry.captureException('test error', 0));
+ }
+ });
+ /* eslint-enable */
+ });
+
+ const request = await requestPromise;
+ const eventData = envelopeRequestParser(request);
+
+ expect(eventData.exception?.values).toHaveLength(1);
+
+ expect(eventData?.breadcrumbs?.length).toBe(1);
+ expect(eventData!.breadcrumbs![0]).toEqual({
+ timestamp: expect.any(Number),
+ category: 'xhr',
+ type: 'http',
+ data: {
+ method: 'POST',
+ request_body_size: 10,
+ status_code: 200,
+ url: '/foo',
+ },
+ });
+
+ const replayReq1 = await replayRequestPromise1;
+ const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
+ expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([
+ {
+ data: {
+ method: 'POST',
+ statusCode: 200,
+ request: {
+ size: 10,
+ headers: {},
+ body: 'input body',
+ },
+ },
+ description: '/foo',
+ endTimestamp: expect.any(Number),
+ op: 'resource.xhr',
+ startTimestamp: expect.any(Number),
+ },
+ ]);
+});
+
sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page, browserName }) => {
// These are a bit flaky on non-chromium browsers
if (shouldSkipReplayTest() || browserName !== 'chromium') {
diff --git a/packages/browser-integration-tests/suites/replay/keyboardEvents/init.js b/packages/browser-integration-tests/suites/replay/keyboardEvents/init.js
new file mode 100644
index 000000000000..1b5f4f447543
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/keyboardEvents/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = new Sentry.Replay({
+ flushMinDelay: 1000,
+ flushMaxDelay: 1000,
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 0,
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ integrations: [window.Replay],
+});
diff --git a/packages/browser-integration-tests/suites/replay/keyboardEvents/template.html b/packages/browser-integration-tests/suites/replay/keyboardEvents/template.html
new file mode 100644
index 000000000000..e35f2650aa72
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/keyboardEvents/template.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/browser-integration-tests/suites/replay/keyboardEvents/test.ts b/packages/browser-integration-tests/suites/replay/keyboardEvents/test.ts
new file mode 100644
index 000000000000..edeacb7f2db0
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/keyboardEvents/test.ts
@@ -0,0 +1,111 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../utils/fixtures';
+import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
+
+sentryTest('captures keyboard events', async ({ forceFlushReplay, getLocalTestPath, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const reqPromise0 = waitForReplayRequest(page, 0);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+
+ await page.goto(url);
+ await reqPromise0;
+ await forceFlushReplay();
+
+ const reqPromise1 = waitForReplayRequest(page, (event, res) => {
+ return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.keyDown');
+ });
+ const reqPromise2 = waitForReplayRequest(page, (event, res) => {
+ return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.input');
+ });
+
+ // Trigger keyboard unfocused
+ await page.keyboard.press('a');
+ await page.keyboard.press('Control+A');
+
+ // Type unfocused
+ await page.keyboard.type('Hello', { delay: 10 });
+
+ // Type focused
+ await page.locator('#input').focus();
+
+ await page.keyboard.press('Control+A');
+ await page.keyboard.type('Hello', { delay: 10 });
+
+ await forceFlushReplay();
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+ const { breadcrumbs: breadcrumbs2 } = getCustomRecordingEvents(await reqPromise2);
+
+ // Combine the two together
+ // Usually, this should all be in a single request, but it _may_ be split out, so we combine this together here.
+ breadcrumbs2.forEach(breadcrumb => {
+ if (!breadcrumbs.some(b => b.category === breadcrumb.category && b.timestamp === breadcrumb.timestamp)) {
+ breadcrumbs.push(breadcrumb);
+ }
+ });
+
+ expect(breadcrumbs).toEqual([
+ {
+ timestamp: expect.any(Number),
+ type: 'default',
+ category: 'ui.keyDown',
+ message: 'body',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: {},
+ id: expect.any(Number),
+ tagName: 'body',
+ textContent: '',
+ },
+ metaKey: false,
+ shiftKey: false,
+ ctrlKey: true,
+ altKey: false,
+ key: 'Control',
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ type: 'default',
+ category: 'ui.keyDown',
+ message: 'body',
+ data: {
+ nodeId: expect.any(Number),
+ node: { attributes: {}, id: expect.any(Number), tagName: 'body', textContent: '' },
+ metaKey: false,
+ shiftKey: false,
+ ctrlKey: true,
+ altKey: false,
+ key: 'A',
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ type: 'default',
+ category: 'ui.input',
+ message: 'body > input#input',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: { id: 'input' },
+ id: expect.any(Number),
+ tagName: 'input',
+ textContent: '',
+ },
+ },
+ },
+ ]);
+});
diff --git a/packages/browser/test/unit/sdk.test.ts b/packages/browser/test/unit/sdk.test.ts
index 4afac502c562..75b10d5c3ed3 100644
--- a/packages/browser/test/unit/sdk.test.ts
+++ b/packages/browser/test/unit/sdk.test.ts
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { createTransport, Scope } from '@sentry/core';
-import { MockIntegration } from '@sentry/core/test/lib/sdk.test';
import type { Client, Integration } from '@sentry/types';
import { resolvedSyncPromise } from '@sentry/utils';
@@ -18,6 +17,14 @@ function getDefaultBrowserOptions(options: Partial = {}): Browse
};
}
+export class MockIntegration implements Integration {
+ public name: string;
+ public setupOnce: () => void = jest.fn();
+ public constructor(name: string) {
+ this.name = name;
+ }
+}
+
jest.mock('@sentry/core', () => {
const original = jest.requireActual('@sentry/core');
return {
diff --git a/packages/node/src/checkin.ts b/packages/core/src/checkin.ts
similarity index 100%
rename from packages/node/src/checkin.ts
rename to packages/core/src/checkin.ts
diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts
index 72a5f8587f94..ba283914fbbd 100644
--- a/packages/core/src/exports.ts
+++ b/packages/core/src/exports.ts
@@ -1,17 +1,20 @@
import type {
Breadcrumb,
CaptureContext,
+ CheckIn,
CustomSamplingContext,
Event,
EventHint,
Extra,
Extras,
+ MonitorConfig,
Primitive,
Severity,
SeverityLevel,
TransactionContext,
User,
} from '@sentry/types';
+import { logger, uuid4 } from '@sentry/utils';
import type { Hub } from './hub';
import { getCurrentHub } from './hub';
@@ -184,3 +187,23 @@ export function startTransaction(
): ReturnType {
return getCurrentHub().startTransaction({ ...context }, customSamplingContext);
}
+
+/**
+ * Create a cron monitor check in and send it to Sentry.
+ *
+ * @param checkIn An object that describes a check in.
+ * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
+ * to create a monitor automatically when sending a check in.
+ */
+export function captureCheckIn(checkIn: CheckIn, upsertMonitorConfig?: MonitorConfig): string {
+ const client = getCurrentHub().getClient();
+ if (!client) {
+ __DEBUG_BUILD__ && logger.warn('Cannot capture check-in. No client defined.');
+ } else if (!client.captureCheckIn) {
+ __DEBUG_BUILD__ && logger.warn('Cannot capture check-in. Client does not support sending check-ins.');
+ } else {
+ return client.captureCheckIn(checkIn, upsertMonitorConfig);
+ }
+
+ return uuid4();
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1acf0264f69b..db9d21c2f2e8 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -17,6 +17,7 @@ export {
setTags,
setUser,
withScope,
+ captureCheckIn,
} from './exports';
export {
getCurrentHub,
@@ -42,6 +43,7 @@ export { SDK_VERSION } from './version';
export { getIntegrationsToSetup } from './integration';
export { FunctionToString, InboundFilters } from './integrations';
export { prepareEvent } from './utils/prepareEvent';
+export { createCheckInEnvelope } from './checkin';
export { hasTracingEnabled } from './utils/hasTracingEnabled';
export { DEFAULT_ENVIRONMENT } from './constants';
diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts
index 7c2319596ebd..998c7ab8203d 100644
--- a/packages/core/src/integrations/inboundfilters.ts
+++ b/packages/core/src/integrations/inboundfilters.ts
@@ -152,8 +152,9 @@ function _getPossibleEventMessages(event: Event): string[] {
return [event.message];
}
if (event.exception) {
+ const { values } = event.exception;
try {
- const { type = '', value = '' } = (event.exception.values && event.exception.values[0]) || {};
+ const { type = '', value = '' } = (values && values[values.length - 1]) || {};
return [`${value}`, `${type}: ${value}`];
} catch (oO) {
__DEBUG_BUILD__ && logger.error(`Cannot extract message for event ${getEventDescription(event)}`);
diff --git a/packages/node/test/checkin.test.ts b/packages/core/test/lib/checkin.test.ts
similarity index 96%
rename from packages/node/test/checkin.test.ts
rename to packages/core/test/lib/checkin.test.ts
index 9fe59ad66971..38a8fce56e95 100644
--- a/packages/node/test/checkin.test.ts
+++ b/packages/core/test/lib/checkin.test.ts
@@ -1,8 +1,8 @@
import type { SerializedCheckIn } from '@sentry/types';
-import { createCheckInEnvelope } from '../src/checkin';
+import { createCheckInEnvelope } from '../../src/checkin';
-describe('CheckIn', () => {
+describe('createCheckInEnvelope', () => {
test('creates a check in envelope header', () => {
const envelope = createCheckInEnvelope(
{
diff --git a/packages/core/test/lib/integrations/inboundfilters.test.ts b/packages/core/test/lib/integrations/inboundfilters.test.ts
index 51b5e9106d64..a528d765bfeb 100644
--- a/packages/core/test/lib/integrations/inboundfilters.test.ts
+++ b/packages/core/test/lib/integrations/inboundfilters.test.ts
@@ -143,6 +143,21 @@ const EXCEPTION_EVENT_WITH_FRAMES: Event = {
},
};
+const EXCEPTION_EVENT_WITH_LINKED_ERRORS: Event = {
+ exception: {
+ values: [
+ {
+ type: 'ReferenceError',
+ value: '`tooManyTreats` is not defined',
+ },
+ {
+ type: 'TypeError',
+ value: 'incorrect type given for parameter `chewToy`: Shoe',
+ },
+ ],
+ },
+};
+
const SENTRY_EVENT: Event = {
exception: {
values: [
@@ -271,6 +286,20 @@ describe('InboundFilters', () => {
expect(eventProcessor(SCRIPT_ERROR_EVENT, {})).toBe(null);
});
+ it('filters on last exception when multiple present', () => {
+ const eventProcessor = createInboundFiltersEventProcessor({
+ ignoreErrors: ['incorrect type given for parameter `chewToy`'],
+ });
+ expect(eventProcessor(EXCEPTION_EVENT_WITH_LINKED_ERRORS, {})).toBe(null);
+ });
+
+ it("doesn't filter on `cause` exception when multiple present", () => {
+ const eventProcessor = createInboundFiltersEventProcessor({
+ ignoreErrors: ['`tooManyTreats` is not defined'],
+ });
+ expect(eventProcessor(EXCEPTION_EVENT_WITH_LINKED_ERRORS, {})).toBe(EXCEPTION_EVENT_WITH_LINKED_ERRORS);
+ });
+
describe('on exception', () => {
it('uses exception data when message is unavailable', () => {
const eventProcessor = createInboundFiltersEventProcessor({
diff --git a/packages/core/test/lib/sdk.test.ts b/packages/core/test/lib/sdk.test.ts
index c2838d7ed115..afb1987262d9 100644
--- a/packages/core/test/lib/sdk.test.ts
+++ b/packages/core/test/lib/sdk.test.ts
@@ -1,4 +1,4 @@
-import { Scope } from '@sentry/core';
+import { captureCheckIn, getCurrentHub } from '@sentry/core';
import type { Client, Integration } from '@sentry/types';
import { installedIntegrations } from '../../src/integration';
@@ -10,31 +10,6 @@ declare var global: any;
const PUBLIC_DSN = 'https://username@domain/123';
-jest.mock('@sentry/core', () => {
- const original = jest.requireActual('@sentry/core');
- return {
- ...original,
- getCurrentHub(): {
- bindClient(client: Client): boolean;
- getClient(): boolean;
- getScope(): Scope;
- } {
- return {
- getClient(): boolean {
- return false;
- },
- getScope(): Scope {
- return new Scope();
- },
- bindClient(client: Client): boolean {
- client.setupIntegrations();
- return true;
- },
- };
- },
- };
-});
-
export class MockIntegration implements Integration {
public name: string;
public setupOnce: () => void = jest.fn();
@@ -62,3 +37,23 @@ describe('SDK', () => {
});
});
});
+
+describe('captureCheckIn', () => {
+ it('returns an id when client is defined', () => {
+ const hub = getCurrentHub();
+ jest.spyOn(hub, 'getClient').mockImplementation(() => {
+ return {
+ captureCheckIn: () => 'some-id-wasd-1234',
+ } as unknown as Client;
+ });
+
+ expect(captureCheckIn({ monitorSlug: 'gogogo', status: 'in_progress' })).toStrictEqual('some-id-wasd-1234');
+ });
+
+ it('returns an id when client is undefined', () => {
+ const hub = getCurrentHub();
+ jest.spyOn(hub, 'getClient').mockImplementation(() => undefined);
+
+ expect(captureCheckIn({ monitorSlug: 'gogogo', status: 'in_progress' })).toStrictEqual(expect.any(String));
+ });
+});
diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts
index cfac0c460a84..6f1c9e5b2c4b 100644
--- a/packages/nextjs/src/common/types.ts
+++ b/packages/nextjs/src/common/types.ts
@@ -4,3 +4,5 @@ export type ServerComponentContext = {
sentryTraceHeader?: string;
baggageHeader?: string;
};
+
+export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined;
diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts
new file mode 100644
index 000000000000..3b9bc8ca7045
--- /dev/null
+++ b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts
@@ -0,0 +1,108 @@
+import { captureCheckIn, runWithAsyncContext } from '@sentry/core';
+import type { NextApiRequest } from 'next';
+
+import type { VercelCronsConfig } from './types';
+
+type EdgeRequest = {
+ nextUrl: URL;
+ headers: Headers;
+};
+
+/**
+ * Wraps a function with Sentry crons instrumentation by automaticaly sending check-ins for the given Vercel crons config.
+ */
+export function wrapApiHandlerWithSentryVercelCrons any>(
+ handler: F,
+ vercelCronsConfig: VercelCronsConfig,
+): F {
+ return new Proxy(handler, {
+ apply: (originalFunction, thisArg, args: [NextApiRequest | EdgeRequest | undefined] | undefined) => {
+ return runWithAsyncContext(() => {
+ if (!args || !args[0]) {
+ return originalFunction.apply(thisArg, args);
+ }
+
+ const [req] = args;
+
+ let maybePromiseResult;
+ const cronsKey = 'nextUrl' in req ? req.nextUrl.pathname : req.url;
+ const userAgentHeader = 'nextUrl' in req ? req.headers.get('user-agent') : req.headers['user-agent'];
+
+ if (
+ !vercelCronsConfig || // do nothing if vercel crons config is missing
+ !userAgentHeader?.includes('vercel-cron') // do nothing if endpoint is not called from vercel crons
+ ) {
+ return originalFunction.apply(thisArg, args);
+ }
+
+ const vercelCron = vercelCronsConfig.find(vercelCron => vercelCron.path === cronsKey);
+
+ if (!vercelCron || !vercelCron.path || !vercelCron.schedule) {
+ return originalFunction.apply(thisArg, args);
+ }
+
+ const monitorSlug = vercelCron.path;
+
+ const checkInId = captureCheckIn(
+ {
+ monitorSlug,
+ status: 'in_progress',
+ },
+ {
+ maxRuntime: 60 * 12, // (minutes) so 12 hours - just a very high arbitrary number since we don't know the actual duration of the users cron job
+ schedule: {
+ type: 'crontab',
+ value: vercelCron.schedule,
+ },
+ },
+ );
+
+ const startTime = Date.now() / 1000;
+
+ const handleErrorCase = (): void => {
+ captureCheckIn({
+ checkInId,
+ monitorSlug,
+ status: 'error',
+ duration: Date.now() / 1000 - startTime,
+ });
+ };
+
+ try {
+ maybePromiseResult = originalFunction.apply(thisArg, args);
+ } catch (e) {
+ handleErrorCase();
+ throw e;
+ }
+
+ if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) {
+ Promise.resolve(maybePromiseResult).then(
+ () => {
+ captureCheckIn({
+ checkInId,
+ monitorSlug,
+ status: 'ok',
+ duration: Date.now() / 1000 - startTime,
+ });
+ },
+ () => {
+ handleErrorCase();
+ },
+ );
+
+ // It is very important that we return the original promise here, because Next.js attaches various properties
+ // to that promise and will throw if they are not on the returned value.
+ return maybePromiseResult;
+ } else {
+ captureCheckIn({
+ checkInId,
+ monitorSlug,
+ status: 'ok',
+ duration: Date.now() / 1000 - startTime,
+ });
+ return maybePromiseResult;
+ }
+ });
+ },
+ });
+}
diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts
index 2cd83f672fd5..28c8f58b2eb9 100644
--- a/packages/nextjs/src/config/loaders/wrappingLoader.ts
+++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts
@@ -5,6 +5,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { rollup } from 'rollup';
+import type { VercelCronsConfig } from '../../common/types';
import type { LoaderThis } from './types';
// Just a simple placeholder to make referencing module consistent
@@ -44,6 +45,7 @@ type LoaderOptions = {
excludeServerRoutes: Array;
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component';
sentryConfigFilePath?: string;
+ vercelCronsConfig?: VercelCronsConfig;
};
function moduleExists(id: string): boolean {
@@ -74,6 +76,7 @@ export default function wrappingLoader(
excludeServerRoutes = [],
wrappingTargetKind,
sentryConfigFilePath,
+ vercelCronsConfig,
} = 'getOptions' in this ? this.getOptions() : this.query;
this.async();
@@ -113,6 +116,8 @@ export default function wrappingLoader(
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
}
+ templateCode = templateCode.replace(/__VERCEL_CRONS_CONFIGURATION__/g, JSON.stringify(vercelCronsConfig));
+
// Inject the route and the path to the file we're wrapping into the template
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
} else if (wrappingTargetKind === 'server-component') {
diff --git a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts b/packages/nextjs/src/config/templates/apiWrapperTemplate.ts
index 91cf5ef1e0c6..0eccf3024a76 100644
--- a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts
+++ b/packages/nextjs/src/config/templates/apiWrapperTemplate.ts
@@ -13,6 +13,7 @@ import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__';
import * as Sentry from '@sentry/nextjs';
import type { PageConfig } from 'next';
+import type { VercelCronsConfig } from '../../common/types';
// We import this from `wrappers` rather than directly from `next` because our version can work simultaneously with
// multiple versions of next. See note in `wrappers/types` for more.
import type { NextApiHandler } from '../../server/types';
@@ -54,7 +55,19 @@ export const config = {
},
};
-export default userProvidedHandler ? Sentry.wrapApiHandlerWithSentry(userProvidedHandler, '__ROUTE__') : undefined;
+declare const __VERCEL_CRONS_CONFIGURATION__: VercelCronsConfig;
+
+let wrappedHandler = userProvidedHandler;
+
+if (wrappedHandler) {
+ wrappedHandler = Sentry.wrapApiHandlerWithSentry(wrappedHandler, '__ROUTE__');
+}
+
+if (wrappedHandler && __VERCEL_CRONS_CONFIGURATION__) {
+ wrappedHandler = Sentry.wrapApiHandlerWithSentryVercelCrons(wrappedHandler, __VERCEL_CRONS_CONFIGURATION__);
+}
+
+export default wrappedHandler;
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
// not include anything whose name matchs something we've explicitly exported above.
diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts
index f2156382e6f3..28f70d62dc05 100644
--- a/packages/nextjs/src/config/types.ts
+++ b/packages/nextjs/src/config/types.ts
@@ -133,6 +133,13 @@ export type UserSentryOptions = {
* Tree shakes Sentry SDK logger statements from the bundle.
*/
disableLogger?: boolean;
+
+ /**
+ * Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`.
+ *
+ * Defaults to `true`.
+ */
+ automaticVercelMonitors?: boolean;
};
export type NextConfigFunction = (phase: string, defaults: { defaultConfig: NextConfigObject }) => NextConfigObject;
diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts
index 73fb60660451..0bb42f98b7ec 100644
--- a/packages/nextjs/src/config/webpack.ts
+++ b/packages/nextjs/src/config/webpack.ts
@@ -7,6 +7,7 @@ import * as chalk from 'chalk';
import * as fs from 'fs';
import * as path from 'path';
+import type { VercelCronsConfig } from '../common/types';
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
// circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306.
import type {
@@ -163,6 +164,31 @@ export function constructWebpackConfigFunction(
],
});
+ let vercelCronsConfig: VercelCronsConfig = undefined;
+ try {
+ if (process.env.VERCEL && userSentryOptions.automaticVercelMonitors !== false) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons;
+ if (vercelCronsConfig) {
+ logger.info(
+ `${chalk.cyan(
+ 'info',
+ )} - Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the ${chalk.bold.cyan(
+ 'automaticVercelMonitors',
+ )} option to false in you Next.js config.`,
+ );
+ }
+ }
+ } catch (e) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (e.code === 'ENOENT') {
+ // noop if file does not exist
+ } else {
+ // log but noop
+ logger.error(`${chalk.red('error')} - Sentry failed to read vercel.json`, e);
+ }
+ }
+
// Wrap api routes
newConfig.module.rules.unshift({
test: resourcePath => {
@@ -177,6 +203,7 @@ export function constructWebpackConfigFunction(
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
options: {
...staticWrappingLoaderOptions,
+ vercelCronsConfig,
wrappingTargetKind: 'api-route',
},
},
diff --git a/packages/nextjs/src/edge/edgeclient.ts b/packages/nextjs/src/edge/edgeclient.ts
index 16aed66d1ca4..ce13d6666448 100644
--- a/packages/nextjs/src/edge/edgeclient.ts
+++ b/packages/nextjs/src/edge/edgeclient.ts
@@ -1,6 +1,16 @@
import type { Scope } from '@sentry/core';
-import { addTracingExtensions, BaseClient, SDK_VERSION } from '@sentry/core';
-import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types';
+import { addTracingExtensions, BaseClient, createCheckInEnvelope, SDK_VERSION } from '@sentry/core';
+import type {
+ CheckIn,
+ ClientOptions,
+ Event,
+ EventHint,
+ MonitorConfig,
+ SerializedCheckIn,
+ Severity,
+ SeverityLevel,
+} from '@sentry/types';
+import { logger, uuid4 } from '@sentry/utils';
import { eventFromMessage, eventFromUnknownInput } from './eventbuilder';
import type { EdgeTransportOptions } from './transport';
@@ -55,6 +65,51 @@ export class EdgeClient extends BaseClient {
);
}
+ /**
+ * Create a cron monitor check in and send it to Sentry.
+ *
+ * @param checkIn An object that describes a check in.
+ * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
+ * to create a monitor automatically when sending a check in.
+ */
+ public captureCheckIn(checkIn: CheckIn, monitorConfig?: MonitorConfig): string {
+ const id = checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn.checkInId : uuid4();
+ if (!this._isEnabled()) {
+ __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.');
+ return id;
+ }
+
+ const options = this.getOptions();
+ const { release, environment, tunnel } = options;
+
+ const serializedCheckIn: SerializedCheckIn = {
+ check_in_id: id,
+ monitor_slug: checkIn.monitorSlug,
+ status: checkIn.status,
+ release,
+ environment,
+ };
+
+ if (checkIn.status !== 'in_progress') {
+ serializedCheckIn.duration = checkIn.duration;
+ }
+
+ if (monitorConfig) {
+ serializedCheckIn.monitor_config = {
+ schedule: monitorConfig.schedule,
+ checkin_margin: monitorConfig.checkinMargin,
+ max_runtime: monitorConfig.maxRuntime,
+ timezone: monitorConfig.timezone,
+ };
+ }
+
+ const envelope = createCheckInEnvelope(serializedCheckIn, this.getSdkMetadata(), tunnel, this.getDsn());
+
+ __DEBUG_BUILD__ && logger.info('Sending checkin:', checkIn.monitorSlug, checkIn.status);
+ void this._sendEnvelope(envelope);
+ return id;
+ }
+
/**
* @inheritDoc
*/
diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts
index f7785aaa06d6..ca56ce3facbe 100644
--- a/packages/nextjs/src/edge/index.ts
+++ b/packages/nextjs/src/edge/index.ts
@@ -136,6 +136,8 @@ export {
wrapApiHandlerWithSentry,
} from './wrapApiHandlerWithSentry';
+export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons';
+
export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';
export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';
diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts
index 97e48b6d8314..a75b61e02ea3 100644
--- a/packages/nextjs/src/index.types.ts
+++ b/packages/nextjs/src/index.types.ts
@@ -10,7 +10,7 @@ export * from './edge';
import type { Integration, Options, StackParser } from '@sentry/types';
import type * as clientSdk from './client';
-import type { ServerComponentContext } from './common/types';
+import type { ServerComponentContext, VercelCronsConfig } from './common/types';
import type * as edgeSdk from './edge';
import type * as serverSdk from './server';
@@ -178,3 +178,11 @@ export declare function wrapServerComponentWithSentry any>(
+ WrappingTarget: F,
+ vercelCronsConfig: VercelCronsConfig,
+): F;
diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts
index dc036921e436..d92e406cda3f 100644
--- a/packages/nextjs/src/server/index.ts
+++ b/packages/nextjs/src/server/index.ts
@@ -157,6 +157,8 @@ const deprecatedIsBuild = (): boolean => isBuild();
// eslint-disable-next-line deprecation/deprecation
export { deprecatedIsBuild as isBuild };
+export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons';
+
export {
// eslint-disable-next-line deprecation/deprecation
withSentryGetStaticProps,
diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts
index af39f786ac3c..7424e4b10a26 100644
--- a/packages/node/src/client.ts
+++ b/packages/node/src/client.ts
@@ -1,5 +1,5 @@
import type { Scope } from '@sentry/core';
-import { addTracingExtensions, BaseClient, SDK_VERSION, SessionFlusher } from '@sentry/core';
+import { addTracingExtensions, BaseClient, createCheckInEnvelope, SDK_VERSION, SessionFlusher } from '@sentry/core';
import type {
CheckIn,
Event,
@@ -13,7 +13,6 @@ import { logger, resolvedSyncPromise, uuid4 } from '@sentry/utils';
import * as os from 'os';
import { TextEncoder } from 'util';
-import { createCheckInEnvelope } from './checkin';
import { eventFromMessage, eventFromUnknownInput } from './eventbuilder';
import type { NodeClientOptions } from './types';
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index b9e6202b9496..0e57936a02a9 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -50,22 +50,14 @@ export {
spanStatusfromHttpCode,
trace,
withScope,
+ captureCheckIn,
} from '@sentry/core';
export type { SpanStatusType } from '@sentry/core';
export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing';
export { NodeClient } from './client';
export { makeNodeTransport } from './transports';
-export {
- defaultIntegrations,
- init,
- defaultStackParser,
- lastEventId,
- flush,
- close,
- getSentryRelease,
- captureCheckIn,
-} from './sdk';
+export { defaultIntegrations, init, defaultStackParser, lastEventId, flush, close, getSentryRelease } from './sdk';
export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from './requestdata';
export { deepReadDirSync } from './utils';
diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts
index d4e2df4ac5f9..d0a02c746247 100644
--- a/packages/node/src/sdk.ts
+++ b/packages/node/src/sdk.ts
@@ -6,14 +6,13 @@ import {
initAndBind,
Integrations as CoreIntegrations,
} from '@sentry/core';
-import type { CheckIn, MonitorConfig, SessionStatus, StackParser } from '@sentry/types';
+import type { SessionStatus, StackParser } from '@sentry/types';
import {
createStackParser,
GLOBAL_OBJ,
logger,
nodeStackLineParser,
stackParserFromStackParserOptions,
- uuid4,
} from '@sentry/utils';
import { setNodeAsyncContextStrategy } from './async';
@@ -263,30 +262,6 @@ export function getSentryRelease(fallback?: string): string | undefined {
);
}
-/**
- * Create a cron monitor check in and send it to Sentry.
- *
- * @param checkIn An object that describes a check in.
- * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
- * to create a monitor automatically when sending a check in.
- */
-export function captureCheckIn(
- checkIn: CheckIn,
- upsertMonitorConfig?: MonitorConfig,
-): ReturnType {
- const capturedCheckIn =
- checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn : { ...checkIn, checkInId: uuid4() };
-
- const client = getCurrentHub().getClient();
- if (client) {
- client.captureCheckIn(capturedCheckIn, upsertMonitorConfig);
- } else {
- __DEBUG_BUILD__ && logger.warn('Cannot capture check in. No client defined.');
- }
-
- return capturedCheckIn.checkInId;
-}
-
/** Node.js stack parser */
export const defaultStackParser: StackParser = createStackParser(nodeStackLineParser(getModule));
diff --git a/packages/node/test/sdk.test.ts b/packages/node/test/sdk.test.ts
index f7c2595c66a5..abd0265b62c4 100644
--- a/packages/node/test/sdk.test.ts
+++ b/packages/node/test/sdk.test.ts
@@ -1,7 +1,5 @@
-import { getCurrentHub } from '@sentry/core';
import type { Integration } from '@sentry/types';
-import type { NodeClient } from '../build/types';
import { init } from '../src/sdk';
import * as sdk from '../src/sdk';
@@ -92,21 +90,3 @@ describe('init()', () => {
expect(newIntegration.setupOnce as jest.Mock).toHaveBeenCalledTimes(1);
});
});
-
-describe('captureCheckIn', () => {
- it('always returns an id', () => {
- const hub = getCurrentHub();
- const client = hub.getClient();
- expect(client).toBeDefined();
-
- const captureCheckInSpy = jest.spyOn(client!, 'captureCheckIn');
-
- // test if captureCheckIn returns an id even if client is not defined
- hub.bindClient(undefined);
-
- expect(captureCheckInSpy).toHaveBeenCalledTimes(0);
- expect(sdk.captureCheckIn({ monitorSlug: 'gogogo', status: 'in_progress' })).toBeTruthy();
-
- hub.bindClient(client);
- });
-});
diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts
index f98a92725861..00d274760511 100644
--- a/packages/replay/src/coreHandlers/handleDom.ts
+++ b/packages/replay/src/coreHandlers/handleDom.ts
@@ -10,7 +10,7 @@ import { getAttributesToRecord } from './util/getAttributesToRecord';
export interface DomHandlerData {
name: string;
- event: Node | { target: Node };
+ event: Node | { target: EventTarget };
}
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void =
@@ -29,39 +29,21 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa
addBreadcrumbEvent(replay, result);
};
-/**
- * An event handler to react to DOM events.
- * Exported for tests only.
- */
-export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
- let target;
- let targetNode: Node | INode | undefined;
-
- const isClick = handlerData.name === 'click';
-
- // Accessing event.target can throw (see getsentry/raven-js#838, #768)
- try {
- targetNode = isClick ? getClickTargetNode(handlerData.event) : getTargetNode(handlerData.event);
- target = htmlTreeAsString(targetNode, { maxStringLength: 200 });
- } catch (e) {
- target = '';
- }
-
+/** Get the base DOM breadcrumb. */
+export function getBaseDomBreadcrumb(target: Node | INode | null, message: string): Breadcrumb {
// `__sn` property is the serialized node created by rrweb
- const serializedNode =
- targetNode && '__sn' in targetNode && targetNode.__sn.type === NodeType.Element ? targetNode.__sn : null;
+ const serializedNode = target && isRrwebNode(target) && target.__sn.type === NodeType.Element ? target.__sn : null;
- return createBreadcrumb({
- category: `ui.${handlerData.name}`,
- message: target,
+ return {
+ message,
data: serializedNode
? {
nodeId: serializedNode.id,
node: {
id: serializedNode.id,
tagName: serializedNode.tagName,
- textContent: targetNode
- ? Array.from(targetNode.childNodes)
+ textContent: target
+ ? Array.from(target.childNodes)
.map(
(node: Node | INode) => '__sn' in node && node.__sn.type === NodeType.Text && node.__sn.textContent,
)
@@ -73,12 +55,46 @@ export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
},
}
: {},
+ };
+}
+
+/**
+ * An event handler to react to DOM events.
+ * Exported for tests.
+ */
+export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
+ const { target, message } = getDomTarget(handlerData);
+
+ return createBreadcrumb({
+ category: `ui.${handlerData.name}`,
+ ...getBaseDomBreadcrumb(target, message),
});
}
-function getTargetNode(event: DomHandlerData['event']): Node {
+function getDomTarget(handlerData: DomHandlerData): { target: Node | INode | null; message: string } {
+ const isClick = handlerData.name === 'click';
+
+ let message: string | undefined;
+ let target: Node | INode | null = null;
+
+ // Accessing event.target can throw (see getsentry/raven-js#838, #768)
+ try {
+ target = isClick ? getClickTargetNode(handlerData.event) : getTargetNode(handlerData.event);
+ message = htmlTreeAsString(target, { maxStringLength: 200 }) || '';
+ } catch (e) {
+ message = '';
+ }
+
+ return { target, message };
+}
+
+function isRrwebNode(node: EventTarget): node is INode {
+ return '__sn' in node;
+}
+
+function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
if (isEventWithTarget(event)) {
- return event.target;
+ return event.target as Node | null;
}
return event;
@@ -90,7 +106,7 @@ const INTERACTIVE_SELECTOR = 'button,a';
// If so, we use this as the target instead
// This is useful because if you click on the image in ,
// The target will be the image, not the button, which we don't want here
-function getClickTargetNode(event: DomHandlerData['event']): Node {
+function getClickTargetNode(event: DomHandlerData['event']): Node | INode | null {
const target = getTargetNode(event);
if (!target || !(target instanceof Element)) {
@@ -101,6 +117,6 @@ function getClickTargetNode(event: DomHandlerData['event']): Node {
return closestInteractive || target;
}
-function isEventWithTarget(event: unknown): event is { target: Node } {
- return !!(event as { target?: Node }).target;
+function isEventWithTarget(event: unknown): event is { target: EventTarget | null } {
+ return typeof event === 'object' && !!event && 'target' in event;
}
diff --git a/packages/replay/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts
new file mode 100644
index 000000000000..e50f5e6e3ab5
--- /dev/null
+++ b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts
@@ -0,0 +1,64 @@
+import type { Breadcrumb } from '@sentry/types';
+import { htmlTreeAsString } from '@sentry/utils';
+
+import type { ReplayContainer } from '../types';
+import { createBreadcrumb } from '../util/createBreadcrumb';
+import { getBaseDomBreadcrumb } from './handleDom';
+import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
+
+/** Handle keyboard events & create breadcrumbs. */
+export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEvent): void {
+ if (!replay.isEnabled()) {
+ return;
+ }
+
+ replay.triggerUserActivity();
+
+ const breadcrumb = getKeyboardBreadcrumb(event);
+
+ if (!breadcrumb) {
+ return;
+ }
+
+ addBreadcrumbEvent(replay, breadcrumb);
+}
+
+/** exported only for tests */
+export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null {
+ const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event;
+
+ // never capture for input fields
+ if (!target || isInputElement(target as HTMLElement)) {
+ return null;
+ }
+
+ // Note: We do not consider shift here, as that means "uppercase"
+ const hasModifierKey = metaKey || ctrlKey || altKey;
+ const isCharacterKey = key.length === 1; // other keys like Escape, Tab, etc have a longer length
+
+ // Do not capture breadcrumb if only a word key is pressed
+ // This could leak e.g. user input
+ if (!hasModifierKey && isCharacterKey) {
+ return null;
+ }
+
+ const message = htmlTreeAsString(target, { maxStringLength: 200 }) || '';
+ const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message);
+
+ return createBreadcrumb({
+ category: 'ui.keyDown',
+ message,
+ data: {
+ ...baseBreadcrumb.data,
+ metaKey,
+ shiftKey,
+ ctrlKey,
+ altKey,
+ key,
+ },
+ });
+}
+
+function isInputElement(target: HTMLElement): boolean {
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
+}
diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts
index 8ee1a87b7b6d..8d73c5660463 100644
--- a/packages/replay/src/coreHandlers/util/networkUtils.ts
+++ b/packages/replay/src/coreHandlers/util/networkUtils.ts
@@ -1,7 +1,7 @@
import type { TextEncoderInternal } from '@sentry/types';
import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/utils';
-import { NETWORK_BODY_MAX_SIZE } from '../../constants';
+import { NETWORK_BODY_MAX_SIZE, WINDOW } from '../../constants';
import type {
NetworkBody,
NetworkMetaWarning,
@@ -234,5 +234,30 @@ function _strIsProbablyJson(str: string): boolean {
/** Match an URL against a list of strings/Regex. */
export function urlMatches(url: string, urls: (string | RegExp)[]): boolean {
- return stringMatchesSomePattern(url, urls);
+ const fullUrl = getFullUrl(url);
+
+ return stringMatchesSomePattern(fullUrl, urls);
+}
+
+/** exported for tests */
+export function getFullUrl(url: string, baseURI = WINDOW.document.baseURI): string {
+ // Short circuit for common cases:
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith(WINDOW.location.origin)) {
+ return url;
+ }
+ const fixedUrl = new URL(url, baseURI);
+
+ // If these do not match, we are not dealing with a relative URL, so just return it
+ if (fixedUrl.origin !== new URL(baseURI).origin) {
+ return url;
+ }
+
+ const fullUrl = fixedUrl.href;
+
+ // Remove trailing slashes, if they don't match the original URL
+ if (!url.endsWith('/') && fullUrl.endsWith('/')) {
+ return fullUrl.slice(0, -1);
+ }
+
+ return fullUrl;
}
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts
index 3d2fe4a6d7af..8d89aa0b2653 100644
--- a/packages/replay/src/replay.ts
+++ b/packages/replay/src/replay.ts
@@ -11,6 +11,7 @@ import {
SESSION_IDLE_PAUSE_DURATION,
WINDOW,
} from './constants';
+import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
import { createEventBuffer } from './eventBuffer';
import { clearSession } from './session/clearSession';
@@ -701,8 +702,8 @@ export class ReplayContainer implements ReplayContainerInterface {
};
/** Ensure page remains active when a key is pressed. */
- private _handleKeyboardEvent: (event: KeyboardEvent) => void = () => {
- this.triggerUserActivity();
+ private _handleKeyboardEvent: (event: KeyboardEvent) => void = (event: KeyboardEvent) => {
+ handleKeyboardEvent(this, event);
};
/**
diff --git a/packages/replay/test/unit/coreHandlers/handleKeyboardEvent.test.ts b/packages/replay/test/unit/coreHandlers/handleKeyboardEvent.test.ts
new file mode 100644
index 000000000000..d08f1ef1a800
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleKeyboardEvent.test.ts
@@ -0,0 +1,104 @@
+import { getKeyboardBreadcrumb } from '../../../src/coreHandlers/handleKeyboardEvent';
+
+describe('Unit | coreHandlers | handleKeyboardEvent', () => {
+ describe('getKeyboardBreadcrumb', () => {
+ it('returns null for event on input', function () {
+ const event = makeKeyboardEvent({ tagName: 'input', key: 'Escape' });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toBeNull();
+ });
+
+ it('returns null for event on textarea', function () {
+ const event = makeKeyboardEvent({ tagName: 'textarea', key: 'Escape' });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toBeNull();
+ });
+
+ it('returns null for event on contenteditable div', function () {
+ // JSOM does not support contentEditable properly :(
+ const target = document.createElement('div');
+ Object.defineProperty(target, 'isContentEditable', {
+ get: function () {
+ return true;
+ },
+ });
+
+ const event = makeKeyboardEvent({ target, key: 'Escape' });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toBeNull();
+ });
+
+ it('returns breadcrumb for Escape event on body', function () {
+ const event = makeKeyboardEvent({ tagName: 'body', key: 'Escape' });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toEqual({
+ category: 'ui.keyDown',
+ data: {
+ altKey: false,
+ ctrlKey: false,
+ key: 'Escape',
+ metaKey: false,
+ shiftKey: false,
+ },
+ message: 'body',
+ timestamp: expect.any(Number),
+ type: 'default',
+ });
+ });
+
+ it.each(['a', '1', '!', '~', ']'])('returns null for %s key on body', key => {
+ const event = makeKeyboardEvent({ tagName: 'body', key });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toEqual(null);
+ });
+
+ it.each(['a', '1', '!', '~', ']'])('returns null for %s key + Shift on body', key => {
+ const event = makeKeyboardEvent({ tagName: 'body', key, shiftKey: true });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toEqual(null);
+ });
+
+ it.each(['a', '1', '!', '~', ']'])('returns breadcrumb for %s key + Ctrl on body', key => {
+ const event = makeKeyboardEvent({ tagName: 'body', key, ctrlKey: true });
+ const actual = getKeyboardBreadcrumb(event);
+ expect(actual).toEqual({
+ category: 'ui.keyDown',
+ data: {
+ altKey: false,
+ ctrlKey: true,
+ key,
+ metaKey: false,
+ shiftKey: false,
+ },
+ message: 'body',
+ timestamp: expect.any(Number),
+ type: 'default',
+ });
+ });
+ });
+});
+
+function makeKeyboardEvent({
+ metaKey = false,
+ shiftKey = false,
+ ctrlKey = false,
+ altKey = false,
+ key,
+ tagName,
+ target,
+}: {
+ metaKey?: boolean;
+ shiftKey?: boolean;
+ ctrlKey?: boolean;
+ altKey?: boolean;
+ key: string;
+ tagName?: string;
+ target?: HTMLElement;
+}): KeyboardEvent {
+ const event = new KeyboardEvent('keydown', { metaKey, shiftKey, ctrlKey, altKey, key });
+
+ const element = target || document.createElement(tagName || 'div');
+ element.dispatchEvent(event);
+
+ return event;
+}
diff --git a/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts
index 2fcafbe98669..04f67087c272 100644
--- a/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts
+++ b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts
@@ -4,6 +4,7 @@ import { NETWORK_BODY_MAX_SIZE } from '../../../../src/constants';
import {
buildNetworkRequestOrResponse,
getBodySize,
+ getFullUrl,
parseContentLengthHeader,
} from '../../../../src/coreHandlers/util/networkUtils';
@@ -219,4 +220,29 @@ describe('Unit | coreHandlers | util | networkUtils', () => {
expect(actual).toEqual({ size: 1, headers: {}, body: expectedBody, _meta: expectedMeta });
});
});
+
+ describe('getFullUrl', () => {
+ it.each([
+ ['http://example.com', 'http://example.com', 'http://example.com'],
+ ['https://example.com', 'https://example.com', 'https://example.com'],
+ ['//example.com', 'https://example.com', 'https://example.com'],
+ ['//example.com', 'http://example.com', 'http://example.com'],
+ ['//example.com/', 'http://example.com', 'http://example.com/'],
+ ['//example.com/sub/aha.html', 'http://example.com', 'http://example.com/sub/aha.html'],
+ ['https://example.com/sub/aha.html', 'http://example.com', 'https://example.com/sub/aha.html'],
+ ['sub/aha.html', 'http://example.com', 'http://example.com/sub/aha.html'],
+ ['sub/aha.html', 'http://example.com/initial', 'http://example.com/sub/aha.html'],
+ ['sub/aha', 'http://example.com/initial/', 'http://example.com/initial/sub/aha'],
+ ['sub/aha/', 'http://example.com/initial/', 'http://example.com/initial/sub/aha/'],
+ ['sub/aha.html', 'http://example.com/initial/', 'http://example.com/initial/sub/aha.html'],
+ ['/sub/aha.html', 'http://example.com/initial/', 'http://example.com/sub/aha.html'],
+ ['./sub/aha.html', 'http://example.com/initial/', 'http://example.com/initial/sub/aha.html'],
+ ['../sub/aha.html', 'http://example.com/initial/', 'http://example.com/sub/aha.html'],
+ ['sub/aha.html', 'file:///Users/folder/file.html', 'file:///Users/folder/sub/aha.html'],
+ ['ws://example.com/sub/aha.html', 'http://example.com/initial/', 'ws://example.com/sub/aha.html'],
+ ])('works with %s & baseURI %s', (url, baseURI, expected) => {
+ const actual = getFullUrl(url, baseURI);
+ expect(actual).toBe(expected);
+ });
+ });
});
diff --git a/packages/svelte/README.md b/packages/svelte/README.md
index 4fb87a431787..5ea7bbed0fb8 100644
--- a/packages/svelte/README.md
+++ b/packages/svelte/README.md
@@ -10,7 +10,8 @@
[](https://www.npmjs.com/package/@sentry/svelte)
[](https://www.npmjs.com/package/@sentry/svelte)
-This SDK currently only supports [Svelte](https://svelte.dev/) and is not yet fully compatible with with [SvelteKit](https://kit.svelte.dev/). If you would like the SDK to be fully compatible with SvelteKit, please reach out to us on [GitHub](https://github.com/getsentry/sentry-javascript/discussions/5838).
+This SDK currently only supports [Svelte](https://svelte.dev/) apps in the browser.
+If you're using SvelteKit, we recommend using our dedicated [Sentry SvelteKit SDK](https://github.com/getsentry/sentry-javascript/tree/develop/packages/sveltekit).
## General
diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md
index 3951ffcac04f..309b59fcd2ac 100644
--- a/packages/sveltekit/README.md
+++ b/packages/sveltekit/README.md
@@ -10,28 +10,33 @@
[](https://www.npmjs.com/package/@sentry/sveltekit)
[](https://www.npmjs.com/package/@sentry/sveltekit)
-
-
-## SDK Status
-
-This SDK is currently in **Beta state**. Bugs and issues might still appear and we're still actively working
-on the SDK. Also, we're still adding features.
-If you experience problems or have feedback, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose).
## Compatibility
Currently, the minimum supported version of SvelteKit is `1.0.0`.
+The SDK works best with Vite 4.2 and newer.
+Older Vite versions might not generate source maps correctly.
## General
This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit.
-## Setup
+## Automatic Setup
+
+We recommend installing the SDK by running the [Sentry wizard](https://docs.sentry.io/platforms/javascript/guides/sveltekit/#install) in the root directory of your project:
+
+```sh
+npx @sentry/wizard@latest -i sveltekit
+```
+
+Take a look at the sections below if you want to customize your SDK configuration.
+
+## Manual Setup
+
+If the setup through the wizard doesn't work for you, you can also set up the SDK manually.
### 1. Prerequesits & Installation
diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts
index fc3fd6d74fc0..94587c0380b1 100644
--- a/packages/tracing-internal/src/browser/request.ts
+++ b/packages/tracing-internal/src/browser/request.ts
@@ -10,7 +10,7 @@ import {
stringMatchesSomePattern,
} from '@sentry/utils';
-export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\//];
+export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/];
/** Options for Request Instrumentation */
export interface RequestInstrumentationOptions {
diff --git a/packages/tracing-internal/test/browser/request.test.ts b/packages/tracing-internal/test/browser/request.test.ts
index 9c5567e3fdcd..c677818752cc 100644
--- a/packages/tracing-internal/test/browser/request.test.ts
+++ b/packages/tracing-internal/test/browser/request.test.ts
@@ -400,12 +400,17 @@ describe('shouldAttachHeaders', () => {
'http://localhost:3000/test',
'http://somewhere.com/test/localhost/123',
'http://somewhere.com/test?url=localhost:3000&test=123',
+ '//localhost:3000/test',
+ '/',
])('return `true` for urls matching defaults (%s)', url => {
expect(shouldAttachHeaders(url, undefined)).toBe(true);
});
- it.each(['notmydoman/api/test', 'example.com'])('return `false` for urls not matching defaults (%s)', url => {
- expect(shouldAttachHeaders(url, undefined)).toBe(false);
- });
+ it.each(['notmydoman/api/test', 'example.com', '//example.com'])(
+ 'return `false` for urls not matching defaults (%s)',
+ url => {
+ expect(shouldAttachHeaders(url, undefined)).toBe(false);
+ },
+ );
});
});
diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts
index d455c4a7590b..831b1c10e9f2 100644
--- a/packages/types/src/client.ts
+++ b/packages/types/src/client.ts
@@ -1,4 +1,5 @@
import type { Breadcrumb, BreadcrumbHint } from './breadcrumb';
+import type { CheckIn, MonitorConfig } from './checkin';
import type { EventDropReason } from './clientreport';
import type { DataCategory } from './datacategory';
import type { DsnComponents } from './dsn';
@@ -67,6 +68,16 @@ export interface Client {
*/
captureSession?(session: Session): void;
+ /**
+ * Create a cron monitor check in and send it to Sentry. This method is not available on all clients.
+ *
+ * @param checkIn An object that describes a check in.
+ * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
+ * to create a monitor automatically when sending a check in.
+ * @returns A string representing the id of the check in.
+ */
+ captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig): string;
+
/** Returns the current Dsn. */
getDsn(): DsnComponents | undefined;
diff --git a/rollup/plugins/bundlePlugins.js b/rollup/plugins/bundlePlugins.js
index c2b939e3db41..ece32a26f4b3 100644
--- a/rollup/plugins/bundlePlugins.js
+++ b/rollup/plugins/bundlePlugins.js
@@ -129,6 +129,8 @@ export function makeTerserPlugin() {
'_integrations',
// _meta is used to store metadata of replay network events
'_meta',
+ // Object we inject debug IDs into with bundler plugins
+ '_sentryDebugIds',
],
},
},