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 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/svelte.svg)](https://www.npmjs.com/package/@sentry/svelte) [![npm dt](https://img.shields.io/npm/dt/@sentry/svelte.svg)](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 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/sveltekit.svg)](https://www.npmjs.com/package/@sentry/sveltekit) [![npm dt](https://img.shields.io/npm/dt/@sentry/sveltekit.svg)](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', ], }, },