diff --git a/.size-limit.js b/.size-limit.js index c3105a772987..c1725577c856 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -120,7 +120,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '40.5 KB', + limit: '41 KB', }, // Vue SDK (ESM) { @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '39 KB', + limit: '40 KB', }, // Node SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e6d2189e3a..e70a011716be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.29.0 + +### Important Changes + +- **feat(browser): Update `web-vitals` to 5.0.2 ([#16492](https://github.com/getsentry/sentry-javascript/pull/16492))** + +This release upgrades the `web-vitals` library to version 5.0.2. This upgrade could slightly change the collected web vital values and potentially also influence alerts and performance scores in the Sentry UI. + +### Other Changes + +- feat(deps): Bump @sentry/rollup-plugin from 3.4.0 to 3.5.0 ([#16524](https://github.com/getsentry/sentry-javascript/pull/16524)) +- feat(ember): Stop warning for `onError` usage ([#16547](https://github.com/getsentry/sentry-javascript/pull/16547)) +- feat(node): Allow to force activate `vercelAiIntegration` ([#16551](https://github.com/getsentry/sentry-javascript/pull/16551)) +- feat(node): Introduce `ignoreLayersType` option to koa integration ([#16553](https://github.com/getsentry/sentry-javascript/pull/16553)) +- fix(browser): Ensure `suppressTracing` does not leak when async ([#16545](https://github.com/getsentry/sentry-javascript/pull/16545)) +- fix(vue): Ensure root component render span always ends ([#16488](https://github.com/getsentry/sentry-javascript/pull/16488)) + ## 9.28.1 - feat(deps): Bump @sentry/cli from 2.45.0 to 2.46.0 ([#16516](https://github.com/getsentry/sentry-javascript/pull/16516)) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 7d448325b6ef..942230b4594e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -33,9 +34,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 8056cd88c3e5..435ed8398668 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -35,9 +36,7 @@ sentryTest( await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index 46f943b08551..9d83d2608893 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -3,6 +3,7 @@ import type { SpanEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -33,9 +34,7 @@ sentryTest( await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js index a941877ff88e..546698dc3d11 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js @@ -14,6 +14,7 @@ Sentry.init({ }), ], tracesSampleRate: 1, + debug: true, }); const client = Sentry.getClient(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index ac8dccd13dce..bf85d0ad99af 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, + hidePage, properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../../utils/helpers'; @@ -32,9 +33,7 @@ sentryTest('should capture an INP click event span during pageload', async ({ br await page.waitForTimeout(500); // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await hidePage(page); // Get the INP span envelope const spanEnvelope = (await spanEnvelopePromise)[0]; @@ -118,6 +117,14 @@ sentryTest( }); // Page hide to trigger INP + + // Important: Purposefully not using hidePage() here to test the hidden state + // via the `pagehide` event. This is necessary because iOS Safari 14.4 + // still doesn't fully emit the `visibilitychange` events but it's the lower + // bound for Safari on iOS that we support. + // If this test times out or fails, it's likely because we tried updating + // the web-vitals library which officially already dropped support for + // this iOS version await page.evaluate(() => { window.dispatchEvent(new Event('pagehide')); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json index 9ba374954190..f5302ee3531e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json @@ -44,5 +44,10 @@ "ts-loader": "^9.4.3", "tsconfig-paths": "^4.2.0", "typescript": "~5.0.0" + }, + "pnpm": { + "overrides": { + "minimatch": "10.0.1" + } } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json index 94da7baed3ab..c9c47cc7ce54 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-t3/package.json @@ -17,9 +17,9 @@ "@sentry/nextjs": "latest || *", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "^5.50.0", - "@trpc/client": "^11.0.0-rc.446", - "@trpc/react-query": "^11.0.0-rc.446", - "@trpc/server": "^11.0.0-rc.446", + "@trpc/client": "~11.3.0", + "@trpc/react-query": "~11.3.0", + "@trpc/server": "~11.3.0", "geist": "^1.3.0", "next": "14.2.29", "react": "18.3.1", diff --git a/dev-packages/node-integration-tests/README.md b/dev-packages/node-integration-tests/README.md index c920f05d5e31..b2d8db2124d1 100644 --- a/dev-packages/node-integration-tests/README.md +++ b/dev-packages/node-integration-tests/README.md @@ -47,3 +47,20 @@ To run tests with Vitest's watch mode: To filter tests by their title: `yarn test -t "set different properties of a scope"` + +## Debugging Tests + +To enable verbose logging during test execution, set the `DEBUG` environment variable: + +`DEBUG=1 yarn test` + +When `DEBUG` is enabled, the test runner will output: + +- Test scenario startup information (path, flags, DSN) +- Docker Compose output when using `withDockerCompose` +- Child process stdout and stderr output +- HTTP requests made during tests +- Process errors and exceptions +- Line-by-line output from test scenarios + +This is particularly useful when debugging failing tests or understanding the test execution flow. diff --git a/dev-packages/opentelemetry-v2-tests/package.json b/dev-packages/opentelemetry-v2-tests/package.json index e30b856aaee3..8674b30b91de 100644 --- a/dev-packages/opentelemetry-v2-tests/package.json +++ b/dev-packages/opentelemetry-v2-tests/package.json @@ -7,6 +7,8 @@ "node": ">=18" }, "scripts": { + "lint": "eslint . --format stylish", + "fix": "eslint . --format stylish --fix", "test": "vitest run", "test:watch": "vitest --watch" }, diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts index 3146551e3da7..a0ba28173b9e 100644 --- a/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts @@ -1,5 +1,5 @@ import type { Span } from '@opentelemetry/api'; -import { INVALID_TRACEID, INVALID_SPANID, type SpanContext } from '@opentelemetry/api'; +import { type SpanContext, INVALID_SPANID, INVALID_TRACEID } from '@opentelemetry/api'; export const isSpan = (value: unknown): value is Span => { return ( diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts index eb112d017a1c..12372f60ea85 100644 --- a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts @@ -3,11 +3,11 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { ClientOptions, Options } from '@sentry/core'; import { flush, getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import { setOpenTelemetryContextAsyncContextStrategy } from '../../../../packages/opentelemetry/src/asyncContextStrategy'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; import { clearOpenTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; import { initOtel } from './initOtel'; import { init as initTestClient } from './TestClient'; -import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; const PUBLIC_DSN = 'https://username@domain/123'; diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 3bdf6c113555..7e2bf79f6ec0 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -16,7 +16,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { SENTRY_TRACE_STATE_DSC } from '../../../../packages/opentelemetry/src/constants'; import { startInactiveSpan, startSpan } from '../../../../packages/opentelemetry/src/trace'; import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; -import { cleanupOtel, getProvider, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; import type { TestClientInterface } from '../helpers/TestClient'; describe('Integration | Transactions', () => { @@ -514,7 +514,6 @@ describe('Integration | Transactions', () => { }, }); - const provider = getProvider(); const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; @@ -548,57 +547,56 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); -it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); + it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); -}); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); + }); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; @@ -619,7 +617,6 @@ it('collects child spans that are finished within 5 minutes their parent span ha }, }); - const provider = getProvider(); const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; diff --git a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts index 84be427a1fb3..52d5e67477d0 100644 --- a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts @@ -28,13 +28,13 @@ import { } from '../../../packages/opentelemetry/src/trace'; import type { AbstractSpan } from '../../../packages/opentelemetry/src/types'; import { getActiveSpan } from '../../../packages/opentelemetry/src/utils/getActiveSpan'; +import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; import { getSpanKind } from '../../../packages/opentelemetry/src/utils/getSpanKind'; import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; import { spanHasAttributes, spanHasName } from '../../../packages/opentelemetry/src/utils/spanTypes'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; import { isSpan } from './helpers/isSpan'; -import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; describe('trace', () => { beforeEach(() => { diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index a6c53800ccc1..1d35ff53853f 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -58,6 +58,7 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); + // TODO: Figure out if we can switch to using whenIdleOrHidden instead of onHidden // use pagehide event from web-vitals onHidden(() => { _collectClsOnce(); diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index c4b2b1a1c0cf..4f9d29e5f02f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -2,10 +2,10 @@ > A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. -This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2 The commit SHA used is: -[3d2b3dc8576cc003618952fa39902fab764a53e2](https://github.com/GoogleChrome/web-vitals/tree/3d2b3dc8576cc003618952fa39902fab764a53e2) +[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb) Current vendored web vitals are: @@ -27,6 +27,12 @@ web-vitals only report once per pageload. ## CHANGELOG +https://github.com/getsentry/sentry-javascript/pull/16492 + +- Bumped from Web Vitals 4.2.5 to 5.0.2 + - Mainly fixes some INP, LCP and FCP edge cases + - Original library removed FID; we still keep it around for now + https://github.com/getsentry/sentry-javascript/pull/14439 - Bumped from Web Vitals v3.5.2 to v4.2.4 diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index a9b6f9f26999..1b4d50a7c44e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; +import { initUnique } from './lib/initUnique'; +import { LayoutShiftManager } from './lib/LayoutShiftManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { onFCP } from './onFCP'; import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types'; @@ -54,58 +56,37 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = const metric = initMetric('CLS', 0); let report: ReturnType; - let sessionValue = 0; - let sessionEntries: LayoutShift[] = []; + const layoutShiftManager = initUnique(opts, LayoutShiftManager); const handleEntries = (entries: LayoutShift[]) => { - entries.forEach(entry => { - // Only count layout shifts without recent user input. - if (!entry.hadRecentInput) { - const firstSessionEntry = sessionEntries[0]; - const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; - - // If the entry occurred less than 1 second after the previous entry - // and less than 5 seconds after the first entry in the session, - // include the entry in the current session. Otherwise, start a new - // session. - if ( - sessionValue && - firstSessionEntry && - lastSessionEntry && - entry.startTime - lastSessionEntry.startTime < 1000 && - entry.startTime - firstSessionEntry.startTime < 5000 - ) { - sessionValue += entry.value; - sessionEntries.push(entry); - } else { - sessionValue = entry.value; - sessionEntries = [entry]; - } - } - }); + for (const entry of entries) { + layoutShiftManager._processEntry(entry); + } // If the current session value is larger than the current CLS value, // update CLS and the entries contributing to it. - if (sessionValue > metric.value) { - metric.value = sessionValue; - metric.entries = sessionEntries; + if (layoutShiftManager._sessionValue > metric.value) { + metric.value = layoutShiftManager._sessionValue; + metric.entries = layoutShiftManager._sessionEntries; report(); } }; const po = observe('layout-shift', handleEntries); if (po) { - report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + report = bindReporter(onReport, metric, CLSThresholds, opts!.reportAllChanges); - onHidden(() => { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); + WINDOW.document?.addEventListener('visibilitychange', () => { + if (WINDOW.document?.visibilityState === 'hidden') { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); + } }); // Queue a task to report (if nothing else triggers a report first). // This allows CLS to be reported as soon as FCP fires when // `reportAllChanges` is true. - setTimeout(report, 0); + WINDOW?.setTimeout?.(report); } }), ); diff --git a/packages/browser-utils/src/metrics/web-vitals/getFID.ts b/packages/browser-utils/src/metrics/web-vitals/getFID.ts index e8fd4fa908e7..b549f4c07c7c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getFID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getFID.ts @@ -12,6 +12,10 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * // Sentry: web-vitals removed FID reporting from v5. We're keeping it around + * for the time being. + * // TODO(v10): Remove FID reporting! */ import { bindReporter } from './lib/bindReporter'; @@ -60,6 +64,7 @@ export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts = report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges); if (po) { + // sentry: TODO: Figure out if we can use new whinIdleOrHidden insteard of onHidden onHidden( runOnce(() => { handleEntries(po.takeRecords() as FIDMetric['entries']); diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index af5bd05fb413..f5efbcbc3afc 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -14,31 +14,37 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; -import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions'; +import { initUnique } from './lib/initUnique'; +import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; import { whenActivated } from './lib/whenActivated'; -import { whenIdle } from './lib/whenIdle'; -import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; +import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; +// The default `durationThreshold` used across this library for observing +// `event` entries via PerformanceObserver. +const DEFAULT_DURATION_THRESHOLD = 40; + /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with * the `event` performance entries reported for that interaction. The reported * value is a `DOMHighResTimeStamp`. * - * A custom `durationThreshold` configuration option can optionally be passed to - * control what `event-timing` entries are considered for INP reporting. The - * default threshold is `40`, which means INP scores of less than 40 are - * reported as 0. Note that this will not affect your 75th percentile INP value - * unless that value is also less than 40 (well below the recommended + * A custom `durationThreshold` configuration option can optionally be passed + * to control what `event-timing` entries are considered for INP reporting. The + * default threshold is `40`, which means INP scores of less than 40 will not + * be reported. To avoid reporting no interactions in these cases, the library + * will fall back to the input delay of the first interaction. Note that this + * will not affect your 75th percentile INP value unless that value is also + * less than 40 (well below the recommended * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold). * * If the `reportAllChanges` configuration option is set to `true`, the @@ -55,9 +61,9 @@ export const INPThresholds: MetricRatingThresholds = [200, 500]; * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => { +export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts = {}) => { // Return if the browser doesn't support all APIs needed to measure INP. - if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) { + if (!(globalThis.PerformanceEventTiming && 'interactionId' in PerformanceEventTiming.prototype)) { return; } @@ -69,6 +75,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // eslint-disable-next-line prefer-const let report: ReturnType; + const interactionManager = initUnique(opts, InteractionManager); + const handleEntries = (entries: INPMetric['entries']) => { // Queue the `handleEntries()` callback in the next idle task. // This is needed to increase the chances that all event entries that @@ -76,13 +84,15 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // have been dispatched. Note: there is currently an experiment // running in Chrome (EventTimingKeypressAndCompositionInteractionId) // 123+ that if rolled out fully may make this no longer necessary. - whenIdle(() => { - entries.forEach(processInteractionEntry); + whenIdleOrHidden(() => { + for (const entry of entries) { + interactionManager._processEntry(entry); + } - const inp = estimateP98LongestInteraction(); + const inp = interactionManager._estimateP98LongestInteraction(); - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; + if (inp && inp._latency !== metric.value) { + metric.value = inp._latency; metric.entries = inp.entries; report(); } @@ -96,7 +106,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // and performance. Running this callback for any interaction that spans // just one or two frames is likely not worth the insight that could be // gained. - durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : DEFAULT_DURATION_THRESHOLD, + durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, }); report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); @@ -106,6 +116,9 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = // where the first interaction is less than the `durationThreshold`. po.observe({ type: 'first-input', buffered: true }); + // sentry: we use onHidden instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); report(true); diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 17fd374e7611..0f2f821d9bcc 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -19,18 +19,17 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; +import { initUnique } from './lib/initUnique'; +import { LCPEntryManager } from './lib/LCPEntryManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { whenActivated } from './lib/whenActivated'; -import { whenIdle } from './lib/whenIdle'; +import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; -const reportedMetricIDs: Record = {}; - /** * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and * calls the `callback` function once the value is ready (along with the @@ -48,28 +47,32 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = const metric = initMetric('LCP'); let report: ReturnType; + const lcpEntryManager = initUnique(opts, LCPEntryManager); + const handleEntries = (entries: LCPMetric['entries']) => { // If reportAllChanges is set then call this function for each entry, // otherwise only consider the last one. - if (!opts.reportAllChanges) { + if (!opts!.reportAllChanges) { // eslint-disable-next-line no-param-reassign entries = entries.slice(-1); } - entries.forEach(entry => { + for (const entry of entries) { + lcpEntryManager._processEntry(entry); + // Only report if the page wasn't hidden prior to LCP. if (entry.startTime < visibilityWatcher.firstHiddenTime) { // The startTime attribute returns the value of the renderTime if it is // not 0, and the value of the loadTime otherwise. The activationStart // reference is used because LCP should be relative to page activation - // rather than navigation start if the page was pre-rendered. But in cases + // rather than navigation start if the page was prerendered. But in cases // where `activationStart` occurs after the LCP, this time should be // clamped at 0. metric.value = Math.max(entry.startTime - getActivationStart(), 0); metric.entries = [entry]; report(); } - }); + } }; const po = observe('largest-contentful-paint', handleEntries); @@ -77,31 +80,29 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = if (po) { report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); + // Ensure this logic only runs once, since it can be triggered from + // any of three different event listeners below. const stopListening = runOnce(() => { - if (!reportedMetricIDs[metric.id]) { - handleEntries(po.takeRecords() as LCPMetric['entries']); - po.disconnect(); - reportedMetricIDs[metric.id] = true; - report(true); - } + handleEntries(po.takeRecords() as LCPMetric['entries']); + po.disconnect(); + report(true); }); - // Stop listening after input. Note: while scrolling is an input that - // stops LCP observation, it's unreliable since it can be programmatically - // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 - ['keydown', 'click'].forEach(type => { - // Wrap in a setTimeout so the callback is run in a separate task - // to avoid extending the keyboard/click handler to reduce INP impact + // Stop listening after input or visibilitychange. + // Note: while scrolling is an input that stops LCP observation, it's + // unreliable since it can be programmatically generated. + // See: https://github.com/GoogleChrome/web-vitals/issues/75 + for (const type of ['keydown', 'click', 'visibilitychange']) { + // Wrap the listener in an idle callback so it's run in a separate + // task to reduce potential INP impact. // https://github.com/GoogleChrome/web-vitals/issues/383 if (WINDOW.document) { - addEventListener(type, () => whenIdle(stopListening as () => void), { - once: true, + addEventListener(type, () => whenIdleOrHidden(stopListening), { capture: true, + once: true, }); } - }); - - onHidden(stopListening); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts new file mode 100644 index 000000000000..033cdb2cb836 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getInteractionCount } from './polyfills/interactionCountPolyfill.js'; + +export interface Interaction { + _latency: number; + // While the `id` and `entries` properties are also internal and could be + // mangled by prefixing with an underscore, since they correspond to public + // symbols there is no need to mangle them as the library will compress + // better if we reuse the existing names. + id: number; + entries: PerformanceEventTiming[]; +} + +// To prevent unnecessary memory usage on pages with lots of interactions, +// store at most 10 of the longest interactions to consider as INP candidates. +const MAX_INTERACTIONS_TO_CONSIDER = 10; + +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; +}; + +/** + * + */ +export class InteractionManager { + /** + * A list of longest interactions on the page (by latency) sorted so the + * longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER + * long. + */ + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _longestInteractionList: Interaction[] = []; + + /** + * A mapping of longest interactions by their interaction ID. + * This is used for faster lookup. + */ + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _longestInteractionMap: Map = new Map(); + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onBeforeProcessingEntry?: (entry: PerformanceEventTiming) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onAfterProcessingINPCandidate?: (interaction: Interaction) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc + _resetInteractions() { + prevInteractionCount = getInteractionCount(); + this._longestInteractionList.length = 0; + this._longestInteractionMap.clear(); + } + + /** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _estimateP98LongestInteraction() { + const candidateInteractionIndex = Math.min( + this._longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return this._longestInteractionList[candidateInteractionIndex]; + } + + /** + * Takes a performance entry and adds it to the list of worst interactions + * if its duration is long enough to make it among the worst. If the + * entry is part of an existing interaction, it is merged and the latency + * and entries list is updated as needed. + */ + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _processEntry(entry: PerformanceEventTiming) { + this._onBeforeProcessingEntry?.(entry); + + // Skip further processing for entries that cannot be INP candidates. + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + // The least-long of the 10 longest interactions. + const minLongestInteraction = this._longestInteractionList.at(-1); + + let interaction = this._longestInteractionMap.get(entry.interactionId!); + + // Only process the entry if it's possibly one of the ten longest, + // or if it's part of an existing interaction. + if ( + interaction || + this._longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || + // If the above conditions are false, `minLongestInteraction` will be set. + entry.duration > minLongestInteraction!._latency + ) { + // If the interaction already exists, update it. Otherwise create one. + if (interaction) { + // If the new entry has a longer duration, replace the old entries, + // otherwise add to the array. + if (entry.duration > interaction._latency) { + interaction.entries = [entry]; + interaction._latency = entry.duration; + } else if (entry.duration === interaction._latency && entry.startTime === interaction.entries[0]!.startTime) { + interaction.entries.push(entry); + } + } else { + interaction = { + id: entry.interactionId!, + entries: [entry], + _latency: entry.duration, + }; + this._longestInteractionMap.set(interaction.id, interaction); + this._longestInteractionList.push(interaction); + } + + // Sort the entries by latency (descending) and keep only the top ten. + this._longestInteractionList.sort((a, b) => b._latency - a._latency); + if (this._longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { + const removedInteractions = this._longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER); + + for (const interaction of removedInteractions) { + this._longestInteractionMap.delete(interaction.id); + } + } + + // Call any post-processing on the interaction + this._onAfterProcessingINPCandidate?.(interaction); + } + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts new file mode 100644 index 000000000000..752c6c41469b --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line jsdoc/require-jsdoc +export class LCPEntryManager { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc + _processEntry(entry: LargestContentfulPaint) { + this._onBeforeProcessingEntry?.(entry); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts new file mode 100644 index 000000000000..76de0eb8290c --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LayoutShiftManager.ts @@ -0,0 +1,55 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class LayoutShiftManager { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _onAfterProcessingUnexpectedShift?: (entry: LayoutShift) => void; + + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _sessionValue = 0; + // eslint-disable-next-line @sentry-internal/sdk/no-class-field-initializers, @typescript-eslint/explicit-member-accessibility + _sessionEntries: LayoutShift[] = []; + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + _processEntry(entry: LayoutShift) { + // Only count layout shifts without recent user input. + if (entry.hadRecentInput) return; + + const firstSessionEntry = this._sessionEntries[0]; + const lastSessionEntry = this._sessionEntries.at(-1); + + // If the entry occurred less than 1 second after the previous entry + // and less than 5 seconds after the first entry in the session, + // include the entry in the current session. Otherwise, start a new + // session. + if ( + this._sessionValue && + firstSessionEntry && + lastSessionEntry && + entry.startTime - lastSessionEntry.startTime < 1000 && + entry.startTime - firstSessionEntry.startTime < 5000 + ) { + this._sessionValue += entry.value; + this._sessionEntries.push(entry); + } else { + this._sessionValue = entry.value; + this._sessionEntries = [entry]; + } + + this._onAfterProcessingUnexpectedShift?.(entry); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts b/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts index 43fdc8d9e541..2eba91d9effb 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts @@ -37,7 +37,7 @@ export const bindReporter = ( return (forceReport?: boolean) => { if (metric.value >= 0) { if (forceReport || reportAllChanges) { - delta = metric.value - (prevValue || 0); + delta = metric.value - (prevValue ?? 0); // Report the metric if there's a non-zero delta or if no previous // value exists (which can happen in the case of the document becoming diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts index 637d01398e0a..983ebc81ea4a 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts @@ -20,5 +20,5 @@ * @return {string} */ export const generateUniqueID = () => { - return `v4-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; + return `v5-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts index 4bdafc0c718c..33677466faf9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts @@ -18,5 +18,5 @@ import { getNavigationEntry } from './getNavigationEntry'; export const getActivationStart = (): number => { const navEntry = getNavigationEntry(); - return navEntry?.activationStart || 0; + return navEntry?.activationStart ?? 0; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts index f2c85f6127bc..77c68999b918 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts @@ -21,12 +21,12 @@ import { WINDOW } from '../../../types'; export const getNavigationEntry = (checkResponseStart = true): PerformanceNavigationTiming | void => { const navigationEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0]; // Check to ensure the `responseStart` property is present and valid. - // In some cases no value is reported by the browser (for + // In some cases a zero value is reported by the browser (for // privacy/security reasons), and in other cases (bugs) the value is // negative or is larger than the current page time. Ignore these cases: - // https://github.com/GoogleChrome/web-vitals/issues/137 - // https://github.com/GoogleChrome/web-vitals/issues/162 - // https://github.com/GoogleChrome/web-vitals/issues/275 + // - https://github.com/GoogleChrome/web-vitals/issues/137 + // - https://github.com/GoogleChrome/web-vitals/issues/162 + // - https://github.com/GoogleChrome/web-vitals/issues/275 if ( // sentry-specific change: // We don't want to check for responseStart for our own use of `getNavigationEntry` diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index b658be9475e9..3a6c0a2e42a9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types'; +import { getActivationStart } from './getActivationStart'; let firstHiddenTime = -1; @@ -24,7 +25,7 @@ const initHiddenTime = () => { // that visibility state is always 'hidden' during prerendering, so we have // to ignore that case until prerendering finishes (see: `prerenderingchange` // event logic below). - return WINDOW.document!.visibilityState === 'hidden' && !WINDOW.document!.prerendering ? 0 : Infinity; + return WINDOW.document?.visibilityState === 'hidden' && !WINDOW.document?.prerendering ? 0 : Infinity; }; const onVisibilityUpdate = (event: Event) => { @@ -61,11 +62,22 @@ const removeChangeListeners = () => { export const getVisibilityWatcher = () => { if (WINDOW.document && firstHiddenTime < 0) { - // If the document is hidden when this code runs, assume it was hidden - // since navigation start. This isn't a perfect heuristic, but it's the - // best we can do until an API is available to support querying past - // visibilityState. - firstHiddenTime = initHiddenTime(); + // Check if we have a previous hidden `visibility-state` performance entry. + const activationStart = getActivationStart(); + const firstVisibilityStateHiddenTime = !WINDOW.document.prerendering + ? globalThis.performance + .getEntriesByType('visibility-state') + .filter(e => e.name === 'hidden' && e.startTime > activationStart)[0]?.startTime + : undefined; + + // Prefer that, but if it's not available and the document is hidden when + // this code runs, assume it was hidden since navigation start. This isn't + // a perfect heuristic, but it's the best we can do until the + // `visibility-state` performance entry becomes available in all browsers. + firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime(); + // We're still going to listen to for changes so we can handle things like + // bfcache restores and/or prerender without having to examine individual + // timestamps in detail. addChangeListeners(); } return { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts index b2cfbc609a25..8771a5966c9f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts @@ -20,7 +20,7 @@ import { generateUniqueID } from './generateUniqueID'; import { getActivationStart } from './getActivationStart'; import { getNavigationEntry } from './getNavigationEntry'; -export const initMetric = (name: MetricName, value?: number) => { +export const initMetric = (name: MetricName, value: number = -1) => { const navEntry = getNavigationEntry(); let navigationType: MetricType['navigationType'] = 'navigate'; @@ -39,7 +39,7 @@ export const initMetric = (name: MetricNa return { name, - value: typeof value === 'undefined' ? -1 : value, + value, rating: 'good' as const, // If needed, will be updated when reported. `const` to keep the type from widening to `string`. delta: 0, entries, diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts new file mode 100644 index 000000000000..1eda48705b08 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const instanceMap: WeakMap = new WeakMap(); + +/** + * A function that accepts and identity object and a class object and returns + * either a new instance of that class or an existing instance, if the + * identity object was previously used. + */ +export function initUnique(identityObj: object, ClassObj: new () => T): T { + if (!instanceMap.get(identityObj)) { + instanceMap.set(identityObj, new ClassObj()); + } + return instanceMap.get(identityObj)! as T; +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts index ad71468b6fb6..9af0116cd0b1 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -41,7 +41,7 @@ interface PerformanceEntryMap { export const observe = ( type: K, callback: (entries: PerformanceEntryMap[K]) => void, - opts?: PerformanceObserverInit, + opts: PerformanceObserverInit = {}, ): PerformanceObserver | undefined => { try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { @@ -54,18 +54,10 @@ export const observe = ( callback(list.getEntries() as PerformanceEntryMap[K]); }); }); - po.observe( - Object.assign( - { - type, - buffered: true, - }, - opts || {}, - ) as PerformanceObserverInit, - ); + po.observe({ type, buffered: true, ...opts }); return po; } - } catch (e) { + } catch { // Do nothing. } return; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index f1640d4fcdac..1844a616a479 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -21,13 +21,13 @@ export interface OnHiddenCallback { } // Sentry-specific change: -// This function's logic was NOT updated to web-vitals 4.2.4 but we continue -// to use the web-vitals 3.5.2 due to us having stricter browser support. +// This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue +// to use the web-vitals 3.5.2 versiondue to us having stricter browser support. // PR with context that made the changes: https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 // The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. -// This is "more correct" but some browsers we still support (Safari 12.1-14.0) don't fully support `visibilitychange` +// This is "more correct" but some browsers we still support (Safari <14.4) don't fully support `visibilitychange` // or have known bugs w.r.t the `visibilitychange` event. -// TODO (v9): If we decide to drop support for Safari 12.1-14.0, we can use the logic from web-vitals 4.2.4 +// TODO (v10): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 // In this case, we also need to update the integration tests that currently trigger the `pagehide` event to // simulate the page being hidden. export const onHidden = (cb: OnHiddenCallback) => { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts similarity index 67% rename from packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 8914c45d7bb3..32dae5f30f8b 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -14,27 +14,28 @@ * limitations under the License. */ -import { WINDOW } from '../../../types'; -import { onHidden } from './onHidden'; -import { runOnce } from './runOnce'; +import { WINDOW } from '../../../types.js'; +import { onHidden } from './onHidden.js'; +import { runOnce } from './runOnce.js'; /** * Runs the passed callback during the next idle period, or immediately * if the browser's visibility state is (or becomes) hidden. */ -export const whenIdle = (cb: () => void): number => { +export const whenIdleOrHidden = (cb: () => void) => { const rIC = WINDOW.requestIdleCallback || WINDOW.setTimeout; - let handle = -1; - // eslint-disable-next-line no-param-reassign - cb = runOnce(cb) as () => void; // If the document is hidden, run the callback immediately, otherwise // race an idle callback with the next `visibilitychange` event. if (WINDOW.document?.visibilityState === 'hidden') { cb(); } else { - handle = rIC(cb); + // eslint-disable-next-line no-param-reassign + cb = runOnce(cb); + rIC(cb); + // sentry: we use onHidden instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. onHidden(cb); } - return handle; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts index d01001ad48ec..12fd51e29ef7 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts @@ -38,7 +38,7 @@ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = let report: ReturnType; const handleEntries = (entries: FCPMetric['entries']) => { - entries.forEach(entry => { + for (const entry of entries) { if (entry.name === 'first-contentful-paint') { po!.disconnect(); @@ -53,7 +53,7 @@ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = report(true); } } - }); + } }; const po = observe('paint', handleEntries); diff --git a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts index 235895d093aa..4633b3cd83cb 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts @@ -36,7 +36,7 @@ const whenReady = (callback: () => void) => { addEventListener('load', () => whenReady(callback), true); } else { // Queue a task so the callback runs after `loadEventEnd`. - setTimeout(callback, 0); + setTimeout(callback); } }; diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 5a17b811db96..033fbee09926 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -19,7 +19,7 @@ export * from './types/polyfills'; export * from './types/cls'; export * from './types/fcp'; -export * from './types/fid'; +export * from './types/fid'; // FIX was removed in 5.0.2 but we keep it around for now export * from './types/inp'; export * from './types/lcp'; export * from './types/ttfb'; @@ -65,7 +65,7 @@ declare global { // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution interface LayoutShiftAttribution { - node?: Node; + node: Node | null; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; } @@ -87,9 +87,48 @@ declare global { readonly element: Element | null; } + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + export type ScriptInvokerType = + | 'classic-script' + | 'module-script' + | 'event-listener' + | 'user-callback' + | 'resolve-promise' + | 'reject-promise'; + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + export type ScriptWindowAttribution = 'self' | 'descendant' | 'ancestor' | 'same-page' | 'other'; + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + interface PerformanceScriptTiming extends PerformanceEntry { + /* Overloading PerformanceEntry */ + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + + readonly invokerType: ScriptInvokerType; + readonly invoker: string; + readonly executionStart: DOMHighResTimeStamp; + readonly sourceURL: string; + readonly sourceFunctionName: string; + readonly sourceCharPosition: number; + readonly pauseDuration: DOMHighResTimeStamp; + readonly forcedStyleAndLayoutDuration: DOMHighResTimeStamp; + readonly window?: Window; + readonly windowAttribution: ScriptWindowAttribution; + } + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { - renderStart: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; + readonly startTime: DOMHighResTimeStamp; + readonly duration: DOMHighResTimeStamp; + readonly name: string; + readonly entryType: string; + readonly renderStart: DOMHighResTimeStamp; + readonly styleAndLayoutStart: DOMHighResTimeStamp; + readonly blockingDuration: DOMHighResTimeStamp; + readonly firstUIEventTimestamp: DOMHighResTimeStamp; + readonly scripts: PerformanceScriptTiming[]; } } diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index 846744d96da5..d8315b817f4a 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -24,6 +24,7 @@ import type { TTFBMetric, TTFBMetricWithAttribution } from './ttfb'; export interface Metric { /** * The name of the metric (in acronym form). + * // sentry: re-added FID here since we continue supporting it for now */ name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; @@ -78,6 +79,7 @@ export interface Metric { } /** The union of supported metric types. */ +// sentry: re-added FIDMetric here since we continue supporting it for now export type MetricType = CLSMetric | FCPMetric | FIDMetric | INPMetric | LCPMetric | TTFBMetric; /** The union of supported metric attribution types. */ @@ -104,9 +106,21 @@ export type MetricWithAttribution = */ export type MetricRatingThresholds = [number, number]; +/** + * @deprecated Use metric-specific function types instead, such as: + * `(metric: LCPMetric) => void`. If a single callback type is needed for + * multiple metrics, use `(metric: MetricType) => void`. + */ +export interface ReportCallback { + (metric: MetricType): void; +} + export interface ReportOpts { reportAllChanges?: boolean; - durationThreshold?: number; +} + +export interface AttributionReportOpts extends ReportOpts { + generateTarget?: (el: Node | null) => string; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts index 1d17c2d3eedb..5acaaa27c9ab 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -31,9 +31,10 @@ export interface CLSMetric extends Metric { */ export interface CLSAttribution { /** - * A selector identifying the first element (in document order) that - * shifted when the single largest layout shift contributing to the page's - * CLS score occurred. + * By default, a selector identifying the first element (in document order) + * that shifted when the single largest layout shift that contributed to the + * page's CLS score occurred. If the `generateTarget` configuration option + * was passed, then this will instead be the return value of that function. */ largestShiftTarget?: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts index c19be79a1ce0..e73743866301 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -14,7 +14,15 @@ * limitations under the License. */ -import type { LoadState, Metric } from './base'; +import type { AttributionReportOpts, LoadState, Metric, ReportOpts } from './base'; + +export interface INPReportOpts extends ReportOpts { + durationThreshold?: number; +} + +export interface INPAttributionReportOpts extends AttributionReportOpts { + durationThreshold?: number; +} /** * An INP-specific version of the Metric object. @@ -24,6 +32,22 @@ export interface INPMetric extends Metric { entries: PerformanceEventTiming[]; } +export interface INPLongestScriptSummary { + /** + * The longest Long Animation Frame script entry that intersects the INP + * interaction. + */ + entry: PerformanceScriptTiming; + /** + * The INP subpart where the longest script ran. + */ + subpart: 'input-delay' | 'processing-duration' | 'presentation-delay'; + /** + * The amount of time the longest script intersected the INP duration. + */ + intersectingDuration: number; +} + /** * An object containing potentially-helpful debugging information that * can be sent along with the INP value for the current page visit in order @@ -31,37 +55,20 @@ export interface INPMetric extends Metric { */ export interface INPAttribution { /** - * A selector identifying the element that the user first interacted with - * as part of the frame where the INP candidate interaction occurred. - * If this value is an empty string, that generally means the element was - * removed from the DOM after the interaction. + * By default, a selector identifying the element that the user first + * interacted with as part of the frame where the INP candidate interaction + * occurred. If this value is an empty string, that generally means the + * element was removed from the DOM after the interaction. If the + * `generateTarget` configuration option was passed, then this will instead + * be the return value of that function. */ interactionTarget: string; - /** - * A reference to the HTML element identified by `interactionTargetSelector`. - * NOTE: for attribution purpose, a selector identifying the element is - * typically more useful than the element itself. However, the element is - * also made available in case additional context is needed. - */ - interactionTargetElement: Node | undefined; /** * The time when the user first interacted during the frame where the INP * candidate interaction occurred (if more than one interaction occurred * within the frame, only the first time is reported). */ interactionTime: DOMHighResTimeStamp; - /** - * The best-guess timestamp of the next paint after the interaction. - * In general, this timestamp is the same as the `startTime + duration` of - * the event timing entry. However, since `duration` values are rounded to - * the nearest 8ms, it can sometimes appear that the paint occurred before - * processing ended (which cannot happen). This value clamps the paint time - * so it's always after `processingEnd` from the Event Timing API and - * `renderStart` from the Long Animation Frame API (where available). - * It also averages the duration values for all entries in the same - * animation frame, which should be closer to the "real" value. - */ - nextPaintTime: DOMHighResTimeStamp; /** * The type of interaction, based on the event type of the `event` entry * that corresponds to the interaction (i.e. the first `event` entry @@ -70,20 +77,19 @@ export interface INPAttribution { * and for "keydown" or "keyup" events this will be "keyboard". */ interactionType: 'pointer' | 'keyboard'; + /** + * The best-guess timestamp of the next paint after the interaction. + * In general, this timestamp is the same as the `startTime + duration` of + * the event timing entry. However, since duration values are rounded to the + * nearest 8ms (and can be rounded down), this value is clamped to always be + * reported after the processing times. + */ + nextPaintTime: DOMHighResTimeStamp; /** * An array of Event Timing entries that were processed within the same * animation frame as the INP candidate interaction. */ processedEventEntries: PerformanceEventTiming[]; - /** - * If the browser supports the Long Animation Frame API, this array will - * include any `long-animation-frame` entries that intersect with the INP - * candidate interaction's `startTime` and the `processingEnd` time of the - * last event processed within that animation frame. If the browser does not - * support the Long Animation Frame API or no `long-animation-frame` entries - * are detect, this array will be empty. - */ - longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; /** * The time from when the user interacted with the page until when the * browser was first able to start processing event listeners for that @@ -112,6 +118,48 @@ export interface INPAttribution { * (e.g. usually in the `dom-interactive` phase) it can result in long delays. */ loadState: LoadState; + /** + * If the browser supports the Long Animation Frame API, this array will + * include any `long-animation-frame` entries that intersect with the INP + * candidate interaction's `startTime` and the `processingEnd` time of the + * last event processed within that animation frame. If the browser does not + * support the Long Animation Frame API or no `long-animation-frame` entries + * are detected, this array will be empty. + */ + longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + /** + * Summary information about the longest script entry intersecting the INP + * duration. Note, only script entries above 5 milliseconds are reported by + * the Long Animation Frame API. + */ + longestScript?: INPLongestScriptSummary; + /** + * The total duration of Long Animation Frame scripts that intersect the INP + * duration excluding any forced style and layout (that is included in + * totalStyleAndLayout). Note, this is limited to scripts > 5 milliseconds. + */ + totalScriptDuration?: number; + /** + * The total style and layout duration from any Long Animation Frames + * intersecting the INP interaction. This includes any end-of-frame style and + * layout duration + any forced style and layout duration. + */ + totalStyleAndLayoutDuration?: number; + /** + * The off main-thread presentation delay from the end of the last Long + * Animation Frame (where available) until the INP end point. + */ + totalPaintDuration?: number; + /** + * The total unattributed time not included in any of the previous totals. + * This includes scripts < 5 milliseconds and other timings not attributed + * by Long Animation Frame (including when a frame is < 50ms and so has no + * Long Animation Frame). + * When no Long Animation Frames are present this will be undefined, rather + * than everything being unattributed to make it clearer when it's expected + * to be small. + */ + totalUnattributedDuration?: number; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index 2dd5ea34f798..293531b3d45c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Metric } from './base'; +import type { Metric } from './base.js'; /** * An LCP-specific version of the Metric object. @@ -31,9 +31,12 @@ export interface LCPMetric extends Metric { */ export interface LCPAttribution { /** - * The element corresponding to the largest contentful paint for the page. + * By default, a selector identifying the element corresponding to the + * largest contentful paint for the page. If the `generateTarget` + * configuration option was passed, then this will instead be the return + * value of that function. */ - element?: string; + target?: string; /** * The URL (if applicable) of the LCP image resource. If the LCP element * is a text node, this value will not be set. diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts index cd20a8d083de..66f2d439a3ce 100644 --- a/packages/cloudflare/src/async.ts +++ b/packages/cloudflare/src/async.ts @@ -64,7 +64,16 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { }); } + // In contrast to the browser, we can rely on async context isolation here + function suppressTracing(callback: () => T): T { + return withScope(scope => { + scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + return callback(); + }); + } + setAsyncContextStrategy({ + suppressTracing, withScope, withSetScope, withIsolationScope, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index a96159692ac3..427e4ebb0bf6 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -253,8 +253,15 @@ export function suppressTracing(callback: () => T): T { } return withScope(scope => { + // Note: We do not wait for the callback to finish before we reset the metadata + // the reason for this is that otherwise, in the browser this can lead to very weird behavior + // as there is only a single top scope, if the callback takes longer to finish, + // other, unrelated spans may also be suppressed, which we do not want + // so instead, we only suppress tracing synchronoysly in the browser scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); - return callback(); + const res = callback(); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: undefined }); + return res; }); } diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 83f4b150a6d6..6d25afe13d3e 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1975,6 +1975,40 @@ describe('suppressTracing', () => { expect(spanIsSampled(child)).toBe(false); }); }); + + it('works with parallel processes', async () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + // Note: This is unintuitive, but it is the expected behavior + // because we only suppress tracing synchronously in the browser + const span2Promise = suppressTracing(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return startInactiveSpan({ name: 'span2' }); + }); + + const span3Promise = suppressTracing(async () => { + const span = startInactiveSpan({ name: 'span3' }); + await new Promise(resolve => setTimeout(resolve, 100)); + return span; + }); + + const span4 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span5 = startInactiveSpan({ name: 'span5' }); + + const span2 = await span2Promise; + const span3 = await span3Promise; + + expect(spanIsSampled(span)).toBe(false); + expect(spanIsSampled(span2)).toBe(true); + expect(spanIsSampled(span3)).toBe(false); + expect(spanIsSampled(span4)).toBe(false); + expect(spanIsSampled(span5)).toBe(true); + }); }); describe('startNewTrace', () => { diff --git a/packages/ember/README.md b/packages/ember/README.md index e0c9694d7d49..f28d86f194a1 100644 --- a/packages/ember/README.md +++ b/packages/ember/README.md @@ -70,9 +70,6 @@ following Ember specific configuration: ```javascript ENV['@sentry/ember'] = { - // Will silence Ember.onError warning without the need of using Ember debugging tools. - ignoreEmberOnErrorWarning: false, - // Will disable automatic instrumentation of performance. // Manual instrumentation will still be sent. disablePerformance: true, diff --git a/packages/ember/addon/index.ts b/packages/ember/addon/index.ts index 4a7a59566731..bebd89cb09c3 100644 --- a/packages/ember/addon/index.ts +++ b/packages/ember/addon/index.ts @@ -1,7 +1,6 @@ -import { assert, warn } from '@ember/debug'; +import { assert } from '@ember/debug'; import type Route from '@ember/routing/route'; -import { next } from '@ember/runloop'; -import { getOwnConfig, isDevelopingApp, macroCondition } from '@embroider/macros'; +import { getOwnConfig } from '@embroider/macros'; import type { BrowserOptions } from '@sentry/browser'; import { startSpan } from '@sentry/browser'; import * as Sentry from '@sentry/browser'; @@ -12,7 +11,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; -import Ember from 'ember'; import type { EmberSentryConfig, GlobalConfig, OwnConfig } from './types'; function _getSentryInitConfig(): EmberSentryConfig['sentry'] { @@ -45,24 +43,7 @@ export function init(_runtimeConfig?: BrowserOptions): Client | undefined { const sentryInitConfig = _getSentryInitConfig(); Object.assign(sentryInitConfig, initConfig); - const client = Sentry.init(initConfig); - - if (macroCondition(isDevelopingApp())) { - if (environmentConfig.ignoreEmberOnErrorWarning) { - return client; - } - next(null, function () { - warn( - 'Ember.onerror found. Using Ember.onerror can hide some errors (such as flushed runloop errors) from Sentry. Use Sentry.captureException to capture errors within Ember.onError or remove it to have errors caught by Sentry directly. This error can be silenced via addon configuration.', - !Ember.onerror, - { - id: '@sentry/ember.ember-onerror-detected', - }, - ); - }); - } - - return client; + return Sentry.init(initConfig); } type RouteConstructor = new (...args: ConstructorParameters) => Route; diff --git a/packages/ember/addon/types.ts b/packages/ember/addon/types.ts index 468cde6c310f..a66a290004f0 100644 --- a/packages/ember/addon/types.ts +++ b/packages/ember/addon/types.ts @@ -5,6 +5,9 @@ type BrowserTracingOptions = Parameters[0]; export type EmberSentryConfig = { sentry: BrowserOptions & { browserTracingOptions?: BrowserTracingOptions }; transitionTimeout: number; + /** + * @deprecated This option is no longer used and will be removed in the next major version. + */ ignoreEmberOnErrorWarning: boolean; disableInstrumentComponents: boolean; disablePerformance: boolean; diff --git a/packages/ember/tests/dummy/config/environment.js b/packages/ember/tests/dummy/config/environment.js index 144a6aebe1fa..96f525aaa568 100644 --- a/packages/ember/tests/dummy/config/environment.js +++ b/packages/ember/tests/dummy/config/environment.js @@ -32,7 +32,6 @@ module.exports = function (environment) { }, }, }, - ignoreEmberOnErrorWarning: true, minimumRunloopQueueDuration: 0, minimumComponentRenderDuration: 0, }; diff --git a/packages/node/src/integrations/tracing/koa.ts b/packages/node/src/integrations/tracing/koa.ts index e2a2a52264ae..43b901afebee 100644 --- a/packages/node/src/integrations/tracing/koa.ts +++ b/packages/node/src/integrations/tracing/koa.ts @@ -1,6 +1,7 @@ +import type { KoaInstrumentationConfig, KoaLayerType } from '@opentelemetry/instrumentation-koa'; import { KoaInstrumentation } from '@opentelemetry/instrumentation-koa'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import type { IntegrationFn, Span } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; import { captureException, defineIntegration, @@ -8,27 +9,51 @@ import { getIsolationScope, logger, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { generateInstrumentOnce } from '../../otel/instrument'; +import { addOriginToSpan } from '../../utils/addOriginToSpan'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; +interface KoaOptions { + /** + * Ignore layers of specified types + */ + ignoreLayersType?: Array<'middleware' | 'router'>; +} + const INTEGRATION_NAME = 'Koa'; export const instrumentKoa = generateInstrumentOnce( INTEGRATION_NAME, - () => - new KoaInstrumentation({ + KoaInstrumentation, + (options: KoaOptions = {}) => { + return { + ignoreLayersType: options.ignoreLayersType as KoaLayerType[], requestHook(span, info) { - addKoaSpanAttributes(span); + addOriginToSpan(span, 'auto.http.otel.koa'); + + const attributes = spanToJSON(span).data; + + // this is one of: middleware, router + const type = attributes['koa.type']; + if (type) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.koa`); + } + + // Also update the name + const name = attributes['koa.name']; + if (typeof name === 'string') { + // Somehow, name is sometimes `''` for middleware spans + // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 + span.updateName(name || '< unknown >'); + } if (getIsolationScope() === getDefaultIsolationScope()) { DEBUG_BUILD && logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); return; } - const attributes = spanToJSON(span).data; const route = attributes[ATTR_HTTP_ROUTE]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const method = info.context?.request?.method?.toUpperCase() || 'GET'; @@ -36,14 +61,15 @@ export const instrumentKoa = generateInstrumentOnce( getIsolationScope().setTransactionName(`${method} ${route}`); } }, - }), + } satisfies KoaInstrumentationConfig; + }, ); -const _koaIntegration = (() => { +const _koaIntegration = ((options: KoaOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { - instrumentKoa(); + instrumentKoa(options); }, }; }) satisfies IntegrationFn; @@ -55,6 +81,8 @@ const _koaIntegration = (() => { * * For more information, see the [koa documentation](https://docs.sentry.io/platforms/javascript/guides/koa/). * + * @param {KoaOptions} options Configuration options for the Koa integration. + * * @example * ```javascript * const Sentry = require('@sentry/node'); @@ -63,6 +91,20 @@ const _koaIntegration = (() => { * integrations: [Sentry.koaIntegration()], * }) * ``` + * + * @example + * ```javascript + * // To ignore middleware spans + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [ + * Sentry.koaIntegration({ + * ignoreLayersType: ['middleware'] + * }) + * ], + * }) + * ``` */ export const koaIntegration = defineIntegration(_koaIntegration); @@ -101,24 +143,3 @@ export const setupKoaErrorHandler = (app: { use: (arg0: (ctx: any, next: any) => ensureIsWrapped(app.use, 'koa'); }; - -function addKoaSpanAttributes(span: Span): void { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.http.otel.koa'); - - const attributes = spanToJSON(span).data; - - // this is one of: middleware, router - const type = attributes['koa.type']; - - if (type) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.koa`); - } - - // Also update the name - const name = attributes['koa.name']; - if (typeof name === 'string') { - // Somehow, name is sometimes `''` for middleware spans - // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 - span.updateName(name || '< unknown >'); - } -} diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index 2c5faf04acef..d2f73e02adc3 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -33,7 +33,7 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { instrumentation = instrumentVercelAi(); }, setup(client) { - instrumentation?.callWhenPatched(() => { + function registerProcessors(): void { client.on('spanStart', span => { const { data: attributes, description: name } = spanToJSON(span); @@ -188,7 +188,13 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { return event; }); - }); + } + + if (options.force) { + registerProcessors(); + } else { + instrumentation?.callWhenPatched(registerProcessors); + } }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/vercelai/types.ts b/packages/node/src/integrations/tracing/vercelai/types.ts index 50434b70604f..35cfeb33a112 100644 --- a/packages/node/src/integrations/tracing/vercelai/types.ts +++ b/packages/node/src/integrations/tracing/vercelai/types.ts @@ -56,6 +56,12 @@ export interface VercelAiOptions { * or if you set `isEnabled` to `true` in your ai SDK method telemetry settings */ recordOutputs?: boolean; + + /** + * By default, the instrumentation will register span processors only when the ai package is used. + * If you want to register the span processors even when the ai package usage cannot be detected, you can set `force` to `true`. + */ + force?: boolean; } export interface VercelAiIntegration extends Integration { diff --git a/packages/node/test/integrations/tracing/koa.test.ts b/packages/node/test/integrations/tracing/koa.test.ts new file mode 100644 index 000000000000..9ca221dfba03 --- /dev/null +++ b/packages/node/test/integrations/tracing/koa.test.ts @@ -0,0 +1,83 @@ +import { KoaInstrumentation } from '@opentelemetry/instrumentation-koa'; +import { type MockInstance, beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentKoa, koaIntegration } from '../../../src/integrations/tracing/koa'; +import { INSTRUMENTED } from '../../../src/otel/instrument'; + +vi.mock('@opentelemetry/instrumentation-koa'); + +describe('Koa', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete INSTRUMENTED.Koa; + + (KoaInstrumentation as unknown as MockInstance).mockImplementation(() => { + return { + setTracerProvider: () => undefined, + setMeterProvider: () => undefined, + getConfig: () => ({}), + setConfig: () => ({}), + enable: () => undefined, + }; + }); + }); + + it('defaults are correct for instrumentKoa', () => { + instrumentKoa({}); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: undefined, + requestHook: expect.any(Function), + }); + }); + + it('passes ignoreLayersType option to instrumentation', () => { + instrumentKoa({ ignoreLayersType: ['middleware'] }); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: ['middleware'], + requestHook: expect.any(Function), + }); + }); + + it('passes multiple ignoreLayersType values to instrumentation', () => { + instrumentKoa({ ignoreLayersType: ['middleware', 'router'] }); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: ['middleware', 'router'], + requestHook: expect.any(Function), + }); + }); + + it('defaults are correct for koaIntegration', () => { + koaIntegration().setupOnce!(); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: undefined, + requestHook: expect.any(Function), + }); + }); + + it('passes options from koaIntegration to instrumentation', () => { + koaIntegration({ ignoreLayersType: ['middleware'] }).setupOnce!(); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: ['middleware'], + requestHook: expect.any(Function), + }); + }); + + it('passes multiple options from koaIntegration to instrumentation', () => { + koaIntegration({ ignoreLayersType: ['router', 'middleware'] }).setupOnce!(); + + expect(KoaInstrumentation).toHaveBeenCalledTimes(1); + expect(KoaInstrumentation).toHaveBeenCalledWith({ + ignoreLayersType: ['router', 'middleware'], + requestHook: expect.any(Function), + }); + }); +}); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 17f2d90f0ca4..0cbea66817fc 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -47,7 +47,7 @@ "@sentry/core": "9.28.1", "@sentry/node": "9.28.1", "@sentry/opentelemetry": "9.28.1", - "@sentry/rollup-plugin": "3.4.0", + "@sentry/rollup-plugin": "3.5.0", "@sentry/vite-plugin": "3.2.4", "@sentry/vue": "9.28.1" }, diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index f9aed823a4a4..d8432172a601 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -1921,6 +1921,38 @@ describe('suppressTracing', () => { expect(spanIsSampled(child)).toBe(false); }); }); + + it('works with parallel processes', async () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span2Promise = suppressTracing(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return startInactiveSpan({ name: 'span2' }); + }); + + const span3Promise = suppressTracing(async () => { + const span = startInactiveSpan({ name: 'span3' }); + await new Promise(resolve => setTimeout(resolve, 100)); + return span; + }); + + const span4 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span5 = startInactiveSpan({ name: 'span5' }); + + const span2 = await span2Promise; + const span3 = await span3Promise; + + expect(spanIsSampled(span)).toBe(false); + expect(spanIsSampled(span2)).toBe(false); + expect(spanIsSampled(span3)).toBe(false); + expect(spanIsSampled(span4)).toBe(false); + expect(spanIsSampled(span5)).toBe(true); + }); }); function getSpanName(span: AbstractSpan): string | undefined { diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index 5aadfdd876be..202face147d9 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -31,8 +31,8 @@ const HOOKS: { [key in Operation]: Hook[] } = { update: ['beforeUpdate', 'updated'], }; -/** Finish top-level component span and activity with a debounce configured using `timeout` option */ -function finishRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { +/** End the top-level component span and activity with a debounce configured using `timeout` option */ +function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { if (vm.$_sentryRootComponentSpanTimer) { clearTimeout(vm.$_sentryRootComponentSpanTimer); } @@ -66,6 +66,8 @@ export const createTracingMixins = (options: Partial = {}): Mixi const mixins: Mixins = {}; + const rootComponentSpanFinalTimeout = options.timeout || 2000; + for (const operation of hooks) { // Retrieve corresponding hooks from Vue lifecycle. // eg. mount => ['beforeMount', 'mounted'] @@ -91,6 +93,9 @@ export const createTracingMixins = (options: Partial = {}): Mixi }, onlyIfParent: true, }); + + // call debounced end function once directly, just in case no child components call it + maybeEndRootComponentSpan(this, timestampInSeconds(), rootComponentSpanFinalTimeout); } // 2. Component tracking filter @@ -102,7 +107,10 @@ export const createTracingMixins = (options: Partial = {}): Mixi ? findTrackComponent(options.trackComponents, componentName) : options.trackComponents); + // We always want to track root component if (!shouldTrack) { + // even if we don't track `this` component, we still want to end the root span eventually + maybeEndRootComponentSpan(this, timestampInSeconds(), rootComponentSpanFinalTimeout); return; } @@ -117,7 +125,7 @@ export const createTracingMixins = (options: Partial = {}): Mixi if (activeSpan) { // Cancel any existing span for this operation (safety measure) // We're actually not sure if it will ever be the case that cleanup hooks were not called. - // However, we had users report that spans didn't get finished, so we finished the span before + // However, we had users report that spans didn't end, so we end the span before // starting a new one, just to be sure. const oldSpan = this.$_sentryComponentSpans[operation]; if (oldSpan) { @@ -142,8 +150,8 @@ export const createTracingMixins = (options: Partial = {}): Mixi if (!span) return; // Skip if no span was created in the "before" hook span.end(); - // For any "after" hook, also schedule the root component span to finish - finishRootComponentSpan(this, timestampInSeconds(), options.timeout || 2000); + // For any "after" hook, also schedule the root component span to end + maybeEndRootComponentSpan(this, timestampInSeconds(), rootComponentSpanFinalTimeout); } }; } diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts index d67690271ed2..2c08a20c61cd 100644 --- a/packages/vue/test/tracing/tracingMixin.test.ts +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -27,11 +27,10 @@ vi.mock('../../src/vendor/components', () => { }; }); -const mockSpanFactory = (): { name?: string; op?: string; end: Mock; startChild: Mock } => ({ +const mockSpanFactory = (): { name?: string; op?: string; end: Mock } => ({ name: undefined, op: undefined, end: vi.fn(), - startChild: vi.fn(), }); vi.useFakeTimers(); @@ -127,23 +126,25 @@ describe('Vue Tracing Mixins', () => { ); }); - it('should finish root component span on timer after component spans end', () => { - // todo/fixme: This root component span is only finished if trackComponents is true --> it should probably be always finished - const mixins = createTracingMixins({ trackComponents: true, timeout: 1000 }); - const rootMockSpan = mockSpanFactory(); - mockRootInstance.$_sentryRootComponentSpan = rootMockSpan; - - // Create and finish a component span - mixins.beforeMount.call(mockVueInstance); - mixins.mounted.call(mockVueInstance); - - // Root component span should not end immediately - expect(rootMockSpan.end).not.toHaveBeenCalled(); - - // After timeout, root component span should end - vi.advanceTimersByTime(1001); - expect(rootMockSpan.end).toHaveBeenCalled(); - }); + it.each([true, false])( + 'should finish root component span on timer after component spans end, if trackComponents is %s', + trackComponents => { + const mixins = createTracingMixins({ trackComponents, timeout: 1000 }); + const rootMockSpan = mockSpanFactory(); + mockRootInstance.$_sentryRootComponentSpan = rootMockSpan; + + // Create and finish a component span + mixins.beforeMount.call(mockVueInstance); + mixins.mounted.call(mockVueInstance); + + // Root component span should not end immediately + expect(rootMockSpan.end).not.toHaveBeenCalled(); + + // After timeout, root component span should end + vi.advanceTimersByTime(1001); + expect(rootMockSpan.end).toHaveBeenCalled(); + }, + ); }); describe('Component Span Lifecycle', () => { diff --git a/yarn.lock b/yarn.lock index 050e00b29f6a..02a6f885056b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6571,11 +6571,6 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.4.tgz#c0877df6e5ce227bf51754bf27da2fa5227af847" integrity sha512-yBzRn3GEUSv1RPtE4xB4LnuH74ZxtdoRJ5cmQ9i6mzlmGDxlrnKuvem5++AolZTE9oJqAD3Tx2rd1PqmpWnLoA== -"@sentry/babel-plugin-component-annotate@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz#f47a7652e16f84556df82cbc38f0004bca1335d1" - integrity sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg== - "@sentry/babel-plugin-component-annotate@3.5.0": version "3.5.0" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz#1b0d01f903b725da876117d551610085c3dd21c7" @@ -6609,20 +6604,6 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/bundler-plugin-core@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz#3a3459aba94cbeb093347f5730f15df25153fd0a" - integrity sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "3.4.0" - "@sentry/cli" "2.42.2" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" - "@sentry/bundler-plugin-core@3.5.0": version "3.5.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz#b62af5be1b1a862e7062181655829c556c7d7c0b" @@ -6751,12 +6732,12 @@ "@sentry/cli-win32-i686" "2.46.0" "@sentry/cli-win32-x64" "2.46.0" -"@sentry/rollup-plugin@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-3.4.0.tgz#326618d6fe91a030ee4ab335e1bab35f201090b0" - integrity sha512-oqDcjV+aaTZZ7oOadk90KlShOYfKEEQsvbZtzHl7HPHNt5kmtTaQyWphPIDt2Z9OCK8QF5T8GLsr1MCOXJ6vqA== +"@sentry/rollup-plugin@3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-3.5.0.tgz#9015c48e00257f8440597167498499804371329b" + integrity sha512-aMPCvdNMkv//LZYjYCJsEcNiNiaQFinBO75+9NJVEe1OrKNdGqDi3hky2ll7zuY+xozEtZCZcUKJJz/aAYAS8A== dependencies: - "@sentry/bundler-plugin-core" "3.4.0" + "@sentry/bundler-plugin-core" "3.5.0" unplugin "1.0.1" "@sentry/vite-plugin@2.22.6", "@sentry/vite-plugin@^2.22.6":