diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31431df1c0cc..1c7d639db7ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -864,13 +864,16 @@ jobs: 'create-remix-app-v2', 'debug-id-sourcemaps', 'nextjs-app-dir', + 'nextjs-14', 'react-create-hash-router', 'react-router-6-use-routes', 'standard-frontend-react', 'standard-frontend-react-tracing-import', 'sveltekit', + 'sveltekit-2', 'generic-ts3.8', 'node-experimental-fastify-app', + 'node-hapi-app', ] build-command: - false diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index ed05e9bfd1af..9801407515cd 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -73,6 +73,12 @@ jobs: - test-application: 'nextjs-app-dir' build-command: 'test:build-latest' label: 'nextjs-app-dir (latest)' + - test-application: 'nextjs-14' + build-command: 'test:build-canary' + label: 'nextjs-14 (canary)' + - test-application: 'nextjs-14' + build-command: 'test:build-latest' + label: 'nextjs-14 (latest)' - test-application: 'react-create-hash-router' build-command: 'test:build-canary' label: 'react-create-hash-router (canary)' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7284b059cd4d..4345f368f6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.89.0 + +### Important Changes + +#### Deprecations + +- **feat(core): Deprecate `configureScope` (#9887)** +- **feat(core): Deprecate `pushScope` & `popScope` (#9890)** + +This release deprecates `configureScope`, `pushScope`, and `popScope`, which will be removed in the upcoming v8 major release. + +#### Hapi Integration + +- **feat(node): Add Hapi Integration (#9539)** + +This release adds an integration for Hapi. It can be used as follows: + +```ts +const Sentry = require('@sentry/node'); +const Hapi = require('@hapi/hapi'); + +const init = async () => { + const server = Hapi.server({ + // your server configuration ... + }); + + Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, + integrations: [ + new Sentry.Integrations.Hapi({ server }), + ], + }); + + server.route({ + // your route configuration ... + }); + + await server.start(); +}; +``` + +#### SvelteKit 2.0 + +- **chore(sveltekit): Add SvelteKit 2.0 to peer dependencies (#9861)** + +This release adds support for SvelteKit 2.0 in the `@sentry/sveltekit` package. If you're upgrading from SvelteKit 1.x to 2.x and already use the Sentry SvelteKit SDK, no changes apart from upgrading to this (or a newer) version are necessary. + +### Other Changes + +- feat(core): Add type & utility for function-based integrations (#9818) +- feat(core): Update `withScope` to return callback return value (#9866) +- feat(deno): Support `Deno.CronSchedule` for cron jobs (#9880) +- feat(nextjs): Auto instrument generation functions (#9781) +- feat(nextjs): Connect server component transactions if there is no incoming trace (#9845) +- feat(node-experimental): Update to new Scope APIs (#9799) +- feat(replay): Add `canvas.type` setting (#9877) +- fix(nextjs): Export `createReduxEnhancer` (#9854) +- fix(remix): Do not capture thrown redirect responses. (#9909) +- fix(sveltekit): Add conditional exports (#9872) +- fix(sveltekit): Avoid capturing 404 errors on client side (#9902) +- fix(utils): Do not use `Event` type in worldwide (#9864) +- fix(utils): Support crypto.getRandomValues in old Chromium versions (#9251) +- fix(utils): Update `eventFromUnknownInput` to avoid scope pollution & `getCurrentHub` (#9868) +- ref: Use `addBreadcrumb` directly & allow to pass hint (#9867) + +Work in this release contributed by @adam187, and @jghinestrosa. Thank you for your contributions! + ## 7.88.0 ### Important Changes diff --git a/MIGRATION.md b/MIGRATION.md index 78f5f16d3002..101f9de4469d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -8,6 +8,18 @@ npx @sentry/migr8@latest This will let you select which updates to run, and automatically update your code. Make sure to still review all code changes! +## Deprecate `pushScope` & `popScope` in favor of `withScope` + +Instead of manually pushing/popping a scope, you should use `Sentry.withScope(callback: (scope: Scope))` instead. + +## Deprecate `configureScope` in favor of using `getCurrentScope()` + +Instead of updating the scope in a callback via `configureScope()`, you should access it via `getCurrentScope()` and configure it directly: + +```js +Sentry.getCurrentScope().setTag('xx', 'yy'); +``` + ## Deprecate `addGlobalEventProcessor` in favor of `addEventProcessor` Instead of using `addGlobalEventProcessor`, you should use `addEventProcessor` which does not add the event processor globally, but to the current client. diff --git a/codecov.yml b/codecov.yml index fcc0885b060b..1013e1b11e24 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,6 +3,11 @@ codecov: notify: require_ci_to_pass: no +ai_pr_review: + enabled: true + method: "label" + label_name: "ci-codecov-ai-review" + coverage: precision: 2 round: down diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index 88a2490c1d58..25ddc2fd8dcf 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -7,7 +7,7 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router // eslint-disable-next-line @typescript-eslint/consistent-type-imports import { NavigationCancel, NavigationError, Router } from '@angular/router'; import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router'; -import { WINDOW, getCurrentHub } from '@sentry/browser'; +import { WINDOW, getCurrentScope } from '@sentry/browser'; import type { Span, Transaction, TransactionContext } from '@sentry/types'; import { logger, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/utils'; import type { Observable } from 'rxjs'; @@ -50,14 +50,7 @@ export const instrumentAngularRouting = routingInstrumentation; * Grabs active transaction off scope */ export function getActiveTransaction(): Transaction | undefined { - const currentHub = getCurrentHub(); - - if (currentHub) { - const scope = currentHub.getScope(); - return scope.getTransaction(); - } - - return undefined; + return getCurrentScope().getTransaction(); } /** diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index e290850241c8..635c8847b9bf 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -21,16 +21,12 @@ jest.mock('@sentry/browser', () => { const original = jest.requireActual('@sentry/browser'); return { ...original, - getCurrentHub: () => { + getCurrentScope() { return { - getScope: () => { - return { - getTransaction: () => { - return transaction; - }, - }; + getTransaction: () => { + return transaction; }, - } as unknown as Hub; + }; }, }; }); diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index aa32e9dcc095..2fd98b8a96cd 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -1,6 +1,6 @@ import type { BrowserOptions } from '@sentry/browser'; import { BrowserTracing, init as initBrowserSdk } from '@sentry/browser'; -import { configureScope, hasTracingEnabled } from '@sentry/core'; +import { getCurrentScope, hasTracingEnabled } from '@sentry/core'; import { addOrUpdateIntegration } from '@sentry/utils'; import { applySdkMetadata } from '../common/metadata'; @@ -20,9 +20,7 @@ export function init(options: BrowserOptions): void { initBrowserSdk(options); - configureScope(scope => { - scope.setTag('runtime', 'browser'); - }); + getCurrentScope().setTag('runtime', 'browser'); } function addClientIntegrations(options: BrowserOptions): void { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index c62590180266..adcf95527364 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -17,6 +17,7 @@ export { captureMessage, captureCheckIn, withMonitor, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/astro/src/server/meta.ts b/packages/astro/src/server/meta.ts index 4264be2733f5..7f1f544a19e6 100644 --- a/packages/astro/src/server/meta.ts +++ b/packages/astro/src/server/meta.ts @@ -1,5 +1,5 @@ import { getDynamicSamplingContextFromClient } from '@sentry/core'; -import type { Hub, Span } from '@sentry/types'; +import type { Client, Scope, Span } from '@sentry/types'; import { TRACEPARENT_REGEXP, dynamicSamplingContextToSentryBaggageHeader, @@ -22,9 +22,11 @@ import { * * @returns an object with the two serialized tags */ -export function getTracingMetaTags(span: Span | undefined, hub: Hub): { sentryTrace: string; baggage?: string } { - const scope = hub.getScope(); - const client = hub.getClient(); +export function getTracingMetaTags( + span: Span | undefined, + scope: Scope, + client: Client | undefined, +): { sentryTrace: string; baggage?: string } { const { dsc, sampled, traceId } = scope.getPropagationContext(); const transaction = span?.transaction; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 37603a2cdb62..7b4a02cceddf 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,12 +1,12 @@ import { captureException, - configureScope, continueTrace, - getCurrentHub, + getClient, + getCurrentScope, runWithAsyncContext, startSpan, } from '@sentry/node'; -import type { Hub, Span } from '@sentry/types'; +import type { Client, Scope, Span } from '@sentry/types'; import { addNonEnumerableProperty, objectify, stripUrlQueryAndFragment } from '@sentry/utils'; import type { APIContext, MiddlewareResponseHandler } from 'astro'; @@ -69,7 +69,7 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH // if there is an active span, we know that this handle call is nested and hence // we don't create a new domain for it. If we created one, nested server calls would // create new transactions instead of adding a child span to the currently active span. - if (getCurrentHub().getScope().getSpan()) { + if (getCurrentScope().getSpan()) { return instrumentRequest(ctx, next, handlerOptions); } return runWithAsyncContext(() => { @@ -106,9 +106,7 @@ async function instrumentRequest( } if (options.trackClientIp) { - configureScope(scope => { - scope.setUser({ ip_address: ctx.clientAddress }); - }); + getCurrentScope().setUser({ ip_address: ctx.clientAddress }); } try { @@ -141,8 +139,8 @@ async function instrumentRequest( span.setHttpStatus(originalResponse.status); } - const hub = getCurrentHub(); - const client = hub.getClient(); + const scope = getCurrentScope(); + const client = getClient(); const contentType = originalResponse.headers.get('content-type'); const isPageloadRequest = contentType && contentType.startsWith('text/html'); @@ -165,7 +163,7 @@ async function instrumentRequest( start: async controller => { for await (const chunk of originalBody) { const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk); - const modifiedHtml = addMetaTagToHead(html, hub, span); + const modifiedHtml = addMetaTagToHead(html, scope, client, span); controller.enqueue(new TextEncoder().encode(modifiedHtml)); } controller.close(); @@ -187,12 +185,12 @@ async function instrumentRequest( * This function optimistically assumes that the HTML coming in chunks will not be split * within the tag. If this still happens, we simply won't replace anything. */ -function addMetaTagToHead(htmlChunk: string, hub: Hub, span?: Span): string { +function addMetaTagToHead(htmlChunk: string, scope: Scope, client: Client, span?: Span): string { if (typeof htmlChunk !== 'string') { return htmlChunk; } - const { sentryTrace, baggage } = getTracingMetaTags(span, hub); + const { sentryTrace, baggage } = getTracingMetaTags(span, scope, client); const content = `\n${sentryTrace}\n${baggage}\n`; return htmlChunk.replace('', content); } diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 8c867ca46fc2..e69d27781ed5 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,4 +1,4 @@ -import { configureScope } from '@sentry/core'; +import { getCurrentScope } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; @@ -13,7 +13,5 @@ export function init(options: NodeOptions): void { initNodeSdk(options); - configureScope(scope => { - scope.setTag('runtime', 'node'); - }); + getCurrentScope().setTag('runtime', 'node'); } diff --git a/packages/astro/test/server/meta.test.ts b/packages/astro/test/server/meta.test.ts index 6298f5f2a20b..279f36395107 100644 --- a/packages/astro/test/server/meta.test.ts +++ b/packages/astro/test/server/meta.test.ts @@ -10,22 +10,20 @@ const mockedSpan = { environment: 'production', }), }, -}; +} as any; -const mockedHub = { - getScope: () => ({ - getPropagationContext: () => ({ - traceId: '123', - }), +const mockedClient = {} as any; + +const mockedScope = { + getPropagationContext: () => ({ + traceId: '123', }), - getClient: () => ({}), -}; +} as any; describe('getTracingMetaTags', () => { it('returns the tracing tags from the span, if it is provided', () => { { - // @ts-expect-error - only passing a partial span object - const tags = getTracingMetaTags(mockedSpan, mockedHub); + const tags = getTracingMetaTags(mockedSpan, mockedScope, mockedClient); expect(tags).toEqual({ sentryTrace: '', @@ -35,10 +33,9 @@ describe('getTracingMetaTags', () => { }); it('returns propagationContext DSC data if no span is available', () => { - const tags = getTracingMetaTags(undefined, { - ...mockedHub, - // @ts-expect-error - only passing a partial scope object - getScope: () => ({ + const tags = getTracingMetaTags( + undefined, + { getPropagationContext: () => ({ traceId: '12345678901234567890123456789012', sampled: true, @@ -49,8 +46,9 @@ describe('getTracingMetaTags', () => { trace_id: '12345678901234567890123456789012', }, }), - }), - }); + } as any, + mockedClient, + ); expect(tags).toEqual({ sentryTrace: expect.stringMatching( @@ -73,7 +71,8 @@ describe('getTracingMetaTags', () => { toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1', transaction: undefined, }, - mockedHub, + mockedScope, + mockedClient, ); expect(tags).toEqual({ @@ -93,10 +92,8 @@ describe('getTracingMetaTags', () => { toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1', transaction: undefined, }, - { - ...mockedHub, - getClient: () => undefined, - }, + mockedScope, + undefined, ); expect(tags).toEqual({ diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index dc3b0139b965..5e56c6bd70ed 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -1,5 +1,6 @@ import * as SentryNode from '@sentry/node'; -import { vi } from 'vitest'; +import type { Client } from '@sentry/types'; +import { SpyInstance, vi } from 'vitest'; import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; @@ -14,14 +15,17 @@ describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); const getSpanMock = vi.fn(() => {}); - // @ts-expect-error only returning a partial hub here - vi.spyOn(SentryNode, 'getCurrentHub').mockImplementation(() => { - return { - getScope: () => ({ + const setUserMock = vi.fn(); + + beforeEach(() => { + vi.spyOn(SentryNode, 'getCurrentScope').mockImplementation(() => { + return { + setUser: setUserMock, + setPropagationContext: vi.fn(), getSpan: getSpanMock, - }), - getClient: () => ({}), - }; + } as any; + }); + vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({}) as Client); }); const nextResult = Promise.resolve(new Response(null, { status: 200, headers: new Headers() })); @@ -170,10 +174,6 @@ describe('sentryMiddleware', () => { }); it('attaches client IP and request headers if options are set', async () => { - const scope = { setUser: vi.fn(), setPropagationContext: vi.fn() }; - // @ts-expect-error, only passing a partial Scope object - const configureScopeSpy = vi.spyOn(SentryNode, 'configureScope').mockImplementation(cb => cb(scope)); - const middleware = handleRequest({ trackClientIp: true, trackHeaders: true }); const ctx = { request: { @@ -192,8 +192,7 @@ describe('sentryMiddleware', () => { // @ts-expect-error, a partial ctx object is fine here await middleware(ctx, next); - expect(configureScopeSpy).toHaveBeenCalledTimes(1); - expect(scope.setUser).toHaveBeenCalledWith({ ip_address: '192.168.0.1' }); + expect(setUserMock).toHaveBeenCalledWith({ ip_address: '192.168.0.1' }); expect(startSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/browser-integration-tests/suites/replay/dsc/test.ts b/packages/browser-integration-tests/suites/replay/dsc/test.ts index ffd2cf1877da..4468a254bde4 100644 --- a/packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -21,6 +21,9 @@ sentryTest( const transactionReq = waitForTransactionRequest(page); + // Wait for this to be available + await page.waitForFunction('!!window.Replay'); + await page.evaluate(() => { (window as unknown as TestWindow).Replay.start(); }); @@ -28,10 +31,9 @@ sentryTest( await waitForReplayRunning(page); await page.evaluate(() => { - (window as unknown as TestWindow).Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); - }); + const scope = (window as unknown as TestWindow).Sentry.getCurrentScope(); + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); }); const req0 = await transactionReq; @@ -74,10 +76,9 @@ sentryTest( await waitForReplayRunning(page); await page.evaluate(() => { - (window as unknown as TestWindow).Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); - }); + const scope = (window as unknown as TestWindow).Sentry.getCurrentScope(); + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); }); const req0 = await transactionReq; @@ -132,10 +133,9 @@ sentryTest( }); await page.evaluate(() => { - (window as unknown as TestWindow).Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); - }); + const scope = (window as unknown as TestWindow).Sentry.getCurrentScope(); + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); }); const req0 = await transactionReq; @@ -181,10 +181,9 @@ sentryTest( const transactionReq = waitForTransactionRequest(page); await page.evaluate(async () => { - (window as unknown as TestWindow).Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); - }); + const scope = (window as unknown as TestWindow).Sentry.getCurrentScope(); + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); }); const req0 = await transactionReq; diff --git a/packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/init.js b/packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/init.js index efb7b577f75b..7d000c0ac2cd 100644 --- a/packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/init.js +++ b/packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/init.js @@ -11,8 +11,7 @@ Sentry.init({ debug: true, }); -Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); - scope.getTransaction().setMetadata({ source: 'custom' }); -}); +const scope = Sentry.getCurrentScope(); +scope.setUser({ id: 'user123', segment: 'segmentB' }); +scope.setTransactionName('testTransactionDSC'); +scope.getTransaction().setMetadata({ source: 'custom' }); diff --git a/packages/browser-integration-tests/suites/tracing/envelope-header/init.js b/packages/browser-integration-tests/suites/tracing/envelope-header/init.js index fbce5a16116a..f382a49c153d 100644 --- a/packages/browser-integration-tests/suites/tracing/envelope-header/init.js +++ b/packages/browser-integration-tests/suites/tracing/envelope-header/init.js @@ -11,7 +11,6 @@ Sentry.init({ debug: true, }); -Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); -}); +const scope = Sentry.getCurrentScope(); +scope.setUser({ id: 'user123', segment: 'segmentB' }); +scope.setTransactionName('testTransactionDSC'); diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index e361f1366cf3..6955fbfa26fe 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { Event, EventHint, Exception, Severity, SeverityLevel, StackFrame, StackParser } from '@sentry/types'; import { addExceptionMechanism, @@ -48,8 +48,7 @@ export function eventFromPlainObject( syntheticException?: Error, isUnhandledRejection?: boolean, ): Event { - const hub = getCurrentHub(); - const client = hub.getClient(); + const client = getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; const event: Event = { diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 82eedf4e846f..cc9712517b2b 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -30,6 +30,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, flush, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index cfcb255f5999..9c6b4cfb9764 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import { getClient, getCurrentHub } from '@sentry/core'; +import { addBreadcrumb, getClient } from '@sentry/core'; import type { Event as SentryEvent, HandlerDataConsole, @@ -123,7 +123,7 @@ export class Breadcrumbs implements Integration { * Adds a breadcrumb for Sentry events or transactions if this option is enabled. */ function addSentryBreadcrumb(event: SentryEvent): void { - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: `sentry.${event.type === 'transaction' ? 'transaction' : 'event'}`, event_id: event.event_id, @@ -173,7 +173,7 @@ function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDa return; } - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: `ui.${handlerData.name}`, message: target, @@ -213,7 +213,7 @@ function _consoleBreadcrumb(handlerData: HandlerDataConsole): void { } } - getCurrentHub().addBreadcrumb(breadcrumb, { + addBreadcrumb(breadcrumb, { input: handlerData.args, level: handlerData.level, }); @@ -247,7 +247,7 @@ function _xhrBreadcrumb(handlerData: HandlerDataXhr): void { endTimestamp, }; - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'xhr', data, @@ -282,7 +282,7 @@ function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { endTimestamp, }; - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'fetch', data, @@ -303,7 +303,7 @@ function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { startTimestamp, endTimestamp, }; - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'fetch', data, @@ -338,7 +338,7 @@ function _historyBreadcrumb(handlerData: HandlerDataHistory): void { from = parsedFrom.relative; } - getCurrentHub().addBreadcrumb({ + addBreadcrumb({ category: 'navigation', data: { from, diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index 0c3f3be60e2d..079ef6083212 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { getCurrentHub } from '@sentry/core'; -import type { Event, Hub, Integration, Primitive, StackParser } from '@sentry/types'; +import { captureEvent, getClient } from '@sentry/core'; +import type { Client, Event, Integration, Primitive, StackParser } from '@sentry/types'; import { addGlobalErrorInstrumentationHandler, addGlobalUnhandledRejectionInstrumentationHandler, @@ -36,12 +36,6 @@ export class GlobalHandlers implements Integration { /** JSDoc */ private readonly _options: GlobalHandlersIntegrations; - /** - * Stores references functions to installing handlers. Will set to undefined - * after they have been run so that they are not used twice. - */ - private _installFunc: Record void) | undefined>; - /** JSDoc */ public constructor(options?: GlobalHandlersIntegrations) { this.name = GlobalHandlers.id; @@ -50,43 +44,36 @@ export class GlobalHandlers implements Integration { onunhandledrejection: true, ...options, }; - - this._installFunc = { - onerror: _installGlobalOnErrorHandler, - onunhandledrejection: _installGlobalOnUnhandledRejectionHandler, - }; } /** * @inheritDoc */ public setupOnce(): void { Error.stackTraceLimit = 50; - const options = this._options; - - // We can disable guard-for-in as we construct the options object above + do checks against - // `this._installFunc` for the property. - // eslint-disable-next-line guard-for-in - for (const key in options) { - const installFunc = this._installFunc[key as GlobalHandlersIntegrationsOptionKeys]; - if (installFunc && options[key as GlobalHandlersIntegrationsOptionKeys]) { - globalHandlerLog(key); - installFunc(); - this._installFunc[key as GlobalHandlersIntegrationsOptionKeys] = undefined; - } + } + + /** @inheritdoc */ + public setup(client: Client): void { + if (this._options.onerror) { + _installGlobalOnErrorHandler(client); + globalHandlerLog('onerror'); + } + if (this._options.onunhandledrejection) { + _installGlobalOnUnhandledRejectionHandler(client); + globalHandlerLog('onunhandledrejection'); } } } -function _installGlobalOnErrorHandler(): void { +function _installGlobalOnErrorHandler(client: Client): void { addGlobalErrorInstrumentationHandler(data => { - const [hub, stackParser, attachStacktrace] = getHubAndOptions(); - if (!hub.getIntegration(GlobalHandlers)) { + const { stackParser, attachStacktrace } = getOptions(); + + if (getClient() !== client || shouldIgnoreOnError()) { return; } + const { msg, url, line, column, error } = data; - if (shouldIgnoreOnError()) { - return; - } const event = error === undefined && isString(msg) @@ -100,7 +87,7 @@ function _installGlobalOnErrorHandler(): void { event.level = 'error'; - hub.captureEvent(event, { + captureEvent(event, { originalException: error, mechanism: { handled: false, @@ -110,15 +97,12 @@ function _installGlobalOnErrorHandler(): void { }); } -function _installGlobalOnUnhandledRejectionHandler(): void { +function _installGlobalOnUnhandledRejectionHandler(client: Client): void { addGlobalUnhandledRejectionInstrumentationHandler(e => { - const [hub, stackParser, attachStacktrace] = getHubAndOptions(); - if (!hub.getIntegration(GlobalHandlers)) { - return; - } + const { stackParser, attachStacktrace } = getOptions(); - if (shouldIgnoreOnError()) { - return true; + if (getClient() !== client || shouldIgnoreOnError()) { + return; } const error = _getUnhandledRejectionError(e as unknown); @@ -129,15 +113,13 @@ function _installGlobalOnUnhandledRejectionHandler(): void { event.level = 'error'; - hub.captureEvent(event, { + captureEvent(event, { originalException: error, mechanism: { handled: false, type: 'onunhandledrejection', }, }); - - return; }); } @@ -258,12 +240,11 @@ function globalHandlerLog(type: string): void { DEBUG_BUILD && logger.log(`Global Handler attached: ${type}`); } -function getHubAndOptions(): [Hub, StackParser, boolean | undefined] { - const hub = getCurrentHub(); - const client = hub.getClient(); +function getOptions(): { stackParser: StackParser; attachStacktrace?: boolean } { + const client = getClient(); const options = (client && client.getOptions()) || { stackParser: () => [], attachStacktrace: false, }; - return [hub, options.stackParser, options.attachStacktrace]; + return options; } diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 326af29492cf..5173705feaa6 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,4 +1,5 @@ -import type { EventEnvelope, EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; +import { getCurrentScope } from '@sentry/core'; +import type { Client, EventEnvelope, EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; import type { Profile } from '@sentry/types/src/profiling'; import { logger } from '@sentry/utils'; @@ -29,6 +30,7 @@ export class BrowserProfilingIntegration implements Integration { public readonly name: string; + /** @deprecated This is never set. */ public getCurrentHub?: () => Hub; public constructor() { @@ -38,12 +40,13 @@ export class BrowserProfilingIntegration implements Integration { /** * @inheritDoc */ - public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - this.getCurrentHub = getCurrentHub; + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { + // noop + } - const hub = this.getCurrentHub(); - const client = hub.getClient(); - const scope = hub.getScope(); + /** @inheritdoc */ + public setup(client: Client): void { + const scope = getCurrentScope(); const transaction = scope.getTransaction(); @@ -53,67 +56,68 @@ export class BrowserProfilingIntegration implements Integration { } } - if (client && typeof client.on === 'function') { - client.on('startTransaction', (transaction: Transaction) => { - if (shouldProfileTransaction(transaction)) { - startProfileForTransaction(transaction); + if (typeof client.on !== 'function') { + logger.warn('[Profiling] Client does not support hooks, profiling will be disabled'); + return; + } + + client.on('startTransaction', (transaction: Transaction) => { + if (shouldProfileTransaction(transaction)) { + startProfileForTransaction(transaction); + } + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!getActiveProfilesCount()) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction && profiledTransaction.contexts; + const profile_id = context && context['profile'] && context['profile']['profile_id']; + const start_timestamp = context && context['profile'] && context['profile']['start_timestamp']; + + if (typeof profile_id !== 'string') { + DEBUG_BUILD && logger.log('[Profiling] cannot find profile for a transaction without a profile context'); + continue; } - }); - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; + if (!profile_id) { + DEBUG_BUILD && logger.log('[Profiling] cannot find profile for a transaction without a profile context'); + continue; } - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (context && context['profile']) { + delete context.profile; } - const profilesToAddToEnvelope: Profile[] = []; - - for (const profiledTransaction of profiledTransactionEvents) { - const context = profiledTransaction && profiledTransaction.contexts; - const profile_id = context && context['profile'] && context['profile']['profile_id']; - const start_timestamp = context && context['profile'] && context['profile']['start_timestamp']; - - if (typeof profile_id !== 'string') { - DEBUG_BUILD && logger.log('[Profiling] cannot find profile for a transaction without a profile context'); - continue; - } - - if (!profile_id) { - DEBUG_BUILD && logger.log('[Profiling] cannot find profile for a transaction without a profile context'); - continue; - } - - // Remove the profile from the transaction context before sending, relay will take care of the rest. - if (context && context['profile']) { - delete context.profile; - } - - const profile = takeProfileFromGlobalCache(profile_id); - if (!profile) { - DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); - continue; - } - - const profileEvent = createProfilingEvent( - profile_id, - start_timestamp as number | undefined, - profile, - profiledTransaction as ProfiledEvent, - ); - if (profileEvent) { - profilesToAddToEnvelope.push(profileEvent); - } + const profile = takeProfileFromGlobalCache(profile_id); + if (!profile) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; } - addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); - }); - } else { - logger.warn('[Profiling] Client does not support hooks, profiling will be disabled'); - } + const profileEvent = createProfilingEvent( + profile_id, + start_timestamp as number | undefined, + profile, + profiledTransaction as ProfiledEvent, + ); + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } + } + + addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); + }); } } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 3edb82e0b539..f2fdc5e4c10d 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ -import { DEFAULT_ENVIRONMENT, getClient, getCurrentHub } from '@sentry/core'; +import { DEFAULT_ENVIRONMENT, getClient } from '@sentry/core'; import type { DebugImage, Envelope, Event, EventEnvelope, StackFrame, StackParser, Transaction } from '@sentry/types'; import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; import { GLOBAL_OBJ, browserPerformanceTimeOrigin, forEachEnvelopeItem, logger, uuid4 } from '@sentry/utils'; @@ -347,19 +347,10 @@ export function applyDebugMetadata(resource_paths: ReadonlyArray): Debug return []; } - const hub = getCurrentHub(); - if (!hub) { - return []; - } - const client = hub.getClient(); - if (!client) { - return []; - } - const options = client.getOptions(); - if (!options) { - return []; - } - const stackParser = options.stackParser; + const client = getClient(); + const options = client && client.getOptions(); + const stackParser = options && options.stackParser; + if (!stackParser) { return []; } diff --git a/packages/browser/test/unit/index.test.ts b/packages/browser/test/unit/index.test.ts index bc0058ba7d16..5968349adc1a 100644 --- a/packages/browser/test/unit/index.test.ts +++ b/packages/browser/test/unit/index.test.ts @@ -12,10 +12,10 @@ import { captureEvent, captureException, captureMessage, - configureScope, flush, getClient, getCurrentHub, + getCurrentScope, init, showReportDialog, wrap, @@ -37,9 +37,10 @@ jest.mock('@sentry/core', () => { }); describe('SentryBrowser', () => { - const beforeSend = jest.fn(); + const beforeSend = jest.fn(event => event); - beforeAll(() => { + beforeEach(() => { + WINDOW.__SENTRY__ = { hub: undefined, logger: undefined, globalEventProcessors: [] }; init({ beforeSend, dsn, @@ -47,39 +48,28 @@ describe('SentryBrowser', () => { }); }); - beforeEach(() => { - getCurrentHub().pushScope(); - }); - afterEach(() => { - getCurrentHub().popScope(); - beforeSend.mockReset(); + beforeSend.mockClear(); }); describe('getContext() / setContext()', () => { it('should store/load extra', () => { - configureScope((scope: Scope) => { - scope.setExtra('abc', { def: [1] }); - }); - expect(global.__SENTRY__.hub._stack[1].scope._extra).toEqual({ + getCurrentScope().setExtra('abc', { def: [1] }); + expect(global.__SENTRY__.hub._stack[0].scope._extra).toEqual({ abc: { def: [1] }, }); }); it('should store/load tags', () => { - configureScope((scope: Scope) => { - scope.setTag('abc', 'def'); - }); - expect(global.__SENTRY__.hub._stack[1].scope._tags).toEqual({ + getCurrentScope().setTag('abc', 'def'); + expect(global.__SENTRY__.hub._stack[0].scope._tags).toEqual({ abc: 'def', }); }); it('should store/load user', () => { - configureScope((scope: Scope) => { - scope.setUser({ id: 'def' }); - }); - expect(global.__SENTRY__.hub._stack[1].scope._user).toEqual({ + getCurrentScope().setUser({ id: 'def' }); + expect(global.__SENTRY__.hub._stack[0].scope._user).toEqual({ id: 'def', }); }); @@ -95,9 +85,7 @@ describe('SentryBrowser', () => { const options = getDefaultBrowserClientOptions({ dsn }); const client = new BrowserClient(options); it('uses the user on the scope', () => { - configureScope(scope => { - scope.setUser(EX_USER); - }); + getCurrentScope().setUser(EX_USER); getCurrentHub().bindClient(client); showReportDialog(); @@ -110,9 +98,7 @@ describe('SentryBrowser', () => { }); it('prioritizes options user over scope user', () => { - configureScope(scope => { - scope.setUser(EX_USER); - }); + getCurrentScope().setUser(EX_USER); getCurrentHub().bindClient(client); const DIALOG_OPTION_USER = { email: 'option@example.com' }; diff --git a/packages/browser/test/unit/integrations/breadcrumbs.test.ts b/packages/browser/test/unit/integrations/breadcrumbs.test.ts index d81107a69c38..e87454737482 100644 --- a/packages/browser/test/unit/integrations/breadcrumbs.test.ts +++ b/packages/browser/test/unit/integrations/breadcrumbs.test.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import type { Client } from '@sentry/types'; import { Breadcrumbs, BrowserClient, Hub, flush } from '../../../src'; @@ -18,21 +18,20 @@ jest.mock('@sentry/core', () => { describe('Breadcrumbs', () => { it('Should add sentry breadcrumb', async () => { - const addBreadcrumb = jest.fn(); - hub.addBreadcrumb = addBreadcrumb; - client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [new Breadcrumbs()], }); - getCurrentHub().bindClient(client); + SentryCore.getCurrentHub().bindClient(client); + + const addBreadcrumbSpy = jest.spyOn(SentryCore, 'addBreadcrumb').mockImplementation(() => {}); client.captureMessage('test'); await flush(2000); - expect(addBreadcrumb.mock.calls[0][0].category).toEqual('sentry.event'); - expect(addBreadcrumb.mock.calls[0][0].message).toEqual('test'); + expect(addBreadcrumbSpy.mock.calls[0][0].category).toEqual('sentry.event'); + expect(addBreadcrumbSpy.mock.calls[0][0].message).toEqual('test'); }); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5a4260aaec38..499e969f3843 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -33,6 +33,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index a6e0575a2e67..3fbdd13250f8 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -48,7 +48,7 @@ import { import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; import { DEBUG_BUILD } from './debug-build'; import { createEventEnvelope, createSessionEnvelope } from './envelope'; -import { getCurrentHub } from './hub'; +import { getClient } from './exports'; import type { IntegrationIndex } from './integration'; import { setupIntegration, setupIntegrations } from './integration'; import { createMetricEnvelope } from './metrics/envelope'; @@ -115,14 +115,14 @@ export abstract class BaseClient implements Client { /** Number of calls being processed */ protected _numProcessing: number; + protected _eventProcessors: EventProcessor[]; + /** Holds flushable */ private _outcomes: { [key: string]: number }; // eslint-disable-next-line @typescript-eslint/ban-types private _hooks: Record; - private _eventProcessors: EventProcessor[]; - /** * Initializes this client instance. * @@ -870,7 +870,7 @@ function isTransactionEvent(event: Event): event is TransactionEvent { * This event processor will run for all events processed by this client. */ export function addEventProcessor(callback: EventProcessor): void { - const client = getCurrentHub().getClient(); + const client = getClient(); if (!client || !client.addEventProcessor) { return; diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 6f71e7dfbccb..95c1e4b63de3 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -1,5 +1,6 @@ import type { Breadcrumb, + BreadcrumbHint, CaptureContext, CheckIn, Client, @@ -77,8 +78,11 @@ export function captureEvent(event: Event, hint?: EventHint): ReturnType void): ReturnType { + // eslint-disable-next-line deprecation/deprecation getCurrentHub().configureScope(callback); } @@ -90,8 +94,8 @@ export function configureScope(callback: (scope: Scope) => void): ReturnType { - getCurrentHub().addBreadcrumb(breadcrumb); +export function addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): ReturnType { + getCurrentHub().addBreadcrumb(breadcrumb, hint); } /** @@ -163,8 +167,8 @@ export function setUser(user: User | null): ReturnType { * * @param callback that will be enclosed into push/popScope. */ -export function withScope(callback: (scope: Scope) => void): ReturnType { - getCurrentHub().withScope(callback); +export function withScope(callback: (scope: Scope) => T): T { + return getCurrentHub().withScope(callback); } /** @@ -202,9 +206,8 @@ export function startTransaction( * to create a monitor automatically when sending a check in. */ export function captureCheckIn(checkIn: CheckIn, upsertMonitorConfig?: MonitorConfig): string { - const hub = getCurrentHub(); - const scope = hub.getScope(); - const client = hub.getClient(); + const scope = getCurrentScope(); + const client = getClient(); if (!client) { DEBUG_BUILD && logger.warn('Cannot capture check-in. No client defined.'); } else if (!client.captureCheckIn) { diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index 13d5fd059e93..75960550081a 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -139,6 +139,8 @@ export class Hub implements HubInterface { /** * @inheritDoc + * + * @deprecated Use `withScope` instead. */ public pushScope(): Scope { // We want to clone the content of prev scope @@ -152,6 +154,8 @@ export class Hub implements HubInterface { /** * @inheritDoc + * + * @deprecated Use `withScope` instead. */ public popScope(): boolean { if (this.getStack().length <= 1) return false; @@ -161,11 +165,13 @@ export class Hub implements HubInterface { /** * @inheritDoc */ - public withScope(callback: (scope: Scope) => void): void { + public withScope(callback: (scope: Scope) => T): T { + // eslint-disable-next-line deprecation/deprecation const scope = this.pushScope(); try { - callback(scope); + return callback(scope); } finally { + // eslint-disable-next-line deprecation/deprecation this.popScope(); } } @@ -335,6 +341,8 @@ export class Hub implements HubInterface { /** * @inheritDoc + * + * @deprecated Use `getScope()` directly. */ public configureScope(callback: (scope: Scope) => void): void { const { scope, client } = this.getStackTop(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ccf219031b8f..6468c312bbe1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, flush, lastEventId, @@ -43,6 +44,7 @@ export { makeSession, closeSession, updateSession } from './session'; export { SessionFlusher } from './sessionflusher'; export { Scope } from './scope'; export { + notifyEventProcessors, // eslint-disable-next-line deprecation/deprecation addGlobalEventProcessor, } from './eventProcessors'; @@ -54,7 +56,12 @@ export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; export { makeMultiplexedTransport } from './transports/multiplexed'; export { SDK_VERSION } from './version'; -export { getIntegrationsToSetup, addIntegration } from './integration'; +export { + getIntegrationsToSetup, + addIntegration, + // eslint-disable-next-line deprecation/deprecation + convertIntegrationFnToClass, +} from './integration'; export { FunctionToString, InboundFilters, LinkedErrors } from './integrations'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 7be22a316e53..b7782fcfa65c 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,4 +1,4 @@ -import type { Client, Event, EventHint, Integration, Options } from '@sentry/types'; +import type { Client, Event, EventHint, Integration, IntegrationClass, IntegrationFn, Options } from '@sentry/types'; import { arrayify, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; @@ -46,7 +46,7 @@ function filterDuplicates(integrations: Integration[]): Integration[] { } /** Gets integrations to install */ -export function getIntegrationsToSetup(options: Options): Integration[] { +export function getIntegrationsToSetup(options: Pick): Integration[] { const defaultIntegrations = options.defaultIntegrations || []; const userIntegrations = options.integrations; @@ -155,3 +155,26 @@ function findIndex(arr: T[], callback: (item: T) => boolean): number { return -1; } + +/** + * Convert a new integration function to the legacy class syntax. + * In v8, we can remove this and instead export the integration functions directly. + * + * @deprecated This will be removed in v8! + */ +export function convertIntegrationFnToClass( + name: string, + fn: Fn, +): IntegrationClass { + return Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function ConvertedIntegration(...rest: any[]) { + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + setupOnce: () => {}, + ...fn(...rest), + }; + }, + { id: name }, + ) as unknown as IntegrationClass; +} diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 9d348a4b4d23..57c0387b25e4 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -1,7 +1,8 @@ -import type { Client, Event, EventHint, Integration, StackFrame } from '@sentry/types'; +import type { Event, IntegrationFn, StackFrame } from '@sentry/types'; import { getEventDescription, logger, stringMatchesSomePattern } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; +import { convertIntegrationFnToClass } from '../integration'; // "Script error." is hard coded into browsers for errors that it can't read. // this is the result of a script being pulled in from an external domain and CORS. @@ -28,42 +29,23 @@ export interface InboundFiltersOptions { disableTransactionDefaults: boolean; } -/** Inbound filters configurable by the user */ -export class InboundFilters implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'InboundFilters'; - - /** - * @inheritDoc - */ - public name: string; - - private readonly _options: Partial; - - public constructor(options: Partial = {}) { - this.name = InboundFilters.id; - this._options = options; - } - - /** - * @inheritDoc - */ - public setupOnce(_addGlobalEventProcessor: unknown, _getCurrentHub: unknown): void { - // noop - } +const INTEGRATION_NAME = 'InboundFilters'; +const inboundFiltersIntegration: IntegrationFn = (options: Partial) => { + return { + name: INTEGRATION_NAME, + processEvent(event, _hint, client) { + const clientOptions = client.getOptions(); + const mergedOptions = _mergeOptions(options, clientOptions); + return _shouldDropEvent(event, mergedOptions) ? null : event; + }, + }; +}; - /** @inheritDoc */ - public processEvent(event: Event, _eventHint: EventHint, client: Client): Event | null { - const clientOptions = client.getOptions(); - const options = _mergeOptions(this._options, clientOptions); - return _shouldDropEvent(event, options) ? null : event; - } -} +/** Inbound filters configurable by the user */ +// eslint-disable-next-line deprecation/deprecation +export const InboundFilters = convertIntegrationFnToClass(INTEGRATION_NAME, inboundFiltersIntegration); -/** JSDoc */ -export function _mergeOptions( +function _mergeOptions( internalOptions: Partial = {}, clientOptions: Partial = {}, ): Partial { @@ -84,8 +66,7 @@ export function _mergeOptions( }; } -/** JSDoc */ -export function _shouldDropEvent(event: Event, options: Partial): boolean { +function _shouldDropEvent(event: Event, options: Partial): boolean { if (options.ignoreInternal && _isSentryError(event)) { DEBUG_BUILD && logger.warn(`Event dropped due to being internal Sentry Error.\nEvent: ${getEventDescription(event)}`); diff --git a/packages/core/src/metrics/exports.ts b/packages/core/src/metrics/exports.ts index c27e76cf79b1..22a5e83ffb3d 100644 --- a/packages/core/src/metrics/exports.ts +++ b/packages/core/src/metrics/exports.ts @@ -2,7 +2,7 @@ import type { ClientOptions, MeasurementUnit, Primitive } from '@sentry/types'; import { logger } from '@sentry/utils'; import type { BaseClient } from '../baseclient'; import { DEBUG_BUILD } from '../debug-build'; -import { getCurrentHub } from '../hub'; +import { getClient, getCurrentScope } from '../exports'; import { COUNTER_METRIC_TYPE, DISTRIBUTION_METRIC_TYPE, GAUGE_METRIC_TYPE, SET_METRIC_TYPE } from './constants'; import { MetricsAggregator } from './integration'; import type { MetricType } from './types'; @@ -19,9 +19,8 @@ function addToMetricsAggregator( value: number | string, data: MetricData = {}, ): void { - const hub = getCurrentHub(); - const client = hub.getClient() as BaseClient; - const scope = hub.getScope(); + const client = getClient>(); + const scope = getCurrentScope(); if (client) { if (!client.metricsAggregator) { DEBUG_BUILD && diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 4165aec8fc46..719e2b81f086 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -16,7 +16,7 @@ import { eventFromMessage, eventFromUnknownInput, logger, resolvedSyncPromise, u import { BaseClient } from './baseclient'; import { createCheckInEnvelope } from './checkin'; import { DEBUG_BUILD } from './debug-build'; -import { getCurrentHub } from './hub'; +import { getClient } from './exports'; import type { Scope } from './scope'; import { SessionFlusher } from './sessionflusher'; import { addTracingExtensions, getDynamicSamplingContextFromClient } from './tracing'; @@ -50,7 +50,7 @@ export class ServerRuntimeClient< * @inheritDoc */ public eventFromException(exception: unknown, hint?: EventHint): PromiseLike { - return resolvedSyncPromise(eventFromUnknownInput(getCurrentHub, this._options.stackParser, exception, hint)); + return resolvedSyncPromise(eventFromUnknownInput(getClient(), this._options.stackParser, exception, hint)); } /** diff --git a/packages/core/src/sessionflusher.ts b/packages/core/src/sessionflusher.ts index 0b0bc8455480..dac81b82336d 100644 --- a/packages/core/src/sessionflusher.ts +++ b/packages/core/src/sessionflusher.ts @@ -6,8 +6,7 @@ import type { SessionFlusherLike, } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; - -import { getCurrentHub } from './hub'; +import { getCurrentScope } from './exports'; type ReleaseHealthAttributes = { environment?: string; @@ -75,7 +74,7 @@ export class SessionFlusher implements SessionFlusherLike { if (!this._isEnabled) { return; } - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const requestSession = scope.getRequestSession(); if (requestSession && requestSession.status) { diff --git a/packages/core/src/tracing/idletransaction.ts b/packages/core/src/tracing/idletransaction.ts index b49b1d15e9b1..75630de373f1 100644 --- a/packages/core/src/tracing/idletransaction.ts +++ b/packages/core/src/tracing/idletransaction.ts @@ -121,7 +121,7 @@ export class IdleTransaction extends Transaction { // We set the transaction here on the scope so error events pick up the trace // context and attach it to the error. DEBUG_BUILD && logger.log(`Setting idle transaction on scope. Span ID: ${this.spanId}`); - _idleHub.configureScope(scope => scope.setSpan(this)); + _idleHub.getScope().setSpan(this); } this._restartIdleTimeout(); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 667bedaaef6c..cc73fe009e3d 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -2,6 +2,7 @@ import type { TransactionContext } from '@sentry/types'; import { dropUndefinedKeys, isThenable, logger, tracingContextFromHeaders } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; +import { getCurrentScope } from '../exports'; import type { Hub } from '../hub'; import { getCurrentHub } from '../hub'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; @@ -23,12 +24,14 @@ export function trace( context: TransactionContext, callback: (span?: Span) => T, // eslint-disable-next-line @typescript-eslint/no-empty-function - onError: (error: unknown) => void = () => {}, + onError: (error: unknown, span?: Span) => void = () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + afterFinish: () => void = () => {}, ): T { const ctx = normalizeContext(context); const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); const activeSpan = createChildSpanOrTransaction(hub, parentSpan, ctx); @@ -37,7 +40,7 @@ export function trace( function finishAndSetSpan(): void { activeSpan && activeSpan.finish(); - hub.getScope().setSpan(parentSpan); + scope.setSpan(parentSpan); } let maybePromiseResult: T; @@ -45,8 +48,9 @@ export function trace( maybePromiseResult = callback(activeSpan); } catch (e) { activeSpan && activeSpan.setStatus('internal_error'); - onError(e); + onError(e, activeSpan); finishAndSetSpan(); + afterFinish(); throw e; } @@ -54,15 +58,18 @@ export function trace( Promise.resolve(maybePromiseResult).then( () => { finishAndSetSpan(); + afterFinish(); }, e => { activeSpan && activeSpan.setStatus('internal_error'); - onError(e); + onError(e, activeSpan); finishAndSetSpan(); + afterFinish(); }, ); } else { finishAndSetSpan(); + afterFinish(); } return maybePromiseResult; @@ -83,7 +90,7 @@ export function startSpan(context: TransactionContext, callback: (span: Span const ctx = normalizeContext(context); const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); const activeSpan = createChildSpanOrTransaction(hub, parentSpan, ctx); @@ -91,7 +98,7 @@ export function startSpan(context: TransactionContext, callback: (span: Span function finishAndSetSpan(): void { activeSpan && activeSpan.finish(); - hub.getScope().setSpan(parentSpan); + scope.setSpan(parentSpan); } let maybePromiseResult: T; @@ -143,7 +150,7 @@ export function startSpanManual( const ctx = normalizeContext(context); const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); const activeSpan = createChildSpanOrTransaction(hub, parentSpan, ctx); @@ -151,7 +158,7 @@ export function startSpanManual( function finishAndSetSpan(): void { activeSpan && activeSpan.finish(); - hub.getScope().setSpan(parentSpan); + scope.setSpan(parentSpan); } let maybePromiseResult: T; @@ -201,7 +208,7 @@ export function startInactiveSpan(context: TransactionContext): Span | undefined * Returns the currently active span. */ export function getActiveSpan(): Span | undefined { - return getCurrentHub().getScope().getSpan(); + return getCurrentScope().getSpan(); } export function continueTrace({ @@ -238,8 +245,7 @@ export function continueTrace( }, callback?: (transactionContext: Partial) => V, ): V | Partial { - const hub = getCurrentHub(); - const currentScope = hub.getScope(); + const currentScope = getCurrentScope(); const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( sentryTrace, diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts index 0256e3cf7835..3a31f63cf46c 100644 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -1,11 +1,13 @@ -import type { DsnComponents, Hub } from '@sentry/types'; +import type { Client, DsnComponents, Hub } from '@sentry/types'; /** * Checks whether given url points to Sentry server * @param url url to verify + * + * TODO(v8): Remove Hub fallback type */ -export function isSentryRequestUrl(url: string, hub: Hub): boolean { - const client = hub.getClient(); +export function isSentryRequestUrl(url: string, hubOrClient: Hub | Client | undefined): boolean { + const client = hubOrClient && isHub(hubOrClient) ? hubOrClient.getClient() : hubOrClient; const dsn = client && client.getDsn(); const tunnel = client && client.getOptions().tunnel; @@ -27,3 +29,7 @@ function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { function removeTrailingSlash(str: string): string { return str[str.length - 1] === '/' ? str.slice(0, -1) : str; } + +function isHub(hubOrClient: Hub | Client | undefined): hubOrClient is Hub { + return (hubOrClient as Hub).getClient !== undefined; +} diff --git a/packages/core/test/lib/exports.test.ts b/packages/core/test/lib/exports.test.ts new file mode 100644 index 000000000000..89b4fd9105d5 --- /dev/null +++ b/packages/core/test/lib/exports.test.ts @@ -0,0 +1,53 @@ +import { Hub, Scope, getCurrentScope, makeMain, withScope } from '../../src'; +import { TestClient, getDefaultTestClientOptions } from '../mocks/client'; + +function getTestClient(): TestClient { + return new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + }), + ); +} + +describe('withScope', () => { + beforeEach(() => { + const client = getTestClient(); + const hub = new Hub(client); + makeMain(hub); + }); + + it('works without a return value', () => { + const scope1 = getCurrentScope(); + expect(scope1).toBeInstanceOf(Scope); + + scope1.setTag('foo', 'bar'); + + const res = withScope(scope => { + expect(scope).toBeInstanceOf(Scope); + expect(scope).not.toBe(scope1); + expect(scope['_tags']).toEqual({ foo: 'bar' }); + + expect(getCurrentScope()).toBe(scope); + }); + + expect(getCurrentScope()).toBe(scope1); + expect(res).toBe(undefined); + }); + + it('works with a return value', () => { + const res = withScope(scope => { + return 'foo'; + }); + + expect(res).toBe('foo'); + }); + + it('works with an async function', async () => { + const res = withScope(async scope => { + return 'foo'; + }); + + expect(res).toBeInstanceOf(Promise); + expect(await res).toBe('foo'); + }); +}); diff --git a/packages/core/test/lib/hint.test.ts b/packages/core/test/lib/hint.test.ts index cdcfa9368cbe..5fb69ce39fff 100644 --- a/packages/core/test/lib/hint.test.ts +++ b/packages/core/test/lib/hint.test.ts @@ -1,4 +1,4 @@ -import { captureEvent, configureScope } from '@sentry/core'; +import { captureEvent, getCurrentScope } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/utils'; import { initAndBind } from '../../src/sdk'; @@ -109,7 +109,7 @@ describe('Hint', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); initAndBind(TestClient, options); - configureScope(scope => scope.addAttachment({ filename: 'scope.file', data: 'great content!' })); + getCurrentScope().addAttachment({ filename: 'scope.file', data: 'great content!' }); captureEvent({}, { attachments: [{ filename: 'some-file.txt', data: 'Hello' }] }); diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 54bd426abb5c..65bf30483d86 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -2,7 +2,13 @@ import type { Integration, Options } from '@sentry/types'; import { logger } from '@sentry/utils'; import { Hub, makeMain } from '../../src/hub'; -import { addIntegration, getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration'; +import { + addIntegration, + convertIntegrationFnToClass, + getIntegrationsToSetup, + installedIntegrations, + setupIntegration, +} from '../../src/integration'; import { TestClient, getDefaultTestClientOptions } from '../mocks/client'; function getTestClient(): TestClient { @@ -647,3 +653,51 @@ describe('addIntegration', () => { expect(warnings).toHaveBeenCalledWith('Cannot add integration "test" because no SDK Client is available.'); }); }); + +describe('convertIntegrationFnToClass', () => { + /* eslint-disable deprecation/deprecation */ + it('works with a minimal integration', () => { + const integrationFn = () => ({ name: 'testName' }); + + const IntegrationClass = convertIntegrationFnToClass('testName', integrationFn); + + expect(IntegrationClass.id).toBe('testName'); + + const integration = new IntegrationClass(); + expect(integration).toEqual({ + name: 'testName', + setupOnce: expect.any(Function), + }); + }); + + it('works with integration hooks', () => { + const setup = jest.fn(); + const setupOnce = jest.fn(); + const processEvent = jest.fn(); + const preprocessEvent = jest.fn(); + + const integrationFn = () => { + return { + name: 'testName', + setup, + setupOnce, + processEvent, + preprocessEvent, + }; + }; + + const IntegrationClass = convertIntegrationFnToClass('testName', integrationFn); + + expect(IntegrationClass.id).toBe('testName'); + + const integration = new IntegrationClass(); + expect(integration).toEqual({ + name: 'testName', + setupOnce, + setup, + processEvent, + preprocessEvent, + }); + }); + /* eslint-enable deprecation/deprecation */ +}); diff --git a/packages/core/test/lib/tracing/errors.test.ts b/packages/core/test/lib/tracing/errors.test.ts index f4de76234ca2..20db043865a9 100644 --- a/packages/core/test/lib/tracing/errors.test.ts +++ b/packages/core/test/lib/tracing/errors.test.ts @@ -40,7 +40,7 @@ describe('registerErrorHandlers()', () => { }); afterEach(() => { - hub.configureScope(scope => scope.setSpan(undefined)); + hub.getScope().setSpan(undefined); }); it('registers error instrumentation', () => { @@ -67,7 +67,7 @@ describe('registerErrorHandlers()', () => { it('sets status for transaction on scope on error', () => { registerErrorInstrumentation(); const transaction = hub.startTransaction({ name: 'test' }); - hub.configureScope(scope => scope.setSpan(transaction)); + hub.getScope().setSpan(transaction); mockErrorCallback({} as HandlerDataError); expect(transaction.status).toBe('internal_error'); @@ -78,7 +78,7 @@ describe('registerErrorHandlers()', () => { it('sets status for transaction on scope on unhandledrejection', () => { registerErrorInstrumentation(); const transaction = hub.startTransaction({ name: 'test' }); - hub.configureScope(scope => scope.setSpan(transaction)); + hub.getScope().setSpan(transaction); mockUnhandledRejectionCallback({}); expect(transaction.status).toBe('internal_error'); diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts index b1671b9410e8..98fd7e54207b 100644 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -1,4 +1,4 @@ -import type { Hub } from '@sentry/types'; +import type { Client, Hub } from '@sentry/types'; import { isSentryRequestUrl } from '../../../src'; @@ -12,15 +12,21 @@ describe('isSentryRequestUrl', () => { ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { + const client = { + getOptions: () => ({ tunnel }), + getDsn: () => ({ host: dsn }), + } as unknown as Client; + const hub = { getClient: () => { - return { - getOptions: () => ({ tunnel }), - getDsn: () => ({ host: dsn }), - }; + return client; }, } as unknown as Hub; + // Works with hub passed expect(isSentryRequestUrl(url, hub)).toBe(expected); + + // Works with client passed + expect(isSentryRequestUrl(url, client)).toBe(expected); }); }); diff --git a/packages/core/test/mocks/integration.ts b/packages/core/test/mocks/integration.ts index ce95d04520a7..4c229ce27294 100644 --- a/packages/core/test/mocks/integration.ts +++ b/packages/core/test/mocks/integration.ts @@ -1,6 +1,6 @@ import type { Event, EventProcessor, Integration } from '@sentry/types'; -import { configureScope, getCurrentHub } from '../../src'; +import { getCurrentHub, getCurrentScope } from '../../src'; export class TestIntegration implements Integration { public static id: string = 'TestIntegration'; @@ -18,9 +18,7 @@ export class TestIntegration implements Integration { eventProcessor.id = this.name; - configureScope(scope => { - scope.addEventProcessor(eventProcessor); - }); + getCurrentScope().addEventProcessor(eventProcessor); } } diff --git a/packages/deno/lib.deno.d.ts b/packages/deno/lib.deno.d.ts index b576de192b36..62eec898407c 100644 --- a/packages/deno/lib.deno.d.ts +++ b/packages/deno/lib.deno.d.ts @@ -544,7 +544,7 @@ declare namespace Deno { * Examples: * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "inherit", @@ -559,7 +559,7 @@ declare namespace Deno { * ``` * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "true", @@ -574,7 +574,7 @@ declare namespace Deno { * ``` * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "false", @@ -589,7 +589,7 @@ declare namespace Deno { * ``` * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "localhost:8080", @@ -818,7 +818,7 @@ declare namespace Deno { * `fn` can be async if required. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "example test", @@ -859,7 +859,7 @@ declare namespace Deno { * `fn` can be async if required. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test({ * name: "example test", @@ -896,7 +896,7 @@ declare namespace Deno { * `fn` can be async if required. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test("My test description", () => { * assertEquals("hello", "hello"); @@ -922,7 +922,7 @@ declare namespace Deno { * `fn` can be async if required. Declared function must have a name. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test(function myTestName() { * assertEquals("hello", "hello"); @@ -945,7 +945,7 @@ declare namespace Deno { * `fn` can be async if required. * * ```ts - * import {assert, fail, assertEquals} from "https://deno.land/std/testing/asserts.ts"; + * import {assert, fail, assertEquals} from "https://deno.land/std/assert/mod.ts"; * * Deno.test("My test description", { permissions: { read: true } }, (): void => { * assertEquals("hello", "hello"); @@ -972,7 +972,7 @@ declare namespace Deno { * `fn` can be async if required. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test( * { @@ -1010,7 +1010,7 @@ declare namespace Deno { * `fn` can be async if required. Declared function must have a name. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.test( * { permissions: { read: true } }, @@ -1234,7 +1234,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench({ * name: "example test", @@ -1273,7 +1273,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench("My test description", () => { * assertEquals("hello", "hello"); @@ -1301,7 +1301,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench(function myTestName() { * assertEquals("hello", "hello"); @@ -1326,7 +1326,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench( * "My test description", @@ -1363,7 +1363,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench( * { name: "My test description", permissions: { read: true } }, @@ -1397,7 +1397,7 @@ declare namespace Deno { * will await resolution to consider the test complete. * * ```ts - * import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + * import { assertEquals } from "https://deno.land/std/assert/mod.ts"; * * Deno.bench( * { permissions: { read: true } }, @@ -1624,6 +1624,9 @@ declare namespace Deno { * An abstract interface which when implemented provides an interface to read * bytes into an array buffer asynchronously. * + * @deprecated Use {@linkcode ReadableStream} instead. {@linkcode Reader} + * will be removed in v2.0.0. + * * @category I/O */ export interface Reader { /** Reads up to `p.byteLength` bytes into `p`. It resolves to the number of @@ -1658,6 +1661,9 @@ declare namespace Deno { * An abstract interface which when implemented provides an interface to read * bytes into an array buffer synchronously. * + * @deprecated Use {@linkcode ReadableStream} instead. {@linkcode ReaderSync} + * will be removed in v2.0.0. + * * @category I/O */ export interface ReaderSync { /** Reads up to `p.byteLength` bytes into `p`. It resolves to the number @@ -1692,6 +1698,9 @@ declare namespace Deno { * An abstract interface which when implemented provides an interface to write * bytes from an array buffer to a file/resource asynchronously. * + * @deprecated Use {@linkcode WritableStream} instead. {@linkcode Writer} + * will be removed in v2.0.0. + * * @category I/O */ export interface Writer { /** Writes `p.byteLength` bytes from `p` to the underlying data stream. It @@ -1716,6 +1725,9 @@ declare namespace Deno { * An abstract interface which when implemented provides an interface to write * bytes from an array buffer to a file/resource synchronously. * + * @deprecated Use {@linkcode WritableStream} instead. {@linkcode WriterSync} + * will be removed in v2.0.0. + * * @category I/O */ export interface WriterSync { /** Writes `p.byteLength` bytes from `p` to the underlying data @@ -1734,6 +1746,9 @@ declare namespace Deno { * An abstract interface which when implemented provides an interface to close * files/resources that were previously opened. * + * @deprecated Use {@linkcode ReadableStream} and {@linkcode WritableStream} + * instead. {@linkcode Closer} will be removed in v2.0.0. + * * @category I/O */ export interface Closer { /** Closes the resource, "freeing" the backing file/resource. */ @@ -2451,7 +2466,7 @@ declare namespace Deno { /** Resolves to a {@linkcode Deno.FileInfo} for the file. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const file = await Deno.open("hello.txt"); * const fileInfo = await file.stat(); @@ -2463,7 +2478,7 @@ declare namespace Deno { /** Synchronously returns a {@linkcode Deno.FileInfo} for the file. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const file = Deno.openSync("hello.txt") * const fileInfo = file.statSync(); @@ -3289,10 +3304,10 @@ declare namespace Deno { * * _Linux/Mac OS only._ */ ino: number | null; - /** **UNSTABLE**: Match behavior with Go on Windows for `mode`. + /** The underlying raw `st_mode` bits that contain the standard Unix + * permissions for this file/directory. * - * The underlying raw `st_mode` bits that contain the standard Unix - * permissions for this file/directory. */ + * _Linux/Mac OS only._ */ mode: number | null; /** Number of hard links pointing to this file. * @@ -3513,7 +3528,7 @@ declare namespace Deno { * of what it points to. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * const fileInfo = await Deno.lstat("hello.txt"); * assert(fileInfo.isFile); * ``` @@ -3530,7 +3545,7 @@ declare namespace Deno { * returned instead of what it points to. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * const fileInfo = Deno.lstatSync("hello.txt"); * assert(fileInfo.isFile); * ``` @@ -3546,7 +3561,7 @@ declare namespace Deno { * always follow symlinks. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * const fileInfo = await Deno.stat("hello.txt"); * assert(fileInfo.isFile); * ``` @@ -3562,7 +3577,7 @@ declare namespace Deno { * `path`. Will always follow symlinks. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * const fileInfo = Deno.statSync("hello.txt"); * assert(fileInfo.isFile); * ``` @@ -4312,7 +4327,7 @@ declare namespace Deno { * * @category Sub Process */ - export class ChildProcess implements Disposable { + export class ChildProcess implements AsyncDisposable { get stdin(): WritableStream; get stdout(): ReadableStream; get stderr(): ReadableStream; @@ -4338,7 +4353,7 @@ declare namespace Deno { * process from exiting. */ unref(): void; - [Symbol.dispose](): void; + [Symbol.asyncDispose](): Promise; } /** @@ -4794,7 +4809,7 @@ declare namespace Deno { /** Revokes a permission, and resolves to the state of the permission. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const status = await Deno.permissions.revoke({ name: "run" }); * assert(status.state !== "granted") @@ -4805,7 +4820,7 @@ declare namespace Deno { /** Revokes a permission, and returns the state of the permission. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const status = Deno.permissions.revokeSync({ name: "run" }); * assert(status.state !== "granted") @@ -4883,14 +4898,14 @@ declare namespace Deno { * ### Revoking * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const status = await Deno.permissions.revoke({ name: "run" }); * assert(status.state !== "granted") * ``` * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const status = Deno.permissions.revokeSync({ name: "run" }); * assert(status.state !== "granted") @@ -4986,13 +5001,13 @@ declare namespace Deno { * Give the following command line invocation of Deno: * * ```sh - * deno run --allow-read https://deno.land/std/examples/cat.ts /etc/passwd + * deno run --allow-read https://examples.deno.land/command-line-arguments.ts Sushi * ``` * * Then `Deno.args` will contain: * * ```ts - * [ "/etc/passwd" ] + * [ "Sushi" ] * ``` * * If you are looking for a structured way to parse arguments, there is the @@ -5200,7 +5215,7 @@ declare namespace Deno { * Returns a `Deno.FileInfo` for the given file stream. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const file = await Deno.open("file.txt", { read: true }); * const fileInfo = await Deno.fstat(file.rid); @@ -5216,7 +5231,7 @@ declare namespace Deno { * stream. * * ```ts - * import { assert } from "https://deno.land/std/testing/asserts.ts"; + * import { assert } from "https://deno.land/std/assert/mod.ts"; * * const file = Deno.openSync("file.txt", { read: true }); * const fileInfo = Deno.fstatSync(file.rid); @@ -5939,6 +5954,50 @@ declare namespace Deno { handler: ServeHandler; } + export interface ServeUnixOptions { + /** The unix domain socket path to listen on. */ + path: string; + + /** An {@linkcode AbortSignal} to close the server and all connections. */ + signal?: AbortSignal; + + /** The handler to invoke when route handlers throw an error. */ + onError?: (error: unknown) => Response | Promise; + + /** The callback which is called when the server starts listening. */ + onListen?: (params: { path: string }) => void; + } + + /** Information for a unix domain socket HTTP request. + * + * @category HTTP Server + */ + export interface ServeUnixHandlerInfo { + /** The remote address of the connection. */ + remoteAddr: Deno.UnixAddr; + } + + /** A handler for unix domain socket HTTP requests. Consumes a request and returns a response. + * + * If a handler throws, the server calling the handler will assume the impact + * of the error is isolated to the individual request. It will catch the error + * and if necessary will close the underlying connection. + * + * @category HTTP Server + */ + export type ServeUnixHandler = ( + request: Request, + info: ServeUnixHandlerInfo, + ) => Response | Promise; + + /** + * @category HTTP Server + */ + export interface ServeUnixInit { + /** The handler to invoke to process each incoming request. */ + handler: ServeUnixHandler; + } + /** An instance of the server created using `Deno.serve()` API. * * @category HTTP Server @@ -5959,6 +6018,11 @@ declare namespace Deno { /** Make the server not block the event loop from finishing. */ unref(): void; + + /** Gracefully close the server. No more new connections will be accepted, + * while pending requests will be allowed to finish. + */ + shutdown(): Promise; } /** @@ -5978,6 +6042,55 @@ declare namespace Deno { * @category HTTP Server */ export function serve(handler: ServeHandler): HttpServer; + /** Serves HTTP requests with the given option bag and handler. + * + * You can specify the socket path with `path` option. + * + * ```ts + * Deno.serve( + * { path: "path/to/socket" }, + * (_req) => new Response("Hello, world") + * ); + * ``` + * + * You can stop the server with an {@linkcode AbortSignal}. The abort signal + * needs to be passed as the `signal` option in the options bag. The server + * aborts when the abort signal is aborted. To wait for the server to close, + * await the promise returned from the `Deno.serve` API. + * + * ```ts + * const ac = new AbortController(); + * + * const server = Deno.serve( + * { signal: ac.signal, path: "path/to/socket" }, + * (_req) => new Response("Hello, world") + * ); + * server.finished.then(() => console.log("Server closed")); + * + * console.log("Closing server..."); + * ac.abort(); + * ``` + * + * By default `Deno.serve` prints the message + * `Listening on path/to/socket` on listening. If you like to + * change this behavior, you can specify a custom `onListen` callback. + * + * ```ts + * Deno.serve({ + * onListen({ path }) { + * console.log(`Server started at ${path}`); + * // ... more info specific to your server .. + * }, + * path: "path/to/socket", + * }, (_req) => new Response("Hello, world")); + * ``` + * + * @category HTTP Server + */ + export function serve( + options: ServeUnixOptions, + handler: ServeUnixHandler, + ): HttpServer; /** Serves HTTP requests with the given option bag and handler. * * You can specify an object with a port and hostname option, which is the @@ -6038,6 +6151,33 @@ declare namespace Deno { options: ServeOptions | ServeTlsOptions, handler: ServeHandler, ): HttpServer; + /** Serves HTTP requests with the given option bag. + * + * You can specify an object with the path option, which is the + * unix domain socket to listen on. + * + * ```ts + * const ac = new AbortController(); + * + * const server = Deno.serve({ + * path: "path/to/socket", + * handler: (_req) => new Response("Hello, world"), + * signal: ac.signal, + * onListen({ path }) { + * console.log(`Server started at ${path}`); + * }, + * }); + * server.finished.then(() => console.log("Server closed")); + * + * console.log("Closing server..."); + * ac.abort(); + * ``` + * + * @category HTTP Server + */ + export function serve( + options: ServeUnixInit & ServeUnixOptions, + ): HttpServer; /** Serves HTTP requests with the given option bag. * * You can specify an object with a port and hostname option, which is the @@ -7113,12 +7253,18 @@ declare type ReadableStreamBYOBReadResult = | ReadableStreamBYOBReadDoneResult | ReadableStreamBYOBReadValueResult; +/** @category Streams API */ +declare interface ReadableStreamBYOBReaderReadOptions { + min?: number; +} + /** @category Streams API */ declare interface ReadableStreamBYOBReader { readonly closed: Promise; cancel(reason?: any): Promise; read( view: V, + options?: ReadableStreamBYOBReaderReadOptions, ): Promise>; releaseLock(): void; } @@ -7722,6 +7868,34 @@ declare function reportError( error: any, ): void; +/** @category Web APIs */ +type PredefinedColorSpace = "srgb" | "display-p3"; + +/** @category Web APIs */ +interface ImageDataSettings { + readonly colorSpace?: PredefinedColorSpace; +} + +/** @category Web APIs */ +interface ImageData { + readonly colorSpace: PredefinedColorSpace; + readonly data: Uint8ClampedArray; + readonly height: number; + readonly width: number; +} + +/** @category Web APIs */ +declare var ImageData: { + prototype: ImageData; + new (sw: number, sh: number, settings?: ImageDataSettings): ImageData; + new ( + data: Uint8ClampedArray, + sw: number, + sh?: number, + settings?: ImageDataSettings, + ): ImageData; +}; + // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // deno-lint-ignore-file no-explicit-any no-var @@ -8132,6 +8306,1322 @@ declare function fetch( // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. +// deno-lint-ignore-file no-explicit-any no-empty-interface + +/// +/// + +/** @category WebGPU */ +interface GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUObjectDescriptorBase { + label?: string; +} + +/** @category WebGPU */ +declare class GPUSupportedLimits { + maxTextureDimension1D?: number; + maxTextureDimension2D?: number; + maxTextureDimension3D?: number; + maxTextureArrayLayers?: number; + maxBindGroups?: number; + maxBindingsPerBindGroup?: number; + maxDynamicUniformBuffersPerPipelineLayout?: number; + maxDynamicStorageBuffersPerPipelineLayout?: number; + maxSampledTexturesPerShaderStage?: number; + maxSamplersPerShaderStage?: number; + maxStorageBuffersPerShaderStage?: number; + maxStorageTexturesPerShaderStage?: number; + maxUniformBuffersPerShaderStage?: number; + maxUniformBufferBindingSize?: number; + maxStorageBufferBindingSize?: number; + minUniformBufferOffsetAlignment?: number; + minStorageBufferOffsetAlignment?: number; + maxVertexBuffers?: number; + maxBufferSize?: number; + maxVertexAttributes?: number; + maxVertexBufferArrayStride?: number; + maxInterStageShaderComponents?: number; + maxComputeWorkgroupStorageSize?: number; + maxComputeInvocationsPerWorkgroup?: number; + maxComputeWorkgroupSizeX?: number; + maxComputeWorkgroupSizeY?: number; + maxComputeWorkgroupSizeZ?: number; + maxComputeWorkgroupsPerDimension?: number; +} + +/** @category WebGPU */ +declare class GPUSupportedFeatures { + forEach( + callbackfn: ( + value: GPUFeatureName, + value2: GPUFeatureName, + set: Set, + ) => void, + thisArg?: any, + ): void; + has(value: GPUFeatureName): boolean; + size: number; + [Symbol.iterator](): IterableIterator; + entries(): IterableIterator<[GPUFeatureName, GPUFeatureName]>; + keys(): IterableIterator; + values(): IterableIterator; +} + +/** @category WebGPU */ +declare class GPUAdapterInfo { + readonly vendor: string; + readonly architecture: string; + readonly device: string; + readonly description: string; +} + +/** @category WebGPU */ +declare class GPU { + requestAdapter( + options?: GPURequestAdapterOptions, + ): Promise; +} + +/** @category WebGPU */ +declare interface GPURequestAdapterOptions { + powerPreference?: GPUPowerPreference; + forceFallbackAdapter?: boolean; +} + +/** @category WebGPU */ +declare type GPUPowerPreference = "low-power" | "high-performance"; + +/** @category WebGPU */ +declare class GPUAdapter { + readonly features: GPUSupportedFeatures; + readonly limits: GPUSupportedLimits; + readonly isFallbackAdapter: boolean; + + requestDevice(descriptor?: GPUDeviceDescriptor): Promise; + requestAdapterInfo(unmaskHints?: string[]): Promise; +} + +/** @category WebGPU */ +declare interface GPUDeviceDescriptor extends GPUObjectDescriptorBase { + requiredFeatures?: GPUFeatureName[]; + requiredLimits?: Record; +} + +/** @category WebGPU */ +declare type GPUFeatureName = + | "depth-clip-control" + | "depth32float-stencil8" + | "pipeline-statistics-query" + | "texture-compression-bc" + | "texture-compression-etc2" + | "texture-compression-astc" + | "timestamp-query" + | "indirect-first-instance" + | "shader-f16" + // extended from spec + | "mappable-primary-buffers" + | "sampled-texture-binding-array" + | "sampled-texture-array-dynamic-indexing" + | "sampled-texture-array-non-uniform-indexing" + | "unsized-binding-array" + | "multi-draw-indirect" + | "multi-draw-indirect-count" + | "push-constants" + | "address-mode-clamp-to-border" + | "texture-adapter-specific-format-features" + | "shader-float64" + | "vertex-attribute-64bit"; + +/** @category WebGPU */ +declare class GPUDevice extends EventTarget implements GPUObjectBase { + label: string; + + readonly lost: Promise; + pushErrorScope(filter: GPUErrorFilter): undefined; + popErrorScope(): Promise; + + readonly features: GPUSupportedFeatures; + readonly limits: GPUSupportedLimits; + readonly queue: GPUQueue; + + destroy(): undefined; + + createBuffer(descriptor: GPUBufferDescriptor): GPUBuffer; + createTexture(descriptor: GPUTextureDescriptor): GPUTexture; + createSampler(descriptor?: GPUSamplerDescriptor): GPUSampler; + + createBindGroupLayout( + descriptor: GPUBindGroupLayoutDescriptor, + ): GPUBindGroupLayout; + createPipelineLayout( + descriptor: GPUPipelineLayoutDescriptor, + ): GPUPipelineLayout; + createBindGroup(descriptor: GPUBindGroupDescriptor): GPUBindGroup; + + createShaderModule(descriptor: GPUShaderModuleDescriptor): GPUShaderModule; + createComputePipeline( + descriptor: GPUComputePipelineDescriptor, + ): GPUComputePipeline; + createRenderPipeline( + descriptor: GPURenderPipelineDescriptor, + ): GPURenderPipeline; + createComputePipelineAsync( + descriptor: GPUComputePipelineDescriptor, + ): Promise; + createRenderPipelineAsync( + descriptor: GPURenderPipelineDescriptor, + ): Promise; + + createCommandEncoder( + descriptor?: GPUCommandEncoderDescriptor, + ): GPUCommandEncoder; + createRenderBundleEncoder( + descriptor: GPURenderBundleEncoderDescriptor, + ): GPURenderBundleEncoder; + + createQuerySet(descriptor: GPUQuerySetDescriptor): GPUQuerySet; +} + +/** @category WebGPU */ +declare class GPUBuffer implements GPUObjectBase { + label: string; + + readonly size: number; + readonly usage: GPUFlagsConstant; + readonly mapState: GPUBufferMapState; + + mapAsync( + mode: GPUMapModeFlags, + offset?: number, + size?: number, + ): Promise; + getMappedRange(offset?: number, size?: number): ArrayBuffer; + unmap(): undefined; + + destroy(): undefined; +} + +/** @category WebGPU */ +declare type GPUBufferMapState = "unmapped" | "pending" | "mapped"; + +/** @category WebGPU */ +declare interface GPUBufferDescriptor extends GPUObjectDescriptorBase { + size: number; + usage: GPUBufferUsageFlags; + mappedAtCreation?: boolean; +} + +/** @category WebGPU */ +declare type GPUBufferUsageFlags = number; + +/** @category WebGPU */ +declare type GPUFlagsConstant = number; + +/** @category WebGPU */ +declare class GPUBufferUsage { + static MAP_READ: 0x0001; + static MAP_WRITE: 0x0002; + static COPY_SRC: 0x0004; + static COPY_DST: 0x0008; + static INDEX: 0x0010; + static VERTEX: 0x0020; + static UNIFORM: 0x0040; + static STORAGE: 0x0080; + static INDIRECT: 0x0100; + static QUERY_RESOLVE: 0x0200; +} + +/** @category WebGPU */ +declare type GPUMapModeFlags = number; + +/** @category WebGPU */ +declare class GPUMapMode { + static READ: 0x0001; + static WRITE: 0x0002; +} + +/** @category WebGPU */ +declare class GPUTexture implements GPUObjectBase { + label: string; + + createView(descriptor?: GPUTextureViewDescriptor): GPUTextureView; + destroy(): undefined; + + readonly width: number; + readonly height: number; + readonly depthOrArrayLayers: number; + readonly mipLevelCount: number; + readonly sampleCount: number; + readonly dimension: GPUTextureDimension; + readonly format: GPUTextureFormat; + readonly usage: GPUFlagsConstant; +} + +/** @category WebGPU */ +declare interface GPUTextureDescriptor extends GPUObjectDescriptorBase { + size: GPUExtent3D; + mipLevelCount?: number; + sampleCount?: number; + dimension?: GPUTextureDimension; + format: GPUTextureFormat; + usage: GPUTextureUsageFlags; + viewFormats?: GPUTextureFormat[]; +} + +/** @category WebGPU */ +declare type GPUTextureDimension = "1d" | "2d" | "3d"; + +/** @category WebGPU */ +declare type GPUTextureUsageFlags = number; + +/** @category WebGPU */ +declare class GPUTextureUsage { + static COPY_SRC: 0x01; + static COPY_DST: 0x02; + static TEXTURE_BINDING: 0x04; + static STORAGE_BINDING: 0x08; + static RENDER_ATTACHMENT: 0x10; +} + +/** @category WebGPU */ +declare class GPUTextureView implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUTextureViewDescriptor extends GPUObjectDescriptorBase { + format?: GPUTextureFormat; + dimension?: GPUTextureViewDimension; + aspect?: GPUTextureAspect; + baseMipLevel?: number; + mipLevelCount?: number; + baseArrayLayer?: number; + arrayLayerCount?: number; +} + +/** @category WebGPU */ +declare type GPUTextureViewDimension = + | "1d" + | "2d" + | "2d-array" + | "cube" + | "cube-array" + | "3d"; + +/** @category WebGPU */ +declare type GPUTextureAspect = "all" | "stencil-only" | "depth-only"; + +/** @category WebGPU */ +declare type GPUTextureFormat = + | "r8unorm" + | "r8snorm" + | "r8uint" + | "r8sint" + | "r16uint" + | "r16sint" + | "r16float" + | "rg8unorm" + | "rg8snorm" + | "rg8uint" + | "rg8sint" + | "r32uint" + | "r32sint" + | "r32float" + | "rg16uint" + | "rg16sint" + | "rg16float" + | "rgba8unorm" + | "rgba8unorm-srgb" + | "rgba8snorm" + | "rgba8uint" + | "rgba8sint" + | "bgra8unorm" + | "bgra8unorm-srgb" + | "rgb9e5ufloat" + | "rgb10a2unorm" + | "rg11b10ufloat" + | "rg32uint" + | "rg32sint" + | "rg32float" + | "rgba16uint" + | "rgba16sint" + | "rgba16float" + | "rgba32uint" + | "rgba32sint" + | "rgba32float" + | "stencil8" + | "depth16unorm" + | "depth24plus" + | "depth24plus-stencil8" + | "depth32float" + | "depth32float-stencil8" + | "bc1-rgba-unorm" + | "bc1-rgba-unorm-srgb" + | "bc2-rgba-unorm" + | "bc2-rgba-unorm-srgb" + | "bc3-rgba-unorm" + | "bc3-rgba-unorm-srgb" + | "bc4-r-unorm" + | "bc4-r-snorm" + | "bc5-rg-unorm" + | "bc5-rg-snorm" + | "bc6h-rgb-ufloat" + | "bc6h-rgb-float" + | "bc7-rgba-unorm" + | "bc7-rgba-unorm-srgb" + | "etc2-rgb8unorm" + | "etc2-rgb8unorm-srgb" + | "etc2-rgb8a1unorm" + | "etc2-rgb8a1unorm-srgb" + | "etc2-rgba8unorm" + | "etc2-rgba8unorm-srgb" + | "eac-r11unorm" + | "eac-r11snorm" + | "eac-rg11unorm" + | "eac-rg11snorm" + | "astc-4x4-unorm" + | "astc-4x4-unorm-srgb" + | "astc-5x4-unorm" + | "astc-5x4-unorm-srgb" + | "astc-5x5-unorm" + | "astc-5x5-unorm-srgb" + | "astc-6x5-unorm" + | "astc-6x5-unorm-srgb" + | "astc-6x6-unorm" + | "astc-6x6-unorm-srgb" + | "astc-8x5-unorm" + | "astc-8x5-unorm-srgb" + | "astc-8x6-unorm" + | "astc-8x6-unorm-srgb" + | "astc-8x8-unorm" + | "astc-8x8-unorm-srgb" + | "astc-10x5-unorm" + | "astc-10x5-unorm-srgb" + | "astc-10x6-unorm" + | "astc-10x6-unorm-srgb" + | "astc-10x8-unorm" + | "astc-10x8-unorm-srgb" + | "astc-10x10-unorm" + | "astc-10x10-unorm-srgb" + | "astc-12x10-unorm" + | "astc-12x10-unorm-srgb" + | "astc-12x12-unorm" + | "astc-12x12-unorm-srgb"; + +/** @category WebGPU */ +declare class GPUSampler implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUSamplerDescriptor extends GPUObjectDescriptorBase { + addressModeU?: GPUAddressMode; + addressModeV?: GPUAddressMode; + addressModeW?: GPUAddressMode; + magFilter?: GPUFilterMode; + minFilter?: GPUFilterMode; + mipmapFilter?: GPUMipmapFilterMode; + lodMinClamp?: number; + lodMaxClamp?: number; + compare?: GPUCompareFunction; + maxAnisotropy?: number; +} + +/** @category WebGPU */ +declare type GPUAddressMode = "clamp-to-edge" | "repeat" | "mirror-repeat"; + +/** @category WebGPU */ +declare type GPUFilterMode = "nearest" | "linear"; + +/** @category WebGPU */ +declare type GPUMipmapFilterMode = "nearest" | "linear"; + +/** @category WebGPU */ +declare type GPUCompareFunction = + | "never" + | "less" + | "equal" + | "less-equal" + | "greater" + | "not-equal" + | "greater-equal" + | "always"; + +/** @category WebGPU */ +declare class GPUBindGroupLayout implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUBindGroupLayoutDescriptor extends GPUObjectDescriptorBase { + entries: GPUBindGroupLayoutEntry[]; +} + +/** @category WebGPU */ +declare interface GPUBindGroupLayoutEntry { + binding: number; + visibility: GPUShaderStageFlags; + + buffer?: GPUBufferBindingLayout; + sampler?: GPUSamplerBindingLayout; + texture?: GPUTextureBindingLayout; + storageTexture?: GPUStorageTextureBindingLayout; +} + +/** @category WebGPU */ +declare type GPUShaderStageFlags = number; + +/** @category WebGPU */ +declare class GPUShaderStage { + static VERTEX: 0x1; + static FRAGMENT: 0x2; + static COMPUTE: 0x4; +} + +/** @category WebGPU */ +declare interface GPUBufferBindingLayout { + type?: GPUBufferBindingType; + hasDynamicOffset?: boolean; + minBindingSize?: number; +} + +/** @category WebGPU */ +declare type GPUBufferBindingType = "uniform" | "storage" | "read-only-storage"; + +/** @category WebGPU */ +declare interface GPUSamplerBindingLayout { + type?: GPUSamplerBindingType; +} + +/** @category WebGPU */ +declare type GPUSamplerBindingType = + | "filtering" + | "non-filtering" + | "comparison"; + +/** @category WebGPU */ +declare interface GPUTextureBindingLayout { + sampleType?: GPUTextureSampleType; + viewDimension?: GPUTextureViewDimension; + multisampled?: boolean; +} + +/** @category WebGPU */ +declare type GPUTextureSampleType = + | "float" + | "unfilterable-float" + | "depth" + | "sint" + | "uint"; + +/** @category WebGPU */ +declare type GPUStorageTextureAccess = "write-only"; + +/** @category WebGPU */ +declare interface GPUStorageTextureBindingLayout { + access: GPUStorageTextureAccess; + format: GPUTextureFormat; + viewDimension?: GPUTextureViewDimension; +} + +/** @category WebGPU */ +declare class GPUBindGroup implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUBindGroupDescriptor extends GPUObjectDescriptorBase { + layout: GPUBindGroupLayout; + entries: GPUBindGroupEntry[]; +} + +/** @category WebGPU */ +declare type GPUBindingResource = + | GPUSampler + | GPUTextureView + | GPUBufferBinding; + +/** @category WebGPU */ +declare interface GPUBindGroupEntry { + binding: number; + resource: GPUBindingResource; +} + +/** @category WebGPU */ +declare interface GPUBufferBinding { + buffer: GPUBuffer; + offset?: number; + size?: number; +} + +/** @category WebGPU */ +declare class GPUPipelineLayout implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUPipelineLayoutDescriptor extends GPUObjectDescriptorBase { + bindGroupLayouts: GPUBindGroupLayout[]; +} + +/** @category WebGPU */ +declare type GPUCompilationMessageType = "error" | "warning" | "info"; + +/** @category WebGPU */ +declare interface GPUCompilationMessage { + readonly message: string; + readonly type: GPUCompilationMessageType; + readonly lineNum: number; + readonly linePos: number; +} + +/** @category WebGPU */ +declare interface GPUCompilationInfo { + readonly messages: ReadonlyArray; +} + +/** @category WebGPU */ +declare class GPUShaderModule implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUShaderModuleDescriptor extends GPUObjectDescriptorBase { + code: string; + sourceMap?: any; +} + +/** @category WebGPU */ +declare type GPUAutoLayoutMode = "auto"; + +/** @category WebGPU */ +declare interface GPUPipelineDescriptorBase extends GPUObjectDescriptorBase { + layout: GPUPipelineLayout | GPUAutoLayoutMode; +} + +/** @category WebGPU */ +declare interface GPUPipelineBase { + getBindGroupLayout(index: number): GPUBindGroupLayout; +} + +/** @category WebGPU */ +declare interface GPUProgrammableStage { + module: GPUShaderModule; + entryPoint: string; +} + +/** @category WebGPU */ +declare class GPUComputePipeline implements GPUObjectBase, GPUPipelineBase { + label: string; + + getBindGroupLayout(index: number): GPUBindGroupLayout; +} + +/** @category WebGPU */ +declare interface GPUComputePipelineDescriptor + extends GPUPipelineDescriptorBase { + compute: GPUProgrammableStage; +} + +/** @category WebGPU */ +declare class GPURenderPipeline implements GPUObjectBase, GPUPipelineBase { + label: string; + + getBindGroupLayout(index: number): GPUBindGroupLayout; +} + +/** @category WebGPU */ +declare interface GPURenderPipelineDescriptor + extends GPUPipelineDescriptorBase { + vertex: GPUVertexState; + primitive?: GPUPrimitiveState; + depthStencil?: GPUDepthStencilState; + multisample?: GPUMultisampleState; + fragment?: GPUFragmentState; +} + +/** @category WebGPU */ +declare interface GPUPrimitiveState { + topology?: GPUPrimitiveTopology; + stripIndexFormat?: GPUIndexFormat; + frontFace?: GPUFrontFace; + cullMode?: GPUCullMode; + unclippedDepth?: boolean; +} + +/** @category WebGPU */ +declare type GPUPrimitiveTopology = + | "point-list" + | "line-list" + | "line-strip" + | "triangle-list" + | "triangle-strip"; + +/** @category WebGPU */ +declare type GPUFrontFace = "ccw" | "cw"; + +/** @category WebGPU */ +declare type GPUCullMode = "none" | "front" | "back"; + +/** @category WebGPU */ +declare interface GPUMultisampleState { + count?: number; + mask?: number; + alphaToCoverageEnabled?: boolean; +} + +/** @category WebGPU */ +declare interface GPUFragmentState extends GPUProgrammableStage { + targets: (GPUColorTargetState | null)[]; +} + +/** @category WebGPU */ +declare interface GPUColorTargetState { + format: GPUTextureFormat; + + blend?: GPUBlendState; + writeMask?: GPUColorWriteFlags; +} + +/** @category WebGPU */ +declare interface GPUBlendState { + color: GPUBlendComponent; + alpha: GPUBlendComponent; +} + +/** @category WebGPU */ +declare type GPUColorWriteFlags = number; + +/** @category WebGPU */ +declare class GPUColorWrite { + static RED: 0x1; + static GREEN: 0x2; + static BLUE: 0x4; + static ALPHA: 0x8; + static ALL: 0xF; +} + +/** @category WebGPU */ +declare interface GPUBlendComponent { + operation?: GPUBlendOperation; + srcFactor?: GPUBlendFactor; + dstFactor?: GPUBlendFactor; +} + +/** @category WebGPU */ +declare type GPUBlendFactor = + | "zero" + | "one" + | "src" + | "one-minus-src" + | "src-alpha" + | "one-minus-src-alpha" + | "dst" + | "one-minus-dst" + | "dst-alpha" + | "one-minus-dst-alpha" + | "src-alpha-saturated" + | "constant" + | "one-minus-constant"; + +/** @category WebGPU */ +declare type GPUBlendOperation = + | "add" + | "subtract" + | "reverse-subtract" + | "min" + | "max"; + +/** @category WebGPU */ +declare interface GPUDepthStencilState { + format: GPUTextureFormat; + + depthWriteEnabled: boolean; + depthCompare: GPUCompareFunction; + + stencilFront?: GPUStencilFaceState; + stencilBack?: GPUStencilFaceState; + + stencilReadMask?: number; + stencilWriteMask?: number; + + depthBias?: number; + depthBiasSlopeScale?: number; + depthBiasClamp?: number; +} + +/** @category WebGPU */ +declare interface GPUStencilFaceState { + compare?: GPUCompareFunction; + failOp?: GPUStencilOperation; + depthFailOp?: GPUStencilOperation; + passOp?: GPUStencilOperation; +} + +/** @category WebGPU */ +declare type GPUStencilOperation = + | "keep" + | "zero" + | "replace" + | "invert" + | "increment-clamp" + | "decrement-clamp" + | "increment-wrap" + | "decrement-wrap"; + +/** @category WebGPU */ +declare type GPUIndexFormat = "uint16" | "uint32"; + +/** @category WebGPU */ +declare type GPUVertexFormat = + | "uint8x2" + | "uint8x4" + | "sint8x2" + | "sint8x4" + | "unorm8x2" + | "unorm8x4" + | "snorm8x2" + | "snorm8x4" + | "uint16x2" + | "uint16x4" + | "sint16x2" + | "sint16x4" + | "unorm16x2" + | "unorm16x4" + | "snorm16x2" + | "snorm16x4" + | "float16x2" + | "float16x4" + | "float32" + | "float32x2" + | "float32x3" + | "float32x4" + | "uint32" + | "uint32x2" + | "uint32x3" + | "uint32x4" + | "sint32" + | "sint32x2" + | "sint32x3" + | "sint32x4"; + +/** @category WebGPU */ +declare type GPUVertexStepMode = "vertex" | "instance"; + +/** @category WebGPU */ +declare interface GPUVertexState extends GPUProgrammableStage { + buffers?: (GPUVertexBufferLayout | null)[]; +} + +/** @category WebGPU */ +declare interface GPUVertexBufferLayout { + arrayStride: number; + stepMode?: GPUVertexStepMode; + attributes: GPUVertexAttribute[]; +} + +/** @category WebGPU */ +declare interface GPUVertexAttribute { + format: GPUVertexFormat; + offset: number; + + shaderLocation: number; +} + +/** @category WebGPU */ +declare interface GPUImageDataLayout { + offset?: number; + bytesPerRow?: number; + rowsPerImage?: number; +} + +/** @category WebGPU */ +declare class GPUCommandBuffer implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPUCommandBufferDescriptor extends GPUObjectDescriptorBase {} + +/** @category WebGPU */ +declare class GPUCommandEncoder implements GPUObjectBase { + label: string; + + beginRenderPass(descriptor: GPURenderPassDescriptor): GPURenderPassEncoder; + beginComputePass( + descriptor?: GPUComputePassDescriptor, + ): GPUComputePassEncoder; + + copyBufferToBuffer( + source: GPUBuffer, + sourceOffset: number, + destination: GPUBuffer, + destinationOffset: number, + size: number, + ): undefined; + + copyBufferToTexture( + source: GPUImageCopyBuffer, + destination: GPUImageCopyTexture, + copySize: GPUExtent3D, + ): undefined; + + copyTextureToBuffer( + source: GPUImageCopyTexture, + destination: GPUImageCopyBuffer, + copySize: GPUExtent3D, + ): undefined; + + copyTextureToTexture( + source: GPUImageCopyTexture, + destination: GPUImageCopyTexture, + copySize: GPUExtent3D, + ): undefined; + + clearBuffer( + destination: GPUBuffer, + destinationOffset?: number, + size?: number, + ): undefined; + + pushDebugGroup(groupLabel: string): undefined; + popDebugGroup(): undefined; + insertDebugMarker(markerLabel: string): undefined; + + writeTimestamp(querySet: GPUQuerySet, queryIndex: number): undefined; + + resolveQuerySet( + querySet: GPUQuerySet, + firstQuery: number, + queryCount: number, + destination: GPUBuffer, + destinationOffset: number, + ): undefined; + + finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer; +} + +/** @category WebGPU */ +declare interface GPUCommandEncoderDescriptor extends GPUObjectDescriptorBase {} + +/** @category WebGPU */ +declare interface GPUImageCopyBuffer extends GPUImageDataLayout { + buffer: GPUBuffer; +} + +/** @category WebGPU */ +declare interface GPUImageCopyTexture { + texture: GPUTexture; + mipLevel?: number; + origin?: GPUOrigin3D; + aspect?: GPUTextureAspect; +} + +/** @category WebGPU */ +interface GPUProgrammablePassEncoder { + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsets?: number[], + ): undefined; + + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsetsData: Uint32Array, + dynamicOffsetsDataStart: number, + dynamicOffsetsDataLength: number, + ): undefined; + + pushDebugGroup(groupLabel: string): undefined; + popDebugGroup(): undefined; + insertDebugMarker(markerLabel: string): undefined; +} + +/** @category WebGPU */ +declare class GPUComputePassEncoder + implements GPUObjectBase, GPUProgrammablePassEncoder { + label: string; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsets?: number[], + ): undefined; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsetsData: Uint32Array, + dynamicOffsetsDataStart: number, + dynamicOffsetsDataLength: number, + ): undefined; + pushDebugGroup(groupLabel: string): undefined; + popDebugGroup(): undefined; + insertDebugMarker(markerLabel: string): undefined; + setPipeline(pipeline: GPUComputePipeline): undefined; + dispatchWorkgroups(x: number, y?: number, z?: number): undefined; + dispatchWorkgroupsIndirect( + indirectBuffer: GPUBuffer, + indirectOffset: number, + ): undefined; + + end(): undefined; +} + +/** @category WebGPU */ +declare interface GPUComputePassTimestampWrites { + querySet: GPUQuerySet; + beginningOfPassWriteIndex?: number; + endOfPassWriteIndex?: number; +} + +/** @category WebGPU */ +declare interface GPUComputePassDescriptor extends GPUObjectDescriptorBase { + timestampWrites?: GPUComputePassTimestampWrites; +} + +/** @category WebGPU */ +interface GPURenderEncoderBase { + setPipeline(pipeline: GPURenderPipeline): undefined; + + setIndexBuffer( + buffer: GPUBuffer, + indexFormat: GPUIndexFormat, + offset?: number, + size?: number, + ): undefined; + setVertexBuffer( + slot: number, + buffer: GPUBuffer, + offset?: number, + size?: number, + ): undefined; + + draw( + vertexCount: number, + instanceCount?: number, + firstVertex?: number, + firstInstance?: number, + ): undefined; + drawIndexed( + indexCount: number, + instanceCount?: number, + firstIndex?: number, + baseVertex?: number, + firstInstance?: number, + ): undefined; + + drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: number): undefined; + drawIndexedIndirect( + indirectBuffer: GPUBuffer, + indirectOffset: number, + ): undefined; +} + +/** @category WebGPU */ +declare class GPURenderPassEncoder + implements GPUObjectBase, GPUProgrammablePassEncoder, GPURenderEncoderBase { + label: string; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsets?: number[], + ): undefined; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsetsData: Uint32Array, + dynamicOffsetsDataStart: number, + dynamicOffsetsDataLength: number, + ): undefined; + pushDebugGroup(groupLabel: string): undefined; + popDebugGroup(): undefined; + insertDebugMarker(markerLabel: string): undefined; + setPipeline(pipeline: GPURenderPipeline): undefined; + setIndexBuffer( + buffer: GPUBuffer, + indexFormat: GPUIndexFormat, + offset?: number, + size?: number, + ): undefined; + setVertexBuffer( + slot: number, + buffer: GPUBuffer, + offset?: number, + size?: number, + ): undefined; + draw( + vertexCount: number, + instanceCount?: number, + firstVertex?: number, + firstInstance?: number, + ): undefined; + drawIndexed( + indexCount: number, + instanceCount?: number, + firstIndex?: number, + baseVertex?: number, + firstInstance?: number, + ): undefined; + drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: number): undefined; + drawIndexedIndirect( + indirectBuffer: GPUBuffer, + indirectOffset: number, + ): undefined; + + setViewport( + x: number, + y: number, + width: number, + height: number, + minDepth: number, + maxDepth: number, + ): undefined; + + setScissorRect( + x: number, + y: number, + width: number, + height: number, + ): undefined; + + setBlendConstant(color: GPUColor): undefined; + setStencilReference(reference: number): undefined; + + beginOcclusionQuery(queryIndex: number): undefined; + endOcclusionQuery(): undefined; + + executeBundles(bundles: GPURenderBundle[]): undefined; + end(): undefined; +} + +/** @category WebGPU */ +declare interface GPURenderPassTimestampWrites { + querySet: GPUQuerySet; + beginningOfPassWriteIndex?: number; + endOfPassWriteIndex?: number; +} + +/** @category WebGPU */ +declare interface GPURenderPassDescriptor extends GPUObjectDescriptorBase { + colorAttachments: (GPURenderPassColorAttachment | null)[]; + depthStencilAttachment?: GPURenderPassDepthStencilAttachment; + occlusionQuerySet?: GPUQuerySet; + timestampWrites?: GPURenderPassTimestampWrites; +} + +/** @category WebGPU */ +declare interface GPURenderPassColorAttachment { + view: GPUTextureView; + resolveTarget?: GPUTextureView; + + clearValue?: GPUColor; + loadOp: GPULoadOp; + storeOp: GPUStoreOp; +} + +/** @category WebGPU */ +declare interface GPURenderPassDepthStencilAttachment { + view: GPUTextureView; + + depthClearValue?: number; + depthLoadOp?: GPULoadOp; + depthStoreOp?: GPUStoreOp; + depthReadOnly?: boolean; + + stencilClearValue?: number; + stencilLoadOp?: GPULoadOp; + stencilStoreOp?: GPUStoreOp; + stencilReadOnly?: boolean; +} + +/** @category WebGPU */ +declare type GPULoadOp = "load" | "clear"; + +/** @category WebGPU */ +declare type GPUStoreOp = "store" | "discard"; + +/** @category WebGPU */ +declare class GPURenderBundle implements GPUObjectBase { + label: string; +} + +/** @category WebGPU */ +declare interface GPURenderBundleDescriptor extends GPUObjectDescriptorBase {} + +/** @category WebGPU */ +declare class GPURenderBundleEncoder + implements GPUObjectBase, GPUProgrammablePassEncoder, GPURenderEncoderBase { + label: string; + draw( + vertexCount: number, + instanceCount?: number, + firstVertex?: number, + firstInstance?: number, + ): undefined; + drawIndexed( + indexCount: number, + instanceCount?: number, + firstIndex?: number, + baseVertex?: number, + firstInstance?: number, + ): undefined; + drawIndexedIndirect( + indirectBuffer: GPUBuffer, + indirectOffset: number, + ): undefined; + drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: number): undefined; + insertDebugMarker(markerLabel: string): undefined; + popDebugGroup(): undefined; + pushDebugGroup(groupLabel: string): undefined; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsets?: number[], + ): undefined; + setBindGroup( + index: number, + bindGroup: GPUBindGroup, + dynamicOffsetsData: Uint32Array, + dynamicOffsetsDataStart: number, + dynamicOffsetsDataLength: number, + ): undefined; + setIndexBuffer( + buffer: GPUBuffer, + indexFormat: GPUIndexFormat, + offset?: number, + size?: number, + ): undefined; + setPipeline(pipeline: GPURenderPipeline): undefined; + setVertexBuffer( + slot: number, + buffer: GPUBuffer, + offset?: number, + size?: number, + ): undefined; + + finish(descriptor?: GPURenderBundleDescriptor): GPURenderBundle; +} + +/** @category WebGPU */ +declare interface GPURenderPassLayout extends GPUObjectDescriptorBase { + colorFormats: (GPUTextureFormat | null)[]; + depthStencilFormat?: GPUTextureFormat; + sampleCount?: number; +} + +/** @category WebGPU */ +declare interface GPURenderBundleEncoderDescriptor extends GPURenderPassLayout { + depthReadOnly?: boolean; + stencilReadOnly?: boolean; +} + +/** @category WebGPU */ +declare class GPUQueue implements GPUObjectBase { + label: string; + + submit(commandBuffers: GPUCommandBuffer[]): undefined; + + onSubmittedWorkDone(): Promise; + + writeBuffer( + buffer: GPUBuffer, + bufferOffset: number, + data: BufferSource, + dataOffset?: number, + size?: number, + ): undefined; + + writeTexture( + destination: GPUImageCopyTexture, + data: BufferSource, + dataLayout: GPUImageDataLayout, + size: GPUExtent3D, + ): undefined; +} + +/** @category WebGPU */ +declare class GPUQuerySet implements GPUObjectBase { + label: string; + + destroy(): undefined; + + readonly type: GPUQueryType; + readonly count: number; +} + +/** @category WebGPU */ +declare interface GPUQuerySetDescriptor extends GPUObjectDescriptorBase { + type: GPUQueryType; + count: number; +} + +/** @category WebGPU */ +declare type GPUQueryType = "occlusion" | "timestamp"; + +/** @category WebGPU */ +declare type GPUDeviceLostReason = "destroyed"; + +/** @category WebGPU */ +declare interface GPUDeviceLostInfo { + readonly reason: GPUDeviceLostReason; + readonly message: string; +} + +/** @category WebGPU */ +declare class GPUError { + readonly message: string; +} + +/** @category WebGPU */ +declare class GPUOutOfMemoryError extends GPUError { + constructor(message: string); +} + +/** @category WebGPU */ +declare class GPUValidationError extends GPUError { + constructor(message: string); +} + +/** @category WebGPU */ +declare type GPUErrorFilter = "out-of-memory" | "validation"; + +/** @category WebGPU */ +declare interface GPUColorDict { + r: number; + g: number; + b: number; + a: number; +} + +/** @category WebGPU */ +declare type GPUColor = number[] | GPUColorDict; + +/** @category WebGPU */ +declare interface GPUOrigin3DDict { + x?: number; + y?: number; + z?: number; +} + +/** @category WebGPU */ +declare type GPUOrigin3D = number[] | GPUOrigin3DDict; + +/** @category WebGPU */ +declare interface GPUExtent3DDict { + width: number; + height?: number; + depthOrArrayLayers?: number; +} + +/** @category WebGPU */ +declare type GPUExtent3D = number[] | GPUExtent3DDict; + +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + // deno-lint-ignore-file no-explicit-any no-var /// @@ -9934,6 +11424,7 @@ declare interface CacheQueryOptions { /// /// /// +/// /// /// /// @@ -10033,6 +11524,7 @@ declare var caches: CacheStorage; /** @category Web APIs */ declare interface Navigator { + readonly gpu: GPU; readonly hardwareConcurrency: number; readonly userAgent: string; readonly language: string; @@ -11077,7 +12569,7 @@ declare namespace Deno { * * @category Fetch API */ - export interface HttpClient { + export interface HttpClient extends Disposable { /** The resource ID associated with the client. */ rid: number; /** Close the HTTP client. */ @@ -11547,6 +13039,32 @@ declare namespace Deno { */ export function openKv(path?: string): Promise; + /** **UNSTABLE**: New API, yet to be vetted. + * + * CronScheduleExpression is used as the type of `minute`, `hour`, + * `dayOfMonth`, `month`, and `dayOfWeek` in {@linkcode CronSchedule}. + * @category Cron + */ + type CronScheduleExpression = number | { exact: number | number[] } | { + start?: number; + end?: number; + every?: number; + }; + + /** **UNSTABLE**: New API, yet to be vetted. + * + * CronSchedule is the interface used for JSON format + * cron `schedule`. + * @category Cron + */ + export interface CronSchedule { + minute?: CronScheduleExpression; + hour?: CronScheduleExpression; + dayOfMonth?: CronScheduleExpression; + month?: CronScheduleExpression; + dayOfWeek?: CronScheduleExpression; + } + /** **UNSTABLE**: New API, yet to be vetted. * * Create a cron job that will periodically execute the provided handler @@ -11557,21 +13075,23 @@ declare namespace Deno { * console.log("cron job executed"); * }); * ``` - * `backoffSchedule` option can be used to specify the retry policy for failed - * executions. Each element in the array represents the number of milliseconds - * to wait before retrying the execution. For example, `[1000, 5000, 10000]` - * means that a failed execution will be retried at most 3 times, with 1 - * second, 5 seconds, and 10 seconds delay between each retry. + * + * ```ts + * Deno.cron("sample cron", { hour: { every: 6 } }, () => { + * console.log("cron job executed"); + * }); + * ``` + * + * `schedule` can be a string in the Unix cron format or in JSON format + * as specified by interface {@linkcode CronSchedule}, where time is specified + * using UTC time zone. * * @category Cron - * @deprecated Use other {@linkcode cron} overloads instead. This overload - * will be removed in the future. */ export function cron( name: string, - schedule: string, + schedule: string | CronSchedule, handler: () => Promise | void, - options: { backoffSchedule?: number[]; signal?: AbortSignal }, ): Promise; /** **UNSTABLE**: New API, yet to be vetted. @@ -11580,19 +13100,29 @@ declare namespace Deno { * callback based on the specified schedule. * * ```ts - * Deno.cron("sample cron", "20 * * * *", () => { + * Deno.cron("sample cron", "20 * * * *", { + * backoffSchedule: [10, 20] + * }, () => { * console.log("cron job executed"); * }); * ``` * - * `schedule` is a Unix cron format expression, where time is specified + * `schedule` can be a string in the Unix cron format or in JSON format + * as specified by interface {@linkcode CronSchedule}, where time is specified * using UTC time zone. * + * `backoffSchedule` option can be used to specify the retry policy for failed + * executions. Each element in the array represents the number of milliseconds + * to wait before retrying the execution. For example, `[1000, 5000, 10000]` + * means that a failed execution will be retried at most 3 times, with 1 + * second, 5 seconds, and 10 seconds delay between each retry. + * * @category Cron */ export function cron( name: string, - schedule: string, + schedule: string | CronSchedule, + options: { backoffSchedule?: number[]; signal?: AbortSignal }, handler: () => Promise | void, ): Promise; @@ -11601,17 +13131,15 @@ declare namespace Deno { * Create a cron job that will periodically execute the provided handler * callback based on the specified schedule. * + * `schedule` can be a string in the Unix cron format or in JSON format + * as specified by interface {@linkcode CronSchedule}, where time is specified + * using UTC time zone. + * * ```ts - * Deno.cron("sample cron", "20 * * * *", { - * backoffSchedule: [10, 20] - * }, () => { + * Deno.cron("sample cron", "20 * * * *", () => { * console.log("cron job executed"); * }); * ``` - * - * `schedule` is a Unix cron format expression, where time is specified - * using UTC time zone. - * * `backoffSchedule` option can be used to specify the retry policy for failed * executions. Each element in the array represents the number of milliseconds * to wait before retrying the execution. For example, `[1000, 5000, 10000]` @@ -11619,12 +13147,14 @@ declare namespace Deno { * second, 5 seconds, and 10 seconds delay between each retry. * * @category Cron + * @deprecated Use other {@linkcode cron} overloads instead. This overload + * will be removed in the future. */ export function cron( name: string, - schedule: string, - options: { backoffSchedule?: number[]; signal?: AbortSignal }, + schedule: string | CronSchedule, handler: () => Promise | void, + options: { backoffSchedule?: number[]; signal?: AbortSignal }, ): Promise; /** **UNSTABLE**: New API, yet to be vetted. @@ -11680,7 +13210,13 @@ declare namespace Deno { * * @category KV */ - export type KvKeyPart = Uint8Array | string | number | bigint | boolean; + export type KvKeyPart = + | Uint8Array + | string + | number + | bigint + | boolean + | symbol; /** **UNSTABLE**: New API, yet to be vetted. * @@ -11978,7 +13514,11 @@ declare namespace Deno { */ enqueue( value: unknown, - options?: { delay?: number; keysIfUndelivered?: Deno.KvKey[] }, + options?: { + delay?: number; + keysIfUndelivered?: Deno.KvKey[]; + backoffSchedule?: number[]; + }, ): this; /** * Commit the operation to the KV store. Returns a value indicating whether @@ -12187,14 +13727,28 @@ declare namespace Deno { * listener after several attempts. The values are set to the value of * the queued message. * + * The `backoffSchedule` option can be used to specify the retry policy for + * failed message delivery. Each element in the array represents the number of + * milliseconds to wait before retrying the delivery. For example, + * `[1000, 5000, 10000]` means that a failed delivery will be retried + * at most 3 times, with 1 second, 5 seconds, and 10 seconds delay + * between each retry. + * * ```ts * const db = await Deno.openKv(); - * await db.enqueue("bar", { keysIfUndelivered: [["foo", "bar"]] }); + * await db.enqueue("bar", { + * keysIfUndelivered: [["foo", "bar"]], + * backoffSchedule: [1000, 5000, 10000], + * }); * ``` */ enqueue( value: unknown, - options?: { delay?: number; keysIfUndelivered?: Deno.KvKey[] }, + options?: { + delay?: number; + keysIfUndelivered?: Deno.KvKey[]; + backoffSchedule?: number[]; + }, ): Promise; /** @@ -12273,6 +13827,14 @@ declare namespace Deno { */ close(): void; + /** + * Get a symbol that represents the versionstamp of the current atomic + * operation. This symbol can be used as the last part of a key in + * `.set()`, both directly on the `Kv` object and on an `AtomicOperation` + * object created from this `Kv` instance. + */ + commitVersionstamp(): symbol; + [Symbol.dispose](): void; } @@ -12291,138 +13853,6 @@ declare namespace Deno { readonly value: bigint; } - /** An instance of the server created using `Deno.serve()` API. - * - * @category HTTP Server - */ - export interface HttpServer { - /** Gracefully close the server. No more new connections will be accepted, - * while pending requests will be allowed to finish. - */ - shutdown(): Promise; - } - - export interface ServeUnixOptions { - /** The unix domain socket path to listen on. */ - path: string; - - /** An {@linkcode AbortSignal} to close the server and all connections. */ - signal?: AbortSignal; - - /** The handler to invoke when route handlers throw an error. */ - onError?: (error: unknown) => Response | Promise; - - /** The callback which is called when the server starts listening. */ - onListen?: (params: { path: string }) => void; - } - - /** Information for a unix domain socket HTTP request. - * - * @category HTTP Server - */ - export interface ServeUnixHandlerInfo { - /** The remote address of the connection. */ - remoteAddr: Deno.UnixAddr; - } - - /** A handler for unix domain socket HTTP requests. Consumes a request and returns a response. - * - * If a handler throws, the server calling the handler will assume the impact - * of the error is isolated to the individual request. It will catch the error - * and if necessary will close the underlying connection. - * - * @category HTTP Server - */ - export type ServeUnixHandler = ( - request: Request, - info: ServeUnixHandlerInfo, - ) => Response | Promise; - - /** - * @category HTTP Server - */ - export interface ServeUnixInit { - /** The handler to invoke to process each incoming request. */ - handler: ServeUnixHandler; - } - - /** Serves HTTP requests with the given option bag and handler. - * - * You can specify the socket path with `path` option. - * - * ```ts - * Deno.serve( - * { path: "path/to/socket" }, - * (_req) => new Response("Hello, world") - * ); - * ``` - * - * You can stop the server with an {@linkcode AbortSignal}. The abort signal - * needs to be passed as the `signal` option in the options bag. The server - * aborts when the abort signal is aborted. To wait for the server to close, - * await the promise returned from the `Deno.serve` API. - * - * ```ts - * const ac = new AbortController(); - * - * const server = Deno.serve( - * { signal: ac.signal, path: "path/to/socket" }, - * (_req) => new Response("Hello, world") - * ); - * server.finished.then(() => console.log("Server closed")); - * - * console.log("Closing server..."); - * ac.abort(); - * ``` - * - * By default `Deno.serve` prints the message - * `Listening on path/to/socket` on listening. If you like to - * change this behavior, you can specify a custom `onListen` callback. - * - * ```ts - * Deno.serve({ - * onListen({ path }) { - * console.log(`Server started at ${path}`); - * // ... more info specific to your server .. - * }, - * path: "path/to/socket", - * }, (_req) => new Response("Hello, world")); - * ``` - * - * @category HTTP Server - */ - export function serve( - options: ServeUnixOptions, - handler: ServeUnixHandler, - ): Server; - /** Serves HTTP requests with the given option bag. - * - * You can specify an object with the path option, which is the - * unix domain socket to listen on. - * - * ```ts - * const ac = new AbortController(); - * - * const server = Deno.serve({ - * path: "path/to/socket", - * handler: (_req) => new Response("Hello, world"), - * signal: ac.signal, - * onListen({ path }) { - * console.log(`Server started at ${path}`); - * }, - * }); - * server.finished.then(() => console.log("Server closed")); - * - * console.log("Closing server..."); - * ac.abort(); - * ``` - * - * @category HTTP Server - */ - export function serve( - options: ServeUnixInit & ServeUnixOptions, - ): Server; - /** * A namespace containing runtime APIs available in Jupyter notebooks. * diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 52a878bdde17..bd2a7061019a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -31,6 +31,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/deno/src/integrations/deno-cron-format.ts b/packages/deno/src/integrations/deno-cron-format.ts new file mode 100644 index 000000000000..ac7bfc813fde --- /dev/null +++ b/packages/deno/src/integrations/deno-cron-format.ts @@ -0,0 +1,84 @@ +/** + * These functions were copied from the Deno source code here: + * https://github.com/denoland/deno/blob/cd480b481ee1b4209910aa7a8f81ffa996e7b0f9/ext/cron/01_cron.ts + * Below is the original license: + * + * MIT License + * + * Copyright 2018-2023 the Deno authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +function formatToCronSchedule( + value?: + | number + | { exact: number | number[] } + | { + start?: number; + end?: number; + every?: number; + }, +): string { + if (value === undefined) { + return '*'; + } else if (typeof value === 'number') { + return value.toString(); + } else { + const { exact } = value as { exact: number | number[] }; + if (exact === undefined) { + const { start, end, every } = value as { + start?: number; + end?: number; + every?: number; + }; + if (start !== undefined && end !== undefined && every !== undefined) { + return `${start}-${end}/${every}`; + } else if (start !== undefined && end !== undefined) { + return `${start}-${end}`; + } else if (start !== undefined && every !== undefined) { + return `${start}/${every}`; + } else if (start !== undefined) { + return `${start}/1`; + } else if (end === undefined && every !== undefined) { + return `*/${every}`; + } else { + throw new TypeError('Invalid cron schedule'); + } + } else { + if (typeof exact === 'number') { + return exact.toString(); + } else { + return exact.join(','); + } + } + } +} + +/** */ +export function parseScheduleToString(schedule: string | Deno.CronSchedule): string { + if (typeof schedule === 'string') { + return schedule; + } else { + const { minute, hour, dayOfMonth, month, dayOfWeek } = schedule; + + return `${formatToCronSchedule(minute)} ${formatToCronSchedule(hour)} ${formatToCronSchedule( + dayOfMonth, + )} ${formatToCronSchedule(month)} ${formatToCronSchedule(dayOfWeek)}`; + } +} diff --git a/packages/deno/src/integrations/deno-cron.ts b/packages/deno/src/integrations/deno-cron.ts index f40d696f5e3c..475d3e9131b7 100644 --- a/packages/deno/src/integrations/deno-cron.ts +++ b/packages/deno/src/integrations/deno-cron.ts @@ -1,11 +1,12 @@ import { withMonitor } from '@sentry/core'; import type { Integration } from '@sentry/types'; import type { DenoClient } from '../client'; +import { parseScheduleToString } from './deno-cron-format'; type CronOptions = { backoffSchedule?: number[]; signal?: AbortSignal }; type CronFn = () => void | Promise; // Parameters doesn't work well with the overloads 🤔 -type CronParams = [string, string, CronFn | CronOptions, CronFn | CronOptions | undefined]; +type CronParams = [string, string | Deno.CronSchedule, CronFn | CronOptions, CronFn | CronOptions | undefined]; /** Instruments Deno.cron to automatically capture cron check-ins */ export class DenoCron implements Integration { @@ -21,7 +22,7 @@ export class DenoCron implements Integration { } /** @inheritDoc */ - public setup(client: DenoClient): void { + public setup(): void { // eslint-disable-next-line deprecation/deprecation if (!Deno.cron) { // The cron API is not available in this Deno version use --unstable flag! @@ -45,9 +46,11 @@ export class DenoCron implements Integration { async function cronCalled(): Promise { await withMonitor(monitorSlug, async () => fn(), { - schedule: { type: 'crontab', value: schedule }, + schedule: { type: 'crontab', value: parseScheduleToString(schedule) }, // (minutes) so 12 hours - just a very high arbitrary number since we don't know the actual duration of the users cron job maxRuntime: 60 * 12, + // Deno Deploy docs say that the cron job will be called within 1 minute of the scheduled time + checkinMargin: 1, }); } diff --git a/packages/deno/src/integrations/globalhandlers.ts b/packages/deno/src/integrations/globalhandlers.ts index d173780cfa50..27745d6d6765 100644 --- a/packages/deno/src/integrations/globalhandlers.ts +++ b/packages/deno/src/integrations/globalhandlers.ts @@ -1,6 +1,8 @@ import type { ServerRuntimeClient } from '@sentry/core'; -import { flush, getCurrentHub } from '@sentry/core'; -import type { Event, Hub, Integration, Primitive, StackParser } from '@sentry/types'; +import { captureEvent } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import { flush } from '@sentry/core'; +import type { Client, Event, Integration, Primitive, StackParser } from '@sentry/types'; import { eventFromUnknownInput, isPrimitive } from '@sentry/utils'; type GlobalHandlersIntegrationsOptionKeys = 'error' | 'unhandledrejection'; @@ -25,15 +27,6 @@ export class GlobalHandlers implements Integration { /** JSDoc */ private readonly _options: GlobalHandlersIntegrations; - /** - * Stores references functions to installing handlers. Will set to undefined - * after they have been run so that they are not used twice. - */ - private _installFunc: Record void) | undefined> = { - error: installGlobalErrorHandler, - unhandledrejection: installGlobalUnhandledRejectionHandler, - }; - /** JSDoc */ public constructor(options?: GlobalHandlersIntegrations) { this._options = { @@ -46,35 +39,35 @@ export class GlobalHandlers implements Integration { * @inheritDoc */ public setupOnce(): void { - const options = this._options; - - // We can disable guard-for-in as we construct the options object above + do checks against - // `this._installFunc` for the property. - // eslint-disable-next-line guard-for-in - for (const key in options) { - const installFunc = this._installFunc[key as GlobalHandlersIntegrationsOptionKeys]; - if (installFunc && options[key as GlobalHandlersIntegrationsOptionKeys]) { - installFunc(); - this._installFunc[key as GlobalHandlersIntegrationsOptionKeys] = undefined; - } + // noop + } + + /** @inheritdoc */ + public setup(client: Client): void { + if (this._options.error) { + installGlobalErrorHandler(client); + } + if (this._options.unhandledrejection) { + installGlobalUnhandledRejectionHandler(client); } } } -function installGlobalErrorHandler(): void { +function installGlobalErrorHandler(client: Client): void { globalThis.addEventListener('error', data => { - if (isExiting) { + if (getClient() !== client || isExiting) { return; } - const [hub, stackParser] = getHubAndOptions(); + const stackParser = getStackParser(); + const { message, error } = data; - const event = eventFromUnknownInput(getCurrentHub, stackParser, error || message); + const event = eventFromUnknownInput(getClient(), stackParser, error || message); event.level = 'fatal'; - hub.captureEvent(event, { + captureEvent(event, { originalException: error, mechanism: { handled: false, @@ -93,13 +86,13 @@ function installGlobalErrorHandler(): void { }); } -function installGlobalUnhandledRejectionHandler(): void { +function installGlobalUnhandledRejectionHandler(client: Client): void { globalThis.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { - if (isExiting) { + if (getClient() !== client || isExiting) { return; } - const [hub, stackParser] = getHubAndOptions(); + const stackParser = getStackParser(); let error = e; // dig the object of the rejection out of known event types @@ -113,11 +106,11 @@ function installGlobalUnhandledRejectionHandler(): void { const event = isPrimitive(error) ? eventFromRejectionWithPrimitive(error) - : eventFromUnknownInput(getCurrentHub, stackParser, error, undefined); + : eventFromUnknownInput(getClient(), stackParser, error, undefined); event.level = 'fatal'; - hub.captureEvent(event, { + captureEvent(event, { originalException: error, mechanism: { handled: false, @@ -156,12 +149,12 @@ function eventFromRejectionWithPrimitive(reason: Primitive): Event { }; } -function getHubAndOptions(): [Hub, StackParser] { - const hub = getCurrentHub(); - const client = hub.getClient(); - const options = (client && client.getOptions()) || { - stackParser: () => [], - attachStacktrace: false, - }; - return [hub, options.stackParser]; +function getStackParser(): StackParser { + const client = getClient(); + + if (!client) { + return () => []; + } + + return client.getOptions().stackParser; } diff --git a/packages/e2e-tests/test-applications/create-next-app/pages/api/success.ts b/packages/e2e-tests/test-applications/create-next-app/pages/api/success.ts index ed4372065792..7585c88f0ab1 100644 --- a/packages/e2e-tests/test-applications/create-next-app/pages/api/success.ts +++ b/packages/e2e-tests/test-applications/create-next-app/pages/api/success.ts @@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; export default function handler(req: NextApiRequest, res: NextApiResponse) { const transaction = Sentry.startTransaction({ name: 'test-transaction', op: 'e2e-test' }); - Sentry.getCurrentHub().configureScope(scope => scope.setSpan(transaction)); + Sentry.getCurrentHub().getScope().setSpan(transaction); const span = transaction.startChild(); diff --git a/packages/e2e-tests/test-applications/nextjs-14/.gitignore b/packages/e2e-tests/test-applications/nextjs-14/.gitignore new file mode 100644 index 000000000000..e799cc33c4e7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +# Sentry +.sentryclirc + +.vscode + +test-results diff --git a/packages/e2e-tests/test-applications/nextjs-14/.npmrc b/packages/e2e-tests/test-applications/nextjs-14/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx b/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx new file mode 100644 index 000000000000..5ae73102057d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx @@ -0,0 +1,33 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; +}) { + if (searchParams['shouldThrowInGenerateMetadata']) { + throw new Error('generateMetadata Error'); + } + + return { + title: searchParams['metadataTitle'] ?? 'not set', + }; +} + +export function generateViewport({ + searchParams, +}: { + searchParams: { [key: string]: string | undefined }; +}) { + if (searchParams['shouldThrowInGenerateViewport']) { + throw new Error('generateViewport Error'); + } + + return { + themeColor: searchParams['viewportThemeColor'] ?? 'black', + }; +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx b/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts b/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts new file mode 100644 index 000000000000..9dee679c71e4 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts b/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/next-env.d.ts b/packages/e2e-tests/test-applications/nextjs-14/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/e2e-tests/test-applications/nextjs-14/next.config.js b/packages/e2e-tests/test-applications/nextjs-14/next.config.js new file mode 100644 index 000000000000..4beb4fc356f4 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/next.config.js @@ -0,0 +1,30 @@ +// This file sets a custom webpack configuration to use your Next.js app +// with Sentry. +// https://nextjs.org/docs/api-reference/next.config.js/introduction +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const moduleExports = {}; + +const sentryWebpackPluginOptions = { + // Additional config options for the Sentry Webpack plugin. Keep in mind that + // the following options are set automatically, and overriding them is not + // recommended: + // release, url, org, project, authToken, configFile, stripPrefix, + // urlPrefix, include, ignore + + silent: true, // Suppresses all logs + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options. + + // We're not testing source map uploads at the moment. + dryRun: true, +}; + +// Make sure adding Sentry options is the last code to run before exporting, to +// ensure that your source maps include changes from all other Webpack plugins +module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions, { + hideSourceMaps: true, +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/package.json b/packages/e2e-tests/test-applications/nextjs-14/package.json new file mode 100644 index 000000000000..b822f4316566 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/package.json @@ -0,0 +1,35 @@ +{ + "name": "create-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr", + "clean": "npx rimraf node_modules,pnpm-lock.yaml", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && npx playwright install && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "14.0.4", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "4.9.5", + "wait-port": "1.0.4", + "ts-node": "10.9.1", + "@playwright/test": "^1.27.1" + }, + "devDependencies": { + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts b/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts new file mode 100644 index 000000000000..ab3c40a21471 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const nextPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${nextPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm next dev -p ${nextPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm next start -p ${nextPort}`, + port: nextPort, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts b/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts new file mode 100644 index 000000000000..eb83fd6fb82d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-14', +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts new file mode 100644 index 000000000000..3828312607ea --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; + +test('Should send a transaction event for a generateMetadata() function invokation', async ({ page }) => { + const testTitle = 'foobarasdf'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['metadataTitle'] === testTitle + ); + }); + + await page.goto(`/generation-functions?metadataTitle=${testTitle}`); + + expect(await transactionPromise).toBeDefined(); + + const pageTitle = await page.title(); + expect(pageTitle).toBe(testTitle); +}); + +test('Should send a transaction and an error event for a faulty generateMetadata() function invokation', async ({ + page, +}) => { + const testTitle = 'foobarbaz'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['metadataTitle'] === testTitle + ); + }); + + const errorEventPromise = waitForError('nextjs-14', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'generateMetadata Error'; + }); + + await page.goto(`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`); + + expect(await transactionPromise).toBeDefined(); + expect(await errorEventPromise).toBeDefined(); +}); + +test('Should send a transaction event for a generateViewport() function invokation', async ({ page }) => { + const testTitle = 'floob'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['viewportThemeColor'] === testTitle + ); + }); + + await page.goto(`/generation-functions?viewportThemeColor=${testTitle}`); + + expect(await transactionPromise).toBeDefined(); +}); + +test('Should send a transaction and an error event for a faulty generateViewport() function invokation', async ({ + page, +}) => { + const testTitle = 'blargh'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['viewportThemeColor'] === testTitle + ); + }); + + const errorEventPromise = waitForError('nextjs-14', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'generateViewport Error'; + }); + + await page.goto(`/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1`); + + expect(await transactionPromise).toBeDefined(); + expect(await errorEventPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json b/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json new file mode 100644 index 000000000000..60825545944d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx new file mode 100644 index 000000000000..8077c14d23ca --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata() { + return { + title: 'I am generated metadata', + }; +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts new file mode 100644 index 000000000000..4acc41814d3c --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('Will capture a connected trace for all server components and generation functions when visiting a page', async ({ + page, +}) => { + const someConnectedEvent = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' || + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' || + transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' || + transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' + ); + }); + + const layout1Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const layout2Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const pageTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const generateMetadataTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + await page.goto('/nested-layout'); + + expect(await layout1Transaction).toBeDefined(); + expect(await layout2Transaction).toBeDefined(); + expect(await pageTransaction).toBeDefined(); + expect(await generateMetadataTransaction).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/node-express-app/src/app.ts b/packages/e2e-tests/test-applications/node-express-app/src/app.ts index e9de96631259..330a425cb494 100644 --- a/packages/e2e-tests/test-applications/node-express-app/src/app.ts +++ b/packages/e2e-tests/test-applications/node-express-app/src/app.ts @@ -35,7 +35,7 @@ app.get('/test-param/:param', function (req, res) { app.get('/test-transaction', async function (req, res) { const transaction = Sentry.startTransaction({ name: 'test-transaction', op: 'e2e-test' }); - Sentry.getCurrentHub().configureScope(scope => scope.setSpan(transaction)); + Sentry.getCurrentScope().setSpan(transaction); const span = transaction.startChild(); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/.gitignore b/packages/e2e-tests/test-applications/node-hapi-app/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/e2e-tests/test-applications/node-hapi-app/.npmrc b/packages/e2e-tests/test-applications/node-hapi-app/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts b/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts new file mode 100644 index 000000000000..9dee679c71e4 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/node-hapi-app/package.json b/packages/e2e-tests/test-applications/node-hapi-app/package.json new file mode 100644 index 000000000000..1f667abc8987 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-hapi-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node src/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules,pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@hapi/hapi": "21.3.2", + "@sentry/integrations": "latest || *", + "@sentry/node": "latest || *", + "@sentry/tracing": "latest || *", + "@sentry/types": "latest || *", + "@types/node": "18.15.1", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.27.1", + "ts-node": "10.9.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts b/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts new file mode 100644 index 000000000000..1b478c6ba6da --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const hapiPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${hapiPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: 'pnpm start', + port: hapiPort, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/node-hapi-app/src/app.js b/packages/e2e-tests/test-applications/node-hapi-app/src/app.js new file mode 100644 index 000000000000..4c71802c9be2 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/src/app.js @@ -0,0 +1,61 @@ +const Sentry = require('@sentry/node'); +const Hapi = require('@hapi/hapi'); + +const server = Hapi.server({ + port: 3030, + host: 'localhost', +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + integrations: [new Sentry.Integrations.Hapi({ server })], + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +const init = async () => { + server.route({ + method: 'GET', + path: '/test-success', + handler: function (request, h) { + return { version: 'v1' }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-param/{param}', + handler: function (request, h) { + return { paramWas: request.params.param }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-error', + handler: async function (request, h) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + return { exceptionId }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-failure', + handler: async function (request, h) { + throw new Error('This is an error'); + }, + }); +}; + +(async () => { + init(); + await server.start(); + console.log('Server running on %s', server.info.uri); +})(); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts b/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts new file mode 100644 index 000000000000..7a3ed463e2ae --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-hapi-app', +}); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts b/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts new file mode 100644 index 000000000000..cbcd99e756d7 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts @@ -0,0 +1,194 @@ +import { expect, test } from '@playwright/test'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends captured exception to Sentry', async ({ baseURL }) => { + const { data } = await axios.get(`${baseURL}/test-error`); + const { exceptionId } = data; + + const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`; + + console.log(`Polling for error eventId: ${exceptionId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get(url, { headers: { Authorization: `Bearer ${authToken}` } }); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { timeout: EVENT_POLLING_TIMEOUT }, + ) + .toBe(200); +}); + +test('Sends thrown error to Sentry', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-hapi-app', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'This is an error'; + }); + + try { + await axios.get(`${baseURL}/test-failure`); + } catch (e) {} + + const errorEvent = await errorEventPromise; + const errorEventId = errorEvent.event_id; + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${errorEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends successful transactions to Sentry', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-hapi-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'hapi.request' && transactionEvent?.transaction === '/test-success' + ); + }); + + await axios.get(`${baseURL}/test-success`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends parameterized transactions to Sentry', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-hapi-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'hapi.request' && + transactionEvent?.transaction === '/test-param/{param}' + ); + }); + + await axios.get(`${baseURL}/test-param/123`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + expect(transactionEvent?.contexts?.trace?.op).toBe('hapi.request'); + expect(transactionEvent?.transaction).toBe('/test-param/{param}'); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends sentry-trace and baggage as response headers', async ({ baseURL }) => { + const data = await axios.get(`${baseURL}/test-success`); + + expect(data.headers).toHaveProperty('sentry-trace'); + expect(data.headers).toHaveProperty('baggage'); +}); + +test('Continues trace and baggage from incoming headers', async ({ baseURL }) => { + const traceContent = '12312012123120121231201212312012-1121201211212012-0'; + const baggageContent = 'sentry-release=2.0.0,sentry-environment=myEnv'; + + await axios.get(`${baseURL}/test-success`); + + const data = await axios.get(`${baseURL}/test-success`, { + headers: { + 'sentry-trace': traceContent, + baggage: baggageContent, + }, + }); + + expect(data.headers).toHaveProperty('sentry-trace'); + expect(data.headers).toHaveProperty('baggage'); + + expect(data.headers['sentry-trace']).toContain('12312012123120121231201212312012-'); + expect(data.headers['baggage']).toContain(baggageContent); +}); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json b/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json new file mode 100644 index 000000000000..17bd2c1f4c00 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "outDir": "dist" + }, + "include": ["*.ts"] +} diff --git a/packages/e2e-tests/test-applications/sveltekit-2/.gitignore b/packages/e2e-tests/test-applications/sveltekit-2/.gitignore new file mode 100644 index 000000000000..6635cf554275 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/packages/e2e-tests/test-applications/sveltekit-2/.npmrc b/packages/e2e-tests/test-applications/sveltekit-2/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/packages/e2e-tests/test-applications/sveltekit-2/README.md b/packages/e2e-tests/test-applications/sveltekit-2/README.md new file mode 100644 index 000000000000..7c0d9fbb26ab --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/README.md @@ -0,0 +1,41 @@ +# create-svelte + +Everything you need to build a Svelte project, powered by +[`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm create svelte@latest + +# create a new project in my-app +npm create svelte@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a +development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target +> environment. diff --git a/packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts b/packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts new file mode 100644 index 000000000000..66a9e744846e --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +async function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return await readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/sveltekit-2/package.json b/packages/e2e-tests/test-applications/sveltekit-2/package.json new file mode 100644 index 000000000000..b55d9ff74df6 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/package.json @@ -0,0 +1,42 @@ +{ + "name": "sveltekit-2.0", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "clean": "npx rimraf node_modules,pnpm-lock.yaml", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm -v" + }, + "dependencies": { + "@sentry/sveltekit": "latest || *" + }, + "devDependencies": { + "@playwright/test": "^1.27.1", + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-node": "^2.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.2.8", + "svelte-check": "^3.6.0", + "ts-node": "10.9.1", + "typescript": "^5.0.0", + "vite": "^5.0.3", + "wait-port": "1.0.4" + }, + "pnpm": { + "overrides": { + "@sentry/node": "latest || *", + "@sentry/tracing": "latest || *" + } + }, + "type": "module" +} diff --git a/packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts b/packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts new file mode 100644 index 000000000000..bfa29df7d549 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts @@ -0,0 +1,71 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const port = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './test', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${port}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node --esm start-event-proxy.ts', + port: 3031, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${port} && pnpm dev --port ${port}` + : `pnpm wait-port ${port} && pnpm preview --port ${port}`, + port, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/app.html b/packages/e2e-tests/test-applications/sveltekit-2/src/app.html new file mode 100644 index 000000000000..117bd026151a --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts b/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts new file mode 100644 index 000000000000..bfe90b150886 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.client.ts @@ -0,0 +1,16 @@ +import { env } from '$env/dynamic/public'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: env.PUBLIC_E2E_TEST_DSN, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); + +const myErrorHandler = ({ error, event }: any) => { + console.error('An error occurred on the client side:', error, event); +}; + +export const handleError = Sentry.handleErrorWithSentry(myErrorHandler); diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts b/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts new file mode 100644 index 000000000000..ae99e0e0e7b4 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts @@ -0,0 +1,18 @@ +import { E2E_TEST_DSN } from '$env/static/private'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: E2E_TEST_DSN, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); + +const myErrorHandler = ({ error, event }: any) => { + console.error('An error occurred on the server side:', error, event); +}; + +export const handleError = Sentry.handleErrorWithSentry(myErrorHandler); + +export const handle = Sentry.sentryHandle(); diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte new file mode 100644 index 000000000000..5982b0ae37dd --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit kit.svelte.dev to read the documentation

diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.server.ts b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.server.ts new file mode 100644 index 000000000000..b07376ba97c9 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from './$types'; + +export const load = (async _event => { + return { name: 'building (server)' }; +}) satisfies PageServerLoad; diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte new file mode 100644 index 000000000000..fde274c60705 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte @@ -0,0 +1,6 @@ +

Check Build

+ +

+ This route only exists to check that Typescript definitions + and auto instrumentation are working when the project is built. +

diff --git a/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.ts b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.ts new file mode 100644 index 000000000000..049acdc1fafa --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.ts @@ -0,0 +1,5 @@ +import type { PageLoad } from './$types'; + +export const load = (async _event => { + return { name: 'building' }; +}) satisfies PageLoad; diff --git a/packages/e2e-tests/test-applications/sveltekit-2/start-event-proxy.ts b/packages/e2e-tests/test-applications/sveltekit-2/start-event-proxy.ts new file mode 100644 index 000000000000..3af64eb5960a --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'sveltekit-2', +}); diff --git a/packages/e2e-tests/test-applications/sveltekit-2/static/favicon.png b/packages/e2e-tests/test-applications/sveltekit-2/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/packages/e2e-tests/test-applications/sveltekit-2/static/favicon.png differ diff --git a/packages/e2e-tests/test-applications/sveltekit-2/svelte.config.js b/packages/e2e-tests/test-applications/sveltekit-2/svelte.config.js new file mode 100644 index 000000000000..c521eff7de30 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts b/packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts new file mode 100644 index 000000000000..7d621af34dcf --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +// @ts-expect-error ok ok +import { waitForTransaction } from '../event-proxy-server.ts'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends a pageload transaction', async ({ page }) => { + const pageloadTransactionEventPromise = waitForTransaction('sveltekit', (transactionEvent: any) => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/sveltekit-2/tsconfig.json b/packages/e2e-tests/test-applications/sveltekit-2/tsconfig.json new file mode 100644 index 000000000000..12aa7328fc83 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "allowImportingTsExtensions": true + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node" + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/packages/e2e-tests/test-applications/sveltekit-2/vite.config.js b/packages/e2e-tests/test-applications/sveltekit-2/vite.config.js new file mode 100644 index 000000000000..1a410bee7e11 --- /dev/null +++ b/packages/e2e-tests/test-applications/sveltekit-2/vite.config.js @@ -0,0 +1,12 @@ +import { sentrySvelteKit } from '@sentry/sveltekit'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sentrySvelteKit({ + autoUploadSourceMaps: false, + }), + sveltekit(), + ], +}); diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 7aa8df72cd80..673bf344ea75 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -73,16 +73,15 @@ By default the Feedback integration will attempt to fill in the name/email field ```javascript Sentry.setUser({ - email: 'foo@example.com', + userEmail: 'foo@example.com', fullName: 'Jane Doe', }); - new Feedback({ - useSentryUser({ - email: 'email', - name: 'fullName', - }), + useSentryUser: { + email: 'userEmail', + name: 'fullName', + }, }) ``` diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index b8ec16a15401..f1629a00670a 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -1,4 +1,4 @@ -import { createEventEnvelope, getCurrentHub } from '@sentry/core'; +import { createEventEnvelope, getClient, withScope } from '@sentry/core'; import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types'; import { FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE } from '../constants'; @@ -12,8 +12,7 @@ export async function sendFeedbackRequest( { feedback: { message, email, name, source, url } }: SendFeedbackData, { includeReplay = true }: SendFeedbackOptions = {}, ): Promise { - const hub = getCurrentHub(); - const client = hub.getClient(); + const client = getClient(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); @@ -34,67 +33,58 @@ export async function sendFeedbackRequest( type: 'feedback', }; - return new Promise((resolve, reject) => { - hub.withScope(async scope => { - // No use for breadcrumbs in feedback - scope.clearBreadcrumbs(); + return withScope(async scope => { + // No use for breadcrumbs in feedback + scope.clearBreadcrumbs(); - if ([FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE].includes(String(source))) { - scope.setLevel('info'); - } + if ([FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE].includes(String(source))) { + scope.setLevel('info'); + } - const feedbackEvent = await prepareFeedbackEvent({ - scope, - client, - event: baseEvent, - }); + const feedbackEvent = await prepareFeedbackEvent({ + scope, + client, + event: baseEvent, + }); - if (feedbackEvent === null) { - resolve(); - return; - } + if (!feedbackEvent) { + return; + } - if (client && client.emit) { - client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: Boolean(includeReplay) }); - } + if (client.emit) { + client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: Boolean(includeReplay) }); + } - const envelope = createEventEnvelope( - feedbackEvent, - dsn, - client.getOptions()._metadata, - client.getOptions().tunnel, - ); + const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); - let response: void | TransportMakeRequestResponse; + let response: void | TransportMakeRequestResponse; + + try { + response = await transport.send(envelope); + } catch (err) { + const error = new Error('Unable to send Feedback'); try { - response = await transport.send(envelope); - } catch (err) { - const error = new Error('Unable to send Feedback'); - - try { - // In case browsers don't allow this property to be writable - // @ts-expect-error This needs lib es2022 and newer - error.cause = err; - } catch { - // nothing to do - } - reject(error); + // In case browsers don't allow this property to be writable + // @ts-expect-error This needs lib es2022 and newer + error.cause = err; + } catch { + // nothing to do } + throw error; + } - // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore - if (!response) { - resolve(response); - return; - } + // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore + if (!response) { + return; + } - // Require valid status codes, otherwise can assume feedback was not sent successfully - if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { - reject(new Error('Unable to send Feedback')); - } + // Require valid status codes, otherwise can assume feedback was not sent successfully + if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { + throw new Error('Unable to send Feedback'); + } - resolve(response); - }); + return response; }); } diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts index 35f9fcf51f71..b5e414803121 100644 --- a/packages/feedback/src/widget/createWidget.ts +++ b/packages/feedback/src/widget/createWidget.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getCurrentScope } from '@sentry/core'; import { logger } from '@sentry/utils'; import type { FeedbackFormData, FeedbackInternalOptions, FeedbackWidget } from '../types'; @@ -160,7 +160,7 @@ export function createWidget({ } const userKey = options.useSentryUser; - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const user = scope && scope.getUser(); dialog = Dialog({ diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index 7797b1d0e7d5..057d0e6a9975 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -112,6 +112,7 @@ export const captureMessage = captureMessageCore; /** * @deprecated This export has moved to @sentry/core. The @sentry/hub package will be removed in v8. */ +// eslint-disable-next-line deprecation/deprecation export const configureScope = configureScopeCore; /** diff --git a/packages/integrations/src/httpclient.ts b/packages/integrations/src/httpclient.ts index 1e1ee0318861..c03cd63e6840 100644 --- a/packages/integrations/src/httpclient.ts +++ b/packages/integrations/src/httpclient.ts @@ -1,4 +1,4 @@ -import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import { getClient, isSentryRequestUrl } from '@sentry/core'; import type { Event as SentryEvent, EventProcessor, @@ -348,9 +348,7 @@ export class HttpClient implements Integration { */ private _shouldCaptureResponse(status: number, url: string): boolean { return ( - this._isInGivenStatusRanges(status) && - this._isInGivenRequestTargets(url) && - !isSentryRequestUrl(url, getCurrentHub()) + this._isInGivenStatusRanges(status) && this._isInGivenRequestTargets(url) && !isSentryRequestUrl(url, getClient()) ); } diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 8fd55568e70e..0c10a8344bd7 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -4,8 +4,8 @@ import type { BrowserOptions } from '@sentry/react'; import { BrowserTracing, Integrations, - configureScope, defaultRequestInstrumentationOptions, + getCurrentScope, init as reactInit, } from '@sentry/react'; import type { EventProcessor } from '@sentry/types'; @@ -56,17 +56,16 @@ export function init(options: BrowserOptions): void { reactInit(opts); - configureScope(scope => { - scope.setTag('runtime', 'browser'); - const filterTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === '/404' ? null : event; - filterTransactions.id = 'NextClient404Filter'; - scope.addEventProcessor(filterTransactions); + const scope = getCurrentScope(); + scope.setTag('runtime', 'browser'); + const filterTransactions: EventProcessor = event => + event.type === 'transaction' && event.transaction === '/404' ? null : event; + filterTransactions.id = 'NextClient404Filter'; + scope.addEventProcessor(filterTransactions); - if (process.env.NODE_ENV === 'development') { - scope.addEventProcessor(devErrorSymbolicationEventProcessor); - } - }); + if (process.env.NODE_ENV === 'development') { + scope.addEventProcessor(devErrorSymbolicationEventProcessor); + } } function addClientIntegrations(options: BrowserOptions): void { diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 91929f885ae0..a7c3d5bd2344 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -1,5 +1,5 @@ import type { ParsedUrlQuery } from 'querystring'; -import { getClient, getCurrentHub } from '@sentry/core'; +import { getClient, getCurrentScope } from '@sentry/core'; import { WINDOW } from '@sentry/react'; import type { Primitive, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; import { @@ -124,7 +124,7 @@ export function pagesRouterInstrumentation( baggage, ); - getCurrentHub().getScope().setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); prevLocationName = route || globalObject.location.pathname; if (startTransactionOnPageLoad) { diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index 063c11bff62a..3b0ce67fb16c 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -44,4 +44,6 @@ export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry'; +export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; + export { withServerActionInstrumentation } from './withServerActionInstrumentation'; diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index ffca3dc8ff61..cf7d881e9ea0 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -1,5 +1,6 @@ import type { Transaction, WebFetchHeaders, WrappedFunction } from '@sentry/types'; import type { NextApiRequest, NextApiResponse } from 'next'; +import type { RequestAsyncStorage } from '../config/templates/requestAsyncStorageShim'; export type ServerComponentContext = { componentRoute: string; @@ -17,6 +18,13 @@ export type ServerComponentContext = { headers?: WebFetchHeaders; }; +export type GenerationFunctionContext = { + requestAsyncStorage?: RequestAsyncStorage; + componentRoute: string; + componentType: string; + generationFunctionIdentifier: string; +}; + export interface RouteHandlerContext { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; parameterizedRoute: string; diff --git a/packages/nextjs/src/common/utils/commonObjectTracing.ts b/packages/nextjs/src/common/utils/commonObjectTracing.ts new file mode 100644 index 000000000000..bb5cf130bab1 --- /dev/null +++ b/packages/nextjs/src/common/utils/commonObjectTracing.ts @@ -0,0 +1,23 @@ +import type { PropagationContext } from '@sentry/types'; + +const commonMap = new WeakMap(); + +/** + * Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context. + */ +export function commonObjectToPropagationContext( + commonObject: unknown, + propagationContext: PropagationContext, +): PropagationContext { + if (typeof commonObject === 'object' && commonObject) { + const memoPropagationContext = commonMap.get(commonObject); + if (memoPropagationContext) { + return memoPropagationContext; + } else { + commonMap.set(commonObject, propagationContext); + return propagationContext; + } + } else { + return propagationContext; + } +} diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts index 008f8629f3ab..afdf686499c5 100644 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, captureException, flush, getCurrentHub, startTransaction } from '@sentry/core'; +import { addTracingExtensions, captureException, getCurrentScope, startTransaction } from '@sentry/core'; import type { Span } from '@sentry/types'; import { addExceptionMechanism, @@ -23,7 +23,7 @@ export function withEdgeWrapping( addTracingExtensions(); const req = args[0]; - const currentScope = getCurrentHub().getScope(); + const currentScope = getCurrentScope(); const prevSpan = currentScope.getSpan(); let span: Span | undefined; diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index fedb5ba6f3ff..5451b1264723 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { captureException, getActiveTransaction, - getCurrentHub, + getCurrentScope, runWithAsyncContext, startTransaction, } from '@sentry/core'; @@ -84,8 +84,7 @@ export function withTracedServerSideDataFetcher Pr ): (...params: Parameters) => Promise> { return async function (this: unknown, ...args: Parameters): Promise> { return runWithAsyncContext(async () => { - const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const previousSpan: Span | undefined = getTransactionFromRequest(req) ?? scope.getSpan(); let dataFetcherSpan; diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index eafcff7b9075..d87429ad528c 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -1,4 +1,11 @@ -import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; +import { + addTracingExtensions, + captureException, + getClient, + getCurrentScope, + runWithAsyncContext, + trace, +} from '@sentry/core'; import { logger, tracingContextFromHeaders } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; @@ -49,8 +56,7 @@ async function withServerActionInstrumentationImplementation> { addTracingExtensions(); return runWithAsyncContext(async () => { - const hub = getCurrentHub(); - const sendDefaultPii = hub.getClient()?.getOptions().sendDefaultPii; + const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; let sentryTraceHeader; let baggageHeader; @@ -68,7 +74,7 @@ async function withServerActionInstrumentationImplementation { - const hub = getCurrentHub(); let transaction: Transaction | undefined; - const currentScope = hub.getScope(); - const options = hub.getClient()?.getOptions(); + const currentScope = getCurrentScope(); + const options = getClient()?.getOptions(); currentScope.setSDKProcessingMetadata({ request: req }); diff --git a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts index 1974cf6c5a13..eddf7f4e25e4 100644 --- a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient, getCurrentScope } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type App from 'next/app'; @@ -32,8 +32,7 @@ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetI const { req, res } = context.ctx; const errorWrappedAppGetInitialProps = withErrorInstrumentation(wrappingTarget); - const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); + const options = getClient()?.getOptions(); // Generally we can assume that `req` and `res` are always defined on the server: // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object @@ -53,7 +52,7 @@ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetI }; } = await tracedGetInitialProps.apply(thisArg, args); - const requestTransaction = getTransactionFromRequest(req) ?? hub.getScope().getTransaction(); + const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); // Per definition, `pageProps` is not optional, however an increased amount of users doesn't seem to call // `App.getInitialProps(appContext)` in their custom `_app` pages which is required as per diff --git a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts index a6444a5e3d60..0e4601886cee 100644 --- a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient, getCurrentScope } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { NextPageContext } from 'next'; import type { ErrorProps } from 'next/error'; @@ -35,8 +35,7 @@ export function wrapErrorGetInitialPropsWithSentry( const { req, res } = context; const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); + const options = getClient()?.getOptions(); // Generally we can assume that `req` and `res` are always defined on the server: // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object @@ -54,7 +53,7 @@ export function wrapErrorGetInitialPropsWithSentry( _sentryBaggage?: string; } = await tracedGetInitialProps.apply(thisArg, args); - const requestTransaction = getTransactionFromRequest(req) ?? hub.getScope().getTransaction(); + const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); if (requestTransaction) { errorGetInitialProps._sentryTraceData = requestTransaction.toTraceparent(); diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts new file mode 100644 index 000000000000..3acaa849ff79 --- /dev/null +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -0,0 +1,95 @@ +import { + addTracingExtensions, + captureException, + continueTrace, + getClient, + getCurrentScope, + runWithAsyncContext, + trace, +} from '@sentry/core'; +import type { WebFetchHeaders } from '@sentry/types'; +import { winterCGHeadersToDict } from '@sentry/utils'; + +import type { GenerationFunctionContext } from '../common/types'; +import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; + +/** + * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapGenerationFunctionWithSentry any>( + generationFunction: F, + context: GenerationFunctionContext, +): F { + addTracingExtensions(); + const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; + return new Proxy(generationFunction, { + apply: (originalFunction, thisArg, args) => { + let headers: WebFetchHeaders | undefined = undefined; + // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API + try { + headers = requestAsyncStorage?.getStore()?.headers; + } catch (e) { + /** empty */ + } + + let data: Record | undefined = undefined; + if (getClient()?.getOptions().sendDefaultPii) { + const props: unknown = args[0]; + const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; + const searchParams = + props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; + data = { params, searchParams }; + } + + return runWithAsyncContext(() => { + const transactionContext = continueTrace({ + baggage: headers?.get('baggage'), + sentryTrace: headers?.get('sentry-trace') ?? undefined, + }); + + // If there is no incoming trace, we are setting the transaction context to one that is shared between all other + // transactions for this request. We do this based on the `headers` object, which is the same for all components. + const propagationContext = getCurrentScope().getPropagationContext(); + if (!transactionContext.traceId && !transactionContext.parentSpanId) { + const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( + headers, + propagationContext, + ); + transactionContext.traceId = commonTraceId; + transactionContext.parentSpanId = commonSpanId; + } + + return trace( + { + op: 'function.nextjs', + name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, + origin: 'auto.function.nextjs', + ...transactionContext, + data, + metadata: { + ...transactionContext.metadata, + source: 'url', + request: { + headers: headers ? winterCGHeadersToDict(headers) : undefined, + }, + }, + }, + () => { + return originalFunction.apply(thisArg, args); + }, + err => { + captureException(err, { + mechanism: { + handled: false, + data: { + function: 'wrapGenerationFunctionWithSentry', + }, + }, + }); + }, + ); + }); + }, + }); +} diff --git a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts index 594ef451c385..510cbae5684c 100644 --- a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient, getCurrentScope } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { NextPage } from 'next'; @@ -31,8 +31,7 @@ export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialPro const { req, res } = context; const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); + const options = getClient()?.getOptions(); // Generally we can assume that `req` and `res` are always defined on the server: // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object @@ -50,7 +49,7 @@ export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialPro _sentryBaggage?: string; } = (await tracedGetInitialProps.apply(thisArg, args)) ?? {}; // Next.js allows undefined to be returned from a getInitialPropsFunction. - const requestTransaction = getTransactionFromRequest(req) ?? hub.getScope().getTransaction(); + const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); if (requestTransaction) { initialProps._sentryTraceData = requestTransaction.toTraceparent(); diff --git a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts index 1e10518245b9..f93c7193418e 100644 --- a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient, getCurrentScope } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { GetServerSideProps } from 'next'; @@ -32,8 +32,7 @@ export function wrapGetServerSidePropsWithSentry( const { req, res } = context; const errorWrappedGetServerSideProps = withErrorInstrumentation(wrappingTarget); - const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); + const options = getClient()?.getOptions(); if (options?.instrumenter === 'sentry') { const tracedGetServerSideProps = withTracedServerSideDataFetcher(errorWrappedGetServerSideProps, req, res, { @@ -47,7 +46,7 @@ export function wrapGetServerSidePropsWithSentry( >); if (serverSideProps && 'props' in serverSideProps) { - const requestTransaction = getTransactionFromRequest(req) ?? hub.getScope().getTransaction(); + const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); if (requestTransaction) { serverSideProps.props._sentryTraceData = requestTransaction.toTraceparent(); diff --git a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts index ece566bc2e5a..2051d015b0c4 100644 --- a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, captureException, configureScope, runWithAsyncContext } from '@sentry/core'; +import { addTracingExtensions, captureException, getCurrentScope, runWithAsyncContext } from '@sentry/core'; import { extractTraceparentData } from '@sentry/utils'; interface FunctionComponent { @@ -26,24 +26,23 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C return class SentryWrappedPageComponent extends pageComponent { public render(...args: unknown[]): unknown { return runWithAsyncContext(() => { - configureScope(scope => { - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = - typeof this.props === 'object' && - this.props !== null && - '_sentryTraceData' in this.props && - typeof this.props._sentryTraceData === 'string' - ? this.props._sentryTraceData - : undefined; + const scope = getCurrentScope(); + // We extract the sentry trace data that is put in the component props by datafetcher wrappers + const sentryTraceData = + typeof this.props === 'object' && + this.props !== null && + '_sentryTraceData' in this.props && + typeof this.props._sentryTraceData === 'string' + ? this.props._sentryTraceData + : undefined; - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - }); + if (sentryTraceData) { + const traceparentData = extractTraceparentData(sentryTraceData); + scope.setContext('trace', { + span_id: traceparentData?.parentSpanId, + trace_id: traceparentData?.traceId, + }); + } try { return super.render(...args); @@ -62,18 +61,18 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C return new Proxy(pageComponent, { apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) { return runWithAsyncContext(() => { - configureScope(scope => { - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = argArray?.[0]?._sentryTraceData; + const scope = getCurrentScope(); + // We extract the sentry trace data that is put in the component props by datafetcher wrappers + const sentryTraceData = argArray?.[0]?._sentryTraceData; + + if (sentryTraceData) { + const traceparentData = extractTraceparentData(sentryTraceData); + scope.setContext('trace', { + span_id: traceparentData?.parentSpanId, + trace_id: traceparentData?.traceId, + }); + } - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - }); try { return target.apply(thisArg, argArray); } catch (e) { diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 79eaa78e3dff..1f294283c7d8 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; +import { addTracingExtensions, captureException, getCurrentScope, runWithAsyncContext, trace } from '@sentry/core'; import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; @@ -20,14 +20,11 @@ export function wrapRouteHandlerWithSentry any>( return new Proxy(routeHandler, { apply: (originalFunction, thisArg, args) => { return runWithAsyncContext(async () => { - const hub = getCurrentHub(); - const currentScope = hub.getScope(); - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( sentryTraceHeader ?? headers?.get('sentry-trace') ?? undefined, baggageHeader ?? headers?.get('baggage'), ); - currentScope.setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); let res; try { diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index dade931bf074..8312121ae12c 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -1,14 +1,16 @@ import { addTracingExtensions, captureException, - getCurrentHub, + continueTrace, + getCurrentScope, runWithAsyncContext, - startTransaction, + trace, } from '@sentry/core'; -import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { winterCGHeadersToDict } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; +import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; import { flushQueue } from './utils/responseEnd'; /** @@ -28,90 +30,69 @@ export function wrapServerComponentWithSentry any> return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { return runWithAsyncContext(() => { - const hub = getCurrentHub(); - const currentScope = hub.getScope(); - - let maybePromiseResult; - const completeHeadersDict: Record = context.headers ? winterCGHeadersToDict(context.headers) : {}; - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( + const transactionContext = continueTrace({ // eslint-disable-next-line deprecation/deprecation - context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'], + sentryTrace: context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'], // eslint-disable-next-line deprecation/deprecation - context.baggageHeader ?? completeHeadersDict['baggage'], - ); - currentScope.setPropagationContext(propagationContext); - - const transaction = startTransaction({ - op: 'function.nextjs', - name: `${componentType} Server Component (${componentRoute})`, - status: 'ok', - origin: 'auto.function.nextjs', - ...traceparentData, - metadata: { - request: { - headers: completeHeadersDict, - }, - source: 'component', - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - }, + baggage: context.baggageHeader ?? completeHeadersDict['baggage'], }); - currentScope.setSpan(transaction); - - const handleErrorCase = (e: unknown): void => { - if (isNotFoundNavigationError(e)) { - // We don't want to report "not-found"s - transaction.setStatus('not_found'); - } else if (isRedirectNavigationError(e)) { - // We don't want to report redirects - } else { - transaction.setStatus('internal_error'); - - captureException(e, { - mechanism: { - handled: false, - }, - }); - } - - transaction.finish(); - }; - - try { - maybePromiseResult = originalFunction.apply(thisArg, args); - } catch (e) { - handleErrorCase(e); - void flushQueue(); - throw e; + // If there is no incoming trace, we are setting the transaction context to one that is shared between all other + // transactions for this request. We do this based on the `headers` object, which is the same for all components. + const propagationContext = getCurrentScope().getPropagationContext(); + if (!transactionContext.traceId && !transactionContext.parentSpanId) { + const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( + context.headers, + propagationContext, + ); + transactionContext.traceId = commonTraceId; + transactionContext.parentSpanId = commonSpanId; } - if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - Promise.resolve(maybePromiseResult) - .then( - () => { - transaction.finish(); - }, - e => { - handleErrorCase(e); + const res = trace( + { + ...transactionContext, + op: 'function.nextjs', + name: `${componentType} Server Component (${componentRoute})`, + status: 'ok', + origin: 'auto.function.nextjs', + metadata: { + ...transactionContext.metadata, + request: { + headers: completeHeadersDict, }, - ) - .finally(() => { - void flushQueue(); - }); + source: 'component', + }, + }, + () => originalFunction.apply(thisArg, args), + (e, span) => { + if (isNotFoundNavigationError(e)) { + // We don't want to report "not-found"s + span?.setStatus('not_found'); + } else if (isRedirectNavigationError(e)) { + // We don't want to report redirects + // Since `trace` will automatically set the span status to "internal_error" we need to set it back to "ok" + span?.setStatus('ok'); + } else { + span?.setStatus('internal_error'); - // 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 { - transaction.finish(); - void flushQueue(); - return maybePromiseResult; - } + captureException(e, { + mechanism: { + handled: false, + }, + }); + } + }, + () => { + void flushQueue(); + }, + ); + + return res; }); }, }); diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index a6b852af8b28..3d2fd7c80bb4 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -157,8 +157,6 @@ export default function wrappingLoader( .replace(/(.*)/, '/$1') // Pull off the file name .replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '') - // Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts - .replace(/\/(\(.*?\)\/)+/g, '/') // In case all of the above have left us with an empty string (which will happen if we're dealing with the // homepage), sub back in the root route .replace(/^$/, '/'); diff --git a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts index d0cc4adc4466..56b9853fa1af 100644 --- a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts @@ -12,6 +12,9 @@ declare const requestAsyncStorage: RequestAsyncStorage; declare const serverComponentModule: { default: unknown; + generateMetadata?: () => unknown; + generateImageMetadata?: () => unknown; + generateViewport?: () => unknown; }; const serverComponent = serverComponentModule.default; @@ -30,14 +33,15 @@ if (typeof serverComponent === 'function') { // We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined try { const requestAsyncStore = requestAsyncStorage.getStore(); - sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace'); - baggageHeader = requestAsyncStore?.headers.get('baggage'); + sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; + baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; headers = requestAsyncStore?.headers; } catch (e) { /** empty */ } - return Sentry.wrapServerComponentWithSentry(originalFunction, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + return Sentry.wrapServerComponentWithSentry(originalFunction as any, { componentRoute: '__ROUTE__', componentType: '__COMPONENT_TYPE__', sentryTraceHeader, @@ -50,6 +54,33 @@ if (typeof serverComponent === 'function') { wrappedServerComponent = serverComponent; } +export const generateMetadata = serverComponentModule.generateMetadata + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateMetadata, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateMetadata', + requestAsyncStorage, + }) + : undefined; + +export const generateImageMetadata = serverComponentModule.generateImageMetadata + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateImageMetadata, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateImageMetadata', + requestAsyncStorage, + }) + : undefined; + +export const generateViewport = serverComponentModule.generateViewport + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateViewport, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateViewport', + requestAsyncStorage, + }) + : undefined; + // 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. // @ts-expect-error See above diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 46691b3cdce5..5a9398319ae2 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getCurrentHub, getCurrentScope } from '@sentry/core'; import { withEdgeWrapping } from '../common/utils/edgeWrapperUtils'; import type { EdgeRouteHandler } from './types'; @@ -14,7 +14,7 @@ export function wrapApiHandlerWithSentry( apply: (wrappingTarget, thisArg, args: Parameters) => { const req = args[0]; - const activeSpan = getCurrentHub().getScope().getSpan(); + const activeSpan = getCurrentScope().getSpan(); const wrappedHandler = withEdgeWrapping(wrappingTarget, { spanDescription: diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index b049be2c31b8..f380f949ef6b 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,8 +1,8 @@ import * as path from 'path'; -import { addTracingExtensions } from '@sentry/core'; +import { addTracingExtensions, getClient } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { NodeOptions } from '@sentry/node'; -import { Integrations, configureScope, getCurrentHub, init as nodeInit } from '@sentry/node'; +import { Integrations, getCurrentScope, init as nodeInit } from '@sentry/node'; import type { EventProcessor } from '@sentry/types'; import type { IntegrationWithExclusionOption } from '@sentry/utils'; import { addOrUpdateIntegration, escapeStringForRegex, logger } from '@sentry/utils'; @@ -13,6 +13,7 @@ import { getVercelEnv } from '../common/getVercelEnv'; import { buildMetadata } from '../common/metadata'; import { isBuild } from '../common/utils/isBuild'; +export { createReduxEnhancer } from '@sentry/react'; export * from '@sentry/node'; export { captureUnderscoreErrorException } from '../common/_error'; @@ -101,25 +102,23 @@ export function init(options: NodeOptions): void { filterTransactions.id = 'NextServer404TransactionFilter'; - configureScope(scope => { - scope.setTag('runtime', 'node'); - if (IS_VERCEL) { - scope.setTag('vercel', true); - } + const scope = getCurrentScope(); + scope.setTag('runtime', 'node'); + if (IS_VERCEL) { + scope.setTag('vercel', true); + } - scope.addEventProcessor(filterTransactions); + scope.addEventProcessor(filterTransactions); - if (process.env.NODE_ENV === 'development') { - scope.addEventProcessor(devErrorSymbolicationEventProcessor); - } - }); + if (process.env.NODE_ENV === 'development') { + scope.addEventProcessor(devErrorSymbolicationEventProcessor); + } DEBUG_BUILD && logger.log('SDK successfully initialized'); } function sdkAlreadyInitialized(): boolean { - const hub = getCurrentHub(); - return !!hub.getClient(); + return !!getClient(); } function addServerIntegrations(options: NodeOptions): void { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index b6d29d5ecff2..95b003e4e14d 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -2,10 +2,10 @@ import type { IncomingMessage, ServerResponse } from 'http'; import * as SentryCore from '@sentry/core'; import { addTracingExtensions } from '@sentry/core'; +import type { Client } from '@sentry/types'; import { wrapGetInitialPropsWithSentry, wrapGetServerSidePropsWithSentry } from '../../src/common'; const startTransactionSpy = jest.spyOn(SentryCore, 'startTransaction'); -const originalGetCurrentHub = jest.requireActual('@sentry/node').getCurrentHub; // The wrap* functions require the hub to have tracing extensions. This is normally called by the NodeClient // constructor but the client isn't used in these tests. @@ -22,16 +22,11 @@ describe('data-fetching function wrappers', () => { res = { end: jest.fn() } as unknown as ServerResponse; jest.spyOn(SentryCore, 'hasTracingEnabled').mockReturnValue(true); - jest.spyOn(SentryCore, 'getCurrentHub').mockImplementation(() => { - const hub = originalGetCurrentHub(); - - hub.getClient = () => - ({ - getOptions: () => ({ instrumenter: 'sentry' }), - getDsn: () => {}, - }) as any; - - return hub; + jest.spyOn(SentryCore, 'getClient').mockImplementation(() => { + return { + getOptions: () => ({ instrumenter: 'sentry' }), + getDsn: () => {}, + } as Client; }); }); diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index e80e9ff4dc0b..e45616b2e65a 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -13,7 +13,34 @@ export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanc export * as Handlers from './sdk/handlers'; export type { Span } from './types'; -export { startSpan, startInactiveSpan, getCurrentHub, getClient, getActiveSpan } from '@sentry/opentelemetry'; +export { startSpan, startInactiveSpan, getActiveSpan } from '@sentry/opentelemetry'; +export { + getClient, + addBreadcrumb, + captureException, + captureEvent, + captureMessage, + addGlobalEventProcessor, + addEventProcessor, + lastEventId, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + withScope, + withIsolationScope, + // eslint-disable-next-line deprecation/deprecation + configureScope, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setIsolationScope, + setCurrentScope, +} from './sdk/api'; +export { getCurrentHub, makeMain } from './sdk/hub'; +export { Scope } from './sdk/scope'; export { makeNodeTransport, @@ -24,37 +51,19 @@ export { extractRequestData, deepReadDirSync, getModuleFromFilename, - // eslint-disable-next-line deprecation/deprecation - addGlobalEventProcessor, - addEventProcessor, - addBreadcrumb, - captureException, - captureEvent, - captureMessage, close, - configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation extractTraceparentData, flush, - getActiveTransaction, Hub, - lastEventId, - makeMain, runWithAsyncContext, - Scope, SDK_VERSION, - setContext, - setExtra, - setExtras, - setTag, - setTags, - setUser, spanStatusfromHttpCode, trace, - withScope, captureCheckIn, withMonitor, + hapiErrorPlugin, } from '@sentry/node'; export type { diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 974828fd46fe..4588d1b36b15 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -3,11 +3,13 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; +import { addBreadcrumb, hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; import { _INTERNAL, getClient, getCurrentHub, getSpanKind, setSpanMetadata } from '@sentry/opentelemetry'; import type { EventProcessor, Hub, Integration } from '@sentry/types'; import { stringMatchesSomePattern } from '@sentry/utils'; +import { getIsolationScope, setIsolationScope } from '../sdk/api'; +import { Scope } from '../sdk/scope'; import type { NodeExperimentalClient } from '../types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { getRequestUrl } from '../utils/getRequestUrl'; @@ -102,7 +104,7 @@ export class Http implements Integration { return false; } - if (isSentryRequestUrl(url, getCurrentHub())) { + if (isSentryRequestUrl(url, getClient())) { return true; } @@ -127,6 +129,11 @@ export class Http implements Integration { requireParentforIncomingSpans: false, requestHook: (span, req) => { this._updateSpan(span, req); + + // Update the isolation scope, isolate this request + if (getSpanKind(span) === SpanKind.SERVER) { + setIsolationScope(getIsolationScope().clone()); + } }, responseHook: (span, res) => { this._addRequestBreadcrumb(span, res); @@ -159,7 +166,7 @@ export class Http implements Integration { } const data = _INTERNAL.getRequestSpanData(span); - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'http', data: { diff --git a/packages/node-experimental/src/integrations/node-fetch.ts b/packages/node-experimental/src/integrations/node-fetch.ts index 54d67f33f4c2..2f9db367d511 100644 --- a/packages/node-experimental/src/integrations/node-fetch.ts +++ b/packages/node-experimental/src/integrations/node-fetch.ts @@ -1,8 +1,8 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import type { Instrumentation } from '@opentelemetry/instrumentation'; -import { hasTracingEnabled } from '@sentry/core'; -import { _INTERNAL, getClient, getCurrentHub, getSpanKind } from '@sentry/opentelemetry'; +import { addBreadcrumb, hasTracingEnabled } from '@sentry/core'; +import { _INTERNAL, getClient, getSpanKind } from '@sentry/opentelemetry'; import type { Integration } from '@sentry/types'; import type { NodeExperimentalClient } from '../types'; @@ -114,7 +114,7 @@ export class NodeFetch extends NodePerformanceIntegration impl } const data = _INTERNAL.getRequestSpanData(span); - getCurrentHub().addBreadcrumb({ + addBreadcrumb({ category: 'http', data: { ...data, diff --git a/packages/node-experimental/src/otel/asyncContextStrategy.ts b/packages/node-experimental/src/otel/asyncContextStrategy.ts new file mode 100644 index 000000000000..e0d976c71ff1 --- /dev/null +++ b/packages/node-experimental/src/otel/asyncContextStrategy.ts @@ -0,0 +1,29 @@ +import * as api from '@opentelemetry/api'; + +import { setAsyncContextStrategy } from './../sdk/globals'; +import { getCurrentHub } from './../sdk/hub'; +import type { CurrentScopes } from './../sdk/types'; +import { getScopesFromContext } from './../utils/contextData'; + +/** + * Sets the async context strategy to use follow the OTEL context under the hood. + * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) + */ +export function setOpenTelemetryContextAsyncContextStrategy(): void { + function getScopes(): CurrentScopes | undefined { + const ctx = api.context.active(); + return getScopesFromContext(ctx); + } + + /* This is more or less a NOOP - we rely on the OTEL context manager for this */ + function runWithAsyncContext(callback: () => T): T { + const ctx = api.context.active(); + + // We depend on the otelContextManager to handle the context/hub + return api.context.with(ctx, () => { + return callback(); + }); + } + + setAsyncContextStrategy({ getScopes, getCurrentHub, runWithAsyncContext }); +} diff --git a/packages/node-experimental/src/otel/contextManager.ts b/packages/node-experimental/src/otel/contextManager.ts new file mode 100644 index 000000000000..4ba4f0642b16 --- /dev/null +++ b/packages/node-experimental/src/otel/contextManager.ts @@ -0,0 +1,45 @@ +import type { Context } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { setHubOnContext } from '@sentry/opentelemetry'; +import { getCurrentHub } from '../sdk/hub'; + +import { getCurrentScope, getIsolationScope } from './../sdk/api'; +import { Scope } from './../sdk/scope'; +import type { CurrentScopes } from './../sdk/types'; +import { getScopesFromContext, setScopesOnContext } from './../utils/contextData'; + +/** + * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. + * It ensures that we create a new hub per context, so that the OTEL Context & the Sentry Hub are always in sync. + * + * Note that we currently only support AsyncHooks with this, + * but since this should work for Node 14+ anyhow that should be good enough. + */ +export class SentryContextManager extends AsyncLocalStorageContextManager { + /** + * Overwrite with() of the original AsyncLocalStorageContextManager + * to ensure we also create a new hub per context. + */ + public with ReturnType>( + context: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + const previousScopes = getScopesFromContext(context); + + const currentScope = previousScopes ? previousScopes.scope : getCurrentScope(); + const isolationScope = previousScopes ? previousScopes.isolationScope : getIsolationScope(); + + const newCurrentScope = currentScope.clone(); + const scopes: CurrentScopes = { scope: newCurrentScope, isolationScope }; + + // We also need to "mock" the hub on the context, as the original @sentry/opentelemetry uses that... + const mockHub = { ...getCurrentHub(), getScope: () => scopes.scope }; + + const ctx1 = setHubOnContext(context, mockHub); + const ctx2 = setScopesOnContext(ctx1, scopes); + + return super.with(ctx2, fn, thisArg, ...args); + } +} diff --git a/packages/node-experimental/src/sdk/api.ts b/packages/node-experimental/src/sdk/api.ts new file mode 100644 index 000000000000..1a7ddfd52ad5 --- /dev/null +++ b/packages/node-experimental/src/sdk/api.ts @@ -0,0 +1,225 @@ +// PUBLIC APIS + +import { context } from '@opentelemetry/api'; +import { DEFAULT_ENVIRONMENT, closeSession, makeSession, updateSession } from '@sentry/core'; +import type { + Breadcrumb, + BreadcrumbHint, + CaptureContext, + Client, + Event, + EventHint, + EventProcessor, + Extra, + Extras, + Primitive, + Session, + Severity, + SeverityLevel, + User, +} from '@sentry/types'; +import { GLOBAL_OBJ, consoleSandbox, dateTimestampInSeconds } from '@sentry/utils'; +import { getScopesFromContext, setScopesOnContext } from '../utils/contextData'; + +import type { ExclusiveEventHintOrCaptureContext } from '../utils/prepareEvent'; +import { parseEventHintOrCaptureContext } from '../utils/prepareEvent'; +import type { Scope } from './scope'; +import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from './scope'; + +export { getCurrentScope, getGlobalScope, getIsolationScope, getClient }; +export { setCurrentScope, setIsolationScope } from './scope'; + +/** + * Fork a scope from the current scope, and make it the current scope in the given callback + */ +export function withScope(callback: (scope: Scope) => T): T { + return context.with(context.active(), () => callback(getCurrentScope())); +} + +/** + * For a new isolation scope from the current isolation scope, + * and make it the current isolation scope in the given callback. + */ +export function withIsolationScope(callback: (isolationScope: Scope) => T): T { + const ctx = context.active(); + const currentScopes = getScopesFromContext(ctx); + const scopes = currentScopes + ? { ...currentScopes } + : { + scope: getCurrentScope(), + isolationScope: getIsolationScope(), + }; + + scopes.isolationScope = scopes.isolationScope.clone(); + + return context.with(setScopesOnContext(ctx, scopes), () => { + return callback(getIsolationScope()); + }); +} + +/** Get the ID of the last sent error event. */ +export function lastEventId(): string | undefined { + return getCurrentScope().lastEventId(); +} + +/** + * Configure the current scope. + * @deprecated Use `getCurrentScope()` instead. + */ +export function configureScope(callback: (scope: Scope) => void): void { + callback(getCurrentScope()); +} + +/** Record an exception and send it to Sentry. */ +export function captureException(exception: unknown, hint?: ExclusiveEventHintOrCaptureContext): string { + return getCurrentScope().captureException(exception, parseEventHintOrCaptureContext(hint)); +} + +/** Record a message and send it to Sentry. */ +export function captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + captureContext?: CaptureContext | Severity | SeverityLevel, +): string { + // This is necessary to provide explicit scopes upgrade, without changing the original + // arity of the `captureMessage(message, level)` method. + const level = typeof captureContext === 'string' ? captureContext : undefined; + const context = typeof captureContext !== 'string' ? { captureContext } : undefined; + + return getCurrentScope().captureMessage(message, level, context); +} + +/** Capture a generic event and send it to Sentry. */ +export function captureEvent(event: Event, hint?: EventHint): string { + return getCurrentScope().captureEvent(event, hint); +} + +/** + * Add a breadcrumb to the current isolation scope. + */ +export function addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void { + const client = getClient(); + + const { beforeBreadcrumb, maxBreadcrumbs } = client.getOptions(); + + if (maxBreadcrumbs && maxBreadcrumbs <= 0) return; + + const timestamp = dateTimestampInSeconds(); + const mergedBreadcrumb = { timestamp, ...breadcrumb }; + const finalBreadcrumb = beforeBreadcrumb + ? (consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)) as Breadcrumb | null) + : mergedBreadcrumb; + + if (finalBreadcrumb === null) return; + + if (client.emit) { + client.emit('beforeAddBreadcrumb', finalBreadcrumb, hint); + } + + getIsolationScope().addBreadcrumb(finalBreadcrumb, maxBreadcrumbs); +} + +/** + * Add a global event processor. + */ +export function addGlobalEventProcessor(eventProcessor: EventProcessor): void { + getGlobalScope().addEventProcessor(eventProcessor); +} + +/** + * Add an event processor to the current isolation scope. + */ +export function addEventProcessor(eventProcessor: EventProcessor): void { + getIsolationScope().addEventProcessor(eventProcessor); +} + +/** Set the user for the current isolation scope. */ +export function setUser(user: User | null): void { + getIsolationScope().setUser(user); +} + +/** Set tags for the current isolation scope. */ +export function setTags(tags: { [key: string]: Primitive }): void { + getIsolationScope().setTags(tags); +} + +/** Set a single tag user for the current isolation scope. */ +export function setTag(key: string, value: Primitive): void { + getIsolationScope().setTag(key, value); +} + +/** Set extra data for the current isolation scope. */ +export function setExtra(key: string, extra: Extra): void { + getIsolationScope().setExtra(key, extra); +} + +/** Set multiple extra data for the current isolation scope. */ +export function setExtras(extras: Extras): void { + getIsolationScope().setExtras(extras); +} + +/** Set context data for the current isolation scope. */ +export function setContext( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { [key: string]: any } | null, +): void { + getIsolationScope().setContext(name, context); +} + +/** Start a session on the current isolation scope. */ +export function startSession(context?: Session): Session { + const client = getClient(); + const isolationScope = getIsolationScope(); + + const { release, environment = DEFAULT_ENVIRONMENT } = client.getOptions(); + + // Will fetch userAgent if called from browser sdk + const { userAgent } = GLOBAL_OBJ.navigator || {}; + + const session = makeSession({ + release, + environment, + user: isolationScope.getUser(), + ...(userAgent && { userAgent }), + ...context, + }); + + // End existing session if there's one + const currentSession = isolationScope.getSession && isolationScope.getSession(); + if (currentSession && currentSession.status === 'ok') { + updateSession(currentSession, { status: 'exited' }); + } + endSession(); + + // Afterwards we set the new session on the scope + isolationScope.setSession(session); + + return session; +} + +/** End the session on the current isolation scope. */ +export function endSession(): void { + const isolationScope = getIsolationScope(); + const session = isolationScope.getSession(); + if (session) { + closeSession(session); + } + _sendSessionUpdate(); + + // the session is over; take it off of the scope + isolationScope.setSession(); +} + +/** + * Sends the current Session on the scope + */ +function _sendSessionUpdate(): void { + const scope = getCurrentScope(); + const client = getClient(); + + const session = scope.getSession(); + if (session && client.captureSession) { + client.captureSession(session); + } +} diff --git a/packages/node-experimental/src/sdk/client.ts b/packages/node-experimental/src/sdk/client.ts index 809d1fa49035..8a7626b4ff9c 100644 --- a/packages/node-experimental/src/sdk/client.ts +++ b/packages/node-experimental/src/sdk/client.ts @@ -1,7 +1,16 @@ import { NodeClient, SDK_VERSION } from '@sentry/node'; -import { wrapClientClass } from '@sentry/opentelemetry'; -class NodeExperimentalBaseClient extends NodeClient { +import type { Tracer } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { CaptureContext, Event, EventHint } from '@sentry/types'; +import { Scope } from './scope'; + +/** A client for using Sentry with Node & OpenTelemetry. */ +export class NodeExperimentalClient extends NodeClient { + public traceProvider: BasicTracerProvider | undefined; + private _tracer: Tracer | undefined; + public constructor(options: ConstructorParameters[0]) { options._metadata = options._metadata || {}; options._metadata.sdk = options._metadata.sdk || { @@ -17,6 +26,54 @@ class NodeExperimentalBaseClient extends NodeClient { super(options); } + + /** Get the OTEL tracer. */ + public get tracer(): Tracer { + if (this._tracer) { + return this._tracer; + } + + const name = '@sentry/node-experimental'; + const version = SDK_VERSION; + const tracer = trace.getTracer(name, version); + this._tracer = tracer; + + return tracer; + } + + /** + * @inheritDoc + */ + public async flush(timeout?: number): Promise { + const provider = this.traceProvider; + const spanProcessor = provider?.activeSpanProcessor; + + if (spanProcessor) { + await spanProcessor.forceFlush(); + } + + return super.flush(timeout); + } + + /** + * Extends the base `_prepareEvent` so that we can properly handle `captureContext`. + * This uses `new Scope()`, which we need to replace with our own Scope for this client. + */ + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { + let actualScope = scope; + + // Remove `captureContext` hint and instead clone already here + if (hint && hint.captureContext) { + actualScope = getScopeForEvent(scope, hint.captureContext); + delete hint.captureContext; + } + + return super._prepareEvent(event, hint, actualScope); + } } -export const NodeExperimentalClient = wrapClientClass(NodeExperimentalBaseClient); +function getScopeForEvent(scope: Scope | undefined, captureContext: CaptureContext): Scope | undefined { + const finalScope = scope ? scope.clone() : new Scope(); + finalScope.update(captureContext); + return finalScope; +} diff --git a/packages/node-experimental/src/sdk/globals.ts b/packages/node-experimental/src/sdk/globals.ts new file mode 100644 index 000000000000..a91f07cd206d --- /dev/null +++ b/packages/node-experimental/src/sdk/globals.ts @@ -0,0 +1,38 @@ +import type { Hub } from '@sentry/types'; +import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; + +import type { AsyncContextStrategy, SentryCarrier } from './types'; + +/** Update the async context strategy */ +export function setAsyncContextStrategy(strategy: AsyncContextStrategy | undefined): void { + const carrier = getGlobalCarrier(); + carrier.acs = strategy; +} + +/** + * Returns the global shim registry. + **/ +export function getGlobalCarrier(): SentryCarrier { + GLOBAL_OBJ.__SENTRY__ = GLOBAL_OBJ.__SENTRY__ || { + extensions: {}, + // For legacy reasons... + globalEventProcessors: [], + }; + + return GLOBAL_OBJ.__SENTRY__; +} + +/** + * Calls global extension method and binding current instance to the function call + */ +// @ts-expect-error Function lacks ending return statement and return type does not include 'undefined'. ts(2366) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function callExtensionMethod(hub: Hub, method: string, ...args: any[]): T { + const carrier = getGlobalCarrier(); + + if (carrier.extensions && typeof carrier.extensions[method] === 'function') { + return carrier.extensions[method].apply(hub, args); + } + DEBUG_BUILD && logger.warn(`Extension method ${method} couldn't be found, doing nothing.`); +} diff --git a/packages/node-experimental/src/sdk/hub.ts b/packages/node-experimental/src/sdk/hub.ts new file mode 100644 index 000000000000..21e1c83a34bb --- /dev/null +++ b/packages/node-experimental/src/sdk/hub.ts @@ -0,0 +1,170 @@ +import type { + Client, + CustomSamplingContext, + EventHint, + Hub, + Integration, + IntegrationClass, + Session, + Severity, + SeverityLevel, + TransactionContext, +} from '@sentry/types'; + +import { + addBreadcrumb, + captureEvent, + captureException, + captureMessage, + configureScope, + endSession, + getClient, + getCurrentScope, + lastEventId, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + startSession, + withScope, +} from './api'; +import { callExtensionMethod, getGlobalCarrier } from './globals'; +import type { Scope } from './scope'; +import type { SentryCarrier } from './types'; + +/** Ensure the global hub is our proxied hub. */ +export function setupGlobalHub(): void { + const carrier = getGlobalCarrier(); + carrier.hub = getCurrentHub(); +} + +/** + * This is for legacy reasons, and returns a proxy object instead of a hub to be used. + */ +export function getCurrentHub(): Hub { + return { + isOlderThan(_version: number): boolean { + return false; + }, + + bindClient(client: Client): void { + const scope = getCurrentScope(); + scope.setClient(client); + }, + + pushScope(): Scope { + // TODO: This does not work and is actually deprecated + return getCurrentScope(); + }, + + popScope(): boolean { + // TODO: This does not work and is actually deprecated + return false; + }, + + withScope, + getClient, + getScope: getCurrentScope, + captureException: (exception: unknown, hint?: EventHint) => { + return getCurrentScope().captureException(exception, hint); + }, + captureMessage: ( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ) => { + return getCurrentScope().captureMessage(message, level, hint); + }, + captureEvent, + lastEventId, + addBreadcrumb, + setUser, + setTags, + setTag, + setExtra, + setExtras, + setContext, + // eslint-disable-next-line deprecation/deprecation + configureScope: configureScope, + + run(callback: (hub: Hub) => void): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return withScope(() => callback(this as any)); + }, + + getIntegration(integration: IntegrationClass): T | null { + return getClient().getIntegration(integration); + }, + + traceHeaders(): { [key: string]: string } { + return callExtensionMethod<{ [key: string]: string }>(this, 'traceHeaders'); + }, + + startTransaction( + _context: TransactionContext, + _customSamplingContext?: CustomSamplingContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any { + // eslint-disable-next-line no-console + console.warn('startTransaction is a noop in @sentry/node-experimental. Use `startSpan` instead.'); + // We return an object here as hub.ts checks for the result of this + // and renders a different warning if this is empty + return {}; + }, + + startSession, + + endSession, + + captureSession(endSession?: boolean): void { + // both send the update and pull the session from the scope + if (endSession) { + return this.endSession(); + } + + // only send the update + _sendSessionUpdate(); + }, + + shouldSendDefaultPii(): boolean { + const client = getClient(); + const options = client.getOptions(); + return Boolean(options.sendDefaultPii); + }, + }; +} + +/** + * Replaces the current main hub with the passed one on the global object + * + * @returns The old replaced hub + */ +export function makeMain(hub: Hub): Hub { + // eslint-disable-next-line no-console + console.warn('makeMain is a noop in @sentry/node-experimental. Use `setCurrentScope` instead.'); + return hub; +} + +/** + * Sends the current Session on the scope + */ +function _sendSessionUpdate(): void { + const scope = getCurrentScope(); + const client = getClient(); + + const session = scope.getSession(); + if (session && client.captureSession) { + client.captureSession(session); + } +} + +/** + * Set a mocked hub on the current carrier. + */ +export function setLegacyHubOnCarrier(carrier: SentryCarrier): boolean { + carrier.hub = getCurrentHub(); + return true; +} diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index be4843a5d2f7..e7c6ebf72381 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -1,15 +1,32 @@ -import { hasTracingEnabled } from '@sentry/core'; -import type { NodeClient } from '@sentry/node'; -import { defaultIntegrations as defaultNodeIntegrations, init as initNode } from '@sentry/node'; -import { setOpenTelemetryContextAsyncContextStrategy, setupGlobalHub } from '@sentry/opentelemetry'; +import { getIntegrationsToSetup, hasTracingEnabled } from '@sentry/core'; +import { + Integrations, + defaultIntegrations as defaultNodeIntegrations, + defaultStackParser, + getSentryRelease, + isAnrChildProcess, + makeNodeTransport, +} from '@sentry/node'; import type { Integration } from '@sentry/types'; -import { parseSemver } from '@sentry/utils'; +import { + consoleSandbox, + dropUndefinedKeys, + logger, + parseSemver, + stackParserFromStackParserOptions, + tracingContextFromHeaders, +} from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations'; import { Http } from '../integrations/http'; import { NodeFetch } from '../integrations/node-fetch'; -import type { NodeExperimentalOptions } from '../types'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../otel/asyncContextStrategy'; +import type { NodeExperimentalClientOptions, NodeExperimentalOptions } from '../types'; +import { endSession, getClient, getCurrentScope, getGlobalScope, getIsolationScope, startSession } from './api'; import { NodeExperimentalClient } from './client'; +import { getGlobalCarrier } from './globals'; +import { setLegacyHubOnCarrier } from './hub'; import { initOtel } from './initOtel'; const NODE_VERSION: ReturnType = parseSemver(process.versions.node); @@ -29,24 +46,172 @@ if (NODE_VERSION.major && NODE_VERSION.major >= 16) { * Initialize Sentry for Node. */ export function init(options: NodeExperimentalOptions | undefined = {}): void { - setupGlobalHub(); + const clientOptions = getClientOptions(options); + + if (clientOptions.debug === true) { + if (DEBUG_BUILD) { + logger.enable(); + } else { + // use `console.warn` rather than `logger.warn` since by non-debug bundles have all `logger.x` statements stripped + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry] Cannot initialize SDK with `debug` option using a non-debug bundle.'); + }); + } + } + + const scope = getCurrentScope(); + scope.update(options.initialScope); + + const client = new NodeExperimentalClient(clientOptions); + // The client is on the global scope, from where it generally is inherited + // unless somebody specifically sets a different one on a scope/isolations cope + getGlobalScope().setClient(client); + + client.setupIntegrations(); + + if (options.autoSessionTracking) { + startSessionTracking(); + } + + updateScopeFromEnvVariables(); + + if (options.spotlight) { + const client = getClient(); + if (client.addIntegration) { + // force integrations to be setup even if no DSN was set + client.setupIntegrations(true); + client.addIntegration( + new Integrations.Spotlight({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + } + + // Always init Otel, even if tracing is disabled, because we need it for trace propagation & the HTTP integration + initOtel(); + setOpenTelemetryContextAsyncContextStrategy(); +} + +function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalClientOptions { + const carrier = getGlobalCarrier(); + setLegacyHubOnCarrier(carrier); const isTracingEnabled = hasTracingEnabled(options); - options.defaultIntegrations = + const autoloadedIntegrations = carrier.integrations || []; + + const fullDefaultIntegrations = options.defaultIntegrations === false ? [] : [ ...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : defaultIntegrations), ...(isTracingEnabled ? getAutoPerformanceIntegrations() : []), + ...autoloadedIntegrations, ]; - options.instrumenter = 'otel'; - options.clientClass = NodeExperimentalClient as unknown as typeof NodeClient; + const release = getRelease(options.release); - initNode(options); + // If there is no release, or we are in an ANR child process, we disable autoSessionTracking by default + const autoSessionTracking = + typeof release !== 'string' || isAnrChildProcess() + ? false + : options.autoSessionTracking === undefined + ? true + : options.autoSessionTracking; + // We enforce tracesSampleRate = 0 in ANR child processes + const tracesSampleRate = isAnrChildProcess() ? 0 : getTracesSampleRate(options.tracesSampleRate); - // Always init Otel, even if tracing is disabled, because we need it for trace propagation & the HTTP integration - initOtel(); - setOpenTelemetryContextAsyncContextStrategy(); + const baseOptions = dropUndefinedKeys({ + transport: makeNodeTransport, + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENVIRONMENT, + }); + + const overwriteOptions = dropUndefinedKeys({ + release, + autoSessionTracking, + tracesSampleRate, + }); + + const clientOptions: NodeExperimentalClientOptions = { + ...baseOptions, + ...options, + ...overwriteOptions, + instrumenter: 'otel', + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + integrations: getIntegrationsToSetup({ + defaultIntegrations: fullDefaultIntegrations, + integrations: options.integrations, + }), + }; + + return clientOptions; +} + +function getRelease(release: NodeExperimentalOptions['release']): string | undefined { + if (release !== undefined) { + return release; + } + + const detectedRelease = getSentryRelease(); + if (detectedRelease !== undefined) { + return detectedRelease; + } + + return undefined; +} + +function getTracesSampleRate(tracesSampleRate: NodeExperimentalOptions['tracesSampleRate']): number | undefined { + if (tracesSampleRate !== undefined) { + return tracesSampleRate; + } + + const sampleRateFromEnv = process.env.SENTRY_TRACES_SAMPLE_RATE; + if (!sampleRateFromEnv) { + return undefined; + } + + const parsed = parseFloat(sampleRateFromEnv); + return isFinite(parsed) ? parsed : undefined; +} + +/** + * Update scope and propagation context based on environmental variables. + * + * See https://github.com/getsentry/rfcs/blob/main/text/0071-continue-trace-over-process-boundaries.md + * for more details. + */ +function updateScopeFromEnvVariables(): void { + const sentryUseEnvironment = (process.env.SENTRY_USE_ENVIRONMENT || '').toLowerCase(); + if (!['false', 'n', 'no', 'off', '0'].includes(sentryUseEnvironment)) { + const sentryTraceEnv = process.env.SENTRY_TRACE; + const baggageEnv = process.env.SENTRY_BAGGAGE; + const { propagationContext } = tracingContextFromHeaders(sentryTraceEnv, baggageEnv); + getCurrentScope().setPropagationContext(propagationContext); + } +} + +/** + * Enable automatic Session Tracking for the node process. + */ +function startSessionTracking(): void { + startSession(); + + // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because + // The 'beforeExit' event is not emitted for conditions causing explicit termination, + // such as calling process.exit() or uncaught exceptions. + // Ref: https://nodejs.org/api/process.html#process_event_beforeexit + process.on('beforeExit', () => { + const session = getIsolationScope().getSession(); + + // Only call endSession, if the Session exists on Scope and SessionStatus is not a + // Terminal Status i.e. Exited or Crashed because + // "When a session is moved away from ok it must not be updated anymore." + // Ref: https://develop.sentry.dev/sdk/sessions/ + if (session && session.status !== 'ok') { + endSession(); + } + }); } diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 53f319d313b6..1a078a1c013a 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -1,20 +1,15 @@ import { DiagLogLevel, diag } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { Resource } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; import { SDK_VERSION } from '@sentry/core'; -import { - SentryPropagator, - SentrySampler, - getClient, - setupEventContextTrace, - wrapContextManagerClass, -} from '@sentry/opentelemetry'; +import { SentryPropagator, SentrySampler, setupEventContextTrace } from '@sentry/opentelemetry'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; +import { SentryContextManager } from '../otel/contextManager'; import type { NodeExperimentalClient } from '../types'; +import { getClient } from './api'; import { NodeExperimentalSentrySpanProcessor } from './spanProcessor'; /** @@ -62,8 +57,6 @@ export function setupOtel(client: NodeExperimentalClient): BasicTracerProvider { }); provider.addSpanProcessor(new NodeExperimentalSentrySpanProcessor()); - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - // Initialize the provider provider.register({ propagator: new SentryPropagator(), diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts new file mode 100644 index 000000000000..f3195c7141b6 --- /dev/null +++ b/packages/node-experimental/src/sdk/scope.ts @@ -0,0 +1,406 @@ +import { notifyEventProcessors } from '@sentry/core'; +import { OpenTelemetryScope } from '@sentry/opentelemetry'; +import type { + Attachment, + Breadcrumb, + Client, + Event, + EventHint, + EventProcessor, + Severity, + SeverityLevel, +} from '@sentry/types'; +import { uuid4 } from '@sentry/utils'; + +import { getGlobalCarrier } from './globals'; +import type { CurrentScopes, Scope as ScopeInterface, ScopeData, SentryCarrier } from './types'; + +/** Get the current scope. */ +export function getCurrentScope(): Scope { + return getScopes().scope as Scope; +} + +/** + * Set the current scope on the execution context. + * This should mostly only be called in Sentry.init() + */ +export function setCurrentScope(scope: Scope): void { + getScopes().scope = scope; +} + +/** Get the global scope. */ +export function getGlobalScope(): Scope { + const carrier = getGlobalCarrier(); + + if (!carrier.globalScope) { + carrier.globalScope = new Scope(); + } + + return carrier.globalScope as Scope; +} + +/** Get the currently active isolation scope. */ +export function getIsolationScope(): Scope { + return getScopes().isolationScope as Scope; +} + +/** + * Set the currently active isolation scope. + * Use this with caution! As it updates the isolation scope for the current execution context. + */ +export function setIsolationScope(isolationScope: Scope): void { + getScopes().isolationScope = isolationScope; +} + +/** Get the currently active client. */ +export function getClient(): C { + const currentScope = getCurrentScope(); + const isolationScope = getIsolationScope(); + const globalScope = getGlobalScope(); + + const client = currentScope.getClient() || isolationScope.getClient() || globalScope.getClient(); + if (client) { + return client as C; + } + + // TODO otherwise ensure we use a noop client + return {} as C; +} + +/** A fork of the classic scope with some otel specific stuff. */ +export class Scope extends OpenTelemetryScope implements ScopeInterface { + // Overwrite this if you want to use a specific isolation scope here + public isolationScope: Scope | undefined; + + protected _client: Client | undefined; + + protected _lastEventId: string | undefined; + + /** + * @inheritDoc + */ + public clone(): Scope { + const newScope = new Scope(); + newScope._breadcrumbs = [...this['_breadcrumbs']]; + newScope._tags = { ...this['_tags'] }; + newScope._extra = { ...this['_extra'] }; + newScope._contexts = { ...this['_contexts'] }; + newScope._user = { ...this['_user'] }; + newScope._level = this['_level']; + newScope._span = this['_span']; + newScope._session = this['_session']; + newScope._transactionName = this['_transactionName']; + newScope._fingerprint = this['_fingerprint']; + newScope._eventProcessors = [...this['_eventProcessors']]; + newScope._requestSession = this['_requestSession']; + newScope._attachments = [...this['_attachments']]; + newScope._sdkProcessingMetadata = { ...this['_sdkProcessingMetadata'] }; + newScope._propagationContext = { ...this['_propagationContext'] }; + + return newScope; + } + + /** Update the client on the scope. */ + public setClient(client: Client): void { + this._client = client; + } + + /** + * Get the client assigned to this scope. + * Should generally not be used by users - use top-level `Sentry.getClient()` instead! + * @internal + */ + public getClient(): Client | undefined { + return this._client; + } + + /** @inheritdoc */ + public getAttachments(): Attachment[] { + const data = getGlobalScope().getScopeData(); + const isolationScopeData = this._getIsolationScope().getScopeData(); + const scopeData = this.getScopeData(); + + // Merge data together, in order + mergeData(data, isolationScopeData); + mergeData(data, scopeData); + + return data.attachments; + } + + /** Capture an exception for this scope. */ + public captureException(exception: unknown, hint?: EventHint): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + const syntheticException = new Error('Sentry syntheticException'); + + getClient().captureException( + exception, + { + originalException: exception, + syntheticException, + ...hint, + event_id: eventId, + }, + this, + ); + + this._lastEventId = eventId; + + return eventId; + } + + /** Capture a message for this scope. */ + public captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + const syntheticException = new Error(message); + + getClient().captureMessage( + message, + level, + { + originalException: message, + syntheticException, + ...hint, + event_id: eventId, + }, + this, + ); + + this._lastEventId = eventId; + + return eventId; + } + + /** Capture a message for this scope. */ + public captureEvent(event: Event, hint?: EventHint): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + if (!event.type) { + this._lastEventId = eventId; + } + + getClient().captureEvent(event, { ...hint, event_id: eventId }, this); + + return eventId; + } + + /** Get the ID of the last sent error event. */ + public lastEventId(): string | undefined { + return this._lastEventId; + } + + /** + * @inheritDoc + */ + public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { + return this._addBreadcrumb(breadcrumb, maxBreadcrumbs); + } + + /** Get all relevant data for this scope. */ + public getScopeData(): ScopeData { + const { + _breadcrumbs, + _attachments, + _contexts, + _tags, + _extra, + _user, + _level, + _fingerprint, + _eventProcessors, + _propagationContext, + _sdkProcessingMetadata, + } = this; + + return { + breadcrumbs: _breadcrumbs, + attachments: _attachments, + contexts: _contexts, + tags: _tags, + extra: _extra, + user: _user, + level: _level, + fingerprint: _fingerprint || [], + eventProcessors: _eventProcessors, + propagationContext: _propagationContext, + sdkProcessingMetadata: _sdkProcessingMetadata, + }; + } + + /** + * Applies data from the scope to the event and runs all event processors on it. + * + * @param event Event + * @param hint Object containing additional information about the original exception, for use by the event processors. + * @hidden + */ + public applyToEvent( + event: Event, + hint: EventHint = {}, + additionalEventProcessors: EventProcessor[] = [], + ): PromiseLike { + const data = getGlobalScope().getScopeData(); + const isolationScopeData = this._getIsolationScope().getScopeData(); + const scopeData = this.getScopeData(); + + // Merge data together, in order + mergeData(data, isolationScopeData); + mergeData(data, scopeData); + + // Apply the data + const { extra, tags, user, contexts, level, sdkProcessingMetadata, breadcrumbs, fingerprint, eventProcessors } = + data; + + mergePropKeep(event, 'extra', extra); + mergePropKeep(event, 'tags', tags); + mergePropKeep(event, 'user', user); + mergePropKeep(event, 'contexts', contexts); + mergePropKeep(event, 'sdkProcessingMetadata', sdkProcessingMetadata); + event.sdkProcessingMetadata = { + ...event.sdkProcessingMetadata, + propagationContext: this._propagationContext, + }; + + mergeArray(event, 'breadcrumbs', breadcrumbs); + mergeArray(event, 'fingerprint', fingerprint); + + if (level) { + event.level = level; + } + + const allEventProcessors = [...additionalEventProcessors, ...eventProcessors]; + + // Apply additional things to the event + if (this._transactionName) { + event.transaction = this._transactionName; + } + + return notifyEventProcessors(allEventProcessors, event, hint); + } + + /** + * Get all breadcrumbs attached to this scope. + * @internal + */ + public getBreadcrumbs(): Breadcrumb[] { + return this._breadcrumbs; + } + + /** Get the isolation scope for this scope. */ + protected _getIsolationScope(): Scope { + return this.isolationScope || getIsolationScope(); + } +} + +/** Exported only for tests */ +export function mergeData(data: ScopeData, mergeData: ScopeData): void { + const { + extra, + tags, + user, + contexts, + level, + sdkProcessingMetadata, + breadcrumbs, + fingerprint, + eventProcessors, + attachments, + } = mergeData; + + mergePropOverwrite(data, 'extra', extra); + mergePropOverwrite(data, 'tags', tags); + mergePropOverwrite(data, 'user', user); + mergePropOverwrite(data, 'contexts', contexts); + mergePropOverwrite(data, 'sdkProcessingMetadata', sdkProcessingMetadata); + + if (level) { + data.level = level; + } + + if (breadcrumbs.length) { + data.breadcrumbs = [...data.breadcrumbs, ...breadcrumbs]; + } + + if (fingerprint.length) { + data.fingerprint = [...data.fingerprint, ...fingerprint]; + } + + if (eventProcessors.length) { + data.eventProcessors = [...data.eventProcessors, ...eventProcessors]; + } + + if (attachments.length) { + data.attachments = [...data.attachments, ...attachments]; + } +} + +/** + * Merge properties, overwriting existing keys. + * Exported only for tests. + */ +export function mergePropOverwrite< + Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata', + Data extends ScopeData | Event, +>(data: Data, prop: Prop, mergeVal: Data[Prop]): void { + if (mergeVal && Object.keys(mergeVal).length) { + data[prop] = { ...data[prop], ...mergeVal }; + } +} + +/** + * Merge properties, keeping existing keys. + * Exported only for tests. + */ +export function mergePropKeep< + Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata', + Data extends ScopeData | Event, +>(data: Data, prop: Prop, mergeVal: Data[Prop]): void { + if (mergeVal && Object.keys(mergeVal).length) { + data[prop] = { ...mergeVal, ...data[prop] }; + } +} + +/** Exported only for tests */ +export function mergeArray( + event: Event, + prop: Prop, + mergeVal: ScopeData[Prop], +): void { + const prevVal = event[prop]; + // If we are not merging any new values, + // we only need to proceed if there was an empty array before (as we want to replace it with undefined) + if (!mergeVal.length && (!prevVal || prevVal.length)) { + return; + } + + const merged = [...(prevVal || []), ...mergeVal] as ScopeData[Prop]; + event[prop] = merged.length ? merged : undefined; +} + +function getScopes(): CurrentScopes { + const carrier = getGlobalCarrier(); + + if (carrier.acs && carrier.acs.getScopes) { + const scopes = carrier.acs.getScopes(); + + if (scopes) { + return scopes; + } + } + + return getGlobalCurrentScopes(carrier); +} + +function getGlobalCurrentScopes(carrier: SentryCarrier): CurrentScopes { + if (!carrier.scopes) { + carrier.scopes = { + scope: new Scope(), + isolationScope: new Scope(), + }; + } + + return carrier.scopes; +} diff --git a/packages/node-experimental/src/sdk/spanProcessor.ts b/packages/node-experimental/src/sdk/spanProcessor.ts index 067e1568e90f..175f3681479b 100644 --- a/packages/node-experimental/src/sdk/spanProcessor.ts +++ b/packages/node-experimental/src/sdk/spanProcessor.ts @@ -1,16 +1,35 @@ +import type { Context } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { SentrySpanProcessor, getClient } from '@sentry/opentelemetry'; +import { SentrySpanProcessor, getClient, getSpanFinishScope } from '@sentry/opentelemetry'; import { Http } from '../integrations/http'; import { NodeFetch } from '../integrations/node-fetch'; import type { NodeExperimentalClient } from '../types'; +import { getIsolationScope } from './api'; +import { Scope } from './scope'; /** * Implement custom code to avoid sending spans in certain cases. */ export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor { + public constructor() { + super({ scopeClass: Scope }); + } + + /** @inheritDoc */ + public onStart(span: Span, parentContext: Context): void { + super.onStart(span, parentContext); + + // We need to make sure that we use the correct isolation scope when finishing the span + // so we store it on the span finish scope for later use + const scope = getSpanFinishScope(span) as Scope | undefined; + if (scope) { + scope.isolationScope = getIsolationScope(); + } + } + /** @inheritDoc */ protected _shouldSendSpanToSentry(span: Span): boolean { const client = getClient(); diff --git a/packages/node-experimental/src/sdk/types.ts b/packages/node-experimental/src/sdk/types.ts new file mode 100644 index 000000000000..773c404d65ce --- /dev/null +++ b/packages/node-experimental/src/sdk/types.ts @@ -0,0 +1,91 @@ +import type { + Attachment, + Breadcrumb, + Client, + Contexts, + Event, + EventHint, + EventProcessor, + Extras, + Hub, + Integration, + Primitive, + PropagationContext, + Scope as BaseScope, + Severity, + SeverityLevel, + User, +} from '@sentry/types'; + +export interface ScopeData { + eventProcessors: EventProcessor[]; + breadcrumbs: Breadcrumb[]; + user: User; + tags: { [key: string]: Primitive }; + extra: Extras; + contexts: Contexts; + attachments: Attachment[]; + propagationContext: PropagationContext; + sdkProcessingMetadata: { [key: string]: unknown }; + fingerprint: string[]; + level?: SeverityLevel; +} + +export interface Scope extends BaseScope { + // @ts-expect-error typeof this is what we want here + isolationScope: typeof this | undefined; + // @ts-expect-error typeof this is what we want here + clone(scope?: Scope): typeof this; + setClient(client: Client): void; + getClient(): Client | undefined; + captureException(exception: unknown, hint?: EventHint): string; + captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ): string; + captureEvent(event: Event, hint?: EventHint): string; + lastEventId(): string | undefined; + getScopeData(): ScopeData; +} + +export interface CurrentScopes { + scope: Scope; + isolationScope: Scope; +} + +/** + * Strategy used to track async context. + */ +export interface AsyncContextStrategy { + /** + * Gets the current async context. Returns undefined if there is no current async context. + */ + getScopes: () => CurrentScopes | undefined; + + /** This is here for legacy reasons. */ + getCurrentHub: () => Hub; + + /** + * Runs the supplied callback in its own async context. + */ + runWithAsyncContext(callback: () => T): T; +} + +export interface SentryCarrier { + globalScope?: Scope; + scopes?: CurrentScopes; + acs?: AsyncContextStrategy; + + // hub is here for legacy reasons + hub?: Hub; + + extensions?: { + /** Extension methods for the hub, which are bound to the current Hub instance */ + // eslint-disable-next-line @typescript-eslint/ban-types + [key: string]: Function; + }; + + integrations?: Integration[]; +} diff --git a/packages/node-experimental/src/utils/contextData.ts b/packages/node-experimental/src/utils/contextData.ts new file mode 100644 index 000000000000..5c69f186eb6d --- /dev/null +++ b/packages/node-experimental/src/utils/contextData.ts @@ -0,0 +1,22 @@ +import type { Context } from '@opentelemetry/api'; +import { createContextKey } from '@opentelemetry/api'; + +import type { CurrentScopes } from '../sdk/types'; + +export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); + +/** + * Try to get the current scopes from the given OTEL context. + * This requires a Context Manager that was wrapped with getWrappedContextManager. + */ +export function getScopesFromContext(context: Context): CurrentScopes | undefined { + return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; +} + +/** + * Set the current scopes on an OTEL context. + * This will return a forked context with the Propagation Context set. + */ +export function setScopesOnContext(context: Context, scopes: CurrentScopes): Context { + return context.setValue(SENTRY_SCOPES_CONTEXT_KEY, scopes); +} diff --git a/packages/node-experimental/src/utils/prepareEvent.ts b/packages/node-experimental/src/utils/prepareEvent.ts new file mode 100644 index 000000000000..db89c2b198c0 --- /dev/null +++ b/packages/node-experimental/src/utils/prepareEvent.ts @@ -0,0 +1,58 @@ +import { Scope } from '@sentry/core'; +import type { CaptureContext, EventHint, Scope as ScopeInterface, ScopeContext } from '@sentry/types'; + +/** + * This type makes sure that we get either a CaptureContext, OR an EventHint. + * It does not allow mixing them, which could lead to unexpected outcomes, e.g. this is disallowed: + * { user: { id: '123' }, mechanism: { handled: false } } + */ +export type ExclusiveEventHintOrCaptureContext = + | (CaptureContext & Partial<{ [key in keyof EventHint]: never }>) + | (EventHint & Partial<{ [key in keyof ScopeContext]: never }>); + +/** + * Parse either an `EventHint` directly, or convert a `CaptureContext` to an `EventHint`. + * This is used to allow to update method signatures that used to accept a `CaptureContext` but should now accept an `EventHint`. + */ +export function parseEventHintOrCaptureContext( + hint: ExclusiveEventHintOrCaptureContext | undefined, +): EventHint | undefined { + if (!hint) { + return undefined; + } + + // If you pass a Scope or `() => Scope` as CaptureContext, we just return this as captureContext + if (hintIsScopeOrFunction(hint)) { + return { captureContext: hint }; + } + + if (hintIsScopeContext(hint)) { + return { + captureContext: hint, + }; + } + + return hint; +} + +function hintIsScopeOrFunction( + hint: CaptureContext | EventHint, +): hint is ScopeInterface | ((scope: ScopeInterface) => ScopeInterface) { + return hint instanceof Scope || typeof hint === 'function'; +} + +type ScopeContextProperty = keyof ScopeContext; +const captureContextKeys: readonly ScopeContextProperty[] = [ + 'user', + 'level', + 'extra', + 'contexts', + 'tags', + 'fingerprint', + 'requestSession', + 'propagationContext', +] as const; + +function hintIsScopeContext(hint: Partial | EventHint): hint is Partial { + return Object.keys(hint).some(key => captureContextKeys.includes(key as ScopeContextProperty)); +} diff --git a/packages/node-experimental/test/helpers/mockSdkInit.ts b/packages/node-experimental/test/helpers/mockSdkInit.ts index 82752ab203d0..9cc7692463d5 100644 --- a/packages/node-experimental/test/helpers/mockSdkInit.ts +++ b/packages/node-experimental/test/helpers/mockSdkInit.ts @@ -7,14 +7,17 @@ import type { NodeExperimentalClientOptions } from '../../src/types'; const PUBLIC_DSN = 'https://username@domain/123'; -export function mockSdkInit(options?: Partial) { +export function resetGlobals(): void { GLOBAL_OBJ.__SENTRY__ = { extensions: {}, hub: undefined, globalEventProcessors: [], logger: undefined, }; +} +export function mockSdkInit(options?: Partial) { + resetGlobals(); init({ dsn: PUBLIC_DSN, defaultIntegrations: false, ...options }); } diff --git a/packages/node-experimental/test/integration/breadcrumbs.test.ts b/packages/node-experimental/test/integration/breadcrumbs.test.ts index 80842451c3bf..fea78a353011 100644 --- a/packages/node-experimental/test/integration/breadcrumbs.test.ts +++ b/packages/node-experimental/test/integration/breadcrumbs.test.ts @@ -1,5 +1,6 @@ -import { withScope } from '@sentry/core'; +import { captureException, withScope } from '@sentry/core'; import { getCurrentHub, startSpan } from '@sentry/opentelemetry'; +import { addBreadcrumb, getClient, withIsolationScope } from '../../src/sdk/api'; import type { NodeExperimentalClient } from '../../src/types'; import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; @@ -55,24 +56,23 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = getClient(); const error = new Error('test'); - hub.addBreadcrumb({ timestamp: 123456, message: 'test0' }); + addBreadcrumb({ timestamp: 123456, message: 'test0' }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test2' }); - hub.captureException(error); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test2' }); + captureException(error); }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test3' }); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test3' }); }); await client.flush(); @@ -142,7 +142,7 @@ describe('Integration | breadcrumbs', () => { ); }); - it('correctly adds & retrieves breadcrumbs for the current root span only', async () => { + it('correctly adds & retrieves breadcrumbs for the current isolation span only', async () => { const beforeSend = jest.fn(() => null); const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); @@ -153,22 +153,26 @@ describe('Integration | breadcrumbs', () => { const error = new Error('test'); - startSpan({ name: 'test1' }, () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + withIsolationScope(() => { + startSpan({ name: 'test1' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); - startSpan({ name: 'inner1' }, () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); }); }); - startSpan({ name: 'test2' }, () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + withIsolationScope(() => { + startSpan({ name: 'test2' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); - startSpan({ name: 'inner2' }, () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); + startSpan({ name: 'inner2' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); - hub.captureException(error); + hub.captureException(error); + }); }); await client.flush(); @@ -303,31 +307,35 @@ describe('Integration | breadcrumbs', () => { const error = new Error('test'); - const promise1 = startSpan({ name: 'test' }, async () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + const promise1 = withIsolationScope(async () => { + await startSpan({ name: 'test' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); - await startSpan({ name: 'inner1' }, async () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); - }); + await startSpan({ name: 'inner1' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); - await startSpan({ name: 'inner2' }, async () => { - hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); - }); + await startSpan({ name: 'inner2' }, async () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 10)); - hub.captureException(error); + hub.captureException(error); + }); }); - const promise2 = startSpan({ name: 'test-b' }, async () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + const promise2 = withIsolationScope(async () => { + await startSpan({ name: 'test-b' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); - await startSpan({ name: 'inner1b' }, async () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); + await startSpan({ name: 'inner1b' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); - await startSpan({ name: 'inner2b' }, async () => { - hub.addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + await startSpan({ name: 'inner2b' }, async () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + }); }); }); diff --git a/packages/node-experimental/test/integration/scope.test.ts b/packages/node-experimental/test/integration/scope.test.ts index 78579701e47e..57be6126bcae 100644 --- a/packages/node-experimental/test/integration/scope.test.ts +++ b/packages/node-experimental/test/integration/scope.test.ts @@ -2,7 +2,7 @@ import { getCurrentHub, getSpanScope } from '@sentry/opentelemetry'; import * as Sentry from '../../src/'; import type { NodeExperimentalClient } from '../../src/types'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; describe('Integration | Scope', () => { afterEach(() => { @@ -101,6 +101,7 @@ describe('Integration | Scope', () => { tag1: 'val1', tag2: 'val2', tag3: 'val3', + tag4: 'val4', }, timestamp: expect.any(Number), transaction: 'outer', @@ -226,4 +227,459 @@ describe('Integration | Scope', () => { } }); }); + + describe('global scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const globalScope = Sentry.getGlobalScope(); + expect(globalScope).toBeDefined(); + expect(globalScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(globalScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getGlobalScope()).toBe(globalScope); + + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the global scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + expect(globalScope.getClient()).toBeDefined(); + expect(Sentry.getGlobalScope()).toBe(globalScope); + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const globalScope = Sentry.getGlobalScope(); + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('isolation scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const isolationScope = Sentry.getIsolationScope(); + expect(isolationScope).toBeDefined(); + expect(isolationScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(isolationScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getIsolationScope()).toBe(isolationScope); + + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the isolation scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is only attached to global scope by default + expect(isolationScope.getClient()).toBeUndefined(); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withIsolationScope works', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + initialIsolationScope.setTag('tag2', 'val2'); + + const initialCurrentScope = Sentry.getCurrentScope(); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.withIsolationScope(newIsolationScope => { + expect(Sentry.getCurrentScope()).not.toBe(initialCurrentScope); + expect(Sentry.getIsolationScope()).toBe(newIsolationScope); + expect(newIsolationScope).not.toBe(initialIsolationScope); + + // Data is forked off original isolation scope + expect(newIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag2', 'val2'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('current scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const currentScope = Sentry.getCurrentScope(); + expect(currentScope).toBeDefined(); + expect(currentScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(currentScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getCurrentScope()).toBe(currentScope); + + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the current scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is only attached to global scope by default + expect(currentScope.getClient()).toBeUndefined(); + // current scope remains intact + expect(Sentry.getCurrentScope()).toBe(currentScope); + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const currentScope = Sentry.getCurrentScope(); + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withScope works', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + initialCurrentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + + Sentry.withScope(newCurrentScope => { + newCurrentScope.setTag('tag4', 'val4'); + }); + + Sentry.withScope(newCurrentScope => { + expect(Sentry.getCurrentScope()).toBe(newCurrentScope); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(newCurrentScope).not.toBe(initialCurrentScope); + + // Data is forked off original isolation scope + expect(newCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newCurrentScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag2', 'val2'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag3', 'val3'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('automatically forks with OTEL context', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.startSpan({ name: 'outer' }, () => { + Sentry.getCurrentScope().setTag('tag2', 'val2'); + + Sentry.startSpan({ name: 'inner 1' }, () => { + Sentry.getCurrentScope().setTag('tag3', 'val3'); + + Sentry.startSpan({ name: 'inner 2' }, () => { + Sentry.getCurrentScope().setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('scope merging', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('merges data from global, isolation and current scope', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + Sentry.getGlobalScope().setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(isolationScope => { + Sentry.getCurrentScope().setTag('tag2', 'val2a'); + isolationScope.setTag('tag2', 'val2b'); + isolationScope.setTag('tag3', 'val3'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); }); diff --git a/packages/node-experimental/test/integration/transactions.test.ts b/packages/node-experimental/test/integration/transactions.test.ts index 107377c9a633..1a09b3234d92 100644 --- a/packages/node-experimental/test/integration/transactions.test.ts +++ b/packages/node-experimental/test/integration/transactions.test.ts @@ -8,6 +8,7 @@ import { logger } from '@sentry/utils'; import * as Sentry from '../../src'; import { startSpan } from '../../src'; import type { Http, NodeFetch } from '../../src/integrations'; +import { getIsolationScope } from '../../src/sdk/api'; import type { NodeExperimentalClient } from '../../src/types'; import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; @@ -22,8 +23,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); Sentry.setTag('outer.tag', 'test value'); @@ -128,6 +128,7 @@ describe('Integration | Transactions', () => { start_timestamp: expect.any(Number), tags: { 'outer.tag': 'test value', + 'test.tag': 'test value', }, timestamp: expect.any(Number), transaction: 'test name', @@ -176,49 +177,52 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); - Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + Sentry.withIsolationScope(() => { + Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); - span.setAttributes({ - 'test.outer': 'test value', - }); + span.setAttributes({ + 'test.outer': 'test value', + }); - const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - Sentry.setTag('test.tag', 'test value'); + Sentry.setTag('test.tag', 'test value'); - Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); - innerSpan.setAttributes({ - 'test.inner': 'test value', + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); }); }); }); - Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + Sentry.withIsolationScope(() => { + Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); - span.setAttributes({ - 'test.outer': 'test value b', - }); + span.setAttributes({ + 'test.outer': 'test value b', + }); - const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); - subSpan.end(); + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); - Sentry.setTag('test.tag', 'test value b'); + Sentry.setTag('test.tag', 'test value b'); - Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); - innerSpan.setAttributes({ - 'test.inner': 'test value b', + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); }); }); }); @@ -257,7 +261,7 @@ describe('Integration | Transactions', () => { }), ], start_timestamp: expect.any(Number), - tags: {}, + tags: { 'test.tag': 'test value' }, timestamp: expect.any(Number), transaction: 'test name', transaction_info: { source: 'task' }, @@ -299,7 +303,7 @@ describe('Integration | Transactions', () => { }), ], start_timestamp: expect.any(Number), - tags: {}, + tags: { 'test.tag': 'test value b' }, timestamp: expect.any(Number), transaction: 'test name b', transaction_info: { source: 'custom' }, diff --git a/packages/node-experimental/test/sdk/scope.test.ts b/packages/node-experimental/test/sdk/scope.test.ts new file mode 100644 index 000000000000..a0e179373626 --- /dev/null +++ b/packages/node-experimental/test/sdk/scope.test.ts @@ -0,0 +1,416 @@ +import type { Attachment, Breadcrumb, Client, EventProcessor } from '@sentry/types'; +import { Scope, getIsolationScope } from '../../src'; +import { getGlobalScope, mergeArray, mergeData, mergePropKeep, mergePropOverwrite } from '../../src/sdk/scope'; +import type { ScopeData } from '../../src/sdk/types'; +import { mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; + +describe('Unit | Scope', () => { + it('allows to create & update a scope', () => { + const scope = new Scope(); + + expect(scope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: {}, + extra: {}, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + + scope.update({ + tags: { foo: 'bar' }, + extra: { foo2: 'bar2' }, + }); + + expect(scope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: { + foo: 'bar', + }, + extra: { + foo2: 'bar2', + }, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + }); + + it('allows to clone a scope', () => { + const scope = new Scope(); + + scope.update({ + tags: { foo: 'bar' }, + extra: { foo2: 'bar2' }, + }); + + const newScope = scope.clone(); + expect(newScope).toBeInstanceOf(Scope); + expect(newScope).not.toBe(scope); + + expect(newScope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: { + foo: 'bar', + }, + extra: { + foo2: 'bar2', + }, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + }); + + it('allows to set & get a client', () => { + const scope = new Scope(); + expect(scope.getClient()).toBeUndefined(); + const client = {} as Client; + scope.setClient(client); + expect(scope.getClient()).toBe(client); + }); + + it('gets the correct isolationScope in _getIsolationScope', () => { + resetGlobals(); + + const scope = new Scope(); + const globalIsolationScope = getIsolationScope(); + + expect(scope['_getIsolationScope']()).toBe(globalIsolationScope); + + const customIsolationScope = new Scope(); + scope.isolationScope = customIsolationScope; + + expect(scope['_getIsolationScope']()).toBe(customIsolationScope); + }); + + describe('mergeArray', () => { + it.each([ + [[], [], undefined], + [undefined, [], undefined], + [['a'], [], ['a']], + [['a'], ['b', 'c'], ['a', 'b', 'c']], + [[], ['b', 'c'], ['b', 'c']], + [undefined, ['b', 'c'], ['b', 'c']], + ])('works with %s and %s', (a, b, expected) => { + const data = { fingerprint: a }; + mergeArray(data, 'fingerprint', b); + expect(data.fingerprint).toEqual(expected); + }); + + it('does not mutate the original array if no changes are made', () => { + const fingerprint = ['a']; + const data = { fingerprint }; + mergeArray(data, 'fingerprint', []); + expect(data.fingerprint).toBe(fingerprint); + }); + }); + + describe('mergePropKeep', () => { + it.each([ + [{}, {}, {}], + [{ a: 'aa' }, {}, { a: 'aa' }], + [{ a: 'aa' }, { b: 'bb' }, { a: 'aa', b: 'bb' }], + // Does not overwrite existing keys + [{ a: 'aa' }, { b: 'bb', a: 'cc' }, { a: 'aa', b: 'bb' }], + ])('works with %s and %s', (a, b, expected) => { + const data = { tags: a } as unknown as ScopeData; + mergePropKeep(data, 'tags', b); + expect(data.tags).toEqual(expected); + }); + + it('does not deep merge', () => { + const data = { + contexts: { + app: { app_version: 'v1' }, + culture: { display_name: 'name1' }, + }, + } as unknown as ScopeData; + mergePropKeep(data, 'contexts', { + os: { name: 'os1' }, + app: { app_name: 'name1' }, + }); + expect(data.contexts).toEqual({ + os: { name: 'os1' }, + culture: { display_name: 'name1' }, + app: { app_version: 'v1' }, + }); + }); + + it('does not mutate the original object if no changes are made', () => { + const tags = { a: 'aa' }; + const data = { tags } as unknown as ScopeData; + mergePropKeep(data, 'tags', {}); + expect(data.tags).toBe(tags); + }); + }); + + describe('mergePropOverwrite', () => { + it.each([ + [{}, {}, {}], + [{ a: 'aa' }, {}, { a: 'aa' }], + [{ a: 'aa' }, { b: 'bb' }, { a: 'aa', b: 'bb' }], + // overwrites existing keys + [{ a: 'aa' }, { b: 'bb', a: 'cc' }, { a: 'cc', b: 'bb' }], + ])('works with %s and %s', (a, b, expected) => { + const data = { tags: a } as unknown as ScopeData; + mergePropOverwrite(data, 'tags', b); + expect(data.tags).toEqual(expected); + }); + + it('does not deep merge', () => { + const data = { + contexts: { + app: { app_version: 'v1' }, + culture: { display_name: 'name1' }, + }, + } as unknown as ScopeData; + mergePropOverwrite(data, 'contexts', { + os: { name: 'os1' }, + app: { app_name: 'name1' }, + }); + expect(data.contexts).toEqual({ + os: { name: 'os1' }, + culture: { display_name: 'name1' }, + app: { app_name: 'name1' }, + }); + }); + + it('does not mutate the original object if no changes are made', () => { + const tags = { a: 'aa' }; + const data = { tags } as unknown as ScopeData; + mergePropOverwrite(data, 'tags', {}); + expect(data.tags).toBe(tags); + }); + }); + + describe('mergeData', () => { + it('works with empty data', () => { + const data1: ScopeData = { + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }; + const data2: ScopeData = { + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }; + mergeData(data1, data2); + expect(data1).toEqual({ + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }); + }); + + it('merges data correctly', () => { + const attachment1 = { filename: '1' } as Attachment; + const attachment2 = { filename: '2' } as Attachment; + const attachment3 = { filename: '3' } as Attachment; + + const breadcrumb1 = { message: '1' } as Breadcrumb; + const breadcrumb2 = { message: '2' } as Breadcrumb; + const breadcrumb3 = { message: '3' } as Breadcrumb; + + const eventProcessor1 = ((a: unknown) => null) as EventProcessor; + const eventProcessor2 = ((b: unknown) => null) as EventProcessor; + const eventProcessor3 = ((c: unknown) => null) as EventProcessor; + + const data1: ScopeData = { + eventProcessors: [eventProcessor1], + breadcrumbs: [breadcrumb1], + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + attachments: [attachment1], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: { aa: 'aa', bb: 'aa' }, + fingerprint: ['aa', 'bb'], + }; + const data2: ScopeData = { + eventProcessors: [eventProcessor2, eventProcessor3], + breadcrumbs: [breadcrumb2, breadcrumb3], + user: { id: '2', name: 'foo' }, + tags: { tag2: 'bb', tag3: 'bb' }, + extra: { extra2: 'bb', extra3: 'bb' }, + contexts: { os: { name: 'os2' } }, + attachments: [attachment2, attachment3], + propagationContext: { spanId: '2', traceId: '2' }, + sdkProcessingMetadata: { bb: 'bb', cc: 'bb' }, + fingerprint: ['cc'], + }; + mergeData(data1, data2); + expect(data1).toEqual({ + eventProcessors: [eventProcessor1, eventProcessor2, eventProcessor3], + breadcrumbs: [breadcrumb1, breadcrumb2, breadcrumb3], + user: { id: '2', name: 'foo', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'bb', tag3: 'bb' }, + extra: { extra1: 'aa', extra2: 'bb', extra3: 'bb' }, + contexts: { os: { name: 'os2' }, culture: { display_name: 'name1' } }, + attachments: [attachment1, attachment2, attachment3], + // This is not merged, we always use the one from the scope here anyhow + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: { aa: 'aa', bb: 'bb', cc: 'bb' }, + fingerprint: ['aa', 'bb', 'cc'], + }); + }); + }); + + describe('applyToEvent', () => { + it('works without any data', async () => { + mockSdkInit(); + + const scope = new Scope(); + + const event = await scope.applyToEvent({ message: 'foo' }); + + expect(event).toEqual({ + message: 'foo', + sdkProcessingMetadata: { + propagationContext: { + spanId: expect.any(String), + traceId: expect.any(String), + }, + }, + }); + }); + + it('merges scope data', async () => { + mockSdkInit(); + + const breadcrumb1 = { message: '1', timestamp: 111 } as Breadcrumb; + const breadcrumb2 = { message: '2', timestamp: 222 } as Breadcrumb; + const breadcrumb3 = { message: '3', timestamp: 123 } as Breadcrumb; + const breadcrumb4 = { message: '4', timestamp: 333 } as Breadcrumb; + + const eventProcessor1 = jest.fn((a: unknown) => a) as EventProcessor; + const eventProcessor2 = jest.fn((b: unknown) => b) as EventProcessor; + const eventProcessor3 = jest.fn((c: unknown) => c) as EventProcessor; + + const scope = new Scope(); + scope.update({ + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + propagationContext: { spanId: '1', traceId: '1' }, + fingerprint: ['aa'], + }); + scope.addBreadcrumb(breadcrumb1); + scope.addEventProcessor(eventProcessor1); + + const globalScope = getGlobalScope(); + const isolationScope = getIsolationScope(); + + globalScope.addBreadcrumb(breadcrumb2); + globalScope.addEventProcessor(eventProcessor2); + globalScope.setSDKProcessingMetadata({ aa: 'aa' }); + + isolationScope.addBreadcrumb(breadcrumb3); + isolationScope.addEventProcessor(eventProcessor3); + globalScope.setSDKProcessingMetadata({ bb: 'bb' }); + + const event = await scope.applyToEvent({ + message: 'foo', + breadcrumbs: [breadcrumb4], + fingerprint: ['dd'], + }); + + expect(event).toEqual({ + message: 'foo', + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + fingerprint: ['dd', 'aa'], + breadcrumbs: [breadcrumb4, breadcrumb2, breadcrumb3, breadcrumb1], + sdkProcessingMetadata: { + aa: 'aa', + bb: 'bb', + propagationContext: { + spanId: '1', + traceId: '1', + }, + }, + }); + }); + }); + + describe('getAttachments', () => { + it('works without any data', async () => { + mockSdkInit(); + + const scope = new Scope(); + + const actual = scope.getAttachments(); + expect(actual).toEqual([]); + }); + + it('merges attachments data', async () => { + mockSdkInit(); + + const attachment1 = { filename: '1' } as Attachment; + const attachment2 = { filename: '2' } as Attachment; + const attachment3 = { filename: '3' } as Attachment; + + const scope = new Scope(); + scope.addAttachment(attachment1); + + const globalScope = getGlobalScope(); + const isolationScope = getIsolationScope(); + + globalScope.addAttachment(attachment2); + isolationScope.addAttachment(attachment3); + + const actual = scope.getAttachments(); + expect(actual).toEqual([attachment2, attachment3, attachment1]); + }); + }); +}); diff --git a/packages/node-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts b/packages/node-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts index 93a950c257f2..588c56c273e9 100644 --- a/packages/node-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts +++ b/packages/node-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts @@ -5,11 +5,10 @@ Sentry.init({ release: '1.0', }); -Sentry.configureScope(scope => { - scope.setTag('foo', 'bar'); - scope.setUser({ id: 'baz' }); - scope.setExtra('qux', 'quux'); - scope.clear(); -}); +const scope = Sentry.getCurrentScope(); +scope.setTag('foo', 'bar'); +scope.setUser({ id: 'baz' }); +scope.setExtra('qux', 'quux'); +scope.clear(); Sentry.captureMessage('cleared_scope'); diff --git a/packages/node-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts b/packages/node-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts index 941265006ee8..b3f3f4d4ae15 100644 --- a/packages/node-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts +++ b/packages/node-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts @@ -5,10 +5,9 @@ Sentry.init({ release: '1.0', }); -Sentry.configureScope(scope => { - scope.setTag('foo', 'bar'); - scope.setUser({ id: 'baz' }); - scope.setExtra('qux', 'quux'); -}); +const scope = Sentry.getCurrentScope(); +scope.setTag('foo', 'bar'); +scope.setUser({ id: 'baz' }); +scope.setExtra('qux', 'quux'); Sentry.captureMessage('configured_scope'); diff --git a/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts index 5bd8aa815cbe..7fdbfce0351c 100644 --- a/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts @@ -29,9 +29,7 @@ const server = new ApolloServer({ const transaction = Sentry.startTransaction({ name: 'test_transaction', op: 'transaction' }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); void (async () => { // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts index 31d7356765e9..cae4627e7096 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts @@ -21,9 +21,7 @@ async function run(): Promise { op: 'transaction', }); - Sentry.configureScope(scope => { - scope.setSpan(transaction); - }); + Sentry.getCurrentScope().setSpan(transaction); try { await client.connect(); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withConnect/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withConnect/scenario.ts index 0f576cb793aa..7d94099ea30c 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withConnect/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withConnect/scenario.ts @@ -24,9 +24,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); connection.query('SELECT 1 + 1 AS solution', function () { connection.query('SELECT NOW()', ['1', '2'], () => { diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutCallback/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutCallback/scenario.ts index ac1d6421dec8..4b3346caed20 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutCallback/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutCallback/scenario.ts @@ -24,9 +24,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); const query = connection.query('SELECT 1 + 1 AS solution'); const query2 = connection.query('SELECT NOW()', ['1', '2']); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutConnect/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutConnect/scenario.ts index c7cc0e660fc4..2e13bf49b9ac 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutConnect/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/withoutConnect/scenario.ts @@ -18,9 +18,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); connection.query('SELECT 1 + 1 AS solution', function () { connection.query('SELECT NOW()', ['1', '2'], () => { diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts index a7859fd562a3..c10661094981 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts @@ -13,9 +13,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); const client = new pg.Client(); client.query('SELECT * FROM foo where bar ilike "baz%"', ['a', 'b'], () => diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts b/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts index 27d82a4c4dd1..c7a5ef761a82 100644 --- a/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts @@ -18,9 +18,7 @@ async function run(): Promise { op: 'transaction', }); - Sentry.configureScope(scope => { - scope.setSpan(transaction); - }); + Sentry.getCurrentScope().setSpan(transaction); try { await client.user.create({ diff --git a/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts index 9084c06441fb..d1eb5fe017ed 100644 --- a/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts +++ b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts @@ -12,9 +12,7 @@ Sentry.init({ const transaction = Sentry.startTransaction({ name: 'test_transaction' }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); http.get('http://match-this-url.com/api/v0'); http.get('http://match-this-url.com/api/v1'); diff --git a/packages/node-integration-tests/suites/tracing/apollo-graphql/scenario.ts b/packages/node-integration-tests/suites/tracing/apollo-graphql/scenario.ts index 732d32814f95..4a4d5a989227 100644 --- a/packages/node-integration-tests/suites/tracing/apollo-graphql/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/apollo-graphql/scenario.ts @@ -31,9 +31,7 @@ const server = new ApolloServer({ const transaction = Sentry.startTransaction({ name: 'test_transaction', op: 'transaction' }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); void (async () => { // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/scenario.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/scenario.ts index 896a91181846..5bd16772d50f 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/scenario.ts @@ -22,9 +22,7 @@ async function run(): Promise { op: 'transaction', }); - Sentry.configureScope(scope => { - scope.setSpan(transaction); - }); + Sentry.getCurrentScope().setSpan(transaction); try { await client.connect(); diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/scenario.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/scenario.ts index f852eec7b2df..2cf161c0ab78 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/scenario.ts @@ -25,9 +25,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); connection.query('SELECT 1 + 1 AS solution', function () { connection.query('SELECT NOW()', ['1', '2'], () => { diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/scenario.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/scenario.ts index 97a08d088cce..c39069909082 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/scenario.ts @@ -14,9 +14,7 @@ const transaction = Sentry.startTransaction({ name: 'Test Transaction', }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); const client = new pg.Client(); client.query('SELECT * FROM foo where bar ilike "baz%"', ['a', 'b'], () => diff --git a/packages/node-integration-tests/suites/tracing/prisma-orm/scenario.ts b/packages/node-integration-tests/suites/tracing/prisma-orm/scenario.ts index 7d953353dfe3..0014717b5fc4 100644 --- a/packages/node-integration-tests/suites/tracing/prisma-orm/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/prisma-orm/scenario.ts @@ -20,9 +20,7 @@ async function run(): Promise { op: 'transaction', }); - Sentry.configureScope(scope => { - scope.setSpan(transaction); - }); + Sentry.getCurrentScope().setSpan(transaction); try { await client.user.create({ diff --git a/packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts b/packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts index 23ca6e7122cc..c07faeeb9a3f 100644 --- a/packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts +++ b/packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts @@ -14,9 +14,7 @@ Sentry.init({ const transaction = Sentry.startTransaction({ name: 'test_transaction' }); -Sentry.configureScope(scope => { - scope.setSpan(transaction); -}); +Sentry.getCurrentScope().setSpan(transaction); http.get('http://match-this-url.com/api/v0'); http.get('http://match-this-url.com/api/v1'); diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index 84a3ebe52920..32117f21372b 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -1,9 +1,9 @@ import { spawn } from 'child_process'; -import { getClient, makeSession, updateSession } from '@sentry/core'; +import { getClient, getCurrentScope, makeSession, updateSession } from '@sentry/core'; import type { Event, Session, StackFrame } from '@sentry/types'; import { logger, watchdogTimer } from '@sentry/utils'; -import { addEventProcessor, captureEvent, flush, getCurrentHub } from '..'; +import { addEventProcessor, captureEvent, flush } from '..'; import { captureStackTrace } from './debugger'; const DEFAULT_INTERVAL = 50; @@ -91,8 +91,6 @@ function startChildProcess(options: Options): void { logger.log(`[ANR] ${message}`, ...args); } - const hub = getCurrentHub(); - try { const env = { ...process.env }; env.SENTRY_ANR_CHILD_PROCESS = 'true'; @@ -112,7 +110,7 @@ function startChildProcess(options: Options): void { const timer = setInterval(() => { try { - const currentSession = hub.getScope()?.getSession(); + const currentSession = getCurrentScope()?.getSession(); // We need to copy the session object and remove the toJSON method so it can be sent to the child process // serialized without making it a SerializedSession const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; @@ -126,7 +124,7 @@ function startChildProcess(options: Options): void { child.on('message', (msg: string) => { if (msg === 'session-ended') { log('ANR event sent from child process. Clearing session in this process.'); - hub.getScope()?.setSession(undefined); + getCurrentScope()?.setSession(undefined); } }); diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 3160c77b416a..832d87139f83 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -5,7 +5,7 @@ import { continueTrace, flush, getClient, - getCurrentHub, + getCurrentScope, hasTracingEnabled, runWithAsyncContext, startTransaction, @@ -44,8 +44,7 @@ export function tracingHandler(): ( res: http.ServerResponse, next: (error?: any) => void, ): void { - const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); + const options = getClient()?.getOptions(); if ( !options || @@ -86,9 +85,7 @@ export function tracingHandler(): ( ); // We put the transaction on the scope so users can attach children to it - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + getCurrentScope().setSpan(transaction); // We also set __sentry_transaction on the response so people can grab the transaction there to add // spans to it later. @@ -151,15 +148,14 @@ export function requestHandler( // TODO (v8): Get rid of this const requestDataOptions = convertReqHandlerOptsToAddReqDataOpts(options); - const currentHub = getCurrentHub(); - const client = currentHub.getClient(); + const client = getClient(); // Initialise an instance of SessionFlusher on the client when `autoSessionTracking` is enabled and the // `requestHandler` middleware is used indicating that we are running in SessionAggregates mode if (client && isAutoSessionTrackingEnabled(client)) { client.initSessionFlusher(); // If Scope contains a Single mode Session, it is removed in favor of using Session Aggregates mode - const scope = currentHub.getScope(); + const scope = getCurrentScope(); if (scope.getSession()) { scope.setSession(); } @@ -185,24 +181,21 @@ export function requestHandler( }; } runWithAsyncContext(() => { - const currentHub = getCurrentHub(); - currentHub.configureScope(scope => { - scope.setSDKProcessingMetadata({ - request: req, - // TODO (v8): Stop passing this - requestDataOptionsFromExpressHandler: requestDataOptions, - }); - - const client = currentHub.getClient(); - if (isAutoSessionTrackingEnabled(client)) { - const scope = currentHub.getScope(); - // Set `status` of `RequestSession` to Ok, at the beginning of the request - scope.setRequestSession({ status: 'ok' }); - } + const scope = getCurrentScope(); + scope.setSDKProcessingMetadata({ + request: req, + // TODO (v8): Stop passing this + requestDataOptionsFromExpressHandler: requestDataOptions, }); + const client = getClient(); + if (isAutoSessionTrackingEnabled(client)) { + // Set `status` of `RequestSession` to Ok, at the beginning of the request + scope.setRequestSession({ status: 'ok' }); + } + res.once('finish', () => { - const client = currentHub.getClient(); + const client = getClient(); if (isAutoSessionTrackingEnabled(client)) { setImmediate(() => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -332,9 +325,8 @@ interface TrpcMiddlewareArguments { */ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { return function ({ path, type, next, rawInput }: TrpcMiddlewareArguments): T { - const hub = getCurrentHub(); - const clientOptions = hub.getClient()?.getOptions(); - const sentryTransaction = hub.getScope().getTransaction(); + const clientOptions = getClient()?.getOptions(); + const sentryTransaction = getCurrentScope().getTransaction(); if (sentryTransaction) { sentryTransaction.setName(`trpc/${path}`, 'route'); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 9963258e48bb..06524bcd0c0a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -32,6 +32,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation @@ -78,7 +79,7 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; -export { enableAnrDetection } from './anr'; +export { enableAnrDetection, isAnrChildProcess } from './anr'; import { Integrations as CoreIntegrations } from '@sentry/core'; @@ -93,3 +94,5 @@ const INTEGRATIONS = { }; export { INTEGRATIONS as Integrations, Handlers }; + +export { hapiErrorPlugin } from './integrations/hapi'; diff --git a/packages/node/src/integrations/console.ts b/packages/node/src/integrations/console.ts index f99ab88b43b5..6c5142ae2a40 100644 --- a/packages/node/src/integrations/console.ts +++ b/packages/node/src/integrations/console.ts @@ -1,5 +1,5 @@ import * as util from 'util'; -import { getCurrentHub } from '@sentry/core'; +import { addBreadcrumb, getCurrentHub } from '@sentry/core'; import type { Integration } from '@sentry/types'; import { addConsoleInstrumentationHandler, severityLevelFromString } from '@sentry/utils'; @@ -26,7 +26,7 @@ export class Console implements Integration { return; } - hub.addBreadcrumb( + addBreadcrumb( { category: 'console', level: severityLevelFromString(level), diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts index f7044c509265..ee565c8676e7 100644 --- a/packages/node/src/integrations/context.ts +++ b/packages/node/src/integrations/context.ts @@ -11,7 +11,6 @@ import type { CultureContext, DeviceContext, Event, - EventProcessor, Integration, OsContext, } from '@sentry/types'; @@ -60,20 +59,25 @@ export class Context implements Integration { }, ) {} - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { - addGlobalEventProcessor(event => this.addContext(event)); + /** @inheritDoc */ + public setupOnce(_addGlobaleventProcessor: unknown, _getCurrentHub: unknown): void { + // noop } - /** Processes an event and adds context */ + /** @inheritDoc */ + public processEvent(event: Event): Promise { + return this.addContext(event); + } + + /** + * Processes an event and adds context. + */ public async addContext(event: Event): Promise { if (this._cachedContext === undefined) { this._cachedContext = this._getContexts(); } - const updatedContext = this._updateContext(await this._cachedContext); + const updatedContext = _updateContext(await this._cachedContext); event.contexts = { ...event.contexts, @@ -87,22 +91,6 @@ export class Context implements Integration { return event; } - /** - * Updates the context with dynamic values that can change - */ - private _updateContext(contexts: Contexts): Contexts { - // Only update properties if they exist - if (contexts?.app?.app_memory) { - contexts.app.app_memory = process.memoryUsage().rss; - } - - if (contexts?.device?.free_memory) { - contexts.device.free_memory = os.freemem(); - } - - return contexts; - } - /** * Gets the contexts for the current environment */ @@ -137,6 +125,22 @@ export class Context implements Integration { } } +/** + * Updates the context with dynamic values that can change + */ +function _updateContext(contexts: Contexts): Contexts { + // Only update properties if they exist + if (contexts?.app?.app_memory) { + contexts.app.app_memory = process.memoryUsage().rss; + } + + if (contexts?.device?.free_memory) { + contexts.device.free_memory = os.freemem(); + } + + return contexts; +} + /** * Returns the operating system context. * diff --git a/packages/node/src/integrations/contextlines.ts b/packages/node/src/integrations/contextlines.ts index c48f35adfd8b..2cc9375a879a 100644 --- a/packages/node/src/integrations/contextlines.ts +++ b/packages/node/src/integrations/contextlines.ts @@ -55,14 +55,13 @@ export class ContextLines implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(event => { - const self = getCurrentHub().getIntegration(ContextLines); - if (!self) { - return event; - } - return this.addSourceContext(event); - }); + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + // noop + } + + /** @inheritDoc */ + public processEvent(event: Event): Promise { + return this.addSourceContext(event); } /** Processes an event and adds context lines */ diff --git a/packages/node/src/integrations/hapi/index.ts b/packages/node/src/integrations/hapi/index.ts new file mode 100644 index 000000000000..42e7d27bca9e --- /dev/null +++ b/packages/node/src/integrations/hapi/index.ts @@ -0,0 +1,169 @@ +import { + SDK_VERSION, + captureException, + continueTrace, + getActiveTransaction, + getCurrentScope, + startTransaction, +} from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { dynamicSamplingContextToSentryBaggageHeader, fill } from '@sentry/utils'; + +import type { Boom, RequestEvent, ResponseObject, Server } from './types'; + +function isResponseObject(response: ResponseObject | Boom): response is ResponseObject { + return response && (response as ResponseObject).statusCode !== undefined; +} + +function isBoomObject(response: ResponseObject | Boom): response is Boom { + return response && (response as Boom).isBoom !== undefined; +} + +function isErrorEvent(event: RequestEvent): event is RequestEvent { + return event && (event as RequestEvent).error !== undefined; +} + +function sendErrorToSentry(errorData: object): void { + captureException(errorData, { + mechanism: { + type: 'hapi', + handled: false, + data: { + function: 'hapiErrorPlugin', + }, + }, + }); +} + +export const hapiErrorPlugin = { + name: 'SentryHapiErrorPlugin', + version: SDK_VERSION, + register: async function (serverArg: Record) { + const server = serverArg as unknown as Server; + + server.events.on('request', (request, event) => { + const transaction = getActiveTransaction(); + + if (request.response && isBoomObject(request.response)) { + sendErrorToSentry(request.response); + } else if (isErrorEvent(event)) { + sendErrorToSentry(event.error); + } + + if (transaction) { + transaction.setStatus('internal_error'); + transaction.finish(); + } + }); + }, +}; + +export const hapiTracingPlugin = { + name: 'SentryHapiTracingPlugin', + version: SDK_VERSION, + register: async function (serverArg: Record) { + const server = serverArg as unknown as Server; + + server.ext('onPreHandler', (request, h) => { + const transaction = continueTrace( + { + sentryTrace: request.headers['sentry-trace'] || undefined, + baggage: request.headers['baggage'] || undefined, + }, + transactionContext => { + return startTransaction({ + ...transactionContext, + op: 'hapi.request', + name: request.route.path, + description: `${request.route.method} ${request.path}`, + }); + }, + ); + + getCurrentScope().setSpan(transaction); + + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const transaction = getActiveTransaction(); + + if (request.response && isResponseObject(request.response) && transaction) { + const response = request.response as ResponseObject; + response.header('sentry-trace', transaction.toTraceparent()); + + const dynamicSamplingContext = dynamicSamplingContextToSentryBaggageHeader( + transaction.getDynamicSamplingContext(), + ); + + if (dynamicSamplingContext) { + response.header('baggage', dynamicSamplingContext); + } + } + + return h.continue; + }); + + server.ext('onPostHandler', (request, h) => { + const transaction = getActiveTransaction(); + + if (request.response && isResponseObject(request.response) && transaction) { + transaction.setHttpStatus(request.response.statusCode); + } + + if (transaction) { + transaction.finish(); + } + + return h.continue; + }); + }, +}; + +export type HapiOptions = { + /** Hapi server instance */ + server?: Record; +}; + +/** + * Hapi Framework Integration + */ +export class Hapi implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Hapi'; + + /** + * @inheritDoc + */ + public name: string; + + public _hapiServer: Server | undefined; + + public constructor(options?: HapiOptions) { + if (options?.server) { + const server = options.server as unknown as Server; + + this._hapiServer = server; + } + + this.name = Hapi.id; + } + + /** @inheritDoc */ + public setupOnce(): void { + if (!this._hapiServer) { + return; + } + + fill(this._hapiServer, 'start', (originalStart: () => void) => { + return async function (this: Server) { + await this.register(hapiTracingPlugin); + await this.register(hapiErrorPlugin); + const result = originalStart.apply(this); + return result; + }; + }); + } +} diff --git a/packages/node/src/integrations/hapi/types.ts b/packages/node/src/integrations/hapi/types.ts new file mode 100644 index 000000000000..d74c171ef441 --- /dev/null +++ b/packages/node/src/integrations/hapi/types.ts @@ -0,0 +1,278 @@ +/* eslint-disable @typescript-eslint/no-misused-new */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/unified-signatures */ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/no-namespace */ + +// Vendored and simplified from: +// - @types/hapi__hapi +// v17.8.9999 +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/hapi/v17/index.d.ts +// +// - @types/podium +// v1.0.9999 +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/podium/index.d.ts +// +// - @types/boom +// v7.3.9999 +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/boom/v4/index.d.ts + +import type * as stream from 'stream'; +import type * as url from 'url'; + +interface Podium { + new (events?: Events[]): Podium; + new (events?: Events): Podium; + + registerEvent(events: Events[]): void; + registerEvent(events: Events): void; + + registerPodium?(podiums: Podium[]): void; + registerPodium?(podiums: Podium): void; + + emit( + criteria: string | { name: string; channel?: string | undefined; tags?: string | string[] | undefined }, + data: any, + callback?: () => void, + ): void; + + on(criteria: string | Criteria, listener: Listener): void; + addListener(criteria: string | Criteria, listener: Listener): void; + once(criteria: string | Criteria, listener: Listener): void; + removeListener(name: string, listener: Listener): Podium; + removeAllListeners(name: string): Podium; + hasListeners(name: string): boolean; +} + +export interface Boom extends Error { + isBoom: boolean; + isServer: boolean; + message: string; + output: Output; + reformat: () => string; + isMissing?: boolean | undefined; + data: Data; +} + +export interface Output { + statusCode: number; + headers: { [index: string]: string }; + payload: Payload; +} + +export interface Payload { + statusCode: number; + error: string; + message: string; + attributes?: any; +} + +export type Events = string | EventOptionsObject | Podium; + +export interface EventOptionsObject { + name: string; + channels?: string | string[] | undefined; + clone?: boolean | undefined; + spread?: boolean | undefined; + tags?: boolean | undefined; + shared?: boolean | undefined; +} + +export interface CriteriaObject { + name: string; + block?: boolean | number | undefined; + channels?: string | string[] | undefined; + clone?: boolean | undefined; + count?: number | undefined; + filter?: string | string[] | CriteriaFilterOptionsObject | undefined; + spread?: boolean | undefined; + tags?: boolean | undefined; + listener?: Listener | undefined; +} + +export interface CriteriaFilterOptionsObject { + tags?: string | string[] | undefined; + all?: boolean | undefined; +} + +export type Criteria = string | CriteriaObject; + +export interface Listener { + (data: any, tags?: Tags, callback?: () => void): void; +} + +export type Tags = { [tag: string]: boolean }; + +type Dependencies = + | string + | string[] + | { + [key: string]: string; + }; + +interface PluginNameVersion { + name: string; + version?: string | undefined; +} + +interface PluginPackage { + pkg: any; +} + +interface PluginBase { + register: (server: Server, options: T) => void | Promise; + multiple?: boolean | undefined; + dependencies?: Dependencies | undefined; + requirements?: + | { + node?: string | undefined; + hapi?: string | undefined; + } + | undefined; + + once?: boolean | undefined; +} + +type Plugin = PluginBase & (PluginNameVersion | PluginPackage); + +interface UserCredentials {} + +interface AppCredentials {} + +interface AuthCredentials { + scope?: string[] | undefined; + user?: UserCredentials | undefined; + app?: AppCredentials | undefined; +} + +interface RequestAuth { + artifacts: object; + credentials: AuthCredentials; + error: Error; + isAuthenticated: boolean; + isAuthorized: boolean; + mode: string; + strategy: string; +} + +interface RequestEvents extends Podium { + on(criteria: 'peek', listener: PeekListener): void; + on(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): void; + once(criteria: 'peek', listener: PeekListener): void; + once(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): void; +} + +namespace Lifecycle { + export type Method = (request: Request, h: ResponseToolkit, err?: Error) => ReturnValue; + export type ReturnValue = ReturnValueTypes | Promise; + export type ReturnValueTypes = + | (null | string | number | boolean) + | Buffer + | (Error | Boom) + | stream.Stream + | (object | object[]) + | symbol + | ResponseToolkit; + export type FailAction = 'error' | 'log' | 'ignore' | Method; +} + +namespace Util { + export interface Dictionary { + [key: string]: T; + } + + export type HTTP_METHODS_PARTIAL_LOWERCASE = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options'; + export type HTTP_METHODS_PARTIAL = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'OPTIONS' + | HTTP_METHODS_PARTIAL_LOWERCASE; + export type HTTP_METHODS = 'HEAD' | 'head' | HTTP_METHODS_PARTIAL; +} + +interface RequestRoute { + method: Util.HTTP_METHODS_PARTIAL; + path: string; + vhost?: string | string[] | undefined; + realm: any; + fingerprint: string; + + auth: { + access(request: Request): boolean; + }; +} + +interface Request extends Podium { + app: ApplicationState; + readonly auth: RequestAuth; + events: RequestEvents; + readonly headers: Util.Dictionary; + readonly path: string; + response: ResponseObject | Boom | null; + readonly route: RequestRoute; + readonly url: url.Url; +} + +interface ResponseObjectHeaderOptions { + append?: boolean | undefined; + separator?: string | undefined; + override?: boolean | undefined; + duplicate?: boolean | undefined; +} + +export interface ResponseObject extends Podium { + readonly statusCode: number; + header(name: string, value: string, options?: ResponseObjectHeaderOptions): ResponseObject; +} + +interface ResponseToolkit { + readonly continue: symbol; +} + +interface ServerEventCriteria { + name: T; + channels?: string | string[] | undefined; + clone?: boolean | undefined; + count?: number | undefined; + filter?: string | string[] | { tags: string | string[]; all?: boolean | undefined } | undefined; + spread?: boolean | undefined; + tags?: boolean | undefined; +} + +export interface RequestEvent { + timestamp: string; + tags: string[]; + channel: 'internal' | 'app' | 'error'; + data: object; + error: object; +} + +type RequestEventHandler = (request: Request, event: RequestEvent, tags: { [key: string]: true }) => void; +interface ServerEvents { + on(criteria: 'request' | ServerEventCriteria<'request'>, listener: RequestEventHandler): void; +} + +type RouteRequestExtType = + | 'onPreAuth' + | 'onCredentials' + | 'onPostAuth' + | 'onPreHandler' + | 'onPostHandler' + | 'onPreResponse'; + +type ServerRequestExtType = RouteRequestExtType | 'onRequest'; + +export type Server = Record & { + events: ServerEvents; + ext(event: ServerRequestExtType, method: Lifecycle.Method, options?: Record): void; + initialize(): Promise; + register(plugins: Plugin | Array>, options?: Record): Promise; + start(): Promise; +}; + +interface ApplicationState {} + +type PeekListener = (chunk: string, encoding: string) => void; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 407343a96770..02e79d06b942 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,6 +1,7 @@ import type * as http from 'http'; import type * as https from 'https'; import type { Hub } from '@sentry/core'; +import { addBreadcrumb, getClient, getCurrentScope } from '@sentry/core'; import { getCurrentHub, getDynamicSamplingContextFromClient, isSentryRequestUrl } from '@sentry/core'; import type { DynamicSamplingContext, @@ -213,7 +214,7 @@ function _createWrappedRequestMethodFactory( return; } - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'http', data: { @@ -239,12 +240,11 @@ function _createWrappedRequestMethodFactory( const requestUrl = extractUrl(requestOptions); // we don't want to record requests to Sentry as either breadcrumbs or spans, so just use the original method - if (isSentryRequestUrl(requestUrl, getCurrentHub())) { + if (isSentryRequestUrl(requestUrl, getClient())) { return originalRequestMethod.apply(httpModule, requestArgs); } - const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); const data = getRequestSpanData(requestUrl, requestOptions); @@ -264,7 +264,7 @@ function _createWrappedRequestMethodFactory( const dynamicSamplingContext = requestSpan?.transaction?.getDynamicSamplingContext(); addHeadersToRequestOptions(requestOptions, requestUrl, sentryTraceHeader, dynamicSamplingContext); } else { - const client = hub.getClient(); + const client = getClient(); const { traceId, sampled, dsc } = scope.getPropagationContext(); const sentryTraceHeader = generateSentryTraceHeader(traceId, undefined, sampled); const dynamicSamplingContext = diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 49820882fdc6..f2ac9c25b807 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -9,3 +9,4 @@ export { RequestData } from '@sentry/core'; export { LocalVariables } from './localvariables'; export { Undici } from './undici'; export { Spotlight } from './spotlight'; +export { Hapi } from './hapi'; diff --git a/packages/node/src/integrations/localvariables.ts b/packages/node/src/integrations/localvariables.ts index a41822331ea5..1ba9907c4806 100644 --- a/packages/node/src/integrations/localvariables.ts +++ b/packages/node/src/integrations/localvariables.ts @@ -2,9 +2,9 @@ import type { Event, EventProcessor, Exception, Hub, Integration, StackFrame, StackParser } from '@sentry/types'; import { LRUMap, logger } from '@sentry/utils'; import type { Debugger, InspectorNotification, Runtime, Session } from 'inspector'; +import type { NodeClient } from '../client'; import { NODE_VERSION } from '../nodeVersion'; -import type { NodeClientOptions } from '../types'; type Variables = Record; type OnPauseEvent = InspectorNotification; @@ -332,6 +332,7 @@ export class LocalVariables implements Integration { private readonly _cachedFrames: LRUMap = new LRUMap(20); private _rateLimiter: RateLimitIncrement | undefined; + private _shouldProcessEvent = false; public constructor( private readonly _options: Options = {}, @@ -341,16 +342,15 @@ export class LocalVariables implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - this._setup(addGlobalEventProcessor, getCurrentHub().getClient()?.getOptions()); + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { + // noop } - /** Setup in a way that's easier to call from tests */ - private _setup( - addGlobalEventProcessor: (callback: EventProcessor) => void, - clientOptions: NodeClientOptions | undefined, - ): void { - if (this._session && clientOptions?.includeLocalVariables) { + /** @inheritdoc */ + public setup(client: NodeClient): void { + const clientOptions = client.getOptions(); + + if (this._session && clientOptions.includeLocalVariables) { // Only setup this integration if the Node version is >= v18 // https://github.com/getsentry/sentry-javascript/issues/7697 const unsupportedNodeVersion = (NODE_VERSION.major || 0) < 18; @@ -386,10 +386,19 @@ export class LocalVariables implements Integration { ); } - addGlobalEventProcessor(async event => this._addLocalVariables(event)); + this._shouldProcessEvent = true; } } + /** @inheritdoc */ + public processEvent(event: Event): Event { + if (this._shouldProcessEvent) { + return this._addLocalVariables(event); + } + + return event; + } + /** * Handle the pause event */ diff --git a/packages/node/src/integrations/modules.ts b/packages/node/src/integrations/modules.ts index 9a81d5808425..cc8ecf621bb9 100644 --- a/packages/node/src/integrations/modules.ts +++ b/packages/node/src/integrations/modules.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; -import type { EventProcessor, Hub, Integration } from '@sentry/types'; +import type { Event, EventProcessor, Hub, Integration } from '@sentry/types'; let moduleCache: { [key: string]: string }; @@ -65,6 +65,14 @@ function collectModules(): { return infos; } +/** Fetches the list of modules and the versions loaded by the entry file for your node.js app. */ +function _getModules(): { [key: string]: string } { + if (!moduleCache) { + moduleCache = collectModules(); + } + return moduleCache; +} + /** Add node modules / packages to the event */ export class Modules implements Integration { /** @@ -80,26 +88,18 @@ export class Modules implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor(event => { - if (!getCurrentHub().getIntegration(Modules)) { - return event; - } - return { - ...event, - modules: { - ...event.modules, - ...this._getModules(), - }, - }; - }); + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + // noop } - /** Fetches the list of modules and the versions loaded by the entry file for your node.js app. */ - private _getModules(): { [key: string]: string } { - if (!moduleCache) { - moduleCache = collectModules(); - } - return moduleCache; + /** @inheritdoc */ + public processEvent(event: Event): Event { + return { + ...event, + modules: { + ...event.modules, + ..._getModules(), + }, + }; } } diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts index 50e8dd0b30fb..b67562843d84 100644 --- a/packages/node/src/integrations/undici/index.ts +++ b/packages/node/src/integrations/undici/index.ts @@ -1,4 +1,11 @@ -import { getCurrentHub, getDynamicSamplingContextFromClient, isSentryRequestUrl } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentHub, + getCurrentScope, + getDynamicSamplingContextFromClient, + isSentryRequestUrl, +} from '@sentry/core'; import type { EventProcessor, Integration, Span } from '@sentry/types'; import { LRUMap, @@ -137,17 +144,17 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequestUrl(stringUrl, hub) || request.__sentry_span__ !== undefined) { + const client = getClient(); + if (!client) { return; } - const client = hub.getClient(); - if (!client) { + if (isSentryRequestUrl(stringUrl, client) || request.__sentry_span__ !== undefined) { return; } const clientOptions = client.getOptions(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); @@ -197,7 +204,7 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequestUrl(stringUrl, hub)) { + if (isSentryRequestUrl(stringUrl, getClient())) { return; } @@ -208,7 +215,7 @@ export class Undici implements Integration { } if (this._options.breadcrumbs) { - hub.addBreadcrumb( + addBreadcrumb( { category: 'http', data: { @@ -237,7 +244,7 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequestUrl(stringUrl, hub)) { + if (isSentryRequestUrl(stringUrl, getClient())) { return; } @@ -248,7 +255,7 @@ export class Undici implements Integration { } if (this._options.breadcrumbs) { - hub.addBreadcrumb( + addBreadcrumb( { category: 'http', data: { diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 5ef42128d9ab..07fd3f8b024a 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -1,7 +1,9 @@ /* eslint-disable max-lines */ import { Integrations as CoreIntegrations, + getClient, getCurrentHub, + getCurrentScope, getIntegrationsToSetup, getMainCarrier, initAndBind, @@ -182,7 +184,7 @@ export function init(options: NodeOptions = {}): void { updateScopeFromEnvVariables(); if (options.spotlight) { - const client = getCurrentHub().getClient(); + const client = getClient(); if (client && client.addIntegration) { // force integrations to be setup even if no DSN was set client.setupIntegrations(true); @@ -277,6 +279,6 @@ function updateScopeFromEnvVariables(): void { const sentryTraceEnv = process.env.SENTRY_TRACE; const baggageEnv = process.env.SENTRY_BAGGAGE; const { propagationContext } = tracingContextFromHeaders(sentryTraceEnv, baggageEnv); - getCurrentHub().getScope().setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); } } diff --git a/packages/node/test/eventbuilders.test.ts b/packages/node/test/eventbuilders.test.ts index 53598505d474..ead2d01e9b44 100644 --- a/packages/node/test/eventbuilders.test.ts +++ b/packages/node/test/eventbuilders.test.ts @@ -1,44 +1,51 @@ -import type { Client } from '@sentry/types'; +import type { Hub } from '@sentry/types'; import { eventFromUnknownInput } from '@sentry/utils'; -import { Scope, defaultStackParser, getCurrentHub } from '../src'; +import { defaultStackParser } from '../src'; -const testScope = new Scope(); - -jest.mock('@sentry/core', () => { - const original = jest.requireActual('@sentry/core'); - return { - ...original, - getCurrentHub(): { - getClient(): Client; - getScope(): Scope; - configureScope(scopeFunction: (scope: Scope) => void): void; - } { - return { - getClient(): any { - return { - getOptions(): any { - return { normalizeDepth: 6 }; +describe('eventFromUnknownInput', () => { + test('uses normalizeDepth from init options', () => { + const deepObject = { + a: { + b: { + c: { + d: { + e: { + f: { + g: 'foo', + }, + }, }, - }; - }, - getScope(): Scope { - return new Scope(); - }, - configureScope(scopeFunction: (scope: Scope) => void): void { - scopeFunction(testScope); + }, }, - }; - }, - }; -}); + }, + }; -afterEach(() => { - jest.resetAllMocks(); -}); + const client = { + getOptions(): any { + return { normalizeDepth: 6 }; + }, + } as any; + const event = eventFromUnknownInput(client, defaultStackParser, deepObject); -describe('eventFromUnknownInput', () => { - test('uses normalizeDepth from init options', () => { + const serializedObject = event.extra?.__serialized__; + expect(serializedObject).toBeDefined(); + expect(serializedObject).toEqual({ + a: { + b: { + c: { + d: { + e: { + f: '[Object]', + }, + }, + }, + }, + }, + }); + }); + + test('uses normalizeDepth from init options (passing getCurrentHub)', () => { const deepObject = { a: { b: { @@ -55,9 +62,19 @@ describe('eventFromUnknownInput', () => { }, }; - eventFromUnknownInput(getCurrentHub, defaultStackParser, deepObject); + const getCurrentHub = jest.fn(() => { + return { + getClient: () => ({ + getOptions(): any { + return { normalizeDepth: 6 }; + }, + }), + } as unknown as Hub; + }); + + const event = eventFromUnknownInput(getCurrentHub, defaultStackParser, deepObject); - const serializedObject = (testScope as any)._extra.__serialized__; + const serializedObject = event.extra?.__serialized__; expect(serializedObject).toBeDefined(); expect(serializedObject).toEqual({ a: { diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 37faef621907..cf6dab4d9338 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -458,10 +458,11 @@ describe('tracingHandler', () => { const hub = new sentryCore.Hub(new NodeClient(options)); jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); sentryTracingMiddleware(req, res, next); - const transaction = sentryCore.getCurrentHub().getScope().getTransaction(); + const transaction = sentryCore.getCurrentScope().getTransaction(); expect(transaction?.metadata.request).toEqual(req); }); diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 2b91dc414ee1..30658128d2b4 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -1,16 +1,17 @@ import { LinkedErrors, SDK_VERSION, getMainCarrier, initAndBind, runWithAsyncContext } from '@sentry/core'; import type { EventHint, Integration } from '@sentry/types'; +import { GLOBAL_OBJ } from '@sentry/utils'; -import type { Event, Scope } from '../src'; +import type { Event } from '../src'; import { NodeClient, addBreadcrumb, captureEvent, captureException, captureMessage, - configureScope, getClient, getCurrentHub, + getCurrentScope, init, } from '../src'; import { setNodeAsyncContextStrategy } from '../src/async'; @@ -33,43 +34,33 @@ const dsn = 'https://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291'; declare var global: any; describe('SentryNode', () => { - beforeAll(() => { + beforeEach(() => { + GLOBAL_OBJ.__SENTRY__ = { hub: undefined, logger: undefined, globalEventProcessors: [] }; init({ dsn }); }); beforeEach(() => { jest.clearAllMocks(); - getCurrentHub().pushScope(); - }); - - afterEach(() => { - getCurrentHub().popScope(); }); describe('getContext() / setContext()', () => { test('store/load extra', async () => { - configureScope((scope: Scope) => { - scope.setExtra('abc', { def: [1] }); - }); - expect(global.__SENTRY__.hub._stack[1].scope._extra).toEqual({ + getCurrentScope().setExtra('abc', { def: [1] }); + expect(global.__SENTRY__.hub._stack[0].scope._extra).toEqual({ abc: { def: [1] }, }); }); test('store/load tags', async () => { - configureScope((scope: Scope) => { - scope.setTag('abc', 'def'); - }); - expect(global.__SENTRY__.hub._stack[1].scope._tags).toEqual({ + getCurrentScope().setTag('abc', 'def'); + expect(global.__SENTRY__.hub._stack[0].scope._tags).toEqual({ abc: 'def', }); }); test('store/load user', async () => { - configureScope((scope: Scope) => { - scope.setUser({ id: 'def' }); - }); - expect(global.__SENTRY__.hub._stack[1].scope._user).toEqual({ + getCurrentScope().setUser({ id: 'def' }); + expect(global.__SENTRY__.hub._stack[0].scope._user).toEqual({ id: 'def', }); }); @@ -138,9 +129,7 @@ describe('SentryNode', () => { dsn, }); getCurrentHub().bindClient(new NodeClient(options)); - configureScope((scope: Scope) => { - scope.setTag('test', '1'); - }); + getCurrentScope().setTag('test', '1'); try { throw new Error('test'); } catch (e) { @@ -165,9 +154,7 @@ describe('SentryNode', () => { dsn, }); getCurrentHub().bindClient(new NodeClient(options)); - configureScope((scope: Scope) => { - scope.setTag('test', '1'); - }); + getCurrentScope().setTag('test', '1'); try { throw 'test string exception'; } catch (e) { @@ -175,36 +162,39 @@ describe('SentryNode', () => { } }); - test('capture an exception with pre/post context', done => { - expect.assertions(10); + test('capture an exception with pre/post context', async () => { + const beforeSend = jest.fn((event: Event) => { + expect(event.tags).toEqual({ test: '1' }); + expect(event.exception).not.toBeUndefined(); + expect(event.exception!.values![0]).not.toBeUndefined(); + expect(event.exception!.values![0].stacktrace!).not.toBeUndefined(); + expect(event.exception!.values![0].stacktrace!.frames![1]).not.toBeUndefined(); + expect(event.exception!.values![0].stacktrace!.frames![1].pre_context).not.toBeUndefined(); + expect(event.exception!.values![0].stacktrace!.frames![1].post_context).not.toBeUndefined(); + expect(event.exception!.values![0].type).toBe('Error'); + expect(event.exception!.values![0].value).toBe('test'); + expect(event.exception!.values![0].stacktrace).toBeTruthy(); + return null; + }); + const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, - beforeSend: (event: Event) => { - expect(event.tags).toEqual({ test: '1' }); - expect(event.exception).not.toBeUndefined(); - expect(event.exception!.values![0]).not.toBeUndefined(); - expect(event.exception!.values![0].stacktrace!).not.toBeUndefined(); - expect(event.exception!.values![0].stacktrace!.frames![1]).not.toBeUndefined(); - expect(event.exception!.values![0].stacktrace!.frames![1].pre_context).not.toBeUndefined(); - expect(event.exception!.values![0].stacktrace!.frames![1].post_context).not.toBeUndefined(); - expect(event.exception!.values![0].type).toBe('Error'); - expect(event.exception!.values![0].value).toBe('test'); - expect(event.exception!.values![0].stacktrace).toBeTruthy(); - done(); - return null; - }, + beforeSend, dsn, integrations: [new ContextLines()], }); - getCurrentHub().bindClient(new NodeClient(options)); - configureScope((scope: Scope) => { - scope.setTag('test', '1'); - }); + const client = new NodeClient(options); + getCurrentHub().bindClient(client); + getCurrentScope().setTag('test', '1'); try { throw new Error('test'); } catch (e) { captureException(e); } + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); }); test('capture a linked exception with pre/post context', done => { diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index ba5bbe91151c..2055aefeca39 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -38,14 +38,14 @@ describe('tracing', () => { const hub = new Hub(new NodeClient(options)); addTracingExtensions(); - hub.configureScope(scope => - scope.setUser({ - id: 'uid123', - segment: 'segmentA', - }), - ); + hub.getScope().setUser({ + id: 'uid123', + segment: 'segmentA', + }); jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); + jest.spyOn(sentryCore, 'getClient').mockReturnValue(hub.getClient()); const transaction = hub.startTransaction({ name: 'dogpark', @@ -69,7 +69,8 @@ describe('tracing', () => { }); const hub = new Hub(new NodeClient(options)); jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); - + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); + jest.spyOn(sentryCore, 'getClient').mockReturnValue(hub.getClient()); return hub; } @@ -238,11 +239,7 @@ describe('tracing', () => { const baggageHeader = request.getHeader('baggage') as string; const parts = sentryTraceHeader.split('-'); - expect(parts.length).toEqual(3); - expect(parts[0]).toEqual('86f39e84263a4de99c326acab3bfe3bd'); - expect(parts[1]).toEqual(expect.any(String)); - expect(parts[2]).toEqual('1'); - + expect(parts).toEqual(['86f39e84263a4de99c326acab3bfe3bd', expect.any(String), '1']); expect(baggageHeader).toEqual('sentry-trace_id=86f39e84263a4de99c326acab3bfe3bd,sentry-public_key=test-public-key'); }); @@ -357,7 +354,9 @@ describe('tracing', () => { const hub = new Hub(); - jest.spyOn(sentryCore, 'getCurrentHub').mockImplementation(() => hub); + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); + jest.spyOn(sentryCore, 'getClient').mockReturnValue(hub.getClient()); const client = new NodeClient(options); jest.spyOn(hub, 'getClient').mockImplementation(() => client); @@ -382,6 +381,10 @@ describe('tracing', () => { const hub = createHub({ shouldCreateSpanForRequest: () => false }); + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); + jest.spyOn(sentryCore, 'getClient').mockReturnValue(hub.getClient()); + httpIntegration.setupOnce( () => undefined, () => hub, @@ -487,6 +490,10 @@ describe('tracing', () => { const hub = createHub(); + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => hub.getScope()); + jest.spyOn(sentryCore, 'getClient').mockReturnValue(hub.getClient()); + httpIntegration.setupOnce( () => undefined, () => hub, @@ -585,6 +592,7 @@ describe('default protocols', () => { function captureBreadcrumb(key: string): Promise { const hub = new Hub(); jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryCore, 'addBreadcrumb').mockImplementation((...rest) => hub.addBreadcrumb(...rest)); let resolve: (value: Breadcrumb | PromiseLike) => void; const p = new Promise(r => { diff --git a/packages/node/test/integrations/localvariables.test.ts b/packages/node/test/integrations/localvariables.test.ts index 640a59e03ae4..6a09111370a8 100644 --- a/packages/node/test/integrations/localvariables.test.ts +++ b/packages/node/test/integrations/localvariables.test.ts @@ -2,7 +2,7 @@ import type { ClientOptions, EventProcessor } from '@sentry/types'; import type { LRUMap } from '@sentry/utils'; import type { Debugger, InspectorNotification } from 'inspector'; -import { defaultStackParser } from '../../src'; +import { NodeClient, defaultStackParser } from '../../src'; import type { DebugSession, FrameVariables } from '../../src/integrations/localvariables'; import { LocalVariables, createCallbackList, createRateLimiter } from '../../src/integrations/localvariables'; import { NODE_VERSION } from '../../src/nodeVersion'; @@ -52,7 +52,6 @@ class MockDebugSession implements DebugSession { interface LocalVariablesPrivate { _cachedFrames: LRUMap; - _setup(addGlobalEventProcessor: (callback: EventProcessor) => void, clientOptions: ClientOptions): void; } const exceptionEvent = { @@ -154,8 +153,6 @@ const exceptionEvent100Frames = { describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { it('Adds local variables to stack frames', async () => { - expect.assertions(7); - const session = new MockDebugSession({ '-6224981551105448869.1.2': { name: 'tim' }, '-6224981551105448869.1.6': { arr: [1, 2, 3] }, @@ -164,13 +161,14 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, + integrations: [localVariables], }); - let eventProcessor: EventProcessor | undefined; + const client = new NodeClient(options); + client.setupIntegrations(true); - (localVariables as unknown as LocalVariablesPrivate)._setup(callback => { - eventProcessor = callback; - }, options); + const eventProcessors = client['_eventProcessors']; + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); expect(eventProcessor).toBeDefined(); @@ -189,7 +187,7 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { { function: 'one', vars: { arr: [1, 2, 3] } }, ]); - const event = await eventProcessor?.( + const event = await eventProcessor!( { event_id: '9cbf882ade9a415986632ac4e16918eb', platform: 'node', @@ -249,22 +247,16 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { }); it('Only considers the first 5 frames', async () => { - expect.assertions(4); - const session = new MockDebugSession({}); const localVariables = new LocalVariables({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, + integrations: [localVariables], }); - let eventProcessor: EventProcessor | undefined; - - (localVariables as unknown as LocalVariablesPrivate)._setup(callback => { - eventProcessor = callback; - }, options); - - expect(eventProcessor).toBeDefined(); + const client = new NodeClient(options); + client.setupIntegrations(true); await session.runPause(exceptionEvent100Frames); @@ -280,16 +272,16 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { }); it('Should not lookup variables for non-exception reasons', async () => { - expect.assertions(1); - const session = new MockDebugSession({}, { getLocalVariables: true }); const localVariables = new LocalVariables({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, + integrations: [localVariables], }); - (localVariables as unknown as LocalVariablesPrivate)._setup(_ => {}, options); + const client = new NodeClient(options); + client.setupIntegrations(true); const nonExceptionEvent = { method: exceptionEvent.method, @@ -302,43 +294,41 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { }); it('Should not initialize when disabled', async () => { - expect.assertions(1); - const session = new MockDebugSession({}, { configureAndConnect: true }); const localVariables = new LocalVariables({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, + integrations: [localVariables], }); - let eventProcessor: EventProcessor | undefined; + const client = new NodeClient(options); + client.setupIntegrations(true); - (localVariables as unknown as LocalVariablesPrivate)._setup(callback => { - eventProcessor = callback; - }, options); + const eventProcessors = client['_eventProcessors']; + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); - expect(eventProcessor).toBeUndefined(); + expect(eventProcessor).toBeDefined(); + expect(localVariables['_shouldProcessEvent']).toBe(false); }); it('Should not initialize when inspector not loaded', async () => { - expect.assertions(1); - const localVariables = new LocalVariables({}, undefined); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, + integrations: [localVariables], }); - let eventProcessor: EventProcessor | undefined; + const client = new NodeClient(options); + client.setupIntegrations(true); - (localVariables as unknown as LocalVariablesPrivate)._setup(callback => { - eventProcessor = callback; - }, options); + const eventProcessors = client['_eventProcessors']; + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); - expect(eventProcessor).toBeUndefined(); + expect(eventProcessor).toBeDefined(); + expect(localVariables['_shouldProcessEvent']).toBe(false); }); it('Should cache identical uncaught exception events', async () => { - expect.assertions(1); - const session = new MockDebugSession({ '-6224981551105448869.1.2': { name: 'tim' }, '-6224981551105448869.1.6': { arr: [1, 2, 3] }, @@ -347,9 +337,11 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => { const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, + integrations: [localVariables], }); - (localVariables as unknown as LocalVariablesPrivate)._setup(_ => {}, options); + const client = new NodeClient(options); + client.setupIntegrations(true); await session.runPause(exceptionEvent); await session.runPause(exceptionEvent); diff --git a/packages/opentelemetry-node/src/utils/isSentryRequest.ts b/packages/opentelemetry-node/src/utils/isSentryRequest.ts index 5b285bb0ec68..85cb6c9c77b9 100644 --- a/packages/opentelemetry-node/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry-node/src/utils/isSentryRequest.ts @@ -1,6 +1,6 @@ import type { Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import { getClient, isSentryRequestUrl } from '@sentry/core'; /** * @@ -16,5 +16,5 @@ export function isSentryRequestSpan(otelSpan: OtelSpan): boolean { return false; } - return isSentryRequestUrl(httpUrl.toString(), getCurrentHub()); + return isSentryRequestUrl(httpUrl.toString(), getClient()); } diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index 9de394d2232d..69ef554c132c 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -966,9 +966,7 @@ describe('SentrySpanProcessor', () => { makeMain(hub); const newHub = new Hub(client, hub.getScope().clone()); - newHub.configureScope(scope => { - scope.setTag('foo', 'bar'); - }); + newHub.getScope().setTag('foo', 'bar'); const tracer = provider.getTracer('default'); diff --git a/packages/opentelemetry/src/custom/scope.ts b/packages/opentelemetry/src/custom/scope.ts index e206ba8d8096..e08f8484d87d 100644 --- a/packages/opentelemetry/src/custom/scope.ts +++ b/packages/opentelemetry/src/custom/scope.ts @@ -87,6 +87,11 @@ export class OpenTelemetryScope extends Scope { return this; } + return this._addBreadcrumb(breadcrumb, maxBreadcrumbs); + } + + /** Add a breadcrumb to this scope. */ + protected _addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { return super.addBreadcrumb(breadcrumb, maxBreadcrumbs); } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 3ac617aada9d..f379b4216da5 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -6,9 +6,16 @@ export type { OpenTelemetryClient } from './types'; export { wrapClientClass } from './custom/client'; export { getSpanKind } from './utils/getSpanKind'; -export { getSpanHub, getSpanMetadata, getSpanParent, getSpanScope, setSpanMetadata } from './utils/spanData'; +export { + getSpanHub, + getSpanMetadata, + getSpanParent, + getSpanScope, + setSpanMetadata, + getSpanFinishScope, +} from './utils/spanData'; -export { getPropagationContextFromContext, setPropagationContextOnContext } from './utils/contextData'; +export { getPropagationContextFromContext, setPropagationContextOnContext, setHubOnContext } from './utils/contextData'; export { spanHasAttributes, @@ -25,6 +32,7 @@ export { getActiveSpan, getRootSpan } from './utils/getActiveSpan'; export { startSpan, startInactiveSpan } from './trace'; export { getCurrentHub, setupGlobalHub, getClient } from './custom/hub'; +export { OpenTelemetryScope } from './custom/scope'; export { addTracingExtensions } from './custom/hubextensions'; export { setupEventContextTrace } from './setupEventContextTrace'; diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 95ad13997fb9..c15bd4483a9b 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -20,7 +20,7 @@ import type { SpanNode } from './utils/groupSpansWithParents'; import { groupSpansWithParents } from './utils/groupSpansWithParents'; import { mapStatus } from './utils/mapStatus'; import { parseSpanDescription } from './utils/parseSpanDescription'; -import { getSpanHub, getSpanMetadata, getSpanScope } from './utils/spanData'; +import { getSpanFinishScope, getSpanHub, getSpanMetadata, getSpanScope } from './utils/spanData'; type SpanNodeCompleted = SpanNode & { span: ReadableSpan }; @@ -111,12 +111,9 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { }); // Now finish the transaction, which will send it together with all the spans - // We make sure to use the current span as the activeSpan for this transaction - const scope = getSpanScope(span) as OpenTelemetryScope | undefined; - const forkedScope = scope ? scope.clone() : new OpenTelemetryScope(); - forkedScope.activeSpan = span as unknown as Span; - - transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), forkedScope); + // We make sure to use the finish scope + const scope = getSpanFinishScope(span); + transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), scope); }); return Array.from(remaining) diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index a2d7de69fb00..dd5e6de53cbd 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -5,13 +5,14 @@ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { logger } from '@sentry/utils'; import { getCurrentHub } from './custom/hub'; +import { OpenTelemetryScope } from './custom/scope'; import { DEBUG_BUILD } from './debug-build'; import { SentrySpanExporter } from './spanExporter'; import { maybeCaptureExceptionForTimedEvent } from './utils/captureExceptionForTimedEvent'; import { getHubFromContext } from './utils/contextData'; -import { getSpanHub, setSpanHub, setSpanParent, setSpanScope } from './utils/spanData'; +import { getSpanHub, setSpanFinishScope, setSpanHub, setSpanParent, setSpanScope } from './utils/spanData'; -function onSpanStart(span: Span, parentContext: Context): void { +function onSpanStart(span: Span, parentContext: Context, ScopeClass: typeof OpenTelemetryScope): void { // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK const parentSpan = trace.getSpan(parentContext); const hub = getHubFromContext(parentContext); @@ -30,8 +31,14 @@ function onSpanStart(span: Span, parentContext: Context): void { // We need the scope at time of span creation in order to apply it to the event when the span is finished if (actualHub) { + const scope = actualHub.getScope(); setSpanScope(span, actualHub.getScope()); setSpanHub(span, actualHub); + + // Use this scope for finishing the span + const finishScope = (scope as OpenTelemetryScope).clone(); + finishScope.activeSpan = span; + setSpanFinishScope(span, finishScope); } } @@ -48,15 +55,19 @@ function onSpanEnd(span: Span): void { * the Sentry SDK. */ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProcessorInterface { - public constructor() { + private _scopeClass: typeof OpenTelemetryScope; + + public constructor(options: { scopeClass?: typeof OpenTelemetryScope } = {}) { super(new SentrySpanExporter()); + + this._scopeClass = options.scopeClass || OpenTelemetryScope; } /** * @inheritDoc */ public onStart(span: Span, parentContext: Context): void { - onSpanStart(span, parentContext); + onSpanStart(span, parentContext, this._scopeClass); DEBUG_BUILD && logger.log(`[Tracing] Starting span "${span.name}" (${span.spanContext().spanId})`); diff --git a/packages/opentelemetry/src/utils/isSentryRequest.ts b/packages/opentelemetry/src/utils/isSentryRequest.ts index 361cc89d0ad7..7d146d551e12 100644 --- a/packages/opentelemetry/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry/src/utils/isSentryRequest.ts @@ -1,5 +1,5 @@ import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import { getClient, isSentryRequestUrl } from '@sentry/core'; import type { AbstractSpan } from '../types'; import { spanHasAttributes } from './spanTypes'; @@ -22,5 +22,5 @@ export function isSentryRequestSpan(span: AbstractSpan): boolean { return false; } - return isSentryRequestUrl(httpUrl.toString(), getCurrentHub()); + return isSentryRequestUrl(httpUrl.toString(), getClient()); } diff --git a/packages/opentelemetry/src/utils/spanData.ts b/packages/opentelemetry/src/utils/spanData.ts index e8fe58506866..18d9661a6488 100644 --- a/packages/opentelemetry/src/utils/spanData.ts +++ b/packages/opentelemetry/src/utils/spanData.ts @@ -7,6 +7,7 @@ import type { AbstractSpan } from '../types'; // This way we can enhance the data that an OTEL Span natively gives us // and since we are using weakmaps, we do not need to clean up after ourselves const SpanScope = new WeakMap(); +const SpanFinishScope = new WeakMap(); const SpanHub = new WeakMap(); const SpanParent = new WeakMap(); const SpanMetadata = new WeakMap>(); @@ -50,3 +51,13 @@ export function setSpanMetadata(span: AbstractSpan, metadata: Partial | undefined { return SpanMetadata.get(span); } + +/** Set the Sentry scope to be used for finishing a given OTEL span. */ +export function setSpanFinishScope(span: AbstractSpan, scope: Scope): void { + SpanFinishScope.set(span, scope); +} + +/** Get the Sentry scope to use for finishing an OTEL span. */ +export function getSpanFinishScope(span: AbstractSpan): Scope | undefined { + return SpanFinishScope.get(span); +} diff --git a/packages/opentelemetry/test/custom/hub.test.ts b/packages/opentelemetry/test/custom/hub.test.ts index 3fb707dca18a..e3f2eea70e6b 100644 --- a/packages/opentelemetry/test/custom/hub.test.ts +++ b/packages/opentelemetry/test/custom/hub.test.ts @@ -26,6 +26,7 @@ describe('OpenTelemetryHub', () => { it('pushScope() creates correct scope', () => { const hub = new OpenTelemetryHub(); + // eslint-disable-next-line deprecation/deprecation const scope = hub.pushScope(); expect(scope).toBeInstanceOf(OpenTelemetryScope); diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index b81622f8797c..38f99d7af825 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { addEventProcessor, configureScope, getClient } from '@sentry/browser'; +import { addEventProcessor, getClient, getCurrentScope } from '@sentry/browser'; import type { Scope } from '@sentry/types'; import { addNonEnumerableProperty } from '@sentry/utils'; @@ -116,44 +116,43 @@ function createReduxEnhancer(enhancerOptions?: Partial): const sentryReducer: Reducer = (state, action): S => { const newState = reducer(state, action); - configureScope(scope => { - /* Action breadcrumbs */ - const transformedAction = options.actionTransformer(action); - if (typeof transformedAction !== 'undefined' && transformedAction !== null) { - scope.addBreadcrumb({ - category: ACTION_BREADCRUMB_CATEGORY, - data: transformedAction, - type: ACTION_BREADCRUMB_TYPE, - }); - } - - /* Set latest state to scope */ - const transformedState = options.stateTransformer(newState); - if (typeof transformedState !== 'undefined' && transformedState !== null) { - const client = getClient(); - const options = client && client.getOptions(); - const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3 - - // Set the normalization depth of the redux state to the configured `normalizeDepth` option or a sane number as a fallback - const newStateContext = { state: { type: 'redux', value: transformedState } }; - addNonEnumerableProperty( - newStateContext, - '__sentry_override_normalization_depth__', - 3 + // 3 layers for `state.value.transformedState` - normalizationDepth, // rest for the actual state - ); - - scope.setContext('state', newStateContext); - } else { - scope.setContext('state', null); - } - - /* Allow user to configure scope with latest state */ - const { configureScopeWithState } = options; - if (typeof configureScopeWithState === 'function') { - configureScopeWithState(scope, newState); - } - }); + const scope = getCurrentScope(); + /* Action breadcrumbs */ + const transformedAction = options.actionTransformer(action); + if (typeof transformedAction !== 'undefined' && transformedAction !== null) { + scope.addBreadcrumb({ + category: ACTION_BREADCRUMB_CATEGORY, + data: transformedAction, + type: ACTION_BREADCRUMB_TYPE, + }); + } + + /* Set latest state to scope */ + const transformedState = options.stateTransformer(newState); + if (typeof transformedState !== 'undefined' && transformedState !== null) { + const client = getClient(); + const options = client && client.getOptions(); + const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3 + + // Set the normalization depth of the redux state to the configured `normalizeDepth` option or a sane number as a fallback + const newStateContext = { state: { type: 'redux', value: transformedState } }; + addNonEnumerableProperty( + newStateContext, + '__sentry_override_normalization_depth__', + 3 + // 3 layers for `state.value.transformedState` + normalizationDepth, // rest for the actual state + ); + + scope.setContext('state', newStateContext); + } else { + scope.setContext('state', null); + } + + /* Allow user to configure scope with latest state */ + const { configureScopeWithState } = options; + if (typeof configureScopeWithState === 'function') { + configureScopeWithState(scope, newState); + } return newState; }; diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts index 61b908a1d5fe..60cf59abd74e 100644 --- a/packages/react/test/redux.test.ts +++ b/packages/react/test/redux.test.ts @@ -9,11 +9,12 @@ const mockSetContext = jest.fn(); jest.mock('@sentry/browser', () => ({ ...jest.requireActual('@sentry/browser'), - configureScope: (callback: (scope: any) => Partial) => - callback({ + getCurrentScope() { + return { addBreadcrumb: mockAddBreadcrumb, setContext: mockSetContext, - }), + }; + }, addEventProcessor: jest.fn(), })); @@ -240,8 +241,7 @@ describe('createReduxEnhancer', () => { value: 'latest', }); - let scopeRef; - Sentry.configureScope(scope => (scopeRef = scope)); + const scopeRef = Sentry.getCurrentScope(); expect(configureScopeWithState).toBeCalledWith(scopeRef, { value: 'latest', diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx index fd37f3001d83..63b39253416d 100644 --- a/packages/remix/src/index.client.tsx +++ b/packages/remix/src/index.client.tsx @@ -1,4 +1,4 @@ -import { configureScope, init as reactInit } from '@sentry/react'; +import { getCurrentScope, init as reactInit } from '@sentry/react'; import { buildMetadata } from './utils/metadata'; import type { RemixOptions } from './utils/remixOptions'; @@ -12,7 +12,5 @@ export function init(options: RemixOptions): void { reactInit(options); - configureScope(scope => { - scope.setTag('runtime', 'browser'); - }); + getCurrentScope().setTag('runtime', 'browser'); } diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 12fc10b522cf..c62b3c9c729c 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -1,5 +1,6 @@ import type { NodeOptions } from '@sentry/node'; -import { configureScope, getCurrentHub, init as nodeInit } from '@sentry/node'; +import { getClient } from '@sentry/node'; +import { getCurrentScope, init as nodeInit } from '@sentry/node'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from './utils/debug-build'; @@ -19,6 +20,7 @@ export { captureException, captureEvent, captureMessage, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation @@ -68,8 +70,7 @@ export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express' export type { SentryMetaArgs } from './utils/types'; function sdkAlreadyInitialized(): boolean { - const hub = getCurrentHub(); - return !!hub.getClient(); + return !!getClient(); } /** Initializes Sentry Remix SDK on Node. */ @@ -86,7 +87,5 @@ export function init(options: RemixOptions): void { nodeInit(options as NodeOptions); - configureScope(scope => { - scope.setTag('runtime', 'node'); - }); + getCurrentScope().setTag('runtime', 'node'); } diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 7e98dc123858..ea87b961493c 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import { getActiveTransaction, hasTracingEnabled, runWithAsyncContext } from '@sentry/core'; +import { getActiveTransaction, getClient, getCurrentScope, hasTracingEnabled, runWithAsyncContext } from '@sentry/core'; import type { Hub } from '@sentry/node'; import { captureException, getCurrentHub } from '@sentry/node'; import type { Transaction, TransactionSource, WrappedFunction } from '@sentry/types'; @@ -106,14 +106,13 @@ export function wrapRemixHandleError(err: unknown, { request }: DataFunctionArgs export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { // Skip capturing if the thrown error is not a 5xx response // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders - if (IS_REMIX_V2) { - if (isRouteErrorResponse(err) && err.status < 500) { - return; - } - } else if (isResponse(err) && err.status < 500) { + if (IS_REMIX_V2 && isRouteErrorResponse(err) && err.status < 500) { return; } + if (isResponse(err) && err.status < 500) { + return; + } // Skip capturing if the request is aborted as Remix docs suggest // Ref: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror if (request.signal.aborted) { @@ -225,7 +224,7 @@ function makeWrappedDataFunction( return async function (this: unknown, args: DataFunctionArgs): Promise { let res: Response | AppData; const activeTransaction = getActiveTransaction(); - const currentScope = getCurrentHub().getScope(); + const currentScope = getCurrentScope(); try { const span = activeTransaction?.startChild({ @@ -280,7 +279,7 @@ function getTraceAndBaggage(): { sentryBaggage?: string; } { const transaction = getActiveTransaction(); - const currentScope = getCurrentHub().getScope(); + const currentScope = getCurrentScope(); if (isNodeEnv() && hasTracingEnabled()) { const span = currentScope.getSpan(); @@ -421,8 +420,8 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui return async function (this: unknown, request: RemixRequest, loadContext?: unknown): Promise { return runWithAsyncContext(async () => { const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); - const scope = hub.getScope(); + const options = getClient()?.getOptions(); + const scope = getCurrentScope(); let normalizedRequest: Record = request; diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts index ab638866ffd4..b60b74a8e0ff 100644 --- a/packages/remix/src/utils/serverAdapters/express.ts +++ b/packages/remix/src/utils/serverAdapters/express.ts @@ -1,4 +1,4 @@ -import { getCurrentHub, hasTracingEnabled } from '@sentry/core'; +import { getClient, getCurrentHub, getCurrentScope, hasTracingEnabled } from '@sentry/core'; import { flush } from '@sentry/node'; import type { Transaction } from '@sentry/types'; import { extractRequestData, isString, logger } from '@sentry/utils'; @@ -59,8 +59,8 @@ function wrapExpressRequestHandler( const request = extractRequestData(req); const hub = getCurrentHub(); - const options = hub.getClient()?.getOptions(); - const scope = hub.getScope(); + const options = getClient()?.getOptions(); + const scope = getCurrentScope(); scope.setSDKProcessingMetadata({ request }); diff --git a/packages/remix/test/integration/app_v1/routes/throw-redirect.tsx b/packages/remix/test/integration/app_v1/routes/throw-redirect.tsx new file mode 100644 index 000000000000..4425f3432b58 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/throw-redirect.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/throw-redirect'; +export { default } from '../../common/routes/throw-redirect'; diff --git a/packages/remix/test/integration/app_v2/routes/throw-redirect.tsx b/packages/remix/test/integration/app_v2/routes/throw-redirect.tsx new file mode 100644 index 000000000000..4425f3432b58 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/throw-redirect.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/throw-redirect'; +export { default } from '../../common/routes/throw-redirect'; diff --git a/packages/remix/test/integration/common/routes/throw-redirect.tsx b/packages/remix/test/integration/common/routes/throw-redirect.tsx new file mode 100644 index 000000000000..2d530e41d0c0 --- /dev/null +++ b/packages/remix/test/integration/common/routes/throw-redirect.tsx @@ -0,0 +1,11 @@ +import { LoaderFunction, redirect } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +export const loader: LoaderFunction = async () => { + throw redirect('/'); +}; + +export default function ThrowRedirect() { + const data = useLoaderData(); + return
{data}
; +} diff --git a/packages/remix/test/integration/test/client/throw-redirect.test.ts b/packages/remix/test/integration/test/client/throw-redirect.test.ts new file mode 100644 index 000000000000..60ed3588c79b --- /dev/null +++ b/packages/remix/test/integration/test/client/throw-redirect.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; +import { countEnvelopes } from './utils/helpers'; + +test('should not report thrown redirect response on client side.', async ({ page }) => { + const count = await countEnvelopes(page, { url: '/throw-redirect', envelopeType: 'event' }); + + expect(count).toBe(0); +}); diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index b6d4fc402ea7..24d67422c3ca 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -241,4 +241,16 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada ], }); }); + + it('does not capture thrown redirect responses', async () => { + const env = await RemixTestEnv.init(adapter); + const url = `${env.url}/throw-redirect`; + + const envelopesCount = await env.countEnvelopes({ + url, + envelopeType: ['event'], + }); + + expect(envelopesCount).toBe(0); + }); }); diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 8e1740845c7b..d0dc3097bcf6 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up import { EventType, record } from '@sentry-internal/rrweb'; -import { captureException, getClient, getCurrentHub } from '@sentry/core'; -import type { Event as SentryEvent, ReplayRecordingMode, Transaction } from '@sentry/types'; +import { captureException, getClient, getCurrentScope } from '@sentry/core'; +import type { ReplayRecordingMode, Transaction } from '@sentry/types'; import { logger } from '@sentry/utils'; import { @@ -343,7 +343,10 @@ export class ReplayContainer implements ReplayContainerInterface { ...(canvas && { recordCanvas: true, sampling: { canvas: canvas.fps || 4 }, - dataURLOptions: { quality: canvas.quality || 0.6 }, + dataURLOptions: { + type: canvas.type || 'image/webp', + quality: canvas.quality || 0.6, + }, getCanvasManager: canvas.manager, }), }); @@ -698,7 +701,7 @@ export class ReplayContainer implements ReplayContainerInterface { * This is only available if performance is enabled, and if an instrumented router is used. */ public getCurrentRoute(): string | undefined { - const lastTransaction = this.lastTransaction || getCurrentHub().getScope().getTransaction(); + const lastTransaction = this.lastTransaction || getCurrentScope().getTransaction(); if (!lastTransaction || !['route', 'custom'].includes(lastTransaction.metadata.source)) { return undefined; } diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index d854f258c073..fb1f91c0e1a9 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -235,6 +235,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { canvas: { fps?: number; quality?: number; + type?: string; manager: (options: GetCanvasManagerOptions) => CanvasManagerInterface; }; }>; diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index fac2b278e666..1824e1fa606c 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -1,5 +1,6 @@ import type { BaseClient } from '@sentry/core'; -import { addEventProcessor, getClient, getCurrentHub } from '@sentry/core'; +import { getCurrentScope } from '@sentry/core'; +import { addEventProcessor, getClient } from '@sentry/core'; import type { Client, DynamicSamplingContext } from '@sentry/types'; import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler } from '@sentry/utils'; @@ -17,7 +18,7 @@ import type { ReplayContainer } from '../types'; */ export function addGlobalListeners(replay: ReplayContainer): void { // Listeners from core SDK // - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const client = getClient(); scope.addScopeListener(handleScopeListener(replay)); diff --git a/packages/replay/src/util/log.ts b/packages/replay/src/util/log.ts index 96b6e76dc498..3d16137bbbc0 100644 --- a/packages/replay/src/util/log.ts +++ b/packages/replay/src/util/log.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { addBreadcrumb } from '@sentry/core'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -14,7 +14,7 @@ export function logInfo(message: string, shouldAddBreadcrumb?: boolean): void { logger.info(message); if (shouldAddBreadcrumb) { - addBreadcrumb(message); + addLogBreadcrumb(message); } } @@ -33,14 +33,13 @@ export function logInfoNextTick(message: string, shouldAddBreadcrumb?: boolean): // Wait a tick here to avoid race conditions for some initial logs // which may be added before replay is initialized setTimeout(() => { - addBreadcrumb(message); + addLogBreadcrumb(message); }, 0); } } -function addBreadcrumb(message: string): void { - const hub = getCurrentHub(); - hub.addBreadcrumb( +function addLogBreadcrumb(message: string): void { + addBreadcrumb( { category: 'console', data: { diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 49710916fcb1..c030ea1f8c2f 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient, getCurrentScope } from '@sentry/core'; import type { ReplayEvent, TransportMakeRequestResponse } from '@sentry/types'; import type { RateLimits } from '@sentry/utils'; import { isRateLimited, updateRateLimits } from '@sentry/utils'; @@ -30,9 +30,8 @@ export async function sendReplayRequest({ const { urls, errorIds, traceIds, initialTimestamp } = eventContext; - const hub = getCurrentHub(); - const client = hub.getClient(); - const scope = hub.getScope(); + const client = getClient(); + const scope = getCurrentScope(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); diff --git a/packages/replay/src/util/shouldFilterRequest.ts b/packages/replay/src/util/shouldFilterRequest.ts index 274ad4ee488a..71a268be8d64 100644 --- a/packages/replay/src/util/shouldFilterRequest.ts +++ b/packages/replay/src/util/shouldFilterRequest.ts @@ -1,4 +1,4 @@ -import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import { getClient, isSentryRequestUrl } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { ReplayContainer } from '../types'; @@ -13,5 +13,5 @@ export function shouldFilterRequest(replay: ReplayContainer, url: string): boole return false; } - return isSentryRequestUrl(url, getCurrentHub()); + return isSentryRequestUrl(url, getClient()); } diff --git a/packages/replay/test/integration/eventProcessors.test.ts b/packages/replay/test/integration/eventProcessors.test.ts index b9c86a5c5966..41219c75ae6c 100644 --- a/packages/replay/test/integration/eventProcessors.test.ts +++ b/packages/replay/test/integration/eventProcessors.test.ts @@ -1,5 +1,5 @@ -import { getCurrentHub } from '@sentry/core'; -import type { Event, Hub, Scope } from '@sentry/types'; +import { getClient, getCurrentScope } from '@sentry/core'; +import type { Event } from '@sentry/types'; import { BASE_TIMESTAMP } from '..'; import { resetSdkMock } from '../mocks/resetSdkMock'; @@ -9,16 +9,11 @@ import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); describe('Integration | eventProcessors', () => { - let hub: Hub; - let scope: Scope; - beforeEach(() => { - hub = getCurrentHub(); - scope = hub.pushScope(); + getCurrentScope().clear(); }); afterEach(() => { - hub.popScope(); jest.resetAllMocks(); }); @@ -31,7 +26,7 @@ describe('Integration | eventProcessors', () => { }, }); - const client = hub.getClient()!; + const client = getClient()!; jest.runAllTimers(); const mockTransportSend = jest.spyOn(client.getTransport()!, 'send'); @@ -47,7 +42,7 @@ describe('Integration | eventProcessors', () => { return null; }); - scope.addEventProcessor(handler1); + getCurrentScope().addEventProcessor(handler1); const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); @@ -58,7 +53,7 @@ describe('Integration | eventProcessors', () => { expect(mockTransportSend).toHaveBeenCalledTimes(1); - scope.addEventProcessor(handler2); + getCurrentScope().addEventProcessor(handler2); const TEST_EVENT2 = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); diff --git a/packages/serverless/src/awslambda.ts b/packages/serverless/src/awslambda.ts index 1da44ed1b47c..3f6ed2ad0ef8 100644 --- a/packages/serverless/src/awslambda.ts +++ b/packages/serverless/src/awslambda.ts @@ -305,7 +305,7 @@ export function wrapHandler( sentryTrace, baggage, ); - hub.getScope().setPropagationContext(propagationContext); + Sentry.getCurrentScope().setPropagationContext(propagationContext); transaction = hub.startTransaction({ name: context.functionName, @@ -319,35 +319,35 @@ export function wrapHandler( }); } - const scope = hub.pushScope(); - let rv: TResult; - try { - enhanceScopeWithEnvironmentData(scope, context, START_TIME); - if (options.startTrace) { - enhanceScopeWithTransactionData(scope, context); - // We put the transaction on the scope so users can attach children to it - scope.setSpan(transaction); - } - rv = await asyncHandler(event, context); - - // We manage lambdas that use Promise.allSettled by capturing the errors of failed promises - if (options.captureAllSettledReasons && Array.isArray(rv) && isPromiseAllSettledResult(rv)) { - const reasons = getRejectedReasons(rv); - reasons.forEach(exception => { - captureException(exception, scope => markEventUnhandled(scope)); + return withScope(async scope => { + let rv: TResult; + try { + enhanceScopeWithEnvironmentData(scope, context, START_TIME); + if (options.startTrace) { + enhanceScopeWithTransactionData(scope, context); + // We put the transaction on the scope so users can attach children to it + scope.setSpan(transaction); + } + rv = await asyncHandler(event, context); + + // We manage lambdas that use Promise.allSettled by capturing the errors of failed promises + if (options.captureAllSettledReasons && Array.isArray(rv) && isPromiseAllSettledResult(rv)) { + const reasons = getRejectedReasons(rv); + reasons.forEach(exception => { + captureException(exception, scope => markEventUnhandled(scope)); + }); + } + } catch (e) { + captureException(e, scope => markEventUnhandled(scope)); + throw e; + } finally { + clearTimeout(timeoutWarningTimer); + transaction?.finish(); + await flush(options.flushTimeout).catch(e => { + DEBUG_BUILD && logger.error(e); }); } - } catch (e) { - captureException(e, scope => markEventUnhandled(scope)); - throw e; - } finally { - clearTimeout(timeoutWarningTimer); - transaction?.finish(); - hub.popScope(); - await flush(options.flushTimeout).catch(e => { - DEBUG_BUILD && logger.error(e); - }); - } - return rv; + return rv; + }); }; } diff --git a/packages/serverless/src/awsservices.ts b/packages/serverless/src/awsservices.ts index 699ae9c40ab5..33a3a25b8689 100644 --- a/packages/serverless/src/awsservices.ts +++ b/packages/serverless/src/awsservices.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/node'; +import { getCurrentScope } from '@sentry/node'; import type { Integration, Span } from '@sentry/types'; import { fill } from '@sentry/utils'; // 'aws-sdk/global' import is expected to be type-only so it's erased in the final .js file. @@ -57,7 +57,7 @@ function wrapMakeRequest( ): MakeRequestFunction { return function (this: TService, operation: string, params?: GenericParams, callback?: MakeRequestCallback) { let span: Span | undefined; - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const transaction = scope.getTransaction(); const req = orig.call(this, operation, params); req.on('afterBuild', () => { diff --git a/packages/serverless/src/gcpfunction/cloud_events.ts b/packages/serverless/src/gcpfunction/cloud_events.ts index 05595867c191..63303470d9e9 100644 --- a/packages/serverless/src/gcpfunction/cloud_events.ts +++ b/packages/serverless/src/gcpfunction/cloud_events.ts @@ -1,4 +1,4 @@ -import { captureException, flush, getCurrentHub } from '@sentry/node'; +import { captureException, flush, getCurrentHub, getCurrentScope } from '@sentry/node'; import { isThenable, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -43,11 +43,10 @@ function _wrapCloudEventFunction( // getCurrentHub() is expected to use current active domain as a carrier // since functions-framework creates a domain for each incoming request. // So adding of event processors every time should not lead to memory bloat. - hub.configureScope(scope => { - scope.setContext('gcp.function.context', { ...context }); - // We put the transaction on the scope so users can attach children to it - scope.setSpan(transaction); - }); + const scope = getCurrentScope(); + scope.setContext('gcp.function.context', { ...context }); + // We put the transaction on the scope so users can attach children to it + scope.setSpan(transaction); const newCallback = domainify((...args: unknown[]) => { if (args[0] !== null && args[0] !== undefined) { diff --git a/packages/serverless/src/gcpfunction/events.ts b/packages/serverless/src/gcpfunction/events.ts index c3be42c6a6c2..29d151593990 100644 --- a/packages/serverless/src/gcpfunction/events.ts +++ b/packages/serverless/src/gcpfunction/events.ts @@ -1,4 +1,4 @@ -import { captureException, flush, getCurrentHub } from '@sentry/node'; +import { captureException, flush, getCurrentHub, getCurrentScope } from '@sentry/node'; import { isThenable, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -45,11 +45,10 @@ function _wrapEventFunction // getCurrentHub() is expected to use current active domain as a carrier // since functions-framework creates a domain for each incoming request. // So adding of event processors every time should not lead to memory bloat. - hub.configureScope(scope => { - scope.setContext('gcp.function.context', { ...context }); - // We put the transaction on the scope so users can attach children to it - scope.setSpan(transaction); - }); + const scope = getCurrentScope(); + scope.setContext('gcp.function.context', { ...context }); + // We put the transaction on the scope so users can attach children to it + scope.setSpan(transaction); const newCallback = domainify((...args: unknown[]) => { if (args[0] !== null && args[0] !== undefined) { diff --git a/packages/serverless/src/gcpfunction/http.ts b/packages/serverless/src/gcpfunction/http.ts index 8f4a77099696..95c84cafeb80 100644 --- a/packages/serverless/src/gcpfunction/http.ts +++ b/packages/serverless/src/gcpfunction/http.ts @@ -1,4 +1,5 @@ import type { AddRequestDataToEventOptions } from '@sentry/node'; +import { getCurrentScope } from '@sentry/node'; import { captureException, flush, getCurrentHub } from '@sentry/node'; import { isString, isThenable, logger, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; @@ -63,6 +64,7 @@ function _wrapHttpFunction(fn: HttpFunction, wrapOptions: Partial { const hub = getCurrentHub(); + const scope = getCurrentScope(); const reqMethod = (req.method || '').toUpperCase(); const reqUrl = stripUrlQueryAndFragment(req.originalUrl || req.url || ''); @@ -73,7 +75,7 @@ function _wrapHttpFunction(fn: HttpFunction, wrapOptions: Partial { - scope.setSDKProcessingMetadata({ - request: req, - requestDataOptionsFromGCPWrapper: options.addRequestDataToEventOptions, - }); - // We put the transaction on the scope so users can attach children to it - scope.setSpan(transaction); + scope.setSDKProcessingMetadata({ + request: req, + requestDataOptionsFromGCPWrapper: options.addRequestDataToEventOptions, }); + // We put the transaction on the scope so users can attach children to it + scope.setSpan(transaction); // We also set __sentry_transaction on the response so people can grab the transaction there to add // spans to it later. diff --git a/packages/serverless/src/google-cloud-grpc.ts b/packages/serverless/src/google-cloud-grpc.ts index 8dfbcc092cb4..d475d9b3b421 100644 --- a/packages/serverless/src/google-cloud-grpc.ts +++ b/packages/serverless/src/google-cloud-grpc.ts @@ -1,5 +1,5 @@ import type { EventEmitter } from 'events'; -import { getCurrentHub } from '@sentry/node'; +import { getCurrentScope } from '@sentry/node'; import type { Integration, Span } from '@sentry/types'; import { fill } from '@sentry/utils'; @@ -108,7 +108,7 @@ function fillGrpcFunction(stub: Stub, serviceIdentifier: string, methodName: str return ret; } let span: Span | undefined; - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const transaction = scope.getTransaction(); if (transaction) { span = transaction.startChild({ diff --git a/packages/serverless/src/google-cloud-http.ts b/packages/serverless/src/google-cloud-http.ts index d3ef8646eab7..f9eb9a6cc3cd 100644 --- a/packages/serverless/src/google-cloud-http.ts +++ b/packages/serverless/src/google-cloud-http.ts @@ -1,7 +1,7 @@ // '@google-cloud/common' import is expected to be type-only so it's erased in the final .js file. // When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here. import type * as common from '@google-cloud/common'; -import { getCurrentHub } from '@sentry/node'; +import { getCurrentScope } from '@sentry/node'; import type { Integration, Span } from '@sentry/types'; import { fill } from '@sentry/utils'; @@ -52,7 +52,7 @@ export class GoogleCloudHttp implements Integration { function wrapRequestFunction(orig: RequestFunction): RequestFunction { return function (this: common.Service, reqOpts: RequestOptions, callback: ResponseCallback): void { let span: Span | undefined; - const scope = getCurrentHub().getScope(); + const scope = getCurrentScope(); const transaction = scope.getTransaction(); if (transaction) { const httpMethod = reqOpts.method || 'GET'; diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 6df0dbedb2c3..c8086fc5d69e 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -24,6 +24,7 @@ export { captureMessage, captureCheckIn, withMonitor, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, getActiveTransaction, diff --git a/packages/serverless/test/__mocks__/@sentry/node.ts b/packages/serverless/test/__mocks__/@sentry/node.ts index c29f8f78dd0a..b9eba4b132a9 100644 --- a/packages/serverless/test/__mocks__/@sentry/node.ts +++ b/packages/serverless/test/__mocks__/@sentry/node.ts @@ -35,6 +35,7 @@ export const fakeTransaction = { export const init = jest.fn(); export const addGlobalEventProcessor = jest.fn(); export const getCurrentHub = jest.fn(() => fakeHub); +export const getCurrentScope = jest.fn(() => fakeScope); export const startTransaction = jest.fn(_ => fakeTransaction); export const captureException = jest.fn(); export const captureMessage = jest.fn(); diff --git a/packages/serverless/test/awslambda.test.ts b/packages/serverless/test/awslambda.test.ts index a3085c8b0f65..5c67c8481d4a 100644 --- a/packages/serverless/test/awslambda.test.ts +++ b/packages/serverless/test/awslambda.test.ts @@ -95,8 +95,6 @@ describe('AWSLambda', () => { }); test('captureTimeoutWarning enabled (default)', async () => { - expect.assertions(2); - const handler: Handler = (_event, _context, callback) => { setTimeout(() => { callback(null, 42); @@ -105,14 +103,13 @@ describe('AWSLambda', () => { const wrappedHandler = wrapHandler(handler); await wrappedHandler(fakeEvent, fakeContext, fakeCallback); + expect(Sentry.withScope).toBeCalledTimes(2); expect(Sentry.captureMessage).toBeCalled(); // @ts-expect-error see "Why @ts-expect-error" note expect(SentryNode.fakeScope.setTag).toBeCalledWith('timeout', '1s'); }); test('captureTimeoutWarning disabled', async () => { - expect.assertions(2); - const handler: Handler = (_event, _context, callback) => { setTimeout(() => { callback(null, 42); @@ -123,8 +120,10 @@ describe('AWSLambda', () => { }); await wrappedHandler(fakeEvent, fakeContext, fakeCallback); - expect(Sentry.withScope).not.toBeCalled(); + expect(Sentry.withScope).toBeCalledTimes(1); expect(Sentry.captureMessage).not.toBeCalled(); + // @ts-expect-error see "Why @ts-expect-error" note + expect(SentryNode.fakeScope.setTag).not.toBeCalledWith('timeout', '1s'); }); test('captureTimeoutWarning with configured timeoutWarningLimit', async () => { diff --git a/packages/svelte/src/performance.ts b/packages/svelte/src/performance.ts index 2230db18f9a4..0afd5250a06f 100644 --- a/packages/svelte/src/performance.ts +++ b/packages/svelte/src/performance.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/browser'; +import { getCurrentScope } from '@sentry/browser'; import type { Span, Transaction } from '@sentry/types'; import { afterUpdate, beforeUpdate, onMount } from 'svelte'; import { current_component } from 'svelte/internal'; @@ -92,5 +92,5 @@ function recordUpdateSpans(componentName: string, initSpan?: Span): void { } function getActiveTransaction(): Transaction | undefined { - return getCurrentHub().getScope().getTransaction(); + return getCurrentScope().getTransaction(); } diff --git a/packages/svelte/test/performance.test.ts b/packages/svelte/test/performance.test.ts index aabf4462e8cb..e872ee7a283d 100644 --- a/packages/svelte/test/performance.test.ts +++ b/packages/svelte/test/performance.test.ts @@ -22,18 +22,12 @@ jest.mock('@sentry/core', () => { const original = jest.requireActual('@sentry/core'); return { ...original, - getCurrentHub(): { - getScope(): Scope; - } { + getCurrentScope(): Scope { return { - getScope(): any { - return { - getTransaction: () => { - return returnUndefinedTransaction ? undefined : testTransaction; - }, - }; + getTransaction: () => { + return returnUndefinedTransaction ? undefined : testTransaction; }, - }; + } as Scope; }, }; }); diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 93ccd2f2564d..bff5f1f06dec 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -13,11 +13,21 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "browser": { + "import": "./build/esm/index.client.js", + "require": "./build/cjs/index.client.js" + }, + "node": "./build/cjs/index.server.js" + } + }, "publishConfig": { "access": "public" }, "peerDependencies": { - "@sveltejs/kit": "1.x" + "@sveltejs/kit": "1.x || 2.x" }, "dependencies": { "@sentry-internal/tracing": "7.88.0", @@ -32,10 +42,10 @@ "sorcery": "0.11.0" }, "devDependencies": { - "@sveltejs/kit": "^1.11.0", + "@sveltejs/kit": "^2.0.2", "rollup": "^3.20.2", - "svelte": "^3.44.0", - "vite": "4.0.5" + "svelte": "^4.2.8", + "vite": "^5.0.10" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/sveltekit/src/client/handleError.ts b/packages/sveltekit/src/client/handleError.ts index ae5cd84f17c8..799e6e36db72 100644 --- a/packages/sveltekit/src/client/handleError.ts +++ b/packages/sveltekit/src/client/handleError.ts @@ -11,20 +11,37 @@ function defaultErrorHandler({ error }: Parameters[0]): Retur }); } +type HandleClientErrorInput = Parameters[0]; + +/** + * Backwards-compatible HandleServerError Input type for SvelteKit 1.x and 2.x + * `message` and `status` were added in 2.x. + * For backwards-compatibility, we make them optional + * + * @see https://kit.svelte.dev/docs/migrating-to-sveltekit-2#improved-error-handling + */ +type SafeHandleServerErrorInput = Omit & + Partial>; + /** * Wrapper for the SvelteKit error handler that sends the error to Sentry. * * @param handleError The original SvelteKit error handler. */ export function handleErrorWithSentry(handleError: HandleClientError = defaultErrorHandler): HandleClientError { - return (input: { error: unknown; event: NavigationEvent }): ReturnType => { - captureException(input.error, { - mechanism: { - type: 'sveltekit', - handled: false, - }, - }); + return (input: SafeHandleServerErrorInput): ReturnType => { + // SvelteKit 2.0 offers a reliable way to check for a 404 error: + if (input.status !== 404) { + captureException(input.error, { + mechanism: { + type: 'sveltekit', + handled: false, + }, + }); + } + // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput + // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type return handleError(input); }; } diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index 900813ce7f9b..ebfd1f281404 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -1,6 +1,6 @@ import { hasTracingEnabled } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; -import { BrowserTracing, WINDOW, configureScope, init as initSvelteSdk } from '@sentry/svelte'; +import { BrowserTracing, WINDOW, getCurrentScope, init as initSvelteSdk } from '@sentry/svelte'; import { addOrUpdateIntegration } from '@sentry/utils'; import { applySdkMetadata } from '../common/metadata'; @@ -34,9 +34,7 @@ export function init(options: BrowserOptions): void { restoreFetch(actualFetch); } - configureScope(scope => { - scope.setTag('runtime', 'browser'); - }); + getCurrentScope().setTag('runtime', 'browser'); } function addClientIntegrations(options: BrowserOptions): void { diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 3b16f659f6e0..7f9f581ca3c3 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -1,6 +1,7 @@ /* eslint-disable @sentry-internal/sdk/no-optional-chaining */ import type { Span } from '@sentry/core'; -import { getActiveTransaction, getCurrentHub, runWithAsyncContext, startSpan } from '@sentry/core'; +import { getCurrentScope } from '@sentry/core'; +import { getActiveTransaction, runWithAsyncContext, startSpan } from '@sentry/core'; import { captureException } from '@sentry/node'; import { dynamicSamplingContextToSentryBaggageHeader, objectify } from '@sentry/utils'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; @@ -102,7 +103,7 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { // if there is an active transaction, we know that this handle call is nested and hence // we don't create a new domain for it. If we created one, nested server calls would // create new transactions instead of adding a child span to the currently active span. - if (getCurrentHub().getScope().getSpan()) { + if (getCurrentScope().getSpan()) { return instrumentHandle(input, options); } return runWithAsyncContext(() => { @@ -122,7 +123,7 @@ async function instrumentHandle( } const { dynamicSamplingContext, traceparentData, propagationContext } = getTracePropagationData(event); - getCurrentHub().getScope().setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); try { const resolveResult = await startSpan( diff --git a/packages/sveltekit/src/server/handleError.ts b/packages/sveltekit/src/server/handleError.ts index c89cbaaecc0f..1289e76a5ee2 100644 --- a/packages/sveltekit/src/server/handleError.ts +++ b/packages/sveltekit/src/server/handleError.ts @@ -1,5 +1,5 @@ import { captureException } from '@sentry/node'; -import type { HandleServerError, RequestEvent } from '@sveltejs/kit'; +import type { HandleServerError } from '@sveltejs/kit'; import { flushIfServerless } from './utils'; @@ -11,14 +11,28 @@ function defaultErrorHandler({ error }: Parameters[0]): Retur console.error(error && error.stack); } +type HandleServerErrorInput = Parameters[0]; + +/** + * Backwards-compatible HandleServerError Input type for SvelteKit 1.x and 2.x + * `message` and `status` were added in 2.x. + * For backwards-compatibility, we make them optional + * + * @see https://kit.svelte.dev/docs/migrating-to-sveltekit-2#improved-error-handling + */ +type SafeHandleServerErrorInput = Omit & + Partial>; + /** * Wrapper for the SvelteKit error handler that sends the error to Sentry. * * @param handleError The original SvelteKit error handler. */ export function handleErrorWithSentry(handleError: HandleServerError = defaultErrorHandler): HandleServerError { - return async (input: { error: unknown; event: RequestEvent }): Promise => { + return async (input: SafeHandleServerErrorInput): Promise => { if (isNotFoundError(input)) { + // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput + // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type return handleError(input); } @@ -31,19 +45,26 @@ export function handleErrorWithSentry(handleError: HandleServerError = defaultEr await flushIfServerless(); + // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput + // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type return handleError(input); }; } /** * When a page request fails because the page is not found, SvelteKit throws a "Not found" error. - * In the error handler here, we can't access the response yet (which we do in the load instrumentation), - * so we have to check if the error is a "Not found" error by checking if the route id is missing and - * by checking the error message on top of the raw stack trace. */ -function isNotFoundError(input: { error: unknown; event: RequestEvent }): boolean { - const { error, event } = input; +function isNotFoundError(input: SafeHandleServerErrorInput): boolean { + const { error, event, status } = input; + + // SvelteKit 2.0 offers a reliable way to check for a Not Found error: + if (status === 404) { + return true; + } + // SvelteKit 1.x doesn't offer a reliable way to check for a Not Found error. + // So we check the route id (shouldn't exist) and the raw stack trace + // We can delete all of this below whenever we drop Kit 1.x support const hasNoRouteId = !event.route || !event.route.id; const rawStack: string = diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b75fa24ebe5b..560f839c9fe3 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -14,6 +14,7 @@ export { captureMessage, captureCheckIn, withMonitor, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts index c902fe4376d6..5d0cd3c1cb90 100644 --- a/packages/sveltekit/src/server/load.ts +++ b/packages/sveltekit/src/server/load.ts @@ -1,5 +1,5 @@ /* eslint-disable @sentry-internal/sdk/no-optional-chaining */ -import { getCurrentHub, startSpan } from '@sentry/core'; +import { getCurrentScope, startSpan } from '@sentry/core'; import { captureException } from '@sentry/node'; import type { TransactionContext } from '@sentry/types'; import { addNonEnumerableProperty, objectify } from '@sentry/utils'; @@ -130,7 +130,7 @@ export function wrapServerLoadWithSentry any>(origSe const routeId = event.route && (Object.getOwnPropertyDescriptor(event.route, 'id')?.value as string | undefined); const { dynamicSamplingContext, traceparentData, propagationContext } = getTracePropagationData(event); - getCurrentHub().getScope().setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); const traceLoadContext: TransactionContext = { op: 'function.sveltekit.server.load', diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 613fe8d834f0..03847d6cc4e4 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -1,4 +1,4 @@ -import { configureScope } from '@sentry/core'; +import { getCurrentScope } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; @@ -18,9 +18,7 @@ export function init(options: NodeOptions): void { initNodeSdk(options); - configureScope(scope => { - scope.setTag('runtime', 'node'); - }); + getCurrentScope().setTag('runtime', 'node'); } function addServerIntegrations(options: NodeOptions): void { diff --git a/packages/sveltekit/test/client/handleError.test.ts b/packages/sveltekit/test/client/handleError.test.ts index 0262f0b1b1cc..b4eba9ff56e9 100644 --- a/packages/sveltekit/test/client/handleError.test.ts +++ b/packages/sveltekit/test/client/handleError.test.ts @@ -38,6 +38,7 @@ describe('handleError', () => { it('invokes the default handler if no handleError func is provided', async () => { const wrappedHandleError = handleErrorWithSentry(); const mockError = new Error('test'); + // @ts-expect-error - purposefully omitting status and message to cover SvelteKit 1.x compatibility const returnVal = await wrappedHandleError({ error: mockError, event: navigationEvent }); expect(returnVal).not.toBeDefined(); @@ -50,6 +51,7 @@ describe('handleError', () => { it('invokes the user-provided error handler', async () => { const wrappedHandleError = handleErrorWithSentry(handleError); const mockError = new Error('test'); + // @ts-expect-error - purposefully omitting status and message to cover SvelteKit 1.x compatibility const returnVal = (await wrappedHandleError({ error: mockError, event: navigationEvent })) as any; expect(returnVal.message).toEqual('Whoops!'); @@ -59,4 +61,19 @@ describe('handleError', () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(0); }); }); + + it("doesn't capture 404 errors", async () => { + const wrappedHandleError = handleErrorWithSentry(handleError); + const returnVal = (await wrappedHandleError({ + error: new Error('404 Not Found'), + event: navigationEvent, + status: 404, + message: 'Not Found', + })) as any; + + expect(returnVal.message).toEqual('Whoops!'); + expect(mockCaptureException).not.toHaveBeenCalled(); + // Check that the default handler wasn't invoked + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + }); }); diff --git a/packages/sveltekit/test/common/utils.test.ts b/packages/sveltekit/test/common/utils.test.ts index 5581fe60c5e4..f048494881ec 100644 --- a/packages/sveltekit/test/common/utils.test.ts +++ b/packages/sveltekit/test/common/utils.test.ts @@ -34,7 +34,7 @@ describe('isHttpError', () => { expect(isHttpError(httpErrorObject)).toBe(true); }); - it.each([new Error(), redirect(301, '/users/id'), 'string error', { status: 404 }, { body: 'Not found' }])( + it.each([new Error(), { status: 301, message: '/users/id' }, 'string error', { status: 404 }, { body: 'Not found' }])( 'returns `false` for other thrown objects (%s)', httpErrorObject => { expect(isHttpError(httpErrorObject)).toBe(false); diff --git a/packages/sveltekit/test/server/handleError.test.ts b/packages/sveltekit/test/server/handleError.test.ts index 157108a8b68a..611fac1f9a4d 100644 --- a/packages/sveltekit/test/server/handleError.test.ts +++ b/packages/sveltekit/test/server/handleError.test.ts @@ -26,7 +26,7 @@ describe('handleError', () => { consoleErrorSpy.mockClear(); }); - it('doesn\'t capture "Not found" errors for incorrect navigations', async () => { + it('doesn\'t capture "Not found" errors for incorrect navigations [Kit 1.x]', async () => { const wrappedHandleError = handleErrorWithSentry(); const mockError = new Error('Not found: /asdf/123'); const mockEvent = { @@ -35,6 +35,7 @@ describe('handleError', () => { // ... } as RequestEvent; + // @ts-expect-error - purposefully omitting status and message to cover SvelteKit 1.x compatibility const returnVal = await wrappedHandleError({ error: mockError, event: mockEvent }); expect(returnVal).not.toBeDefined(); @@ -42,11 +43,31 @@ describe('handleError', () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); + it('doesn\'t capture "Not found" errors for incorrect navigations [Kit 2.x]', async () => { + const wrappedHandleError = handleErrorWithSentry(); + + const returnVal = await wrappedHandleError({ + error: new Error('404 /asdf/123'), + event: requestEvent, + status: 404, + message: 'Not Found', + }); + + expect(returnVal).not.toBeDefined(); + expect(mockCaptureException).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + describe('calls captureException', () => { it('invokes the default handler if no handleError func is provided', async () => { const wrappedHandleError = handleErrorWithSentry(); const mockError = new Error('test'); - const returnVal = await wrappedHandleError({ error: mockError, event: requestEvent }); + const returnVal = await wrappedHandleError({ + error: mockError, + event: requestEvent, + status: 500, + message: 'Internal Error', + }); expect(returnVal).not.toBeDefined(); expect(mockCaptureException).toHaveBeenCalledTimes(1); @@ -58,7 +79,12 @@ describe('handleError', () => { it('invokes the user-provided error handler', async () => { const wrappedHandleError = handleErrorWithSentry(handleError); const mockError = new Error('test'); - const returnVal = (await wrappedHandleError({ error: mockError, event: requestEvent })) as any; + const returnVal = (await wrappedHandleError({ + error: mockError, + event: requestEvent, + status: 500, + message: 'Internal Error', + })) as any; expect(returnVal.message).toEqual('Whoops!'); expect(mockCaptureException).toHaveBeenCalledTimes(1); diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index b45e15679805..ab2f73b127f0 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import { getCurrentHub, getDynamicSamplingContextFromClient, hasTracingEnabled } from '@sentry/core'; +import { getClient, getCurrentScope, getDynamicSamplingContextFromClient, hasTracingEnabled } from '@sentry/core'; import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/types'; import { BAGGAGE_HEADER_NAME, @@ -264,8 +264,7 @@ export function xhrCallback( return undefined; } - const hub = getCurrentHub(); - const scope = hub.getScope(); + const scope = getCurrentScope(); const parentSpan = scope.getSpan(); const span = @@ -294,7 +293,7 @@ export function xhrCallback( const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); setHeaderOnXhr(xhr, span.toTraceparent(), sentryBaggageHeader); } else { - const client = hub.getClient(); + const client = getClient(); const { traceId, sampled, dsc } = scope.getPropagationContext(); const sentryTraceHeader = generateSentryTraceHeader(traceId, undefined, sampled); const dynamicSamplingContext = diff --git a/packages/tracing-internal/src/common/fetch.ts b/packages/tracing-internal/src/common/fetch.ts index 9c45da8adfa3..63f7f8ede721 100644 --- a/packages/tracing-internal/src/common/fetch.ts +++ b/packages/tracing-internal/src/common/fetch.ts @@ -1,4 +1,4 @@ -import { getCurrentHub, getDynamicSamplingContextFromClient, hasTracingEnabled } from '@sentry/core'; +import { getClient, getCurrentScope, getDynamicSamplingContextFromClient, hasTracingEnabled } from '@sentry/core'; import type { Client, HandlerDataFetch, Scope, Span, SpanOrigin } from '@sentry/types'; import { BAGGAGE_HEADER_NAME, @@ -65,9 +65,8 @@ export function instrumentFetchRequest( return undefined; } - const hub = getCurrentHub(); - const scope = hub.getScope(); - const client = hub.getClient(); + const scope = getCurrentScope(); + const client = getClient(); const parentSpan = scope.getSpan(); const { method, url } = handlerData.fetchData; diff --git a/packages/tracing-internal/test/browser/backgroundtab.test.ts b/packages/tracing-internal/test/browser/backgroundtab.test.ts index 031d68d01d78..2687d59069c5 100644 --- a/packages/tracing-internal/test/browser/backgroundtab.test.ts +++ b/packages/tracing-internal/test/browser/backgroundtab.test.ts @@ -30,7 +30,7 @@ conditionalTest({ min: 10 })('registerBackgroundTabDetection', () => { afterEach(() => { events = {}; - hub.configureScope(scope => scope.setSpan(undefined)); + hub.getScope().setSpan(undefined); }); it('does not create an event listener if global document is undefined', () => { @@ -48,7 +48,7 @@ conditionalTest({ min: 10 })('registerBackgroundTabDetection', () => { it('finishes a transaction on visibility change', () => { registerBackgroundTabDetection(); const transaction = hub.startTransaction({ name: 'test' }); - hub.configureScope(scope => scope.setSpan(transaction)); + hub.getScope().setSpan(transaction); // Simulate document visibility hidden event // @ts-expect-error need to override global document diff --git a/packages/tracing-internal/test/browser/request.test.ts b/packages/tracing-internal/test/browser/request.test.ts index 29cf7e287f0c..0f3ce191278a 100644 --- a/packages/tracing-internal/test/browser/request.test.ts +++ b/packages/tracing-internal/test/browser/request.test.ts @@ -74,7 +74,7 @@ describe('callbacks', () => { beforeEach(() => { transaction = hub.startTransaction({ name: 'organizations/users/:userid', op: 'pageload' }) as Transaction; - hub.configureScope(scope => scope.setSpan(transaction)); + hub.getScope().setSpan(transaction); }); afterEach(() => { diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts index 86fcf5d6807e..817e88c9c55e 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -40,9 +40,7 @@ describe('Hub', () => { const transaction = hub.startTransaction({ name: 'dogpark' }); transaction.sampled = true; - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + hub.getScope().setSpan(transaction); expect(hub.getScope().getTransaction()).toBe(transaction); }); @@ -53,9 +51,7 @@ describe('Hub', () => { makeMain(hub); const transaction = hub.startTransaction({ name: 'dogpark', sampled: false }); - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + hub.getScope().setSpan(transaction); expect(hub.getScope().getTransaction()).toBe(transaction); }); @@ -472,9 +468,7 @@ The transaction will not be sampled. Please use the otel instrumentation to star makeMain(hub); const transaction = hub.startTransaction({ name: 'dogpark' }); - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + hub.getScope().setSpan(transaction); const request = new XMLHttpRequest(); await new Promise(resolve => { @@ -513,9 +507,7 @@ The transaction will not be sampled. Please use the otel instrumentation to star makeMain(hub); const transaction = hub.startTransaction({ name: 'dogpark', sampled: false }); - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + hub.getScope().setSpan(transaction); const request = new XMLHttpRequest(); await new Promise(resolve => { diff --git a/packages/tracing/test/idletransaction.test.ts b/packages/tracing/test/idletransaction.test.ts index 32be1c2e421a..30cd97f775b1 100644 --- a/packages/tracing/test/idletransaction.test.ts +++ b/packages/tracing/test/idletransaction.test.ts @@ -25,18 +25,16 @@ describe('IdleTransaction', () => { ); transaction.initSpanRecorder(10); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(transaction); - }); + const scope = hub.getScope(); + expect(scope.getTransaction()).toBe(transaction); }); it('does not set the transaction on the scope on creation if onScope is falsey', () => { const transaction = new IdleTransaction({ name: 'foo' }, hub); transaction.initSpanRecorder(10); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(undefined); - }); + const scope = hub.getScope(); + expect(scope.getTransaction()).toBe(undefined); }); it('removes sampled transaction from scope on finish if onScope is true', () => { @@ -53,9 +51,8 @@ describe('IdleTransaction', () => { transaction.finish(); jest.runAllTimers(); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(undefined); - }); + const scope = hub.getScope(); + expect(scope.getTransaction()).toBe(undefined); }); it('removes unsampled transaction from scope on finish if onScope is true', () => { @@ -71,9 +68,8 @@ describe('IdleTransaction', () => { transaction.finish(); jest.runAllTimers(); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(undefined); - }); + const scope = hub.getScope(); + expect(scope.getTransaction()).toBe(undefined); }); it('does not remove transaction from scope on finish if another transaction was set there', () => { @@ -94,9 +90,8 @@ describe('IdleTransaction', () => { transaction.finish(); jest.runAllTimers(); - hub.configureScope(s => { - expect(s.getTransaction()).toBe(otherTransaction); - }); + const scope = hub.getScope(); + expect(scope.getTransaction()).toBe(otherTransaction); }); }); diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts index 1a7981ad95f6..3e6c267b1233 100644 --- a/packages/tracing/test/span.test.ts +++ b/packages/tracing/test/span.test.ts @@ -226,9 +226,7 @@ describe('Span', () => { const childSpanOne = transaction.startChild(); childSpanOne.finish(); - hub.configureScope(scope => { - scope.setSpan(childSpanOne); - }); + hub.getScope().setSpan(childSpanOne); const spanTwo = transaction.startChild(); spanTwo.finish(); @@ -282,9 +280,7 @@ describe('Span', () => { childSpanOne.finish(); - hub.configureScope(scope => { - scope.setSpan(transaction); - }); + hub.getScope().setSpan(transaction); const spanTwo = transaction.startChild({}); spanTwo.finish(); diff --git a/packages/types/src/hub.ts b/packages/types/src/hub.ts index b7649cede039..8d4d47885d40 100644 --- a/packages/types/src/hub.ts +++ b/packages/types/src/hub.ts @@ -40,6 +40,8 @@ export interface Hub { * when the operation finishes or throws. * * @returns Scope, the new cloned scope + * + * @deprecated Use `withScope` instead. */ pushScope(): Scope; @@ -49,6 +51,8 @@ export interface Hub { * This restores the state before the scope was pushed. All breadcrumbs and * context information added since the last call to {@link this.pushScope} are * discarded. + * + * @deprecated Use `withScope` instead. */ popScope(): boolean; @@ -65,7 +69,7 @@ export interface Hub { * * @param callback that will be enclosed into push/popScope. */ - withScope(callback: (scope: Scope) => void): void; + withScope(callback: (scope: Scope) => T): T; /** Returns the client of the top stack. */ getClient(): Client | undefined; @@ -171,6 +175,7 @@ export interface Hub { * Callback to set context information onto the scope. * * @param callback Callback function that receives Scope. + * @deprecated Use `getScope()` directly. */ configureScope(callback: (scope: Scope) => void): void; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7d3531599aa3..a2d75a193414 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -52,7 +52,7 @@ export type { EventProcessor } from './eventprocessor'; export type { Exception } from './exception'; export type { Extra, Extras } from './extra'; export type { Hub } from './hub'; -export type { Integration, IntegrationClass } from './integration'; +export type { Integration, IntegrationClass, IntegrationFn, IntegrationFnResult } from './integration'; export type { Mechanism } from './mechanism'; export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocation } from './misc'; export type { ClientOptions, Options } from './options'; diff --git a/packages/types/src/integration.ts b/packages/types/src/integration.ts index 0c18845414e3..a4108a60c749 100644 --- a/packages/types/src/integration.ts +++ b/packages/types/src/integration.ts @@ -13,6 +13,47 @@ export interface IntegrationClass { new (...args: any[]): T; } +/** + * An integration in function form. + * This is expected to return an integration result, + */ +export type IntegrationFn = (...rest: any[]) => IntegrationFnResult; + +export interface IntegrationFnResult { + /** + * The name of the integration. + */ + name: string; + + /** + * This hook is only called once, even if multiple clients are created. + * It does not receives any arguments, and should only use for e.g. global monkey patching and similar things. + */ + setupOnce?(): void; + + /** + * Set up an integration for the given client. + * Receives the client as argument. + * + * Whenever possible, prefer this over `setupOnce`, as that is only run for the first client, + * whereas `setup` runs for each client. Only truly global things (e.g. registering global handlers) + * should be done in `setupOnce`. + */ + setup?(client: Client): void; + + /** + * An optional hook that allows to preprocess an event _before_ it is passed to all other event processors. + */ + preprocessEvent?(event: Event, hint: EventHint | undefined, client: Client): void; + + /** + * An optional hook that allows to process an event. + * Return `null` to drop the event, or mutate the event & return it. + * This receives the client that the integration was installed for as third argument. + */ + processEvent?(event: Event, hint: EventHint, client: Client): Event | null | PromiseLike; +} + /** Integration interface */ export interface Integration { /** diff --git a/packages/utils/src/eventbuilder.ts b/packages/utils/src/eventbuilder.ts index 03af0b3d1905..28b2d94b0c4f 100644 --- a/packages/utils/src/eventbuilder.ts +++ b/packages/utils/src/eventbuilder.ts @@ -1,7 +1,9 @@ import type { + Client, Event, EventHint, Exception, + Extras, Hub, Mechanism, Severity, @@ -61,14 +63,18 @@ function getMessageForObject(exception: object): string { /** * Builds and Event from a Exception + * + * TODO(v8): Remove getHub fallback * @hidden */ export function eventFromUnknownInput( - getCurrentHub: () => Hub, + getHubOrClient: (() => Hub) | Client | undefined, stackParser: StackParser, exception: unknown, hint?: EventHint, ): Event { + const client = typeof getHubOrClient === 'function' ? getHubOrClient().getClient() : getHubOrClient; + let ex: unknown = exception; const providedMechanism: Mechanism | undefined = hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism; @@ -77,14 +83,12 @@ export function eventFromUnknownInput( type: 'generic', }; + let extras: Extras | undefined; + if (!isError(exception)) { if (isPlainObject(exception)) { - const hub = getCurrentHub(); - const client = hub.getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; - hub.configureScope(scope => { - scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth)); - }); + extras = { ['__serialized__']: normalizeToSize(exception as Record, normalizeDepth) }; const message = getMessageForObject(exception); ex = (hint && hint.syntheticException) || new Error(message); @@ -98,12 +102,16 @@ export function eventFromUnknownInput( mechanism.synthetic = true; } - const event = { + const event: Event = { exception: { values: [exceptionFromError(stackParser, ex as Error)], }, }; + if (extras) { + event.extra = extras; + } + addExceptionTypeValue(event, undefined, undefined); addExceptionMechanism(event, mechanism); diff --git a/packages/utils/src/instrument/globalError.ts b/packages/utils/src/instrument/globalError.ts index df7ff21438cc..0bf50c52f2c4 100644 --- a/packages/utils/src/instrument/globalError.ts +++ b/packages/utils/src/instrument/globalError.ts @@ -21,7 +21,7 @@ function instrumentError(): void { _oldOnErrorHandler = GLOBAL_OBJ.onerror; GLOBAL_OBJ.onerror = function ( - msg: string | Event, + msg: string | object, url?: string, line?: number, column?: number, diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index 9799df421c31..c8afc0818909 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -31,7 +31,15 @@ export function uuid4(): string { return crypto.randomUUID().replace(/-/g, ''); } if (crypto && crypto.getRandomValues) { - getRandomByte = () => crypto.getRandomValues(new Uint8Array(1))[0]; + getRandomByte = () => { + // crypto.getRandomValues might return undefined instead of the typed array + // in old Chromium versions (e.g. 23.0.1235.0 (151422)) + // However, `typedArray` is still filled in-place. + // @see https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#typedarray + const typedArray = new Uint8Array(1); + crypto.getRandomValues(typedArray); + return typedArray[0]; + }; } } catch (_) { // some runtimes can crash invoking crypto diff --git a/packages/utils/src/worldwide.ts b/packages/utils/src/worldwide.ts index 5caf1b137d5a..a2fdfb67e14b 100644 --- a/packages/utils/src/worldwide.ts +++ b/packages/utils/src/worldwide.ts @@ -24,7 +24,7 @@ export interface InternalGlobal { Integrations?: Integration[]; }; onerror?: { - (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error): any; + (event: object | string, source?: string, lineno?: number, colno?: number, error?: Error): any; __SENTRY_INSTRUMENTED__?: true; __SENTRY_LOADER__?: true; }; diff --git a/packages/utils/test/eventbuilder.test.ts b/packages/utils/test/eventbuilder.test.ts index 137860b16ce4..ec3fdf4bf6ee 100644 --- a/packages/utils/test/eventbuilder.test.ts +++ b/packages/utils/test/eventbuilder.test.ts @@ -1,10 +1,17 @@ -import type { Hub } from '@sentry/types'; +import type { Hub, Scope } from '@sentry/types'; import { createStackParser, eventFromUnknownInput, nodeStackLineParser } from '../src'; function getCurrentHub(): Hub { // Some fake hub to get us through - return { getClient: () => undefined, configureScope: () => {} } as unknown as Hub; + return { + getClient: () => undefined, + getScope: () => { + return { + setExtra: () => {}, + } as unknown as Scope; + }, + } as unknown as Hub; } const stackParser = createStackParser(nodeStackLineParser()); @@ -29,4 +36,9 @@ describe('eventFromUnknownInput', () => { const event = eventFromUnknownInput(getCurrentHub, stackParser, { foo: { bar: 'baz' }, message: 'Some message' }); expect(event.exception?.values?.[0].value).toBe('Some message'); }); + + test('passing client directly', () => { + const event = eventFromUnknownInput(undefined, stackParser, { foo: { bar: 'baz' }, prop: 1 }); + expect(event.exception?.values?.[0].value).toBe('Object captured as exception with keys: foo, prop'); + }); }); diff --git a/packages/utils/test/misc.test.ts b/packages/utils/test/misc.test.ts index dc75b70d4286..c1eb978dcdbe 100644 --- a/packages/utils/test/misc.test.ts +++ b/packages/utils/test/misc.test.ts @@ -343,6 +343,25 @@ describe('uuid4 generation', () => { expect(uuid4()).toMatch(uuid4Regex); } }); + + // Corner case related to crypto.getRandomValues being only + // semi-implemented (e.g. Chromium 23.0.1235.0 (151422)) + it('returns valid uuid v4 even if crypto.getRandomValues does not return a typed array', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const cryptoMod = require('crypto'); + + const getRandomValues = (typedArray: Uint8Array) => { + if (cryptoMod.getRandomValues) { + cryptoMod.getRandomValues(typedArray); + } + }; + + (global as any).crypto = { getRandomValues }; + + for (let index = 0; index < 1_000; index++) { + expect(uuid4()).toMatch(uuid4Regex); + } + }); }); describe('arrayify()', () => { diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index ffce59b5dceb..76219f4faafa 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -32,6 +32,7 @@ export { captureEvent, captureMessage, close, + // eslint-disable-next-line deprecation/deprecation configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index ded26ed21a9d..18c4cb25df56 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,5 +1,5 @@ import { instrumentFetchRequest } from '@sentry-internal/tracing'; -import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import { addBreadcrumb, getClient, getCurrentHub, isSentryRequestUrl } from '@sentry/core'; import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types'; import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils'; @@ -54,7 +54,7 @@ export class WinterCGFetch implements Integration { return; } - if (isSentryRequestUrl(handlerData.fetchData.url, hub)) { + if (isSentryRequestUrl(handlerData.fetchData.url, getClient())) { return; } @@ -74,8 +74,7 @@ export class WinterCGFetch implements Integration { /** Decides whether to attach trace data to the outgoing fetch request */ private _shouldAttachTraceData(url: string): boolean { - const hub = getCurrentHub(); - const client = hub.getClient(); + const client = getClient(); if (!client) { return false; @@ -131,7 +130,7 @@ function createBreadcrumb(handlerData: HandlerDataFetch): void { endTimestamp, }; - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'fetch', data, @@ -151,7 +150,7 @@ function createBreadcrumb(handlerData: HandlerDataFetch): void { startTimestamp, endTimestamp, }; - getCurrentHub().addBreadcrumb( + addBreadcrumb( { category: 'fetch', data, diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 22bd960defdf..3e6ac09330e0 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -29,10 +29,12 @@ const fakeHubInstance = new FakeHub( ); jest.spyOn(sentryCore, 'getCurrentHub').mockImplementation(() => fakeHubInstance); +jest.spyOn(sentryCore, 'getCurrentScope').mockImplementation(() => fakeHubInstance.getScope()); +jest.spyOn(sentryCore, 'getClient').mockImplementation(() => fakeHubInstance.getClient()); const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); const instrumentFetchRequestSpy = jest.spyOn(internalTracing, 'instrumentFetchRequest'); -const addBreadcrumbSpy = jest.spyOn(fakeHubInstance, 'addBreadcrumb'); +const addBreadcrumbSpy = jest.spyOn(sentryCore, 'addBreadcrumb'); beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index 82000b9799a4..ef509dcdb406 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/browser'; +import { getCurrentHub, getCurrentScope } from '@sentry/browser'; import type { Span, Transaction } from '@sentry/types'; import { logger, timestampInSeconds } from '@sentry/utils'; @@ -34,7 +34,7 @@ const HOOKS: { [key in Operation]: Hook[] } = { /** Grabs active transaction off scope, if any */ export function getActiveTransaction(): Transaction | undefined { - return getCurrentHub().getScope().getTransaction(); + return getCurrentScope().getTransaction(); } /** Finish top-level span and activity with a debounce configured using `timeout` option */ diff --git a/scripts/prepack.ts b/scripts/prepack.ts index 941c6e6f6218..9118226f08e7 100644 --- a/scripts/prepack.ts +++ b/scripts/prepack.ts @@ -15,6 +15,7 @@ const NPM_IGNORE = fs.existsSync('.npmignore') ? '.npmignore' : '../../.npmignor const ASSETS = ['README.md', 'LICENSE', 'package.json', NPM_IGNORE] as const; const ENTRY_POINTS = ['main', 'module', 'types', 'browser'] as const; +const CONDITIONAL_EXPORT_ENTRY_POINTS = ['import', 'require', ...ENTRY_POINTS] as const; const EXPORT_MAP_ENTRY_POINT = 'exports'; const TYPES_VERSIONS_ENTRY_POINT = 'typesVersions'; @@ -22,6 +23,7 @@ const packageWithBundles = process.argv.includes('--bundles'); const buildDir = packageWithBundles ? NPM_BUILD_DIR : BUILD_DIR; type PackageJsonEntryPoints = Record<(typeof ENTRY_POINTS)[number], string>; +type ConditionalExportEntryPoints = Record<(typeof CONDITIONAL_EXPORT_ENTRY_POINTS)[number], string>; interface TypeVersions { [key: string]: { @@ -29,17 +31,12 @@ interface TypeVersions { }; } +type PackageJsonExports = Partial & { + [key: string]: Partial; +}; + interface PackageJson extends Record, PackageJsonEntryPoints { - [EXPORT_MAP_ENTRY_POINT]: { - [key: string]: { - import: string; - require: string; - types: string; - node: string; - browser: string; - default: string; - }; - }; + [EXPORT_MAP_ENTRY_POINT]: PackageJsonExports; [TYPES_VERSIONS_ENTRY_POINT]: TypeVersions; } @@ -75,14 +72,26 @@ ENTRY_POINTS.filter(entryPoint => newPkgJson[entryPoint]).forEach(entryPoint => newPkgJson[entryPoint] = newPkgJson[entryPoint].replace(`${buildDir}/`, ''); }); +/** + * Recursively traverses the exports object and rewrites all string values to remove the build directory. + */ +function rewriteConditionalExportEntryPoint( + exportsObject: Record>, + key: string, +): void { + const exportsField = exportsObject[key]; + if (typeof exportsField === 'string') { + exportsObject[key] = exportsField.replace(`${buildDir}/`, ''); + return; + } + Object.keys(exportsField).forEach(subfieldKey => { + rewriteConditionalExportEntryPoint(exportsField, subfieldKey); + }); +} + if (newPkgJson[EXPORT_MAP_ENTRY_POINT]) { - Object.entries(newPkgJson[EXPORT_MAP_ENTRY_POINT]).forEach(([key, val]) => { - newPkgJson[EXPORT_MAP_ENTRY_POINT][key] = Object.entries(val).reduce( - (acc, [key, val]) => { - return { ...acc, [key]: val.replace(`${buildDir}/`, '') }; - }, - {} as typeof val, - ); + Object.keys(newPkgJson[EXPORT_MAP_ENTRY_POINT]).forEach(key => { + rewriteConditionalExportEntryPoint(newPkgJson[EXPORT_MAP_ENTRY_POINT], key); }); } @@ -90,7 +99,10 @@ if (newPkgJson[TYPES_VERSIONS_ENTRY_POINT]) { Object.entries(newPkgJson[TYPES_VERSIONS_ENTRY_POINT]).forEach(([key, val]) => { newPkgJson[TYPES_VERSIONS_ENTRY_POINT][key] = Object.entries(val).reduce((acc, [key, val]) => { const newKey = key.replace(`${buildDir}/`, ''); - return { ...acc, [newKey]: val.map(v => v.replace(`${buildDir}/`, '')) }; + return { + ...acc, + [newKey]: val.map(v => v.replace(`${buildDir}/`, '')), + }; }, {}); }); } diff --git a/yarn.lock b/yarn.lock index d130053d19d2..81114ac992a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,7 +17,7 @@ dependencies: "@jridgewell/trace-mapping" "^0.3.0" -"@ampproject/remapping@^2.2.0": +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== @@ -2744,6 +2744,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.4.tgz#74752a09301b8c6b9a415fbda9fb71406a62a7b7" integrity sha512-mRsi2vJsk4Bx/AFsNBqOH2fqedxn5L/moT58xgg51DjX1la64Z3Npicut2VbhvDFO26qjWtPMsVxCd80YTFVeg== +"@esbuild/android-arm64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.9.tgz#683794bdc3d27222d3eced7b74cad15979548031" + integrity sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ== + "@esbuild/android-arm@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.16.17.tgz#025b6246d3f68b7bbaa97069144fb5fb70f2fff2" @@ -2759,6 +2764,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.4.tgz#c27363e1e280e577d9b5c8fa7c7a3be2a8d79bf5" integrity sha512-uBIbiYMeSsy2U0XQoOGVVcpIktjLMEKa7ryz2RLr7L/vTnANNEsPVAh4xOv7ondGz6ac1zVb0F8Jx20rQikffQ== +"@esbuild/android-arm@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.9.tgz#21a4de41f07b2af47401c601d64dfdefd056c595" + integrity sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA== + "@esbuild/android-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.16.17.tgz#c820e0fef982f99a85c4b8bfdd582835f04cd96e" @@ -2774,6 +2784,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.4.tgz#6c9ee03d1488973d928618100048b75b147e0426" integrity sha512-4iPufZ1TMOD3oBlGFqHXBpa3KFT46aLl6Vy7gwed0ZSYgHaZ/mihbYb4t7Z9etjkC9Al3ZYIoOaHrU60gcMy7g== +"@esbuild/android-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.9.tgz#e2d7674bc025ddc8699f0cc76cb97823bb63c252" + integrity sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA== + "@esbuild/darwin-arm64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz#edef4487af6b21afabba7be5132c26d22379b220" @@ -2789,6 +2804,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.4.tgz#64e2ee945e5932cd49812caa80e8896e937e2f8b" integrity sha512-Lviw8EzxsVQKpbS+rSt6/6zjn9ashUZ7Tbuvc2YENgRl0yZTktGlachZ9KMJUsVjZEGFVu336kl5lBgDN6PmpA== +"@esbuild/darwin-arm64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.9.tgz#ae7a582289cc5c0bac15d4b9020a90cb7288f1e9" + integrity sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw== + "@esbuild/darwin-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz#42829168730071c41ef0d028d8319eea0e2904b4" @@ -2804,6 +2824,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.4.tgz#d8e26e1b965df284692e4d1263ba69a49b39ac7a" integrity sha512-YHbSFlLgDwglFn0lAO3Zsdrife9jcQXQhgRp77YiTDja23FrC2uwnhXMNkAucthsf+Psr7sTwYEryxz6FPAVqw== +"@esbuild/darwin-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.9.tgz#8a216c66dcf51addeeb843d8cfaeff712821d12b" + integrity sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ== + "@esbuild/freebsd-arm64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz#1f4af488bfc7e9ced04207034d398e793b570a27" @@ -2819,6 +2844,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.4.tgz#29751a41b242e0a456d89713b228f1da4f45582f" integrity sha512-vz59ijyrTG22Hshaj620e5yhs2dU1WJy723ofc+KUgxVCM6zxQESmWdMuVmUzxtGqtj5heHyB44PjV/HKsEmuQ== +"@esbuild/freebsd-arm64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.9.tgz#63d4f603e421252c3cd836b18d01545be7c6c440" + integrity sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g== + "@esbuild/freebsd-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz#636306f19e9bc981e06aa1d777302dad8fddaf72" @@ -2834,6 +2864,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.4.tgz#873edc0f73e83a82432460ea59bf568c1e90b268" integrity sha512-3sRbQ6W5kAiVQRBWREGJNd1YE7OgzS0AmOGjDmX/qZZecq8NFlQsQH0IfXjjmD0XtUYqr64e0EKNFjMUlPL3Cw== +"@esbuild/freebsd-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.9.tgz#a3db52595be65360eae4de1d1fa3c1afd942e1e4" + integrity sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA== + "@esbuild/linux-arm64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz#a003f7ff237c501e095d4f3a09e58fc7b25a4aca" @@ -2849,6 +2884,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.4.tgz#659f2fa988d448dbf5010b5cc583be757cc1b914" integrity sha512-ZWmWORaPbsPwmyu7eIEATFlaqm0QGt+joRE9sKcnVUG3oBbr/KYdNE2TnkzdQwX6EDRdg/x8Q4EZQTXoClUqqA== +"@esbuild/linux-arm64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.9.tgz#4ae5811ce9f8d7df5eb9edd9765ea9401a534f13" + integrity sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ== + "@esbuild/linux-arm@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz#b591e6a59d9c4fe0eeadd4874b157ab78cf5f196" @@ -2864,6 +2904,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.4.tgz#d5b13a7ec1f1c655ce05c8d319b3950797baee55" integrity sha512-z/4ArqOo9EImzTi4b6Vq+pthLnepFzJ92BnofU1jgNlcVb+UqynVFdoXMCFreTK7FdhqAzH0vmdwW5373Hm9pg== +"@esbuild/linux-arm@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.9.tgz#9807e92cfd335f46326394805ad488e646e506f2" + integrity sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw== + "@esbuild/linux-ia32@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz#24333a11027ef46a18f57019450a5188918e2a54" @@ -2879,6 +2924,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.4.tgz#878cd8bf24c9847c77acdb5dd1b2ef6e4fa27a82" integrity sha512-EGc4vYM7i1GRUIMqRZNCTzJh25MHePYsnQfKDexD8uPTCm9mK56NIL04LUfX2aaJ+C9vyEp2fJ7jbqFEYgO9lQ== +"@esbuild/linux-ia32@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.9.tgz#18892c10f3106652b16f9da88a0362dc95ed46c7" + integrity sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q== + "@esbuild/linux-loong64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz#d5ad459d41ed42bbd4d005256b31882ec52227d8" @@ -2894,6 +2944,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.4.tgz#df890499f6e566b7de3aa2361be6df2b8d5fa015" integrity sha512-WVhIKO26kmm8lPmNrUikxSpXcgd6HDog0cx12BUfA2PkmURHSgx9G6vA19lrlQOMw+UjMZ+l3PpbtzffCxFDRg== +"@esbuild/linux-loong64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.9.tgz#dc2ebf9a125db0a1bba18c2bbfd4fbdcbcaf61c2" + integrity sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA== + "@esbuild/linux-mips64el@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz#4e5967a665c38360b0a8205594377d4dcf9c3726" @@ -2909,6 +2964,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.4.tgz#76eae4e88d2ce9f4f1b457e93892e802851b6807" integrity sha512-keYY+Hlj5w86hNp5JJPuZNbvW4jql7c1eXdBUHIJGTeN/+0QFutU3GrS+c27L+NTmzi73yhtojHk+lr2+502Mw== +"@esbuild/linux-mips64el@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.9.tgz#4c2f7c5d901015e3faf1563c4a89a50776cb07fd" + integrity sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw== + "@esbuild/linux-ppc64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz#206443a02eb568f9fdf0b438fbd47d26e735afc8" @@ -2924,6 +2984,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.4.tgz#c49032f4abbcfa3f747b543a106931fe3dce41ff" integrity sha512-tQ92n0WMXyEsCH4m32S21fND8VxNiVazUbU4IUGVXQpWiaAxOBvtOtbEt3cXIV3GEBydYsY8pyeRMJx9kn3rvw== +"@esbuild/linux-ppc64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.9.tgz#8385332713b4e7812869622163784a5633f76fc4" + integrity sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ== + "@esbuild/linux-riscv64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz#c351e433d009bf256e798ad048152c8d76da2fc9" @@ -2939,6 +3004,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.4.tgz#0f815a090772138503ee0465a747e16865bf94b1" integrity sha512-tRRBey6fG9tqGH6V75xH3lFPpj9E8BH+N+zjSUCnFOX93kEzqS0WdyJHkta/mmJHn7MBaa++9P4ARiU4ykjhig== +"@esbuild/linux-riscv64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.9.tgz#23f1db24fa761be311874f32036c06249aa20cba" + integrity sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg== + "@esbuild/linux-s390x@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz#661f271e5d59615b84b6801d1c2123ad13d9bd87" @@ -2954,6 +3024,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.4.tgz#8d2cca20cd4e7c311fde8701d9f1042664f8b92b" integrity sha512-152aLpQqKZYhThiJ+uAM4PcuLCAOxDsCekIbnGzPKVBRUDlgaaAfaUl5NYkB1hgY6WN4sPkejxKlANgVcGl9Qg== +"@esbuild/linux-s390x@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.9.tgz#2dffe497726b897c9f0109e774006e25b33b4fd0" + integrity sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw== + "@esbuild/linux-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz#e4ba18e8b149a89c982351443a377c723762b85f" @@ -2969,6 +3044,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.4.tgz#f618bec2655de49bff91c588777e37b5e3169d4a" integrity sha512-Mi4aNA3rz1BNFtB7aGadMD0MavmzuuXNTaYL6/uiYIs08U7YMPETpgNn5oue3ICr+inKwItOwSsJDYkrE9ekVg== +"@esbuild/linux-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.9.tgz#ceb1d62cd830724ff5b218e5d3172a8bad59420e" + integrity sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A== + "@esbuild/netbsd-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz#7d4f4041e30c5c07dd24ffa295c73f06038ec775" @@ -2984,6 +3064,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.4.tgz#7889744ca4d60f1538d62382b95e90a49687cef2" integrity sha512-9+Wxx1i5N/CYo505CTT7T+ix4lVzEdz0uCoYGxM5JDVlP2YdDC1Bdz+Khv6IbqmisT0Si928eAxbmGkcbiuM/A== +"@esbuild/netbsd-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.9.tgz#0cbca65e9ef4d3fc41502d3e055e6f49479a8f18" + integrity sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug== + "@esbuild/openbsd-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz#970fa7f8470681f3e6b1db0cc421a4af8060ec35" @@ -2999,6 +3084,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.4.tgz#c3e436eb9271a423d2e8436fcb120e3fd90e2b01" integrity sha512-MFsHleM5/rWRW9EivFssop+OulYVUoVcqkyOkjiynKBCGBj9Lihl7kh9IzrreDyXa4sNkquei5/DTP4uCk25xw== +"@esbuild/openbsd-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.9.tgz#1f57adfbee09c743292c6758a3642e875bcad1cf" + integrity sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw== + "@esbuild/sunos-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz#abc60e7c4abf8b89fb7a4fe69a1484132238022c" @@ -3014,6 +3104,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.4.tgz#f63f5841ba8c8c1a1c840d073afc99b53e8ce740" integrity sha512-6Xq8SpK46yLvrGxjp6HftkDwPP49puU4OF0hEL4dTxqCbfx09LyrbUj/D7tmIRMj5D5FCUPksBbxyQhp8tmHzw== +"@esbuild/sunos-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.9.tgz#116be6adbd2c7479edeeb5f6ea0441002ab4cb9c" + integrity sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw== + "@esbuild/win32-arm64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz#7b0ff9e8c3265537a7a7b1fd9a24e7bd39fcd87a" @@ -3029,6 +3124,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.4.tgz#80be69cec92da4da7781cf7a8351b95cc5a236b0" integrity sha512-PkIl7Jq4mP6ke7QKwyg4fD4Xvn8PXisagV/+HntWoDEdmerB2LTukRZg728Yd1Fj+LuEX75t/hKXE2Ppk8Hh1w== +"@esbuild/win32-arm64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.9.tgz#2be22131ab18af4693fd737b161d1ef34de8ca9d" + integrity sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg== + "@esbuild/win32-ia32@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz#e90fe5267d71a7b7567afdc403dfd198c292eb09" @@ -3044,6 +3144,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.4.tgz#15dc0ed83d2794872b05d8edc4a358fecf97eb54" integrity sha512-ga676Hnvw7/ycdKB53qPusvsKdwrWzEyJ+AtItHGoARszIqvjffTwaaW3b2L6l90i7MO9i+dlAW415INuRhSGg== +"@esbuild/win32-ia32@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.9.tgz#e10ead5a55789b167b4225d2469324538768af7c" + integrity sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg== + "@esbuild/win32-x64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz#c5a1a4bfe1b57f0c3e61b29883525c6da3e5c091" @@ -3059,6 +3164,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.4.tgz#d46a6e220a717f31f39ae80f49477cc3220be0f0" integrity sha512-HP0GDNla1T3ZL8Ko/SHAS2GgtjOg+VmWnnYLhuTksr++EnduYB0f3Y2LzHsUwb2iQ13JGoY6G3R8h6Du/WG6uA== +"@esbuild/win32-x64@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.9.tgz#b2da6219b603e3fa371a78f53f5361260d0c5585" + integrity sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ== + "@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -3874,6 +3984,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.18": + version "0.3.20" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f" + integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jsdevtools/coverage-istanbul-loader@3.0.5": version "3.0.5" resolved "https://registry.yarnpkg.com/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz#2a4bc65d0271df8d4435982db4af35d81754ee26" @@ -5016,6 +5134,71 @@ estree-walker "^2.0.2" picomatch "^2.3.1" +"@rollup/rollup-android-arm-eabi@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.1.tgz#beaf518ee45a196448e294ad3f823d2d4576cf35" + integrity sha512-6vMdBZqtq1dVQ4CWdhFwhKZL6E4L1dV6jUjuBvsavvNJSppzi6dLBbuV+3+IyUREaj9ZFvQefnQm28v4OCXlig== + +"@rollup/rollup-android-arm64@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.1.tgz#6f76cfa759c2d0fdb92122ffe28217181a1664eb" + integrity sha512-Jto9Fl3YQ9OLsTDWtLFPtaIMSL2kwGyGoVCmPC8Gxvym9TCZm4Sie+cVeblPO66YZsYH8MhBKDMGZ2NDxuk/XQ== + +"@rollup/rollup-darwin-arm64@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.1.tgz#9aaefe33a5481d66322d1c62f368171c03eabe2b" + integrity sha512-LtYcLNM+bhsaKAIGwVkh5IOWhaZhjTfNOkGzGqdHvhiCUVuJDalvDxEdSnhFzAn+g23wgsycmZk1vbnaibZwwA== + +"@rollup/rollup-darwin-x64@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.1.tgz#707dcaadcdc6bd3fd6c69f55d9456cd4446306a3" + integrity sha512-KyP/byeXu9V+etKO6Lw3E4tW4QdcnzDG/ake031mg42lob5tN+5qfr+lkcT/SGZaH2PdW4Z1NX9GHEkZ8xV7og== + +"@rollup/rollup-linux-arm-gnueabihf@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.1.tgz#7a4dbbd1dd98731d88a55aefcef0ec4c578fa9c7" + integrity sha512-Yqz/Doumf3QTKplwGNrCHe/B2p9xqDghBZSlAY0/hU6ikuDVQuOUIpDP/YcmoT+447tsZTmirmjgG3znvSCR0Q== + +"@rollup/rollup-linux-arm64-gnu@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.1.tgz#967ba8e6f68a5f21bd00cd97773dcdd6107e94ed" + integrity sha512-u3XkZVvxcvlAOlQJ3UsD1rFvLWqu4Ef/Ggl40WAVCuogf4S1nJPHh5RTgqYFpCOvuGJ7H5yGHabjFKEZGExk5Q== + +"@rollup/rollup-linux-arm64-musl@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.1.tgz#d3a4e1c9f21eef3b9f4e4989f334a519a1341462" + integrity sha512-0XSYN/rfWShW+i+qjZ0phc6vZ7UWI8XWNz4E/l+6edFt+FxoEghrJHjX1EY/kcUGCnZzYYRCl31SNdfOi450Aw== + +"@rollup/rollup-linux-riscv64-gnu@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.1.tgz#415c0533bb752164effd05f5613858e8f6779bc9" + integrity sha512-LmYIO65oZVfFt9t6cpYkbC4d5lKHLYv5B4CSHRpnANq0VZUQXGcCPXHzbCXCz4RQnx7jvlYB1ISVNCE/omz5cw== + +"@rollup/rollup-linux-x64-gnu@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.1.tgz#0983385dd753a2e0ecaddea7a81dd37fea5114f5" + integrity sha512-kr8rEPQ6ns/Lmr/hiw8sEVj9aa07gh1/tQF2Y5HrNCCEPiCBGnBUt9tVusrcBBiJfIt1yNaXN6r1CCmpbFEDpg== + +"@rollup/rollup-linux-x64-musl@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.1.tgz#eb7494ebc5199cbd2e5c38c2b8acbe2603f35e03" + integrity sha512-t4QSR7gN+OEZLG0MiCgPqMWZGwmeHhsM4AkegJ0Kiy6TnJ9vZ8dEIwHw1LcZKhbHxTY32hp9eVCMdR3/I8MGRw== + +"@rollup/rollup-win32-arm64-msvc@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.1.tgz#5bebc66e3a7f82d4b9aa9ff448e7fc13a69656e9" + integrity sha512-7XI4ZCBN34cb+BH557FJPmh0kmNz2c25SCQeT9OiFWEgf8+dL6ZwJ8f9RnUIit+j01u07Yvrsuu1rZGxJCc51g== + +"@rollup/rollup-win32-ia32-msvc@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.1.tgz#34156ebf8b4de3b20e6497260fe519a30263f8cf" + integrity sha512-yE5c2j1lSWOH5jp+Q0qNL3Mdhr8WuqCNVjc6BxbVfS5cAS6zRmdiw7ktb8GNpDCEUJphILY6KACoFoRtKoqNQg== + +"@rollup/rollup-win32-x64-msvc@4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.1.tgz#d146db7a5949e10837b323ce933ed882ac878262" + integrity sha512-PyJsSsafjmIhVgaI1Zdj7m8BB8mMckFah/xbpplObyHfiXzKcI5UOUXRyOdHW7nz4DpMCuzLnF7v5IWHenCwYA== + "@schematics/angular@10.2.4": version "10.2.4" resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-10.2.4.tgz#3b99b9da572b57381d221e2008804e6bb9c98b82" @@ -5411,36 +5594,22 @@ dependencies: highlight.js "^9.15.6" -"@sveltejs/kit@^1.11.0": - version "1.15.2" - resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-1.15.2.tgz#2d351b15aa39ab792c36c2c236c7e31a2010a6b0" - integrity sha512-rLNxZrjbrlPf8AWW8GAU4L/Vvu17e9v8EYl7pUip7x72lTft7RcxeP3z7tsrHpMSBBxC9o4XdKzFvz1vMZyXZw== +"@sveltejs/kit@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.0.2.tgz#bd02523fe570ddaf89148bffb1eb2233c458054b" + integrity sha512-xFpnLxVQ4KgCbj4Cj2zCFUcyfAoO87nn4nf3XcGJ7ZtOwy20tZ91vXWrtyuum8hakJWVwdNYyGXG9aBoIEYpFQ== dependencies: - "@sveltejs/vite-plugin-svelte" "^2.0.0" - "@types/cookie" "^0.5.1" - cookie "^0.5.0" - devalue "^4.3.0" + "@types/cookie" "^0.6.0" + cookie "^0.6.0" + devalue "^4.3.2" esm-env "^1.0.0" kleur "^4.1.5" - magic-string "^0.30.0" - mime "^3.0.0" + magic-string "^0.30.5" + mrmime "^1.0.1" sade "^1.8.1" - set-cookie-parser "^2.5.1" - sirv "^2.0.2" + set-cookie-parser "^2.6.0" + sirv "^2.0.3" tiny-glob "^0.2.9" - undici "5.20.0" - -"@sveltejs/vite-plugin-svelte@^2.0.0": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.0.3.tgz#3d276eab341638dd58691a3de610774e155a7578" - integrity sha512-o+cguBFdwIGtRbNkYOyqTM7KvRUffxh5bfK4oJsWKG2obu+v/cbpT03tJrGl58C7tRXo/aEC0/axN5FVHBj0nA== - dependencies: - debug "^4.3.4" - deepmerge "^4.3.0" - kleur "^4.1.5" - magic-string "^0.29.0" - svelte-hmr "^0.15.1" - vitefu "^0.2.4" "@szmarczak/http-timer@^1.1.2": version "1.1.2" @@ -5660,10 +5829,10 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== -"@types/cookie@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554" - integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g== +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== "@types/cors@2.8.12", "@types/cors@^2.8.12": version "2.8.12" @@ -5914,6 +6083,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== +"@types/estree@^1.0.1": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + "@types/express-serve-static-core@4.17.31": version "4.17.31" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f" @@ -7470,6 +7644,11 @@ acorn@^8.2.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.0, acorn@^8.7.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== +acorn@^8.9.0: + version "8.11.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.2.tgz#ca0d78b51895be5390a5903c5b3bdcdaf78ae40b" + integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" @@ -7939,6 +8118,13 @@ aria-query@^5.0.0, aria-query@^5.0.2: resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.2.tgz#0b8a744295271861e1d933f8feca13f9b70cfdc1" integrity sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q== +aria-query@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + arity-n@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/arity-n/-/arity-n-1.0.4.tgz#d9e76b11733e08569c0847ae7b39b2860b30b745" @@ -8438,6 +8624,13 @@ axios@^1.2.2: form-data "^4.0.0" proxy-from-env "^1.1.0" +axobject-query@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" + integrity sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg== + dependencies: + dequal "^2.0.3" + b4a@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9" @@ -10276,13 +10469,6 @@ bun-types@latest: resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.0.1.tgz#8bcb10ae3a1548a39f0932fdb365f4b3a649efba" integrity sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw== -busboy@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" - integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== - dependencies: - streamsearch "^1.1.0" - byte-size@8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-8.1.1.tgz#3424608c62d59de5bfda05d31e0313c6174842ae" @@ -11109,6 +11295,17 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= +code-red@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/code-red/-/code-red-1.0.4.tgz#59ba5c9d1d320a4ef795bc10a28bd42bfebe3e35" + integrity sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + "@types/estree" "^1.0.1" + acorn "^8.10.0" + estree-walker "^3.0.3" + periscopic "^3.1.0" + codecov@^3.6.5: version "3.8.1" resolved "https://registry.yarnpkg.com/codecov/-/codecov-3.8.1.tgz#06fe026b75525ed1ce864d4a34f1010c52c51546" @@ -11584,6 +11781,11 @@ cookie@^0.4.1, cookie@~0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookie@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + copy-anything@^2.0.1: version "2.0.6" resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-2.0.6.tgz#092454ea9584a7b7ad5573062b2a87f5900fc480" @@ -12000,6 +12202,14 @@ css-tree@^2.0.4: mdn-data "2.0.28" source-map-js "^1.0.1" +css-tree@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== + dependencies: + mdn-data "2.0.30" + source-map-js "^1.0.1" + css-what@^3.2.1: version "3.4.2" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" @@ -12471,11 +12681,6 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== -deepmerge@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b" - integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og== - default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -12600,7 +12805,7 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -12747,11 +12952,6 @@ deterministic-object-hash@^1.3.1: resolved "https://registry.yarnpkg.com/deterministic-object-hash/-/deterministic-object-hash-1.3.1.tgz#8df6723f71d005600041aad39054b35ecdf536ac" integrity sha512-kQDIieBUreEgY+akq0N7o4FzZCr27dPG1xr3wq267vPwDlSXQ3UMcBXHqTGUBaM/5WDS1jwTYjxRhUzHeuiAvw== -devalue@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.0.tgz#d86db8fee63a70317c2355be0d3d1b4d8f89a44e" - integrity sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA== - devalue@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.2.tgz#cc44e4cf3872ac5a78229fbce3b77e57032727b5" @@ -14309,6 +14509,34 @@ esbuild@^0.19.2: "@esbuild/win32-ia32" "0.19.4" "@esbuild/win32-x64" "0.19.4" +esbuild@^0.19.3: + version "0.19.9" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.9.tgz#423a8f35153beb22c0b695da1cd1e6c0c8cdd490" + integrity sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg== + optionalDependencies: + "@esbuild/android-arm" "0.19.9" + "@esbuild/android-arm64" "0.19.9" + "@esbuild/android-x64" "0.19.9" + "@esbuild/darwin-arm64" "0.19.9" + "@esbuild/darwin-x64" "0.19.9" + "@esbuild/freebsd-arm64" "0.19.9" + "@esbuild/freebsd-x64" "0.19.9" + "@esbuild/linux-arm" "0.19.9" + "@esbuild/linux-arm64" "0.19.9" + "@esbuild/linux-ia32" "0.19.9" + "@esbuild/linux-loong64" "0.19.9" + "@esbuild/linux-mips64el" "0.19.9" + "@esbuild/linux-ppc64" "0.19.9" + "@esbuild/linux-riscv64" "0.19.9" + "@esbuild/linux-s390x" "0.19.9" + "@esbuild/linux-x64" "0.19.9" + "@esbuild/netbsd-x64" "0.19.9" + "@esbuild/openbsd-x64" "0.19.9" + "@esbuild/sunos-x64" "0.19.9" + "@esbuild/win32-arm64" "0.19.9" + "@esbuild/win32-ia32" "0.19.9" + "@esbuild/win32-x64" "0.19.9" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -14693,7 +14921,7 @@ estree-walker@^2.0.1, estree-walker@^2.0.2: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -estree-walker@^3.0.3: +estree-walker@^3.0.0, estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== @@ -15842,6 +16070,11 @@ fsevents@~2.1.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -18199,6 +18432,13 @@ is-reference@1.2.1, is-reference@^1.2.1: dependencies: "@types/estree" "*" +is-reference@^3.0.0, is-reference@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.2.tgz#154747a01f45cd962404ee89d43837af2cba247c" + integrity sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg== + dependencies: + "@types/estree" "*" + is-regex@^1.0.4, is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -20064,6 +20304,11 @@ localforage@^1.8.1: dependencies: lie "3.1.1" +locate-character@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974" + integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA== + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -20540,13 +20785,6 @@ magic-string@^0.25.0, magic-string@^0.25.1, magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.29.0: - version "0.29.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.29.0.tgz#f034f79f8c43dba4ae1730ffb5e8c4e084b16cf3" - integrity sha512-WcfidHrDjMY+eLjlU+8OvwREqHwpgCeKVBUpQ3OhYYuvfaYCUgcbuBzappNzZvg/v8onU3oQj+BYpkOJe9Iw4Q== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.13" - magic-string@^0.30.0: version "0.30.0" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.0.tgz#fd58a4748c5c4547338a424e90fa5dd17f4de529" @@ -20561,6 +20799,13 @@ magic-string@^0.30.3, magic-string@^0.30.4: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" +magic-string@^0.30.5: + version "0.30.5" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" + integrity sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + magicast@0.2.8: version "0.2.8" resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.2.8.tgz#02b298c65fbc5b7d1fce52ef779c59caf68cc9cf" @@ -20956,6 +21201,11 @@ mdn-data@2.0.28: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" @@ -21956,6 +22206,11 @@ mrmime@^1.0.0: resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.0.tgz#14d387f0585a5233d291baba339b063752a2398b" integrity sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ== +mrmime@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" + integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -22054,6 +22309,11 @@ nanoid@^3.3.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -24061,6 +24321,15 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +periscopic@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/periscopic/-/periscopic-3.1.0.tgz#7e9037bf51c5855bd33b48928828db4afa79d97a" + integrity sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^3.0.0" + is-reference "^3.0.0" + pg-connection-string@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" @@ -25297,6 +25566,15 @@ postcss@^8.4.27: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.32: + version "8.4.32" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" + integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" @@ -27187,6 +27465,26 @@ rollup@^3.27.1: optionalDependencies: fsevents "~2.3.2" +rollup@^4.2.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.1.tgz#351d6c03e4e6bcd7a0339df3618d2aeeb108b507" + integrity sha512-pgPO9DWzLoW/vIhlSoDByCzcpX92bKEorbgXuZrqxByte3JFk2xSW2JEeAcyLc9Ru9pqcNNW+Ob7ntsk2oT/Xw== + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.9.1" + "@rollup/rollup-android-arm64" "4.9.1" + "@rollup/rollup-darwin-arm64" "4.9.1" + "@rollup/rollup-darwin-x64" "4.9.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.9.1" + "@rollup/rollup-linux-arm64-gnu" "4.9.1" + "@rollup/rollup-linux-arm64-musl" "4.9.1" + "@rollup/rollup-linux-riscv64-gnu" "4.9.1" + "@rollup/rollup-linux-x64-gnu" "4.9.1" + "@rollup/rollup-linux-x64-musl" "4.9.1" + "@rollup/rollup-win32-arm64-msvc" "4.9.1" + "@rollup/rollup-win32-ia32-msvc" "4.9.1" + "@rollup/rollup-win32-x64-msvc" "4.9.1" + fsevents "~2.3.2" + rsvp@^3.0.14, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21, rsvp@^3.0.6, rsvp@^3.1.0: version "3.6.2" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" @@ -27632,11 +27930,16 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= -set-cookie-parser@^2.4.8, set-cookie-parser@^2.5.1: +set-cookie-parser@^2.4.8: version "2.5.1" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz#ddd3e9a566b0e8e0862aca974a6ac0e01349430b" integrity sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ== +set-cookie-parser@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" + integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -27862,10 +28165,10 @@ sinon@^7.3.2: nise "^1.5.2" supports-color "^5.5.0" -sirv@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.2.tgz#128b9a628d77568139cff85703ad5497c46a4760" - integrity sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w== +sirv@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.3.tgz#ca5868b87205a74bef62a469ed0296abceccd446" + integrity sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA== dependencies: "@polka/url" "^1.0.0-next.20" mrmime "^1.0.0" @@ -28628,11 +28931,6 @@ streamroller@^3.0.2: debug "^4.1.1" fs-extra "^10.0.0" -streamsearch@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" - integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== - streamx@^2.15.0: version "2.15.1" resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.1.tgz#396ad286d8bc3eeef8f5cea3f029e81237c024c6" @@ -29116,11 +29414,6 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -svelte-hmr@^0.15.1: - version "0.15.1" - resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.15.1.tgz#d11d878a0bbb12ec1cba030f580cd2049f4ec86b" - integrity sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA== - svelte-jester@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/svelte-jester/-/svelte-jester-2.3.2.tgz#9eb818da30807bbcc940b6130d15b2c34408d64f" @@ -29131,10 +29424,24 @@ svelte@3.49.0: resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.49.0.tgz#5baee3c672306de1070c3b7888fc2204e36a4029" integrity sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA== -svelte@^3.44.0: - version "3.57.0" - resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.57.0.tgz#a3969cfe51f25f2a55e75f7b98dbd02c3af0980b" - integrity sha512-WMXEvF+RtAaclw0t3bPDTUe19pplMlfyKDsixbHQYgCWi9+O9VN0kXU1OppzrB9gPAvz4NALuoca2LfW2bOjTQ== +svelte@^4.2.8: + version "4.2.8" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-4.2.8.tgz#a279d8b6646131ffb11bc692840f8839b8ae4ed1" + integrity sha512-hU6dh1MPl8gh6klQZwK/n73GiAHiR95IkFsesLPbMeEZi36ydaXL/ZAb4g9sayT0MXzpxyZjR28yderJHxcmYA== + dependencies: + "@ampproject/remapping" "^2.2.1" + "@jridgewell/sourcemap-codec" "^1.4.15" + "@jridgewell/trace-mapping" "^0.3.18" + acorn "^8.9.0" + aria-query "^5.3.0" + axobject-query "^3.2.1" + code-red "^1.0.3" + css-tree "^2.3.1" + estree-walker "^3.0.3" + is-reference "^3.0.1" + locate-character "^3.0.0" + magic-string "^0.30.4" + periscopic "^3.1.0" svgo@^1.0.0: version "1.3.2" @@ -30242,13 +30549,6 @@ underscore@>=1.8.3: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== -undici@5.20.0: - version "5.20.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.20.0.tgz#6327462f5ce1d3646bcdac99da7317f455bcc263" - integrity sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g== - dependencies: - busboy "^1.6.0" - undici@^5.21.0: version "5.26.2" resolved "https://registry.yarnpkg.com/undici/-/undici-5.26.2.tgz#fa61bfe40f732540d15e58b0c1271872d8e3c995" @@ -30973,6 +31273,17 @@ vite@^4.4.9: optionalDependencies: fsevents "~2.3.2" +vite@^5.0.10: + version "5.0.10" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.10.tgz#1e13ef5c3cf5aa4eed81f5df6d107b3c3f1f6356" + integrity sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw== + dependencies: + esbuild "^0.19.3" + postcss "^8.4.32" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" + vitefu@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.4.tgz#212dc1a9d0254afe65e579351bed4e25d81e0b35"