diff --git a/.craft.yml b/.craft.yml index 5c1a754a52e9..77bfdfcd14b7 100644 --- a/.craft.yml +++ b/.craft.yml @@ -58,6 +58,9 @@ targets: - name: npm id: '@sentry/react' includeNames: /^sentry-react-\d.*\.tgz$/ + - name: npm + id: '@sentry/solid' + includeNames: /^sentry-solid-\d.*\.tgz$/ - name: npm id: '@sentry/svelte' includeNames: /^sentry-svelte-\d.*\.tgz$/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35fb1d8d1436..bd039420fc3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -993,7 +993,7 @@ jobs: [ 'angular-17', 'angular-18', - 'aws-lambda-layer', + 'aws-lambda-layer-cjs', 'cloudflare-astro', 'node-express', 'create-react-app', @@ -1014,7 +1014,7 @@ jobs: 'react-router-6-use-routes', 'react-router-5', 'react-router-6', - 'solidjs', + 'solid', 'svelte-5', 'sveltekit', 'sveltekit-2', diff --git a/.size-limit.js b/.size-limit.js index f9b62e7198e9..ac2b8591254a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -15,14 +15,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '34 KB', + limit: '35 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '71 KB', + limit: '72 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -48,14 +48,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '75 KB', + limit: '76 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '87 KB', + limit: '89 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback, metrics)', @@ -69,21 +69,21 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '40 KB', + limit: '30 KB', }, { name: '@sentry/browser (incl. Feedback)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '40 KB', + limit: '41 KB', }, { name: '@sentry/browser (incl. sendFeedback)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '28 KB', + limit: '29 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -107,7 +107,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '37 KB', + limit: '38 KB', }, // Vue SDK (ESM) { @@ -143,7 +143,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '36 KB', + limit: '37 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -193,7 +193,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '37 KB', + limit: '38 KB', }, // SvelteKit SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7f203ec826..81a28a8de323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.9.0 + +### Important changes + +- **feat(solid): Add Solid SDK** + + This release adds a dedicated SDK for [Solid JS](https://www.solidjs.com/) in alpha state with instrumentation for + [Solid Router](https://docs.solidjs.com/solid-router) and a custom `ErrorBoundary`. See the + [package README](https://github.com/getsentry/sentry-javascript/blob/develop/packages/solid/README.md) for how to use + the SDK. + +### Other changes + +- feat(deps): bump @opentelemetry/instrumentation-express from 0.40.0 to 0.40.1 (#12438) +- feat(deps): bump @opentelemetry/instrumentation-mongodb from 0.44.0 to 0.45.0 (#12439) +- feat(deps): bump @opentelemetry/propagator-aws-xray from 1.24.1 to 1.25.0 (#12437) +- feat(nextjs): Allow for suppressing warning about missing global error handler file (#12369) +- feat(redis): Add cache logic for redis-4 (#12429) +- feat(replay): Replay Web Vital Breadcrumbs (#12296) +- fix: Fix types export order (#12404) +- fix(astro): Ensure server-side exports work correctly (#12453) +- fix(aws-serverless): Add `op` to Otel-generated lambda function root span (#12430) +- fix(aws-serverless): Only auto-patch handler in CJS when loading `awslambda-auto` (#12392) +- fix(aws-serverless): Only start root span in Sentry wrapper if Otel didn't wrap handler (#12407) +- fix(browser): Fix INP span creation & transaction tagging (#12372) +- fix(nextjs): correct types conditional export ordering (#12355) +- fix(replay): Fix guard for exception event (#12441) +- fix(vue): Handle span name assignment for nested routes in VueRouter (#12398) + +Work in this release was contributed by @soch4n. Thank you for your contribution! + ## 8.8.0 - **feat: Upgrade OTEL dependencies (#12388)** @@ -48,6 +79,8 @@ If you are still encountering issues with OpenTelemetry instrumentation and ESM, - ref(browser): Ensure idle span ending is consistent (#12310) - ref(profiling): unref timer (#12340) +Work in this release contributed by @dohooo, @mohd-akram, and @ykzts. Thank you for your contributions! + ## 8.7.0 ### Important Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a02b096ed125..161cd60ef540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,10 @@ Developer Documentation. ## Setting up an Environment -To run the test suite and our code linter, node.js and yarn are required. +We use [Volta](https://volta.sh/) to ensure we use consistent versions of node, yarn and pnpm. -[`node` download](https://nodejs.org/download) [`yarn` download](https://yarnpkg.com/en/docs/install) +Make sure to also enable [pnpm support in Volta](https://docs.volta.sh/advanced/pnpm) if you want to run the E2E tests +locally. `sentry-javascript` is a monorepo containing several packages, and we use `lerna` to manage them. To get started, install all dependencies, and then perform an initial build, so TypeScript can read all of the linked type definitions. diff --git a/dev-packages/browser-integration-tests/playwright.config.ts b/dev-packages/browser-integration-tests/playwright.config.ts index 77ed6014d230..b03f758e11dc 100644 --- a/dev-packages/browser-integration-tests/playwright.config.ts +++ b/dev-packages/browser-integration-tests/playwright.config.ts @@ -11,7 +11,7 @@ const config: PlaywrightTestConfig = { testMatch: /test.ts/, use: { - trace: process.env.CI ? 'retain-on-failure' : 'off', + trace: 'retain-on-failure', }, projects: [ diff --git a/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/subject.js b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/subject.js new file mode 100644 index 000000000000..2f2e65131b96 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/subject.js @@ -0,0 +1 @@ +fetch('http://localhost:123/fake/endpoint/that/will/fail'); diff --git a/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/test.ts b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/test.ts new file mode 100644 index 000000000000..8d70241ec592 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/fetchStackTrace/test.ts @@ -0,0 +1,13 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests } from '../../../../utils/helpers'; + +sentryTest('should create errors with stack traces for failing fetch calls', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 3, { url, timeout: 10000 }); + const errorEvent = envelopes.find(event => !event.type)!; + expect(errorEvent?.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/init.js b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/init.js new file mode 100644 index 000000000000..d8c94f36fdd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/globalHandlers/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index 41c90d94ffdf..053c31c3881e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -2,8 +2,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; import { + expectedCLSPerformanceSpan, expectedClickBreadcrumb, expectedFCPPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -62,6 +64,8 @@ sentryTest( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush diff --git a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts index 1148847f09c7..7bacf5a8ae17 100644 --- a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts @@ -2,8 +2,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; import { + expectedCLSPerformanceSpan, expectedClickBreadcrumb, expectedFCPPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -78,11 +80,13 @@ sentryTest( const collectedPerformanceSpans = [...recording0.performanceSpans, ...recording1.performanceSpans]; const collectedBreadcrumbs = [...recording0.breadcrumbs, ...recording1.breadcrumbs]; - expect(collectedPerformanceSpans.length).toEqual(6); + expect(collectedPerformanceSpans.length).toEqual(8); expect(collectedPerformanceSpans).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush @@ -116,11 +120,13 @@ sentryTest( const collectedPerformanceSpansAfterReload = [...recording2.performanceSpans, ...recording3.performanceSpans]; const collectedBreadcrumbsAdterReload = [...recording2.breadcrumbs, ...recording3.breadcrumbs]; - expect(collectedPerformanceSpansAfterReload.length).toEqual(6); + expect(collectedPerformanceSpansAfterReload.length).toEqual(8); expect(collectedPerformanceSpansAfterReload).toEqual( expect.arrayContaining([ expectedReloadPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -188,6 +194,8 @@ sentryTest( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -304,11 +312,13 @@ sentryTest( ]; const collectedBreadcrumbsAfterIndexNavigation = [...recording8.breadcrumbs, ...recording9.breadcrumbs]; - expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(6); + expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(8); expect(collectedPerformanceSpansAfterIndexNavigation).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js new file mode 100644 index 000000000000..1044a4b68bda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/subject.js new file mode 100644 index 000000000000..ed6db5b5afe2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/subject.js @@ -0,0 +1,18 @@ +const blockUI = (delay = 70) => e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/template.html new file mode 100644 index 000000000000..25c6920f07e2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + 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 new file mode 100644 index 000000000000..1ec7ec50998a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -0,0 +1,98 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanEnvelope } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest('should capture an INP click event span after pageload', async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + // Get the INP span envelope + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + const traceId = spanEnvelopeHeaders.trace!.trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: traceId, + }, + }); + + const inpValue = spanEnvelopeItem.measurements?.inp.value; + expect(inpValue).toBeGreaterThan(0); + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': inpValue, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + transaction: 'test-url', + }, + measurements: { + inp: { + unit: 'millisecond', + value: inpValue, + }, + }, + description: 'body > NormalButton', + exclusive_time: inpValue, + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + is_segment: true, + segment_id: spanEnvelopeItem.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js new file mode 100644 index 000000000000..895e6f60ff42 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-route', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/subject.js new file mode 100644 index 000000000000..ed6db5b5afe2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/subject.js @@ -0,0 +1,18 @@ +const blockUI = (delay = 70) => e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/template.html new file mode 100644 index 000000000000..25c6920f07e2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + 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 new file mode 100644 index 000000000000..1354c373253e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -0,0 +1,102 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanEnvelope } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest( + 'should capture an INP click event span after pageload for a parametrized transaction', + async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + // Get the INP span envelope + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + const traceId = spanEnvelopeHeaders.trace!.trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: traceId, + transaction: 'test-route', + }, + }); + + const inpValue = spanEnvelopeItem.measurements?.inp.value; + expect(inpValue).toBeGreaterThan(0); + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': inpValue, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + transaction: 'test-route', + }, + measurements: { + inp: { + unit: 'millisecond', + value: inpValue, + }, + }, + description: 'body > NormalButton', + exclusive_time: inpValue, + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + is_segment: true, + segment_id: spanEnvelopeItem.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/init.js new file mode 100644 index 000000000000..fa9619209dfe --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 4000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-route', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/subject.js new file mode 100644 index 000000000000..ed6db5b5afe2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/subject.js @@ -0,0 +1,18 @@ +const blockUI = (delay = 70) => e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/template.html new file mode 100644 index 000000000000..25c6920f07e2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + 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 new file mode 100644 index 000000000000..248cb7d1e510 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -0,0 +1,99 @@ +import { expect } from '@playwright/test'; +import type { SpanEnvelope } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest( + 'should capture an INP click event span during pageload for a parametrized transaction', + async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + // Get the INP span envelope + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + const traceId = spanEnvelopeHeaders.trace!.trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: traceId, + transaction: 'test-route', + }, + }); + + const inpValue = spanEnvelopeItem.measurements?.inp.value; + expect(inpValue).toBeGreaterThan(0); + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': inpValue, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + transaction: 'test-route', + }, + measurements: { + inp: { + unit: 'millisecond', + value: inpValue, + }, + }, + description: 'body > NormalButton', + exclusive_time: inpValue, + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + segment_id: expect.not.stringMatching(spanEnvelopeItem.span_id!), + // parent is the pageload span + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + }, +); 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 b558562e4cd4..a941877ff88e 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 @@ -6,10 +6,22 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, + idleTimeout: 4000, enableLongTask: false, enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, }), ], tracesSampleRate: 1, }); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); 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 582508f7a584..3f9684cf7f2a 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 @@ -9,92 +9,95 @@ import { shouldSkipTracingTest, } from '../../../../utils/helpers'; -sentryTest('should capture an INP click event span.', async ({ browserName, getLocalTestPath, page }) => { - const supportedBrowsers = ['chromium']; - - if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { - sentryTest.skip(); - } - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); - }); +sentryTest( + 'should capture an INP click event span during pageload', + async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; - const url = await getLocalTestPath({ testDir: __dirname }); + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } - await page.goto(url); - await getFirstSentryEnvelopeRequest(page); // wait for page load + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); - const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( - page, - 1, - { envelopeType: 'span' }, - properFullEnvelopeRequestParser, - ); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.locator('[data-test-id=normal-button]').click(); - await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + await page.goto(url); - await page.waitForTimeout(500); + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); - // Page hide to trigger INP - await page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); - // Get the INP span envelope - const spanEnvelope = (await spanEnvelopePromise)[0]; + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); - const spanEnvelopeHeaders = spanEnvelope[0]; - const spanEnvelopeItem = spanEnvelope[1][0][1]; + // Get the INP span envelope + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + const traceId = spanEnvelopeHeaders.trace!.trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: traceId, + // no transaction, because span source is URL + }, + }); - const traceId = spanEnvelopeHeaders.trace!.trace_id; - expect(traceId).toMatch(/[a-f0-9]{32}/); + const inpValue = spanEnvelopeItem.measurements?.inp.value; + expect(inpValue).toBeGreaterThan(0); - expect(spanEnvelopeHeaders).toEqual({ - sent_at: expect.any(String), - trace: { - environment: 'production', - public_key: 'public', - sample_rate: '1', - sampled: 'true', - trace_id: traceId, - }, - }); - - const inpValue = spanEnvelopeItem.measurements?.inp.value; - expect(inpValue).toBeGreaterThan(0); - - expect(spanEnvelopeItem).toEqual({ - data: { - 'sentry.exclusive_time': inpValue, - 'sentry.op': 'ui.interaction.click', - 'sentry.origin': 'manual', - 'sentry.sample_rate': 1, - 'sentry.source': 'custom', - }, - measurements: { - inp: { - unit: 'millisecond', - value: inpValue, + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': inpValue, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + transaction: 'test-url', + }, + measurements: { + inp: { + unit: 'millisecond', + value: inpValue, + }, }, - }, - description: 'body > NormalButton', - exclusive_time: inpValue, - op: 'ui.interaction.click', - origin: 'manual', - is_segment: true, - segment_id: spanEnvelopeItem.span_id, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: traceId, - }); -}); + description: 'body > NormalButton', + exclusive_time: inpValue, + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + segment_id: expect.not.stringMatching(spanEnvelopeItem.span_id!), + // Parent is the pageload span + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + }, +); sentryTest( 'should choose the slowest interaction click event when INP is triggered.', diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts index de6b1521d686..6a98022dcdd2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts @@ -17,12 +17,8 @@ sentryTest('should create spans for fetch requests', async ({ getLocalTestPath, // We will wait 500ms for all envelopes to be sent. Generally, in all browsers, the last sent // envelope contains tracing data. - - // If we are on FF or webkit: - // 1st envelope contains CORS error - // 2nd envelope contains the tracing data we want to check here - const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); - const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + const envelopes = await getMultipleSentryEnvelopeRequests(page, 4, { url, timeout: 10000 }); + const tracingEvent = envelopes.find(event => event.type === 'transaction')!; // last envelope contains tracing data on all browsers const requestSpans = tracingEvent.spans?.filter(({ op }) => op === 'http.client'); diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 03354c6b3185..257c47fbfa9b 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -121,17 +121,56 @@ export const expectedMemoryPerformanceSpan = { }; export const expectedLCPPerformanceSpan = { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { value: expect.any(Number), nodeId: expect.any(Number), + rating: expect.any(String), size: expect.any(Number), }, }; +export const expectedCLSPerformanceSpan = { + op: 'web-vital', + description: 'cumulative-layout-shift', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + }, +}; + +export const expectedFIDPerformanceSpan = { + op: 'web-vital', + description: 'first-input-delay', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + nodeId: expect.any(Number), + }, +}; + +export const expectedINPPerformanceSpan = { + op: 'web-vital', + description: 'interaction-to-next-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + nodeId: expect.any(Number), + }, +}; + export const expectedFCPPerformanceSpan = { op: 'paint', description: 'first-contentful-paint', diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/.npmrc b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/.npmrc similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer/.npmrc rename to dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer/package.json rename to dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/playwright.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer/playwright.config.ts rename to dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/playwright.config.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js new file mode 100644 index 000000000000..c688ed35a0c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js @@ -0,0 +1,21 @@ +const Sentry = require('@sentry/aws-serverless'); + +const http = require('http'); + +async function handle() { + await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + await new Promise(resolve => { + http.get('http://example.com', res => { + res.on('data', d => { + process.stdout.write(d); + }); + + res.on('end', () => { + resolve(); + }); + }); + }); + }); +} + +module.exports = { handle }; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js new file mode 100644 index 000000000000..1d6e059e78f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js @@ -0,0 +1,7 @@ +const { handle } = require('./lambda-function'); +const event = {}; +const context = { + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + functionName: 'my-lambda', +}; +handle(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js similarity index 64% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run.js rename to dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js index 2a99cff2d48e..2605f624ca9a 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run.js +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js @@ -4,8 +4,9 @@ child_process.execSync('node ./src/run-lambda.js', { stdio: 'inherit', env: { ...process.env, - LAMBDA_TASK_ROOT: '.', - _HANDLER: 'handle', + // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler + LAMBDA_TASK_ROOT: process.cwd(), + _HANDLER: 'src/lambda-function.handle', NODE_OPTIONS: '--require @sentry/aws-serverless/dist/awslambda-auto', SENTRY_DSN: 'http://public@localhost:3031/1337', diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs similarity index 71% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs index e64e99cda75b..abc7ea7b0ab2 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs @@ -2,6 +2,6 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'aws-serverless-lambda-layer', + proxyServerName: 'aws-serverless-lambda-layer-cjs', forwardToSentry: false, }); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts new file mode 100644 index 000000000000..53a44c424cf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts @@ -0,0 +1,71 @@ +import * as child_process from 'child_process'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Lambda layer SDK bundle sends events', async ({ request }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-layer-cjs', transactionEvent => { + return transactionEvent?.transaction === 'my-lambda'; + }); + + // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous + // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) + // which are usually enough for us to never have noticed this race condition before. + // This is a workaround but probably sufficient as long as we only experience it in this test. + await new Promise(resolve => + setTimeout(() => { + resolve(); + }, 1000), + ); + + child_process.execSync('pnpm start', { + stdio: 'ignore', + }); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '123453789012', + 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/lambda-function.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/lambda-function.js deleted file mode 100644 index aa8f236b742d..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/lambda-function.js +++ /dev/null @@ -1,19 +0,0 @@ -const Sentry = require('@sentry/aws-serverless'); - -const http = require('http'); - -function handle() { - Sentry.startSpanManual({ name: 'aws-lambda-layer-test-txn', op: 'test' }, span => { - http.get('http://example.com', res => { - res.on('data', d => { - process.stdout.write(d); - }); - - res.on('end', () => { - span.end(); - }); - }); - }); -} - -module.exports = { handle }; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run-lambda.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run-lambda.js deleted file mode 100644 index 5e573c484637..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/src/run-lambda.js +++ /dev/null @@ -1,2 +0,0 @@ -const { handle } = require('./lambda-function'); -handle(); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer/tests/basic.test.ts deleted file mode 100644 index 7f7f5ec1854c..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer/tests/basic.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as child_process from 'child_process'; -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Lambda layer SDK bundle sends events', async ({ request }) => { - const transactionEventPromise = waitForTransaction('aws-serverless-lambda-layer', transactionEvent => { - return transactionEvent?.transaction === 'aws-lambda-layer-test-txn'; - }); - - await new Promise(resolve => - setTimeout(() => { - resolve(); - }, 1000), - ); - - child_process.execSync('pnpm start', { - stdio: 'ignore', - }); - - const transactionEvent = await transactionEventPromise; - - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('aws-lambda-layer-test-txn'); - - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toHaveLength(1); - expect(transactionEvent.spans![0]).toMatchObject({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', - url: 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/package.json b/dev-packages/e2e-tests/test-applications/create-react-app/package.json index 4b1c62920154..ce3471d2a7d1 100644 --- a/dev-packages/e2e-tests/test-applications/create-react-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-react-app/package.json @@ -4,18 +4,13 @@ "private": true, "dependencies": { "@sentry/react": "latest || *", - "@testing-library/jest-dom": "5.14.1", - "@testing-library/react": "13.0.0", - "@testing-library/user-event": "13.2.1", - "@types/jest": "27.0.1", "@types/node": "16.7.13", "@types/react": "18.0.0", "@types/react-dom": "18.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-scripts": "5.0.1", - "typescript": "4.9.5", - "web-vitals": "2.1.0" + "typescript": "4.9.5" }, "scripts": { "start": "react-scripts start", diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx b/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx index 1c10b3e92da6..3dcb4c4fd07f 100644 --- a/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx @@ -3,7 +3,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; -import reportWebVitals from './reportWebVitals'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -21,8 +20,3 @@ root.render( , ); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/src/reportWebVitals.ts b/dev-packages/e2e-tests/test-applications/create-react-app/src/reportWebVitals.ts deleted file mode 100644 index 49a2a16e0fbc..000000000000 --- a/dev-packages/e2e-tests/test-applications/create-react-app/src/reportWebVitals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReportHandler } from 'web-vitals'; - -const reportWebVitals = (onPerfEntry?: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/start-event-proxy.mjs index 4d029c3b87be..7e8016bf98a5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'nextjs-13-app-dir', + proxyServerName: 'nextjs-app-dir', }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts index e5fea269e322..ecce719f0656 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts @@ -2,9 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Should allow for async context isolation in the edge SDK', async ({ request }) => { - // test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); - - const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 5e8e89eec3d8..41e63f910f79 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -4,7 +4,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Creates a pageload transaction for app router routes', async ({ page }) => { const randomRoute = String(Math.random()); - const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && transactionEvent.contexts?.trace?.op === 'pageload' @@ -19,7 +19,7 @@ test('Creates a pageload transaction for app router routes', async ({ page }) => test('Creates a navigation transaction for app router routes', async ({ page }) => { const randomRoute = String(Math.random()); - const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && transactionEvent.contexts?.trace?.op === 'pageload' @@ -30,14 +30,14 @@ test('Creates a navigation transaction for app router routes', async ({ page }) await clientPageloadTransactionPromise; await page.getByText('Page (/server-component/parameter/[parameter])').isVisible(); - const clientNavigationTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' && transactionEvent.contexts?.trace?.op === 'navigation' ); }); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' && (await clientNavigationTransactionPromise).contexts?.trace?.trace_id === diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts new file mode 100644 index 000000000000..b45f61e274a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends a client-side exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); + + await page.getByText('Throw error').click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Click Error'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts index 09a18791db1f..287c1e8f8633 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -4,7 +4,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Will capture a connected trace for all server components and generation functions when visiting a page', async ({ page, }) => { - const someConnectedEvent = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const someConnectedEvent = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' || transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' || @@ -13,28 +13,28 @@ test('Will capture a connected trace for all server components and generation fu ); }); - const layout1Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const layout1Transaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' && (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); }); - const layout2Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const layout2Transaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' && (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); }); - const pageTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const pageTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' && (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); }); - const generateMetadataTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const generateMetadataTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' && (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts index e4b122521c2d..d1ca11ad9a9e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts @@ -10,7 +10,7 @@ test.describe('dev mode error symbolification', () => { test('should have symbolicated dev errors', async ({ page }) => { await page.goto('/'); - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts index df0dda64c4ba..df7ce7afd19a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/edge-endpoint' && transactionEvent?.contexts?.trace?.status === 'ok' ); @@ -24,7 +24,7 @@ test('Should create a transaction for edge routes', async ({ request }) => { }); test('Should create a transaction with error status for faulty edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && transactionEvent?.contexts?.trace?.status === 'internal_error' @@ -47,7 +47,7 @@ test('Should create a transaction with error status for faulty edge routes', asy }); test('Should record exceptions for faulty edge routes', async ({ request }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts index f6c0e7f5bad4..f5277dee6f66 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should record exceptions for faulty edge server components', async ({ page }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Edge Server Component Error'; }); @@ -20,7 +20,7 @@ test('Should record exceptions for faulty edge server components', async ({ page }); test('Should record transaction for edge server components', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'Page Server Component (/edge-server-components)'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts deleted file mode 100644 index 4f0f4a6abfce..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -const authToken = process.env.E2E_TEST_AUTH_TOKEN; -const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; -const EVENT_POLLING_TIMEOUT = 90_000; - -test('Sends a client-side exception to Sentry', async ({ page }) => { - await page.goto('/'); - - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; - }); - - await page.getByText('Throw error').click(); - - const errorEvent = await errorEventPromise; - const exceptionEventId = errorEvent.event_id; - - expect(errorEvent.transaction).toBe('/'); - - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts index 2a7b2deab27c..11a5f48799bd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'ok'; }); @@ -21,7 +21,7 @@ test('Should create a transaction for middleware', async ({ request }) => { }); test('Should create a transaction with error status for faulty middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'internal_error' ); @@ -39,7 +39,7 @@ test('Should create a transaction with error status for faulty middleware', asyn }); test('Records exceptions happening in middleware', async ({ request }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; }); @@ -56,7 +56,7 @@ test('Records exceptions happening in middleware', async ({ request }) => { }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware' && !!transactionEvent.spans?.find(span => span.op === 'http.client') diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts index 9cd49e58990f..a67e4328ba1c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts @@ -2,11 +2,11 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Will capture error for SSR rendering error with a connected trace (Class Component)', async ({ page }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error Class'; }); - const serverComponentTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === '/pages-router/ssr-error-class' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id @@ -20,11 +20,11 @@ test('Will capture error for SSR rendering error with a connected trace (Class C }); test('Will capture error for SSR rendering error with a connected trace (Functional Component)', async ({ page }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error FC'; }); - const ssrTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const ssrTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === '/pages-router/ssr-error-fc' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts index 8d07d0192f44..d032c6985c94 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts @@ -5,7 +5,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Sometimes the request span was included in the handler span, more often it wasn't. I have no idea why. Maybe one day we will // figure it out. Today is not that day. test.skip('Should send a transaction with a http span', async ({ request }) => { - const transactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const transactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'GET /api/request-instrumentation'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index d10f402c0c17..afa02e60884a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'GET /route-handlers/[param]'; }); @@ -19,7 +19,7 @@ test('Should create a transaction for route handlers', async ({ request }) => { test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ request, }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'POST /route-handlers/[param]'; }); @@ -33,11 +33,11 @@ test('Should create a transaction for route handlers and correctly set span stat }); test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'route-handler-error'; }); - const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'PUT /route-handlers/[param]/error'; }); @@ -65,7 +65,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn test.describe('Edge runtime', () => { test('should create a transaction for route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge'; }); @@ -79,11 +79,11 @@ test.describe('Edge runtime', () => { }); test('should record exceptions and transactions for faulty route handlers', async ({ request }) => { - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error'; }); - const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 9c28f78510da..6f0413d0cc61 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -1,16 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -const authToken = process.env.E2E_TEST_AUTH_TOKEN; -const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; -const EVENT_POLLING_TIMEOUT = 90_000; - test('Sends a transaction for a server component', async ({ page }) => { // TODO: Fix that this is flakey on dev server - might be an SDK bug test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'function.nextjs' && transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' @@ -20,37 +15,51 @@ test('Sends a transaction for a server component', async ({ page }) => { await page.goto('/server-component/parameter/1337/42'); const transactionEvent = await serverComponentTransactionPromise; - const transactionEventId = transactionEvent.event_id; - - expect(transactionEvent.request?.headers).toBeDefined(); - - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - return response.status; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'function.nextjs', + 'sentry.origin': 'auto.function.nextjs', + 'sentry.sample_rate': 1, + 'sentry.source': 'component', + }), + op: 'function.nextjs', + origin: 'auto.function.nextjs', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + request: { + cookies: {}, + headers: expect.any(Object), + url: expect.any(String), }, - { - timeout: EVENT_POLLING_TIMEOUT, + transaction: 'Page Server Component (/server-component/parameter/[...parameters])', + type: 'transaction', + transaction_info: { + source: 'component', }, - ) - .toBe(200); + spans: [], + }), + ); }); test('Should not set an error status on a server component transaction when it redirects', async ({ page }) => { // TODO: Fix that this is flakey on dev server - might be an SDK bug test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'Page Server Component (/server-component/redirect)'; }); await page.goto('/server-component/redirect'); - expect((await serverComponentTransactionPromise).contexts?.trace?.status).not.toBe('internal_error'); + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace?.status).not.toBe('internal_error'); }); test('Should set a "not_found" status on a server component transaction when notFound() is called', async ({ @@ -59,21 +68,23 @@ test('Should set a "not_found" status on a server component transaction when not // TODO: Fix that this is flakey on dev server - might be an SDK bug test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'Page Server Component (/server-component/not-found)'; }); await page.goto('/server-component/not-found'); - expect((await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found'); + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace?.status).toBe('not_found'); }); test('Should capture an error and transaction with correct status for a faulty server component', async ({ page }) => { - const transactionEventPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const transactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'Page Server Component (/server-component/faulty)'; }); - const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'I am a faulty server component'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 4d0f71ac9d97..4a5c7771705b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -3,35 +3,43 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; const packageJson = require('../package.json'); -const authToken = process.env.E2E_TEST_AUTH_TOKEN; -const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; -const EVENT_POLLING_TIMEOUT = 90_000; - test('Sends a pageload transaction', async ({ page }) => { - const pageloadTransactionEventPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const pageloadTransactionEventPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; }); await page.goto('/'); const transactionEvent = await pageloadTransactionEventPromise; - const transactionEventId = transactionEvent.event_id; - - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - return response.status; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/', + tags: { runtime: 'browser' }, + transaction_info: { source: 'url' }, + type: 'transaction', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + data: expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.sample_rate': 1, + 'sentry.source': 'url', + }), + }, }, - { - timeout: EVENT_POLLING_TIMEOUT, + request: { + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://localhost:3030/', }, - ) - .toBe(200); + }), + ); }); test('Should send a transaction for instrumented server actions', async ({ page }) => { @@ -39,22 +47,24 @@ test('Should send a transaction for instrumented server actions', async ({ page const nextjsMajor = Number(nextjsVersion.split('.')[0]); test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'serverAction/myServerAction'; }); await page.goto('/server-action'); await page.getByText('Run Action').click(); - expect(await serverComponentTransactionPromise).toBeDefined(); - expect((await serverComponentTransactionPromise).extra).toMatchObject({ + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.extra).toMatchObject({ 'server_action_form_data.some-text-value': 'some-default-value', server_action_result: { city: 'Vienna', }, }); - expect(Object.keys((await serverComponentTransactionPromise).request?.headers || {}).length).toBeGreaterThan(0); + expect(Object.keys(transactionEvent.request?.headers || {}).length).toBeGreaterThan(0); }); test('Should set not_found status for server actions calling notFound()', async ({ page }) => { @@ -62,21 +72,23 @@ test('Should set not_found status for server actions calling notFound()', async const nextjsMajor = Number(nextjsVersion.split('.')[0]); test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return transactionEvent?.transaction === 'serverAction/notFoundServerAction'; }); await page.goto('/server-action'); await page.getByText('Run NotFound Action').click(); - expect(await serverComponentTransactionPromise).toBeDefined(); - expect(await (await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found'); + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.contexts?.trace?.status).toBe('not_found'); }); test('Will not include spans in pageload transaction with faulty timestamps for slow loading pages', async ({ page, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + const pageloadTransactionEventPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/very-slow-component' ); @@ -86,6 +98,5 @@ test('Will not include spans in pageload transaction with faulty timestamps for const pageLoadTransaction = await pageloadTransactionEventPromise; - // @ts-expect-error We are looking at the serialized span format here - expect(pageLoadTransaction.spans?.filter(span => span.timestamp < span.start_timestamp)).toHaveLength(0); + expect(pageLoadTransaction.spans?.filter(span => span.timestamp! < span.start_timestamp)).toHaveLength(0); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs index abb70111543d..cc680866ab1a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs @@ -1,3 +1,4 @@ +import * as http from 'http'; import * as Sentry from '@sentry/node'; import express from 'express'; @@ -25,6 +26,20 @@ app.get('/test-error', function (req, res) { }, 100); }); +app.get('/http-req', function (req, res) { + http + .request('http://example.com', httpRes => { + let data = ''; + httpRes.on('data', d => { + data += d; + }); + httpRes.on('end', () => { + res.status(200).send(data).end(); + }); + }) + .end(); +}); + Sentry.setupExpressErrorHandler(app); app.use(function onError(err, req, res, next) { diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts index f1b06f9d0304..533a44cefaf4 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -121,3 +121,46 @@ test('Should record a transaction for route with parameters', async ({ request } trace_id: expect.any(String), }); }); + +test('Should record spans from http instrumentation', async ({ request }) => { + const transactionEventPromise = waitForTransaction('node-express-esm-preload', transactionEvent => { + return transactionEvent.contexts?.trace?.data?.['http.target'] === '/http-req'; + }); + + await request.get('/http-req'); + + const transactionEvent = await transactionEventPromise; + + const httpClientSpan = transactionEvent.spans?.find(span => span.op === 'http.client'); + + expect(httpClientSpan).toEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'http.flavor': '1.1', + 'http.host': 'example.com:80', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.response_content_length_uncompressed': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/', + 'http.url': 'http://example.com/', + 'net.peer.ip': expect.any(String), + 'net.peer.name': 'example.com', + 'net.peer.port': 80, + 'net.transport': 'ip_tcp', + 'otel.kind': 'CLIENT', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }, + description: 'GET http://example.com/', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'http.client', + origin: 'auto.http.otel.http', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts index aa4704a1d950..fa0ace0e1e5d 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts @@ -12,7 +12,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { await fetch(`${baseURL}/test-transaction`); const transactionEvent = await pageloadTransactionEventPromise; - const transactionEventId = transactionEvent.event_id; expect(transactionEvent.contexts?.trace).toEqual({ data: { diff --git a/dev-packages/e2e-tests/test-applications/react-19/package.json b/dev-packages/e2e-tests/test-applications/react-19/package.json index d83bd81d6c3e..058fd0bb847a 100644 --- a/dev-packages/e2e-tests/test-applications/react-19/package.json +++ b/dev-packages/e2e-tests/test-applications/react-19/package.json @@ -4,20 +4,15 @@ "private": true, "dependencies": { "@sentry/react": "latest || *", - "@testing-library/jest-dom": "5.14.1", - "@testing-library/react": "13.0.0", - "@testing-library/user-event": "13.2.1", "history": "4.9.0", "@types/history": "4.7.11", - "@types/jest": "27.0.1", "@types/node": "16.7.13", "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", "react": "19.0.0-rc-935180c7e0-20240524", "react-dom": "19.0.0-rc-935180c7e0-20240524", "react-scripts": "5.0.1", - "typescript": "4.9.5", - "web-vitals": "2.1.0" + "typescript": "4.9.5" }, "scripts": { "build": "react-scripts build", diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index c289c76af506..d8d6a58fe16a 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -4,10 +4,6 @@ "private": true, "dependencies": { "@sentry/react": "latest || *", - "@testing-library/jest-dom": "5.14.1", - "@testing-library/react": "13.0.0", - "@testing-library/user-event": "13.2.1", - "@types/jest": "27.0.1", "@types/node": "16.7.13", "@types/react": "18.0.0", "@types/react-dom": "18.0.0", diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/package.json b/dev-packages/e2e-tests/test-applications/react-router-5/package.json index e60c3e7f346b..55ccf5492d9f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-5/package.json @@ -4,12 +4,8 @@ "private": true, "dependencies": { "@sentry/react": "latest || *", - "@testing-library/jest-dom": "5.14.1", - "@testing-library/react": "13.0.0", - "@testing-library/user-event": "13.2.1", "history": "4.9.0", "@types/history": "4.7.11", - "@types/jest": "27.0.1", "@types/node": "16.7.13", "@types/react": "18.0.0", "@types/react-dom": "18.0.0", @@ -19,8 +15,7 @@ "react-dom": "18.2.0", "react-router-dom": "5.3.4", "react-scripts": "5.0.1", - "typescript": "4.9.5", - "web-vitals": "2.1.0" + "typescript": "4.9.5" }, "scripts": { "build": "react-scripts build", diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts index 554fac59f88e..e7fd943c0f08 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts @@ -212,7 +212,7 @@ export const ReplayRecordingData = [ data: { tag: 'performanceSpan', payload: { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json index cfe6db496351..4684e3401e63 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json @@ -4,10 +4,6 @@ "private": true, "dependencies": { "@sentry/react": "latest || *", - "@testing-library/jest-dom": "5.14.1", - "@testing-library/react": "13.0.0", - "@testing-library/user-event": "13.2.1", - "@types/jest": "27.0.1", "@types/node": "16.7.13", "@types/react": "18.0.0", "@types/react-dom": "18.0.0", @@ -15,8 +11,7 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "4.9.5", - "web-vitals": "2.1.0" + "typescript": "4.9.5" }, "scripts": { "build": "react-scripts build", diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts index 554fac59f88e..156c2775f5ff 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts @@ -212,18 +212,56 @@ export const ReplayRecordingData = [ data: { tag: 'performanceSpan', payload: { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { value: expect.any(Number), size: expect.any(Number), + rating: expect.any(String), nodeId: 16, }, }, }, }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'web-vital', + description: 'cumulative-layout-shift', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + size: expect.any(Number), + rating: expect.any(String), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'web-vital', + description: 'first-input-delay', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + size: expect.any(Number), + rating: expect.any(String), + nodeId: 10, + }, + }, + }, + }, { type: 5, timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/solidjs/.gitignore b/dev-packages/e2e-tests/test-applications/solid/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/.gitignore rename to dev-packages/e2e-tests/test-applications/solid/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/solidjs/.npmrc b/dev-packages/e2e-tests/test-applications/solid/.npmrc similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/.npmrc rename to dev-packages/e2e-tests/test-applications/solid/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/solidjs/README.md b/dev-packages/e2e-tests/test-applications/solid/README.md similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/README.md rename to dev-packages/e2e-tests/test-applications/solid/README.md diff --git a/dev-packages/e2e-tests/test-applications/solidjs/index.html b/dev-packages/e2e-tests/test-applications/solid/index.html similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/index.html rename to dev-packages/e2e-tests/test-applications/solid/index.html diff --git a/dev-packages/e2e-tests/test-applications/solidjs/package.json b/dev-packages/e2e-tests/test-applications/solid/package.json similarity index 93% rename from dev-packages/e2e-tests/test-applications/solidjs/package.json rename to dev-packages/e2e-tests/test-applications/solid/package.json index 1922a6e26d17..c63d3080748f 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/package.json +++ b/dev-packages/e2e-tests/test-applications/solid/package.json @@ -1,5 +1,5 @@ { - "name": "solidjs", + "name": "solid", "version": "0.0.0", "description": "", "scripts": { @@ -28,6 +28,6 @@ "dependencies": { "@solidjs/router": "^0.13.5", "solid-js": "^1.8.11", - "@sentry/solidjs": "latest || *" + "@sentry/solid": "latest || *" } } diff --git a/dev-packages/e2e-tests/test-applications/solidjs/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solid/playwright.config.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/playwright.config.mjs rename to dev-packages/e2e-tests/test-applications/solid/playwright.config.mjs diff --git a/dev-packages/e2e-tests/test-applications/solidjs/postcss.config.js b/dev-packages/e2e-tests/test-applications/solid/postcss.config.js similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/postcss.config.js rename to dev-packages/e2e-tests/test-applications/solid/postcss.config.js diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/errors/404.tsx b/dev-packages/e2e-tests/test-applications/solid/src/errors/404.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/src/errors/404.tsx rename to dev-packages/e2e-tests/test-applications/solid/src/errors/404.tsx diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/index.css b/dev-packages/e2e-tests/test-applications/solid/src/index.css similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/src/index.css rename to dev-packages/e2e-tests/test-applications/solid/src/index.css diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/index.tsx b/dev-packages/e2e-tests/test-applications/solid/src/index.tsx similarity index 94% rename from dev-packages/e2e-tests/test-applications/solidjs/src/index.tsx rename to dev-packages/e2e-tests/test-applications/solid/src/index.tsx index 47e7c0e52904..b975502ef590 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/solid/src/index.tsx @@ -1,5 +1,5 @@ /* @refresh reload */ -import * as Sentry from '@sentry/solidjs'; +import * as Sentry from '@sentry/solid'; import { Router, useBeforeLeave, useLocation } from '@solidjs/router'; import { render } from 'solid-js/web'; import './index.css'; diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/pageroot.tsx b/dev-packages/e2e-tests/test-applications/solid/src/pageroot.tsx similarity index 77% rename from dev-packages/e2e-tests/test-applications/solidjs/src/pageroot.tsx rename to dev-packages/e2e-tests/test-applications/solid/src/pageroot.tsx index d9770c8a3868..0919c0e362db 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/src/pageroot.tsx +++ b/dev-packages/e2e-tests/test-applications/solid/src/pageroot.tsx @@ -10,6 +10,11 @@ export default function PageRoot(props) { Home +
  • + + Error Boundary Example + +
  • Error diff --git a/dev-packages/e2e-tests/test-applications/solid/src/pages/errorboundaryexample.tsx b/dev-packages/e2e-tests/test-applications/solid/src/pages/errorboundaryexample.tsx new file mode 100644 index 000000000000..b4cb4e93a02f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid/src/pages/errorboundaryexample.tsx @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/solid'; +import { ErrorBoundary } from 'solid-js'; + +const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); + +export default function ErrorBoundaryExample() { + return ( + ( +
    +

    Error Boundary Fallback

    +
    + {error.message} +
    + +
    + )} + > + +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/pages/home.tsx b/dev-packages/e2e-tests/test-applications/solid/src/pages/home.tsx similarity index 93% rename from dev-packages/e2e-tests/test-applications/solidjs/src/pages/home.tsx rename to dev-packages/e2e-tests/test-applications/solid/src/pages/home.tsx index 7500846f0555..08e92728762c 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/src/pages/home.tsx +++ b/dev-packages/e2e-tests/test-applications/solid/src/pages/home.tsx @@ -25,7 +25,7 @@ export default function Home() { class="border rounded-lg px-2 mb-2 border-red-500 text-red-500 cursor-pointer" id="errorBtn" onClick={() => { - throw new Error('Error thrown from SolidJS E2E test app'); + throw new Error('Error thrown from Solid E2E test app'); }} > Throw error diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/pages/user.tsx b/dev-packages/e2e-tests/test-applications/solid/src/pages/user.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/src/pages/user.tsx rename to dev-packages/e2e-tests/test-applications/solid/src/pages/user.tsx diff --git a/dev-packages/e2e-tests/test-applications/solidjs/src/routes.ts b/dev-packages/e2e-tests/test-applications/solid/src/routes.ts similarity index 66% rename from dev-packages/e2e-tests/test-applications/solidjs/src/routes.ts rename to dev-packages/e2e-tests/test-applications/solid/src/routes.ts index 7b115f68c00c..96b78e113ef5 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/solid/src/routes.ts @@ -1,5 +1,6 @@ import { lazy } from 'solid-js'; +import ErrorBoundaryExample from './pages/errorboundaryexample'; import Home from './pages/home'; export const routes = [ @@ -11,6 +12,10 @@ export const routes = [ path: '/user/:id', component: lazy(() => import('./pages/user')), }, + { + path: '/error-boundary-example', + component: ErrorBoundaryExample, + }, { path: '**', component: lazy(() => import('./errors/404')), diff --git a/dev-packages/e2e-tests/test-applications/solidjs/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/solid/start-event-proxy.mjs similarity index 78% rename from dev-packages/e2e-tests/test-applications/solidjs/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/solid/start-event-proxy.mjs index 207afe3f56e1..075d4dcb5cf5 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/solid/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'solidjs', + proxyServerName: 'solid', }); diff --git a/dev-packages/e2e-tests/test-applications/solidjs/tailwind.config.ts b/dev-packages/e2e-tests/test-applications/solid/tailwind.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/tailwind.config.ts rename to dev-packages/e2e-tests/test-applications/solid/tailwind.config.ts diff --git a/dev-packages/e2e-tests/test-applications/solid/tests/errorboundary.test.ts b/dev-packages/e2e-tests/test-applications/solid/tests/errorboundary.test.ts new file mode 100644 index 000000000000..279eec70c059 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid/tests/errorboundary.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('captures an exception', async ({ page }) => { + const errorEventPromise = waitForError('solid', errorEvent => { + return !errorEvent.type; + }); + + const [, errorEvent] = await Promise.all([page.goto('/error-boundary-example'), errorEventPromise]); + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'ReferenceError', + value: 'NonExistentComponent is not defined', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary-example', + }); +}); + +test('captures a second exception after resetting the boundary', async ({ page }) => { + const firstErrorEventPromise = waitForError('solid', errorEvent => { + return !errorEvent.type; + }); + + const [, firstErrorEvent] = await Promise.all([page.goto('/error-boundary-example'), firstErrorEventPromise]); + + expect(firstErrorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'ReferenceError', + value: 'NonExistentComponent is not defined', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary-example', + }); + + const secondErrorEventPromise = waitForError('solid', errorEvent => { + return !errorEvent.type; + }); + + const [, secondErrorEvent] = await Promise.all([ + page.locator('#errorBoundaryResetBtn').click(), + await secondErrorEventPromise, + ]); + + expect(secondErrorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'ReferenceError', + value: 'NonExistentComponent is not defined', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary-example', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidjs/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/solid/tests/errors.test.ts similarity index 81% rename from dev-packages/e2e-tests/test-applications/solidjs/tests/errors.test.ts rename to dev-packages/e2e-tests/test-applications/solid/tests/errors.test.ts index 92618f628407..b55a9192ab1c 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid/tests/errors.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; test('sends an error', async ({ page }) => { - const errorPromise = waitForError('solidjs', async errorEvent => { + const errorPromise = waitForError('solid', async errorEvent => { return !errorEvent.type; }); @@ -15,7 +15,7 @@ test('sends an error', async ({ page }) => { values: [ { type: 'Error', - value: 'Error thrown from SolidJS E2E test app', + value: 'Error thrown from Solid E2E test app', mechanism: { type: 'onerror', handled: false, diff --git a/dev-packages/e2e-tests/test-applications/solidjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/solid/tests/performance.test.ts similarity index 82% rename from dev-packages/e2e-tests/test-applications/solidjs/tests/performance.test.ts rename to dev-packages/e2e-tests/test-applications/solid/tests/performance.test.ts index 166dfe01d32b..f73ff4940527 100644 --- a/dev-packages/e2e-tests/test-applications/solidjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid/tests/performance.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('sends a pageload transaction', async ({ page }) => { - const transactionPromise = waitForTransaction('solidjs', async transactionEvent => { + const transactionPromise = waitForTransaction('solid', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); @@ -23,7 +23,7 @@ test('sends a pageload transaction', async ({ page }) => { }); test('sends a navigation transaction', async ({ page }) => { - const transactionPromise = waitForTransaction('solidjs', async transactionEvent => { + const transactionPromise = waitForTransaction('solid', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); @@ -35,7 +35,7 @@ test('sends a navigation transaction', async ({ page }) => { contexts: { trace: { op: 'navigation', - origin: 'auto.navigation.solidjs.solidrouter', + origin: 'auto.navigation.solid.solidrouter', }, }, transaction: '/user/5', @@ -49,7 +49,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // Solid Router sends a `-1` navigation when using the back button. // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. - const navigationTxnPromise = waitForTransaction('solidjs', async transactionEvent => { + const navigationTxnPromise = waitForTransaction('solid', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); @@ -61,7 +61,7 @@ test('updates the transaction when using the back button', async ({ page }) => { contexts: { trace: { op: 'navigation', - origin: 'auto.navigation.solidjs.solidrouter', + origin: 'auto.navigation.solid.solidrouter', }, }, transaction: '/user/5', @@ -70,7 +70,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, }); - const backNavigationTxnPromise = waitForTransaction('solidjs', async transactionEvent => { + const backNavigationTxnPromise = waitForTransaction('solid', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); @@ -80,7 +80,7 @@ test('updates the transaction when using the back button', async ({ page }) => { contexts: { trace: { op: 'navigation', - origin: 'auto.navigation.solidjs.solidrouter', + origin: 'auto.navigation.solid.solidrouter', }, }, transaction: '/', diff --git a/dev-packages/e2e-tests/test-applications/solidjs/tsconfig.json b/dev-packages/e2e-tests/test-applications/solid/tsconfig.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/tsconfig.json rename to dev-packages/e2e-tests/test-applications/solid/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/solidjs/vite.config.ts b/dev-packages/e2e-tests/test-applications/solid/vite.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidjs/vite.config.ts rename to dev-packages/e2e-tests/test-applications/solid/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index 8c3ac217716f..aac6fb815f43 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -21,6 +21,15 @@ const router = createRouter({ path: '/users-error/:id', component: () => import('../views/UserIdErrorView.vue'), }, + { + path: '/categories', + children: [ + { + path: ':id', + component: () => import('../views/CategoryIdView.vue'), + }, + ], + }, ], }); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/CategoryIdView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/CategoryIdView.vue new file mode 100644 index 000000000000..c3b59c9fb7f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/CategoryIdView.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index bdf7b5b8e1fe..d9a594b5abe7 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -66,6 +66,35 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }); }); +test('sends a pageload transaction with a nested route URL', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/categories/123`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.id': '123', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/categories/:id', + transaction_info: { + source: 'route', + }, + }); +}); + test('sends a pageload transaction with a route name as transaction name if available', async ({ page }) => { const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index d9b11385dfa2..f582d7e02ef0 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -128,7 +128,7 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! - '@sentry/solidjs': + '@sentry/solid': access: $all publish: $all unpublish: $all diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index fc821bff7875..705e417d4f50 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -55,6 +55,7 @@ "node-schedule": "^2.1.1", "pg": "^8.7.3", "proxy": "^2.1.1", + "redis-4": "npm:redis@^4.6.14", "reflect-metadata": "0.2.1", "rxjs": "^7.8.1", "yargs": "^16.2.0" diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index b8c16862c34c..2f79385521d3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -21,63 +21,159 @@ describe('MongoDB experimental Test', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', - spans: expect.arrayContaining([ + spans: [ expect.objectContaining({ - data: expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.operation': 'isMaster', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + 'otel.kind': 'CLIENT', + }, + description: + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }), + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.operation': 'isMaster', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + 'otel.kind': 'CLIENT', + }, + description: + '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', + op: 'db', + origin: 'auto.db.otel.mongo', + }), + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', - 'db.operation': 'insert', 'db.mongodb.collection': 'movies', - }), - description: '{"title":"?","_id":"?"}', + 'db.operation': 'insert', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"title":"?","_id":{"_bsontype":"?","id":{"0":"?","1":"?","2":"?","3":"?","4":"?","5":"?","6":"?","7":"?","8":"?","9":"?","10":"?","11":"?"}}}', + 'otel.kind': 'CLIENT', + }, + description: + '{"title":"?","_id":{"_bsontype":"?","id":{"0":"?","1":"?","2":"?","3":"?","4":"?","5":"?","6":"?","7":"?","8":"?","9":"?","10":"?","11":"?"}}}', op: 'db', origin: 'auto.db.otel.mongo', }), expect.objectContaining({ - data: expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', - 'db.operation': 'find', 'db.mongodb.collection': 'movies', - }), + 'db.operation': 'find', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, description: '{"title":"?"}', op: 'db', origin: 'auto.db.otel.mongo', }), expect.objectContaining({ - data: expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', - 'db.operation': 'update', 'db.mongodb.collection': 'movies', - }), + 'db.operation': 'update', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, description: '{"title":"?"}', op: 'db', origin: 'auto.db.otel.mongo', }), expect.objectContaining({ - data: expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', - 'db.operation': 'find', 'db.mongodb.collection': 'movies', - }), + 'db.operation': 'find', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, description: '{"title":"?"}', op: 'db', origin: 'auto.db.otel.mongo', }), expect.objectContaining({ - data: expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', - 'db.operation': 'find', 'db.mongodb.collection': 'movies', - }), + 'db.operation': 'find', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': '{"title":"?"}', + 'otel.kind': 'CLIENT', + }, description: '{"title":"?"}', op: 'db', origin: 'auto.db.otel.mongo', }), - ]), + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': '$cmd', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': expect.any(Number), + 'db.statement': + '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":{"0":"?","1":"?","2":"?","3":"?","4":"?","5":"?","6":"?","7":"?","8":"?","9":"?","10":"?","11":"?","12":"?","13":"?","14":"?","15":"?"}}}]}', + 'otel.kind': 'CLIENT', + }, + description: + '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":{"0":"?","1":"?","2":"?","3":"?","4":"?","5":"?","6":"?","7":"?","8":"?","9":"?","10":"?","11":"?","12":"?","13":"?","14":"?","15":"?"}}}]}', + op: 'db', + origin: 'auto.db.otel.mongo', + }), + ], }; test('CJS - should auto-instrument `mongodb` package.', done => { diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/scenario-redis-4.js b/dev-packages/node-integration-tests/suites/tracing/redis-cache/scenario-redis-4.js new file mode 100644 index 000000000000..31156674a654 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/scenario-redis-4.js @@ -0,0 +1,46 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.redisIntegration({ cachePrefixes: ['redis-cache:'] })], +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const { createClient } = require('redis-4'); + +async function run() { + const redisClient = await createClient().connect(); + + await Sentry.startSpan( + { + name: 'Test Span Redis 4', + op: 'test-span-redis-4', + }, + async () => { + try { + await redisClient.set('redis-test-key', 'test-value'); + await redisClient.set('redis-cache:test-key', 'test-value'); + + await redisClient.set('redis-cache:test-key-set-EX', 'test-value', { EX: 10 }); + await redisClient.setEx('redis-cache:test-key-setex', 10, 'test-value'); + + await redisClient.get('redis-test-key'); + await redisClient.get('redis-cache:test-key'); + await redisClient.get('redis-cache:unavailable-data'); + + await redisClient.mGet(['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data']); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index adbd88921a66..0c0807c8f480 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -42,7 +42,7 @@ describe('redis cache auto instrumentation', () => { .start(done); }); - test('should create cache spans for prefixed keys', done => { + test('should create cache spans for prefixed keys (ioredis)', done => { const EXPECTED_TRANSACTION = { transaction: 'Test Span', spans: expect.arrayContaining([ @@ -139,4 +139,95 @@ describe('redis cache auto instrumentation', () => { .expect({ transaction: EXPECTED_TRANSACTION }) .start(done); }); + + test('should create cache spans for prefixed keys (redis-4)', done => { + const EXPECTED_REDIS_CONNECT = { + transaction: 'redis-connect', + }; + + const EXPECTED_TRANSACTION = { + transaction: 'Test Span Redis 4', + spans: expect.arrayContaining([ + // SET + expect.objectContaining({ + description: 'redis-cache:test-key', + op: 'cache.put', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'SET redis-cache:test-key [1 other arguments]', + 'cache.key': ['redis-cache:test-key'], + 'cache.item_size': 2, + }), + }), + // SET (with EX) + expect.objectContaining({ + description: 'redis-cache:test-key-set-EX', + op: 'cache.put', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'SET redis-cache:test-key-set-EX [3 other arguments]', + 'cache.key': ['redis-cache:test-key-set-EX'], + 'cache.item_size': 2, + }), + }), + // SETEX + expect.objectContaining({ + description: 'redis-cache:test-key-setex', + op: 'cache.put', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'SETEX redis-cache:test-key-setex [2 other arguments]', + 'cache.key': ['redis-cache:test-key-setex'], + 'cache.item_size': 2, + }), + }), + // GET + expect.objectContaining({ + description: 'redis-cache:test-key', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'GET redis-cache:test-key', + 'cache.hit': true, + 'cache.key': ['redis-cache:test-key'], + 'cache.item_size': 10, + }), + }), + // GET (unavailable - no cache hit) + expect.objectContaining({ + description: 'redis-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'GET redis-cache:unavailable-data', + 'cache.hit': false, + 'cache.key': ['redis-cache:unavailable-data'], + }), + }), + // MGET + expect.objectContaining({ + description: 'redis-test-key, redis-cache:test-key, redis-cache:unavailable-data', + op: 'cache.get', + origin: 'auto.db.otel.redis', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.otel.redis', + 'db.statement': 'MGET [3 other arguments]', + 'cache.hit': true, + 'cache.key': ['redis-test-key', 'redis-cache:test-key', 'redis-cache:unavailable-data'], + }), + }), + ]), + }; + + createRunner(__dirname, 'scenario-redis-4.js') + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port=6379'] }) + .expect({ transaction: EXPECTED_REDIS_CONNECT }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); }); diff --git a/dev-packages/overhead-metrics/package.json b/dev-packages/overhead-metrics/package.json index a2c1dde0fc97..0f5421239d73 100644 --- a/dev-packages/overhead-metrics/package.json +++ b/dev-packages/overhead-metrics/package.json @@ -24,8 +24,8 @@ "filesize": "^10.0.6", "fs-extra": "^11.1.0", "p-timeout": "^6.0.0", - "playwright": "^1.31.1", - "playwright-core": "^1.29.1", + "playwright": "^1.44.1", + "playwright-core": "^1.44.1", "simple-git": "^3.16.0", "simple-statistics": "^7.8.0", "typescript": "4.9.5" diff --git a/package.json b/package.json index 42a1b6cd0b89..bd4246a45862 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "packages/replay-internal", "packages/replay-canvas", "packages/replay-worker", - "packages/solidjs", + "packages/solid", "packages/svelte", "packages/sveltekit", "packages/types", diff --git a/packages/astro/package.json b/packages/astro/package.json index 93071d542f3a..2fd4c8a2cdb4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -31,17 +31,17 @@ "types": "build/types/index.types.d.ts", "exports": { ".": { + "types": "./build/types/index.types.d.ts", "node": "./build/esm/index.server.js", "browser": "./build/esm/index.client.js", "import": "./build/esm/index.client.js", - "require": "./build/cjs/index.server.js", - "types": "./build/types/index.types.d.ts" + "require": "./build/cjs/index.server.js" }, "./middleware": { + "types": "./build/types/integration/middleware/index.types.d.ts", "node": "./build/esm/integration/middleware/index.js", "import": "./build/esm/integration/middleware/index.js", - "require": "./build/cjs/integration/middleware/index.js", - "types": "./build/types/integration/middleware/index.types.d.ts" + "require": "./build/cjs/integration/middleware/index.js" }, "./import": { "import": { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 6657b3030cb1..d1635cdada54 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -92,11 +92,35 @@ export { setupHapiErrorHandler, spotlightIntegration, addOpenTelemetryInstrumentation, + metrics, + NodeClient, + addIntegration, + anrIntegration, + captureConsoleIntegration, + captureSession, + connectIntegration, + createGetModuleFromFilename, + debugIntegration, + dedupeIntegration, + endSession, + extraErrorDataIntegration, + getAutoPerformanceIntegrations, + httpIntegration, + initOpenTelemetry, + koaIntegration, + nativeNodeFetchIntegration, + rewriteFramesIntegration, + sessionTimingIntegration, + setupConnectErrorHandler, + setupKoaErrorHandler, + spanToBaggageHeader, + spanToJSON, + spanToTraceHeader, + startSession, + trpcMiddleware, + zodErrorsIntegration, } from '@sentry/node'; -// We can still leave this for the carrier init and type exports -export * from '@sentry/node'; - export { init } from './server/sdk'; export default sentryAstro; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index e5fe8fd965b4..2227679dff21 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -3,6 +3,9 @@ // exports in this file - which we do below. export * from './index.client'; export * from './index.server'; +export * from '@sentry/node'; + +import type { NodeOptions } from '@sentry/node'; import type { Integration, Options, StackParser } from '@sentry/types'; @@ -11,7 +14,7 @@ import type * as serverSdk from './index.server'; import sentryAstro from './index.server'; /** Initializes Sentry Astro SDK */ -export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): void; +export declare function init(options: Options | clientSdk.BrowserOptions | NodeOptions): void; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; @@ -29,5 +32,5 @@ export declare const continueTrace: typeof clientSdk.continueTrace; export declare const Span: clientSdk.Span; -export declare const metrics: typeof clientSdk.metrics & typeof serverSdk.metrics; +export declare const metrics: typeof clientSdk.metrics & typeof serverSdk; export default sentryAstro; diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index c2398c7d019f..503557d6d7cd 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -10,6 +10,7 @@ export function init(options: NodeOptions): void { const opts = { ...options, }; + applySdkMetadata(opts, 'astro', ['astro', 'node']); initNodeSdk(opts); diff --git a/packages/aws-serverless/src/awslambda-auto.ts b/packages/aws-serverless/src/awslambda-auto.ts index 9cf3ba68ae6e..2f23fe652005 100644 --- a/packages/aws-serverless/src/awslambda-auto.ts +++ b/packages/aws-serverless/src/awslambda-auto.ts @@ -1,3 +1,5 @@ +// Important: This file cannot import anything other than the index file below. +// This is the entry point to the lambda layer, which only contains the entire SDK bundled into the index file import * as Sentry from './index'; const lambdaTaskRoot = process.env.LAMBDA_TASK_ROOT; @@ -21,7 +23,9 @@ if (lambdaTaskRoot) { ), }); - Sentry.tryPatchHandler(lambdaTaskRoot, handlerString); + if (typeof require !== 'undefined') { + Sentry.tryPatchHandler(lambdaTaskRoot, handlerString); + } } else { throw Error('LAMBDA_TASK_ROOT environment variable is not set'); } diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index bc89baad5c38..c6516603b6b8 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,5 +1,5 @@ import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core'; import { addOpenTelemetryInstrumentation } from '@sentry/node'; import type { IntegrationFn } from '@sentry/types'; @@ -11,6 +11,7 @@ const _awsLambdaIntegration = (() => { new AwsLambdaInstrumentation({ requestHook(span) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda'); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda'); }, }), ); diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 7c491fefc008..905672241ffb 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -320,7 +320,9 @@ export function wrapHandler( throw e; } finally { clearTimeout(timeoutWarningTimer); - span?.end(); + if (span && span.isRecording()) { + span.end(); + } await flush(options.flushTimeout).catch(e => { DEBUG_BUILD && logger.error(e); }); @@ -328,7 +330,10 @@ export function wrapHandler( return rv; } - if (options.startTrace) { + // Only start a trace and root span if the handler is not already wrapped by Otel instrumentation + // Otherwise, we create two root spans (one from otel, one from our wrapper). + // If Otel instrumentation didn't work or was filtered by users, we still want to trace the handler. + if (options.startTrace && !isWrappedByOtel(handler)) { const eventWithHeaders = event as { headers?: { [key: string]: string } }; const sentryTrace = @@ -361,3 +366,19 @@ export function wrapHandler( }); }; } + +/** + * Checks if Otel's AWSLambda instrumentation successfully wrapped the handler. + * Check taken from @opentelemetry/core + */ +function isWrappedByOtel( + // eslint-disable-next-line @typescript-eslint/ban-types + handler: Function & { __original?: unknown; __unwrap?: unknown; __wrapped?: boolean }, +): boolean { + return ( + typeof handler === 'function' && + typeof handler.__original === 'function' && + typeof handler.__unwrap === 'function' && + handler.__wrapped === true + ); +} diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7e7c4d0a387a..f59ccbf8da8f 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addFidInstrumentationHandler, addTtfbInstrumentationHandler, addLcpInstrumentationHandler, + addInpInstrumentationHandler, } from './metrics/instrument'; export { @@ -12,6 +13,7 @@ export { startTrackingLongTasks, startTrackingWebVitals, startTrackingINP, + registerInpInteractionListener, } from './metrics/browserMetrics'; export { addClickKeypressInstrumentationHandler } from './instrument/dom'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 877b0612fb06..bb9969f1fde6 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -157,7 +157,7 @@ export function startTrackingInteractions(): void { }); } -export { startTrackingINP } from './inp'; +export { startTrackingINP, registerInpInteractionListener } from './inp'; /** Starts tracking the Cumulative Layout Shift on the current page. */ function _trackCLS(): () => void { diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index c6c0113d6be3..00e524c048b6 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -2,6 +2,7 @@ import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, getActiveSpan, getClient, getCurrentScope, @@ -11,9 +12,21 @@ import { } from '@sentry/core'; import type { Integration, SpanAttributes } from '@sentry/types'; import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString } from '@sentry/utils'; -import { addInpInstrumentationHandler } from './instrument'; +import { + addInpInstrumentationHandler, + addPerformanceInstrumentationHandler, + isPerformanceEventTiming, +} from './instrument'; import { getBrowserPerformanceAPI, msToSec } from './utils'; +// We only care about name here +interface PartialRouteInfo { + name: string | undefined; +} + +const LAST_INTERACTIONS: number[] = []; +const INTERACTIONS_ROUTE_MAP = new Map(); + /** * Start tracking INP webvital events. */ @@ -73,6 +86,7 @@ function _trackINP(): () => void { return; } + const { interactionId } = entry; const interactionType = INP_ENTRY_MAP[entry.name]; const options = client.getOptions(); @@ -83,7 +97,15 @@ function _trackINP(): () => void { const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const routeName = rootSpan ? spanToJSON(rootSpan).description : undefined; + // We first try to lookup the route name from our INTERACTIONS_ROUTE_MAP, + // where we cache the route per interactionId + const cachedRouteName = interactionId != null ? INTERACTIONS_ROUTE_MAP.get(interactionId) : undefined; + + // Else, we try to use the active span. + // Finally, we fall back to look at the transactionName on the scope + const routeName = + cachedRouteName || (rootSpan ? spanToJSON(rootSpan).description : scope.getScopeData().transactionName); + const user = scope.getUser(); // We need to get the replay, user, and activeTransaction from the current scope @@ -107,6 +129,7 @@ function _trackINP(): () => void { environment: options.environment, transaction: routeName, [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: metric.value, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp', user: userDisplay || undefined, profile_id: profileId || undefined, replay_id: replayId || undefined, @@ -130,3 +153,39 @@ function _trackINP(): () => void { span.end(startTime + duration); }); } + +/** Register a listener to cache route information for INP interactions. */ +export function registerInpInteractionListener(latestRoute: PartialRouteInfo): void { + const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => { + entries.forEach(entry => { + if (!isPerformanceEventTiming(entry) || !latestRoute.name) { + return; + } + + const interactionId = entry.interactionId; + if (interactionId == null) { + return; + } + + // If the interaction was already recorded before, nothing more to do + if (INTERACTIONS_ROUTE_MAP.has(interactionId)) { + return; + } + + // We keep max. 10 interactions in the list, then remove the oldest one & clean up + if (LAST_INTERACTIONS.length > 10) { + const last = LAST_INTERACTIONS.shift() as number; + INTERACTIONS_ROUTE_MAP.delete(last); + } + + // We add the interaction to the list of recorded interactions + // and store the route information for this interaction + // (we clone the object because it is mutated when it changes) + LAST_INTERACTIONS.push(interactionId); + INTERACTIONS_ROUTE_MAP.set(interactionId, latestRoute.name); + }); + }; + + addPerformanceInstrumentationHandler('event', handleEntries); + addPerformanceInstrumentationHandler('first-input', handleEntries); +} diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 06a27c4225ff..e22a345e3116 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -8,7 +8,13 @@ import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; import { onTTFB } from './web-vitals/onTTFB'; -type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource'; +type InstrumentHandlerTypePerformanceObserver = + | 'longtask' + | 'event' + | 'navigation' + | 'paint' + | 'resource' + | 'first-input'; type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; @@ -324,3 +330,10 @@ function getCleanupCallback( } }; } + +/** + * Check if a PerformanceEntry is a PerformanceEventTiming by checking for the `duration` property. + */ +export function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming { + return 'duration' in entry; +} diff --git a/packages/browser/package.json b/packages/browser/package.json index 9d1a9138bed8..fa386baf2b8f 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -52,11 +52,7 @@ }, "devDependencies": { "@sentry-internal/integration-shims": "8.8.0", - "@types/md5": "2.1.33", - "btoa": "^1.2.1", "fake-indexeddb": "^4.0.1", - "node-fetch": "^2.6.0", - "playwright": "^1.31.1", "webpack": "^4.47.0" }, "scripts": { diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index f6528e4d155d..c058b1930928 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -2,6 +2,7 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, + registerInpInteractionListener, startTrackingINP, startTrackingInteractions, startTrackingLongTasks, @@ -40,6 +41,11 @@ import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; +interface RouteInfo { + name: string | undefined; + source: TransactionSource | undefined; +} + /** Options for Browser Tracing integration */ export interface BrowserTracingOptions { /** @@ -204,7 +210,7 @@ export const browserTracingIntegration = ((_options: Partial { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 934cbb62612d..479fb9ee69d8 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -15,6 +15,7 @@ "exports": { "./package.json": "./package.json", ".": { + "types": "./build/types/index.types.d.ts", "edge": { "import": "./build/esm/edge/index.js", "require": "./build/cjs/edge/index.js", @@ -40,8 +41,7 @@ "require": "./build/cjs/index.client.js" }, "node": "./build/cjs/index.server.js", - "import": "./build/esm/index.server.js", - "types": "./build/types/index.types.d.ts" + "import": "./build/esm/index.server.js" }, "./import": { "import": { diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 44537ede59f8..e529a2b47f57 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -291,14 +291,18 @@ export function constructWebpackConfigFunction( globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), ); - if (!hasGlobalErrorFile && !showedMissingGlobalErrorWarningMsg) { + if ( + !hasGlobalErrorFile && + !showedMissingGlobalErrorWarningMsg && + !process.env.SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING + ) { // eslint-disable-next-line no-console console.log( `${chalk.yellow( 'warn', )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( 'global-error.js', - )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router`, + )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)`, ); showedMissingGlobalErrorWarningMsg = true; } diff --git a/packages/node/package.json b/packages/node/package.json index dcd3ef83f6a1..8deb575e6a9e 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -75,19 +75,20 @@ "@opentelemetry/core": "^1.25.0", "@opentelemetry/instrumentation": "^0.52.0", "@opentelemetry/instrumentation-connect": "0.37.0", - "@opentelemetry/instrumentation-express": "0.40.0", + "@opentelemetry/instrumentation-express": "0.40.1", "@opentelemetry/instrumentation-fastify": "0.37.0", "@opentelemetry/instrumentation-graphql": "0.41.0", "@opentelemetry/instrumentation-hapi": "0.39.0", "@opentelemetry/instrumentation-http": "0.52.0", "@opentelemetry/instrumentation-ioredis": "0.41.0", "@opentelemetry/instrumentation-koa": "0.41.0", - "@opentelemetry/instrumentation-mongodb": "0.44.0", + "@opentelemetry/instrumentation-mongodb": "0.45.0", "@opentelemetry/instrumentation-mongoose": "0.39.0", "@opentelemetry/instrumentation-mysql": "0.39.0", "@opentelemetry/instrumentation-mysql2": "0.39.0", "@opentelemetry/instrumentation-nestjs-core": "0.38.0", "@opentelemetry/instrumentation-pg": "0.42.0", + "@opentelemetry/instrumentation-redis-4": "0.40.0", "@opentelemetry/resources": "^1.25.0", "@opentelemetry/sdk-trace-base": "^1.25.0", "@opentelemetry/semantic-conventions": "^1.25.0", diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 55a01ba13651..bee4f06db8f5 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -13,7 +13,7 @@ import { instrumentMysql, mysqlIntegration } from './mysql'; import { instrumentMysql2, mysql2Integration } from './mysql2'; import { instrumentNest, nestIntegration } from './nest'; import { instrumentPostgres, postgresIntegration } from './postgres'; -import { redisIntegration } from './redis'; +import { instrumentRedis, redisIntegration } from './redis'; /** * With OTEL, all performance integrations will be added, as OTEL only initializes them when the patched package is actually required. @@ -60,5 +60,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentPostgres, instrumentHapi, instrumentGraphql, + instrumentRedis, ]; } diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis.ts index 87bcf6b9cb25..4204e4a2abe5 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis.ts @@ -1,4 +1,7 @@ +import type { Span } from '@opentelemetry/api'; +import type { RedisResponseCustomAttributeFunction } from '@opentelemetry/instrumentation-ioredis'; import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; +import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis-4'; import { SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, @@ -9,12 +12,14 @@ import { spanToJSON, } from '@sentry/core'; import type { IntegrationFn } from '@sentry/types'; +import { truncate } from '@sentry/utils'; import { generateInstrumentOnce } from '../../otel/instrument'; import { GET_COMMANDS, calculateCacheItemSize, getCacheKeySafely, getCacheOperation, + isInCommands, shouldConsiderForCache, } from '../../utils/redisCache'; @@ -26,64 +31,80 @@ const INTEGRATION_NAME = 'Redis'; let _redisOptions: RedisOptions = {}; -export const instrumentRedis = generateInstrumentOnce(INTEGRATION_NAME, () => { +const cacheResponseHook: RedisResponseCustomAttributeFunction = (span: Span, redisCommand, cmdArgs, response) => { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); + + const safeKey = getCacheKeySafely(redisCommand, cmdArgs); + const cacheOperation = getCacheOperation(redisCommand); + + if ( + !safeKey || + !cacheOperation || + !_redisOptions?.cachePrefixes || + !shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes) + ) { + // not relevant for cache + return; + } + + // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 + // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ + const networkPeerAddress = spanToJSON(span).data?.['net.peer.name']; + const networkPeerPort = spanToJSON(span).data?.['net.peer.port']; + if (networkPeerPort && networkPeerAddress) { + span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); + } + + const cacheItemSize = calculateCacheItemSize(response); + + if (cacheItemSize) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); + } + + if (isInCommands(GET_COMMANDS, redisCommand) && cacheItemSize !== undefined) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); + } + + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, + }); + + const spanDescription = safeKey.join(', '); + + span.updateName(truncate(spanDescription, 1024)); +}; + +const instrumentIORedis = generateInstrumentOnce('IORedis', () => { return new IORedisInstrumentation({ - responseHook: (span, redisCommand, cmdArgs, response) => { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); - - const safeKey = getCacheKeySafely(redisCommand, cmdArgs); - const cacheOperation = getCacheOperation(redisCommand); - - if ( - !safeKey || - !cacheOperation || - !_redisOptions?.cachePrefixes || - !shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes) - ) { - // not relevant for cache - return; - } - - // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 - // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ - const networkPeerAddress = spanToJSON(span).data?.['net.peer.name']; - const networkPeerPort = spanToJSON(span).data?.['net.peer.port']; - if (networkPeerPort && networkPeerAddress) { - span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); - } - - const cacheItemSize = calculateCacheItemSize(response); - - if (cacheItemSize) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); - } - - if (GET_COMMANDS.includes(redisCommand) && cacheItemSize !== undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); - } - - span.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, - }); - - const spanDescription = safeKey.join(', '); - - span.updateName(spanDescription.length > 1024 ? `${spanDescription.substring(0, 1024)}...` : spanDescription); - }, + responseHook: cacheResponseHook, }); }); +const instrumentRedis4 = generateInstrumentOnce('Redis-4', () => { + return new RedisInstrumentation({ + responseHook: cacheResponseHook, + }); +}); + +/** To be able to preload all Redis OTel instrumentations with just one ID ("Redis"), all the instrumentations are generated in this one function */ +export const instrumentRedis = Object.assign( + (): void => { + instrumentIORedis(); + instrumentRedis4(); + + // todo: implement them gradually + // new LegacyRedisInstrumentation({}), + }, + { id: INTEGRATION_NAME }, +); + const _redisIntegration = ((options: RedisOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { _redisOptions = options; instrumentRedis(); - - // todo: implement them gradually - // new LegacyRedisInstrumentation({}), - // new RedisInstrumentation({}), }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/utils/redisCache.ts b/packages/node/src/utils/redisCache.ts index 9b394c6996da..9f0e7e6f1ac8 100644 --- a/packages/node/src/utils/redisCache.ts +++ b/packages/node/src/utils/redisCache.ts @@ -7,15 +7,19 @@ export const GET_COMMANDS = ['get', 'mget']; export const SET_COMMANDS = ['set', 'setex']; // todo: del, expire +/** Checks if a given command is in the list of redis commands. + * Useful because commands can come in lowercase or uppercase (depending on the library). */ +export function isInCommands(redisCommands: string[], command: string): boolean { + return redisCommands.includes(command.toLowerCase()); +} + /** Determine cache operation based on redis statement */ export function getCacheOperation( command: string, ): 'cache.get' | 'cache.put' | 'cache.remove' | 'cache.flush' | undefined { - const lowercaseStatement = command.toLowerCase(); - - if (GET_COMMANDS.includes(lowercaseStatement)) { + if (isInCommands(GET_COMMANDS, command)) { return 'cache.get'; - } else if (SET_COMMANDS.includes(lowercaseStatement)) { + } else if (isInCommands(SET_COMMANDS, command)) { return 'cache.put'; } else { return undefined; @@ -44,7 +48,7 @@ export function getCacheKeySafely(redisCommand: string, cmdArgs: IORedisCommandA } }; - if (SINGLE_ARG_COMMANDS.includes(redisCommand) && cmdArgs.length > 0) { + if (isInCommands(SINGLE_ARG_COMMANDS, redisCommand) && cmdArgs.length > 0) { return processArg(cmdArgs[0]); } diff --git a/packages/node/test/integrations/tracing/redis.test.ts b/packages/node/test/integrations/tracing/redis.test.ts index 307991f24a73..57eb727964be 100644 --- a/packages/node/test/integrations/tracing/redis.test.ts +++ b/packages/node/test/integrations/tracing/redis.test.ts @@ -13,12 +13,18 @@ describe('Redis', () => { expect(result).toBe(undefined); }); - it('should return a string representation of a single argument', () => { + it('should return a string array representation of a single argument', () => { const cmdArgs = ['key1']; const result = getCacheKeySafely('get', cmdArgs); expect(result).toStrictEqual(['key1']); }); + it('should return a string array representation of a single argument (uppercase)', () => { + const cmdArgs = ['key1']; + const result = getCacheKeySafely('GET', cmdArgs); + expect(result).toStrictEqual(['key1']); + }); + it('should return only the key for multiple arguments', () => { const cmdArgs = ['key1', 'the-value']; const result = getCacheKeySafely('get', cmdArgs); diff --git a/packages/profiling-node/bindings/cpu_profiler.cc b/packages/profiling-node/bindings/cpu_profiler.cc index 6f9651bcd6a4..cbff754623bc 100644 --- a/packages/profiling-node/bindings/cpu_profiler.cc +++ b/packages/profiling-node/bindings/cpu_profiler.cc @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,10 @@ static const v8::CpuProfilingNamingMode static const v8::CpuProfilingLoggingMode kDefaultLoggingMode(v8::CpuProfilingLoggingMode::kEagerLogging); +enum ProfileFormat { + kFormatThread = 0, + kFormatChunk = 1, +}; // Allow users to override the default logging mode via env variable. This is // useful because sometimes the flow of the profiled program can be to execute // many sequential transaction - in that case, it may be preferable to set eager @@ -50,6 +55,12 @@ v8::CpuProfilingLoggingMode GetLoggingMode() { return kDefaultLoggingMode; } +uint64_t timestamp_milliseconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + class SentryProfile; class Profiler; @@ -241,6 +252,7 @@ class Profiler { class SentryProfile { private: uint64_t started_at; + uint64_t timestamp; uint16_t heap_write_index = 0; uint16_t cpu_write_index = 0; @@ -258,7 +270,7 @@ class SentryProfile { public: explicit SentryProfile(const char *id) - : started_at(uv_hrtime()), + : started_at(uv_hrtime()), timestamp(timestamp_milliseconds()), memory_sampler_cb([this](uint64_t ts, v8::HeapStatistics &stats) { if ((heap_write_index >= heap_stats_ts.capacity()) || heap_write_index >= heap_stats_usage.capacity()) { @@ -302,6 +314,7 @@ class SentryProfile { const std::vector &cpu_usage_timestamps() const; const std::vector &cpu_usage_values() const; const uint16_t &cpu_usage_write_index() const; + const uint64_t &profile_start_timestamp() const; void Start(Profiler *profiler); v8::CpuProfile *Stop(Profiler *profiler); @@ -314,6 +327,7 @@ void SentryProfile::Start(Profiler *profiler) { .ToLocalChecked(); started_at = uv_hrtime(); + timestamp = timestamp_milliseconds(); // Initialize the CPU Profiler profiler->cpu_profiler->StartProfiling( @@ -368,6 +382,9 @@ const std::vector &SentryProfile::cpu_usage_values() const { const uint16_t &SentryProfile::cpu_usage_write_index() const { return cpu_write_index; }; +const uint64_t &SentryProfile::profile_start_timestamp() const { + return timestamp; +} static void CleanupSentryProfile(Profiler *profiler, SentryProfile *sentry_profile, @@ -528,8 +545,9 @@ CreateFrameNode(const napi_env &env, const v8::CpuProfileNode &node, return js_node; }; -napi_value CreateSample(const napi_env &env, const uint32_t stack_id, - const int64_t sample_timestamp_us, +napi_value CreateSample(const napi_env &env, const enum ProfileFormat format, + const uint32_t stack_id, const int64_t sample_timestamp, + const double chunk_timestamp, const uint32_t thread_id) { napi_value js_node; napi_create_object(env, &js_node); @@ -543,11 +561,20 @@ napi_value CreateSample(const napi_env &env, const uint32_t stack_id, NAPI_AUTO_LENGTH, &thread_id_prop); napi_set_named_property(env, js_node, "thread_id", thread_id_prop); - napi_value elapsed_since_start_ns_prop; - napi_create_int64(env, sample_timestamp_us * 1000, - &elapsed_since_start_ns_prop); - napi_set_named_property(env, js_node, "elapsed_since_start_ns", - elapsed_since_start_ns_prop); + switch (format) { + case ProfileFormat::kFormatThread: { + napi_value timestamp; + napi_create_int64(env, sample_timestamp, ×tamp); + napi_set_named_property(env, js_node, "elapsed_since_start_ns", timestamp); + } break; + case ProfileFormat::kFormatChunk: { + napi_value timestamp; + napi_create_double(env, chunk_timestamp, ×tamp); + napi_set_named_property(env, js_node, "timestamp", timestamp); + } break; + default: + break; + } return js_node; }; @@ -566,11 +593,13 @@ std::string hashCpuProfilerNodeByPath(const v8::CpuProfileNode *node, } static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, + ProfileFormat format, + const uint64_t profile_start_timestamp_ms, const uint32_t thread_id, napi_value &samples, napi_value &stacks, napi_value &frames, napi_value &resources) { const int64_t profile_start_time_us = profile->GetStartTime(); - const int sampleCount = profile->GetSamplesCount(); + const int64_t sampleCount = profile->GetSamplesCount(); uint32_t unique_stack_id = 0; uint32_t unique_frame_id = 0; @@ -590,7 +619,7 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, uint32_t stack_index = unique_stack_id; const v8::CpuProfileNode *node = profile->GetSample(i); - const int64_t sample_timestamp = profile->GetSampleTimestamp(i); + const int64_t sample_timestamp_us = profile->GetSampleTimestamp(i); // If a node was only on top of the stack once, then it will only ever // be inserted once and there is no need for hashing. @@ -609,8 +638,16 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, } } - napi_value sample = CreateSample( - env, stack_index, sample_timestamp - profile_start_time_us, thread_id); + uint64_t sample_delta_us = sample_timestamp_us - profile_start_time_us; + uint64_t sample_timestamp_ns = sample_delta_us * 1e3; + uint64_t sample_offset_from_profile_start_ms = + (sample_timestamp_us - profile_start_time_us) * 1e-3; + double seconds_since_start = + profile_start_timestamp_ms + sample_offset_from_profile_start_ms; + + napi_value sample = nullptr; + sample = CreateSample(env, format, stack_index, sample_timestamp_ns, + seconds_since_start, thread_id); if (stack_index != unique_stack_id) { napi_value index; @@ -671,19 +708,19 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, } } -static napi_value -TranslateMeasurementsDouble(const napi_env &env, const char *unit, - const uint16_t size, - const std::vector &values, - const std::vector ×tamps) { - if (size > values.size() || size > timestamps.size()) { +static napi_value TranslateMeasurementsDouble( + const napi_env &env, const enum ProfileFormat format, const char *unit, + const uint64_t profile_start_timestamp_ms, const uint16_t size, + const std::vector &values, + const std::vector ×tamps_ns) { + if (size > values.size() || size > timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "CPU measurement size is larger than the number of " "values or timestamps"); return nullptr; } - if (values.size() != timestamps.size()) { + if (values.size() != timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "CPU measurement entries are corrupt, expected " "values and timestamps to be of equal length"); @@ -713,11 +750,19 @@ TranslateMeasurementsDouble(const napi_env &env, const char *unit, } } - napi_value ts; - napi_create_int64(env, timestamps[i], &ts); - napi_set_named_property(env, entry, "value", value); - napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + + if (format == ProfileFormat::kFormatThread) { + napi_value ts; + napi_create_int64(env, timestamps_ns[i], &ts); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + } else if (format == ProfileFormat::kFormatChunk) { + napi_value ts; + napi_create_double( + env, profile_start_timestamp_ms + (timestamps_ns[i] * 1e-6), &ts); + napi_set_named_property(env, entry, "timestamp", ts); + } + napi_set_element(env, values_array, i, entry); } @@ -727,17 +772,19 @@ TranslateMeasurementsDouble(const napi_env &env, const char *unit, } static napi_value -TranslateMeasurements(const napi_env &env, const char *unit, +TranslateMeasurements(const napi_env &env, const enum ProfileFormat format, + const char *unit, + const uint64_t profile_start_timestamp_ms, const uint16_t size, const std::vector &values, - const std::vector ×tamps) { - if (size > values.size() || size > timestamps.size()) { + const std::vector ×tamps_ns) { + if (size > values.size() || size > timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "Memory measurement size is larger than the number " "of values or timestamps"); return nullptr; } - if (values.size() != timestamps.size()) { + if (values.size() != timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "Memory measurement entries are corrupt, expected " "values and timestamps to be of equal length"); @@ -761,11 +808,22 @@ TranslateMeasurements(const napi_env &env, const char *unit, napi_value value; napi_create_int64(env, values[i], &value); - napi_value ts; - napi_create_int64(env, timestamps[i], &ts); - napi_set_named_property(env, entry, "value", value); - napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + switch (format) { + case ProfileFormat::kFormatThread: { + napi_value ts; + napi_create_int64(env, timestamps_ns[i], &ts); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + } break; + case ProfileFormat::kFormatChunk: { + napi_value ts; + napi_create_double( + env, profile_start_timestamp_ms + (timestamps_ns[i] * 1e-6), &ts); + napi_set_named_property(env, entry, "timestamp", ts); + } break; + default: + break; + } napi_set_element(env, values_array, i, entry); } @@ -776,6 +834,8 @@ TranslateMeasurements(const napi_env &env, const char *unit, static napi_value TranslateProfile(const napi_env &env, const v8::CpuProfile *profile, + const enum ProfileFormat format, + const uint64_t profile_start_timestamp_ms, const uint32_t thread_id, bool collect_resources) { napi_value js_profile; @@ -805,7 +865,8 @@ static napi_value TranslateProfile(const napi_env &env, napi_set_named_property(env, js_profile, "profiler_logging_mode", logging_mode); - GetSamples(env, profile, thread_id, samples, stacks, frames, resources); + GetSamples(env, profile, format, profile_start_timestamp_ms, thread_id, + samples, stacks, frames, resources); if (collect_resources) { napi_set_named_property(env, js_profile, "resources", resources); @@ -892,14 +953,14 @@ static napi_value StartProfiling(napi_env env, napi_callback_info info) { // StopProfiling(string title) // https://v8docs.nodesource.com/node-18.2/d2/d34/classv8_1_1_cpu_profiler.html#a40ca4c8a8aa4c9233aa2a2706457cc80 static napi_value StopProfiling(napi_env env, napi_callback_info info) { - size_t argc = 3; - napi_value argv[3]; + size_t argc = 4; + napi_value argv[4]; assert(napi_get_cb_info(env, info, &argc, argv, NULL, NULL) == napi_ok); - if (argc < 2) { + if (argc < 3) { napi_throw_error(env, "NAPI_ERROR", - "StopProfiling expects at least two arguments."); + "StopProfiling expects at least three arguments."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -921,14 +982,17 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } - // Verify the second argument is a number - napi_valuetype callbacktype1; - assert(napi_typeof(env, argv[1], &callbacktype1) == napi_ok); + size_t len; + assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); - if (callbacktype1 != napi_number) { + char *title = (char *)malloc(len + 1); + assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == + napi_ok); + + if (len < 1) { napi_throw_error( env, "NAPI_ERROR", - "StopProfiling expects a thread_id integer as second argument."); + "StopProfiling expects a non empty string as first argument."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -936,16 +1000,13 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } - size_t len; - assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); - - char *title = (char *)malloc(len + 1); - assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == - napi_ok); + // Verify the second argument is a number + napi_valuetype callbacktype1; + assert(napi_typeof(env, argv[1], &callbacktype1) == napi_ok); - if (len < 1) { + if (callbacktype1 != napi_number) { napi_throw_error(env, "NAPI_ERROR", - "StopProfiling expects a string as first argument."); + "StopProfiling expects a format type as second argument."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -953,9 +1014,27 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } + // Verify the second argument is a number + napi_valuetype callbacktype2; + assert(napi_typeof(env, argv[2], &callbacktype2) == napi_ok); + + if (callbacktype2 != napi_number) { + napi_throw_error( + env, "NAPI_ERROR", + "StopProfiling expects a thread_id integer as third argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + } + + // Get the value of the second argument and convert it to uint8 + int32_t format; + assert(napi_get_value_int32(env, argv[1], &format) == napi_ok); + // Get the value of the second argument and convert it to uint64 int64_t thread_id; - assert(napi_get_value_int64(env, argv[1], &thread_id) == napi_ok); + assert(napi_get_value_int64(env, argv[2], &thread_id) == napi_ok); // Get profiler from instance data Profiler *profiler; @@ -967,7 +1046,6 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); - return napi_null; } @@ -994,23 +1072,39 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { }; napi_valuetype callbacktype3; - assert(napi_typeof(env, argv[2], &callbacktype3) == napi_ok); + assert(napi_typeof(env, argv[3], &callbacktype3) == napi_ok); bool collect_resources; - napi_get_value_bool(env, argv[2], &collect_resources); + napi_get_value_bool(env, argv[3], &collect_resources); + + const ProfileFormat format_type = static_cast(format); + + if (format_type != ProfileFormat::kFormatThread && + format_type != ProfileFormat::kFormatChunk) { + napi_throw_error( + env, "NAPI_ERROR", + "StopProfiling expects a valid format type as second argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + } - napi_value js_profile = - TranslateProfile(env, cpu_profile, thread_id, collect_resources); + napi_value js_profile = TranslateProfile( + env, cpu_profile, format_type, profile->second->profile_start_timestamp(), + thread_id, collect_resources); napi_value measurements; napi_create_object(env, &measurements); if (profile->second->heap_usage_write_index() > 0) { static const char *memory_unit = "byte"; - napi_value heap_usage_measurements = TranslateMeasurements( - env, memory_unit, profile->second->heap_usage_write_index(), - profile->second->heap_usage_values(), - profile->second->heap_usage_timestamps()); + napi_value heap_usage_measurements = + TranslateMeasurements(env, format_type, memory_unit, + profile->second->profile_start_timestamp(), + profile->second->heap_usage_write_index(), + profile->second->heap_usage_values(), + profile->second->heap_usage_timestamps()); if (heap_usage_measurements != nullptr) { napi_set_named_property(env, measurements, "memory_footprint", @@ -1021,7 +1115,8 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { if (profile->second->cpu_usage_write_index() > 0) { static const char *cpu_unit = "percent"; napi_value cpu_usage_measurements = TranslateMeasurementsDouble( - env, cpu_unit, profile->second->cpu_usage_write_index(), + env, format_type, cpu_unit, profile->second->profile_start_timestamp(), + profile->second->cpu_usage_write_index(), profile->second->cpu_usage_values(), profile->second->cpu_usage_timestamps()); diff --git a/packages/profiling-node/src/cpu_profiler.ts b/packages/profiling-node/src/cpu_profiler.ts index 433ade1d4b46..9ab470e2ca70 100644 --- a/packages/profiling-node/src/cpu_profiler.ts +++ b/packages/profiling-node/src/cpu_profiler.ts @@ -7,7 +7,13 @@ import { getAbi } from 'node-abi'; import { GLOBAL_OBJ, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; -import type { PrivateV8CpuProfilerBindings, V8CpuProfilerBindings } from './types'; +import type { + PrivateV8CpuProfilerBindings, + RawChunkCpuProfile, + RawThreadCpuProfile, + V8CpuProfilerBindings, +} from './types'; +import type { ProfileFormat } from './types'; const stdlib = familySync(); const platform = process.env['BUILD_PLATFORM'] || _platform(); @@ -151,24 +157,39 @@ export function importCppBindingsModule(): PrivateV8CpuProfilerBindings { } const PrivateCpuProfilerBindings: PrivateV8CpuProfilerBindings = importCppBindingsModule(); -const CpuProfilerBindings: V8CpuProfilerBindings = { - startProfiling(name: string) { + +class Bindings implements V8CpuProfilerBindings { + public startProfiling(name: string): void { if (!PrivateCpuProfilerBindings) { DEBUG_BUILD && logger.log('[Profiling] Bindings not loaded, ignoring call to startProfiling.'); return; } return PrivateCpuProfilerBindings.startProfiling(name); - }, - stopProfiling(name: string) { + } + + public stopProfiling(name: string, format: ProfileFormat.THREAD): RawThreadCpuProfile | null; + public stopProfiling(name: string, format: ProfileFormat.CHUNK): RawChunkCpuProfile | null; + public stopProfiling( + name: string, + format: ProfileFormat.CHUNK | ProfileFormat.THREAD, + ): RawThreadCpuProfile | RawChunkCpuProfile | null { if (!PrivateCpuProfilerBindings) { DEBUG_BUILD && logger.log('[Profiling] Bindings not loaded or profile was never started, ignoring call to stopProfiling.'); return null; } - return PrivateCpuProfilerBindings.stopProfiling(name, threadId, !!GLOBAL_OBJ._sentryDebugIds); - }, -}; + + return PrivateCpuProfilerBindings.stopProfiling( + name, + format as unknown as any, + threadId, + !!GLOBAL_OBJ._sentryDebugIds, + ); + } +} + +const CpuProfilerBindings = new Bindings(); export { PrivateCpuProfilerBindings }; export { CpuProfilerBindings }; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index f8a9ae4e5e4d..6cba86fe4f8d 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -1,31 +1,324 @@ -import { defineIntegration, getCurrentScope, getRootSpan, spanToJSON } from '@sentry/core'; +import { defineIntegration, getCurrentScope, getIsolationScope, getRootSpan, spanToJSON } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; -import type { IntegrationFn, Span } from '@sentry/types'; +import type { Integration, IntegrationFn, Profile, ProfileChunk, Span } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { LRUMap, logger, timestampInSeconds, uuid4 } from '@sentry/utils'; +import { CpuProfilerBindings } from './cpu_profiler'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR, NODE_VERSION } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; -import type { Profile, RawThreadCpuProfile } from './types'; +import type { RawThreadCpuProfile } from './types'; +import { ProfileFormat } from './types'; + +import { + addProfilesToEnvelope, + createProfilingChunkEvent, + createProfilingEvent, + findProfiledTransactionsFromEnvelope, + makeProfileChunkEnvelope, +} from './utils'; + +const CHUNK_INTERVAL_MS = 5000; +const PROFILE_MAP = new LRUMap(50); +const PROFILE_TIMEOUTS: Record = {}; -import { addProfilesToEnvelope, createProfilingEvent, findProfiledTransactionsFromEnvelope } from './utils'; +function addToProfileQueue(profile_id: string, profile: RawThreadCpuProfile): void { + PROFILE_MAP.set(profile_id, profile); +} -const MAX_PROFILE_QUEUE_LENGTH = 50; -const PROFILE_QUEUE: RawThreadCpuProfile[] = []; -const PROFILE_TIMEOUTS: Record = {}; +function takeFromProfileQueue(profile_id: string): RawThreadCpuProfile | undefined { + const profile = PROFILE_MAP.get(profile_id); + PROFILE_MAP.remove(profile_id); + return profile; +} + +/** + * Instruments the client to automatically invoke the profiler on span start and stop events. + * @param client + */ +function setupAutomatedSpanProfiling(client: NodeClient): void { + const spanToProfileIdMap = new WeakMap(); + + client.on('spanStart', span => { + if (span !== getRootSpan(span)) { + return; + } + + const profile_id = maybeProfileSpan(client, span); + + if (profile_id) { + const options = client.getOptions(); + // Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that + // currently exceed the default timeout set by the SDKs. + const maxProfileDurationMs = + (options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS; + + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + + // Enqueue a timeout to prevent profiles from running over max duration. + const timeout = global.setTimeout(() => { + DEBUG_BUILD && + logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', spanToJSON(span).description); + + const profile = stopSpanProfile(span, profile_id); + if (profile) { + addToProfileQueue(profile_id, profile); + } + }, maxProfileDurationMs); + + // Unref timeout so it doesn't keep the process alive. + timeout.unref(); + + getCurrentScope().setContext('profile', { profile_id }); + spanToProfileIdMap.set(span, profile_id); + } + }); + + client.on('spanEnd', span => { + const profile_id = spanToProfileIdMap.get(span); + + if (profile_id) { + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + const profile = stopSpanProfile(span, profile_id); + + if (profile) { + addToProfileQueue(profile_id, profile); + } + } + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_MAP.size) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const profileContext = profiledTransaction.contexts?.['profile']; + const profile_id = profileContext?.['profile_id']; + + if (!profile_id) { + throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context'); + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (profileContext) { + delete profiledTransaction.contexts?.['profile']; + } + + const cpuProfile = takeFromProfileQueue(profile_id); + if (!cpuProfile) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + const profile = createProfilingEvent(client, cpuProfile, profiledTransaction); + if (!profile) return; + + profilesToAddToEnvelope.push(profile); + + // @ts-expect-error profile does not inherit from Event + client.emit('preprocessEvent', profile, { + event_id: profiledTransaction.event_id, + }); + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); +} + +interface ChunkData { + id: string; + timer: NodeJS.Timeout | undefined; + startTimestampMS: number; + startTraceID: string; +} +class ContinuousProfiler { + private _profilerId = uuid4(); + private _client: NodeClient | undefined = undefined; + private _chunkData: ChunkData | undefined = undefined; + + /** + * Called when the profiler is attached to the client (continuous mode is enabled). If of the profiler + * methods called before the profiler is initialized will result in a noop action with debug logs. + * @param client + */ + public initialize(client: NodeClient): void { + this._client = client; + } + + /** + * Recursively schedules chunk profiling to start and stop at a set interval. + * Once the user calls stop(), the current chunk will be stopped and flushed to Sentry and no new chunks will + * will be started. To restart continuous mode after calling stop(), the user must call start() again. + * @returns void + */ + public start(): void { + if (!this._client) { + // The client is not attached to the profiler if the user has not enabled continuous profiling. + // In this case, calling start() and stop() is a noop action.The reason this exists is because + // it makes the types easier to work with and avoids users having to do null checks. + DEBUG_BUILD && logger.log('[Profiling] Profiler was never attached to the client.'); + return; + } + if (this._chunkData) { + DEBUG_BUILD && + logger.log( + `[Profiling] Chunk with chunk_id ${this._chunkData.id} is still running, current chunk will be stopped a new chunk will be started.`, + ); + this.stop(); + } + + const traceId = + getCurrentScope().getPropagationContext().traceId || getIsolationScope().getPropagationContext().traceId; + this._initializeChunk(traceId); + this._startChunkProfiling(this._chunkData!); + } + + /** + * Stops the current chunk and flushes the profile to Sentry. + * @returns void + */ + public stop(): void { + if (this._chunkData?.timer) { + global.clearTimeout(this._chunkData.timer); + this._chunkData.timer = undefined; + DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${this._chunkData.id}`); + } + if (!this._client) { + DEBUG_BUILD && + logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + return; + } + if (!this._chunkData?.id) { + DEBUG_BUILD && + logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`); + return; + } + const profile = CpuProfilerBindings.stopProfiling(this._chunkData.id, ProfileFormat.CHUNK); + if (!profile || !this._chunkData.startTimestampMS) { + DEBUG_BUILD && logger.log(`[Profiling] _chunkiledStartTraceID to collect profile for: ${this._chunkData.id}`); + return; + } + if (profile) { + DEBUG_BUILD && logger.log(`[Profiling] Sending profile chunk ${this._chunkData.id}.`); + } + + DEBUG_BUILD && logger.log(`[Profiling] Profile chunk ${this._chunkData.id} sent to Sentry.`); + const chunk = createProfilingChunkEvent( + this._chunkData.startTimestampMS, + this._client, + this._client.getOptions(), + profile, + { + chunk_id: this._chunkData.id, + trace_id: this._chunkData.startTraceID, + profiler_id: this._profilerId, + }, + ); + + if (!chunk) { + DEBUG_BUILD && logger.log(`[Profiling] Failed to create profile chunk for: ${this._chunkData.id}`); + this._reset(); + return; + } + + this._flush(chunk); + // Depending on the profile and stack sizes, stopping the profile and converting + // the format may negatively impact the performance of the application. To avoid + // blocking for too long, enqueue the next chunk start inside the next macrotask. + // clear current chunk + this._reset(); + } + + /** + * Flushes the profile chunk to Sentry. + * @param chunk + */ + private _flush(chunk: ProfileChunk): void { + if (!this._client) { + DEBUG_BUILD && + logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + return; + } + + const transport = this._client.getTransport(); + if (!transport) { + DEBUG_BUILD && logger.log('[Profiling] No transport available to send profile chunk.'); + return; + } + + const dsn = this._client.getDsn(); + const metadata = this._client.getSdkMetadata(); + const tunnel = this._client.getOptions().tunnel; + + const envelope = makeProfileChunkEnvelope(chunk, metadata?.sdk, tunnel, dsn); + transport.send(envelope).then(null, reason => { + DEBUG_BUILD && logger.error('Error while sending profile chunk envelope:', reason); + }); + } + + /** + * Starts the profiler and registers the flush timer for a given chunk. + * @param chunk + */ + private _startChunkProfiling(chunk: ChunkData): void { + CpuProfilerBindings.startProfiling(chunk.id!); + DEBUG_BUILD && logger.log(`[Profiling] starting profiling chunk: ${chunk.id}`); + + chunk.timer = global.setTimeout(() => { + DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); + this.stop(); + DEBUG_BUILD && logger.log('[Profiling] Starting new profiling chunk.'); + setImmediate(this.start.bind(this)); + }, CHUNK_INTERVAL_MS); + + // Unref timeout so it doesn't keep the process alive. + chunk.timer.unref(); + } -function addToProfileQueue(profile: RawThreadCpuProfile): void { - PROFILE_QUEUE.push(profile); + /** + * Initializes new profile chunk metadata + */ + private _initializeChunk(traceId: string): void { + this._chunkData = { + id: uuid4(), + startTraceID: traceId, + startTimestampMS: timestampInSeconds(), + timer: undefined, + }; + } - // We only want to keep the last n profiles in the queue. - if (PROFILE_QUEUE.length > MAX_PROFILE_QUEUE_LENGTH) { - PROFILE_QUEUE.shift(); + /** + * Resets the current chunk state. + */ + private _reset(): void { + this._chunkData = undefined; } } +export interface ProfilingIntegration extends Integration { + _profiler: ContinuousProfiler; +} + /** Exported only for tests. */ -export const _nodeProfilingIntegration = (() => { +export const _nodeProfilingIntegration = ((): ProfilingIntegration => { if (DEBUG_BUILD && ![16, 18, 20, 22].includes(NODE_MAJOR)) { logger.warn( `[Profiling] You are using a Node.js version that does not have prebuilt binaries (${NODE_VERSION}).`, @@ -37,129 +330,32 @@ export const _nodeProfilingIntegration = (() => { return { name: 'ProfilingIntegration', + _profiler: new ContinuousProfiler(), setup(client: NodeClient) { - const spanToProfileIdMap = new WeakMap(); - - client.on('spanStart', span => { - if (span !== getRootSpan(span)) { - return; + DEBUG_BUILD && logger.log('[Profiling] Profiling integration setup.'); + const options = client.getOptions(); + + const mode = + (options.profilesSampleRate === undefined || options.profilesSampleRate === 0) && !options.profilesSampler + ? 'continuous' + : 'span'; + switch (mode) { + case 'continuous': { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiler mode enabled.'); + this._profiler.initialize(client); + break; } - - const profile_id = maybeProfileSpan(client, span, undefined); - - if (profile_id) { - const options = client.getOptions(); - // Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that - // currently exceed the default timeout set by the SDKs. - const maxProfileDurationMs = - (options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS; - - if (PROFILE_TIMEOUTS[profile_id]) { - global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete PROFILE_TIMEOUTS[profile_id]; - } - - // Enqueue a timeout to prevent profiles from running over max duration. - PROFILE_TIMEOUTS[profile_id] = global.setTimeout(() => { - DEBUG_BUILD && - logger.log( - '[Profiling] max profile duration elapsed, stopping profiling for:', - spanToJSON(span).description, - ); - - const profile = stopSpanProfile(span, profile_id); - if (profile) { - addToProfileQueue(profile); - } - }, maxProfileDurationMs); - - getCurrentScope().setContext('profile', { profile_id }); - - spanToProfileIdMap.set(span, profile_id); + // Default to span profiling when no mode profiler mode is set + case 'span': + case undefined: { + DEBUG_BUILD && logger.log('[Profiling] Span profiler mode enabled.'); + setupAutomatedSpanProfiling(client); + break; } - }); - - client.on('spanEnd', span => { - const profile_id = spanToProfileIdMap.get(span); - - if (profile_id) { - if (PROFILE_TIMEOUTS[profile_id]) { - global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete PROFILE_TIMEOUTS[profile_id]; - } - const profile = stopSpanProfile(span, profile_id); - - if (profile) { - addToProfileQueue(profile); - } - } - }); - - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!PROFILE_QUEUE.length) { - return; - } - - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; - } - - const profilesToAddToEnvelope: Profile[] = []; - - for (const profiledTransaction of profiledTransactionEvents) { - const profileContext = profiledTransaction.contexts?.['profile']; - const profile_id = profileContext?.['profile_id']; - - if (!profile_id) { - throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context'); - } - - // Remove the profile from the transaction context before sending, relay will take care of the rest. - if (profileContext) { - delete profiledTransaction.contexts?.['profile']; - } - - // We need to find both a profile and a transaction event for the same profile_id. - const profileIndex = PROFILE_QUEUE.findIndex(p => p.profile_id === profile_id); - if (profileIndex === -1) { - DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); - continue; - } - - const cpuProfile = PROFILE_QUEUE[profileIndex]; - if (!cpuProfile) { - DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); - continue; - } - - // Remove the profile from the queue. - PROFILE_QUEUE.splice(profileIndex, 1); - const profile = createProfilingEvent(client, cpuProfile, profiledTransaction); - - if (client.emit && profile) { - const integrations = - client['_integrations'] && client['_integrations'] !== null && !Array.isArray(client['_integrations']) - ? Object.keys(client['_integrations']) - : undefined; - - // @ts-expect-error bad overload due to unknown event - client.emit('preprocessEvent', profile, { - event_id: profiledTransaction.event_id, - integrations, - }); - } - - if (profile) { - profilesToAddToEnvelope.push(profile); - } + default: { + DEBUG_BUILD && logger.warn(`[Profiling] Unknown profiler mode: ${mode}, profiler was not initialized`); } - - addProfilesToEnvelope(envelope, profilesToAddToEnvelope); - }); + } }, }; }) satisfies IntegrationFn; diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index 957ee0e16303..1b347a61b741 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -5,6 +5,7 @@ import { logger, uuid4 } from '@sentry/utils'; import { CpuProfilerBindings } from './cpu_profiler'; import { DEBUG_BUILD } from './debug-build'; +import type { RawThreadCpuProfile } from './types'; import { isValidSampleRate } from './utils'; export const MAX_PROFILE_DURATION_MS = 30 * 1000; @@ -107,16 +108,13 @@ export function maybeProfileSpan( * @param profile_id * @returns */ -export function stopSpanProfile( - span: Span, - profile_id: string | undefined, -): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null { +export function stopSpanProfile(span: Span, profile_id: string | undefined): RawThreadCpuProfile | null { // Should not happen, but satisfy the type checker and be safe regardless. if (!profile_id) { return null; } - const profile = CpuProfilerBindings.stopProfiling(profile_id); + const profile = CpuProfilerBindings.stopProfiling(profile_id, 0); DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`); diff --git a/packages/profiling-node/src/types.ts b/packages/profiling-node/src/types.ts index 3042335269eb..1c2c444887cd 100644 --- a/packages/profiling-node/src/types.ts +++ b/packages/profiling-node/src/types.ts @@ -6,7 +6,11 @@ interface Sample { elapsed_since_start_ns: string; } -type Stack = number[]; +interface ChunkSample { + stack_id: number; + thread_id: string; + timestamp: number; +} type Frame = { function: string; @@ -23,15 +27,6 @@ interface Measurement { }[]; } -export interface DebugImage { - code_file: string; - type: string; - debug_id: string; - image_addr?: string; - image_size?: number; - image_vmaddr?: string; -} - // Profile is marked as optional because it is deleted from the metadata // by the integration before the event is processed by other integrations. export interface ProfiledEvent extends Event { @@ -40,66 +35,50 @@ export interface ProfiledEvent extends Event { }; } -export interface RawThreadCpuProfile { +interface BaseProfile { profile_id?: string; - stacks: ReadonlyArray; - samples: ReadonlyArray; - frames: ReadonlyArray; - resources: ReadonlyArray; + stacks: number[][]; + frames: Frame[]; + resources: string[]; profiler_logging_mode: 'eager' | 'lazy'; measurements: Record; } -export interface ThreadCpuProfile { - stacks: ReadonlyArray; - samples: ReadonlyArray; - frames: ReadonlyArray; - thread_metadata: Record; - queue_metadata?: Record; +export interface RawThreadCpuProfile extends BaseProfile { + samples: Sample[]; +} + +export interface RawChunkCpuProfile extends BaseProfile { + samples: ChunkSample[]; } export interface PrivateV8CpuProfilerBindings { startProfiling(name: string): void; - stopProfiling(name: string, threadId: number, collectResources: boolean): RawThreadCpuProfile | null; + + stopProfiling( + name: string, + format: ProfileFormat.THREAD, + threadId: number, + collectResources: boolean, + ): RawThreadCpuProfile | null; + stopProfiling( + name: string, + format: ProfileFormat.CHUNK, + threadId: number, + collectResources: boolean, + ): RawChunkCpuProfile | null; + + // Helper methods exposed for testing getFrameModule(abs_path: string): string; } +export enum ProfileFormat { + THREAD = 0, + CHUNK = 1, +} + export interface V8CpuProfilerBindings { startProfiling(name: string): void; - stopProfiling(name: string): RawThreadCpuProfile | null; -} -export interface Profile { - event_id: string; - version: string; - os: { - name: string; - version: string; - build_number: string; - }; - runtime: { - name: string; - version: string; - }; - device: { - architecture: string; - is_emulator: boolean; - locale: string; - manufacturer: string; - model: string; - }; - timestamp: string; - release: string; - environment: string; - platform: string; - profile: ThreadCpuProfile; - debug_meta?: { - images: DebugImage[]; - }; - transaction: { - name: string; - id: string; - trace_id: string; - active_thread_id: string; - }; - measurements: Record; + stopProfiling(name: string, format: ProfileFormat.THREAD): RawThreadCpuProfile | null; + stopProfiling(name: string, format: ProfileFormat.CHUNK): RawChunkCpuProfile | null; } diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts index 884e71c3d10e..5661129791bb 100644 --- a/packages/profiling-node/src/utils.ts +++ b/packages/profiling-node/src/utils.ts @@ -1,19 +1,37 @@ -import * as os from 'node:os'; -import { env, versions } from 'node:process'; -import { isMainThread, threadId } from 'node:worker_threads'; -import type { Client, Context, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; - -import { GLOBAL_OBJ, forEachEnvelopeItem, logger } from '@sentry/utils'; - +/* eslint-disable max-lines */ +import * as os from 'os'; +import type { + Client, + Context, + DebugImage, + DsnComponents, + Envelope, + Event, + EventEnvelopeHeaders, + Profile, + ProfileChunk, + ProfileChunkEnvelope, + SdkInfo, + StackFrame, + StackParser, + ThreadCpuProfile, +} from '@sentry/types'; +import { GLOBAL_OBJ, createEnvelope, dsnToString, forEachEnvelopeItem, logger, uuid4 } from '@sentry/utils'; + +import { env, versions } from 'process'; +import { isMainThread, threadId } from 'worker_threads'; + +import type { ProfileChunkItem } from '@sentry/types/build/types/envelope'; +import type { ContinuousThreadCpuProfile } from '../../types/src/profiling'; import { DEBUG_BUILD } from './debug-build'; -import type { Profile, RawThreadCpuProfile, ThreadCpuProfile } from './types'; -import type { DebugImage } from './types'; +import type { RawChunkCpuProfile, RawThreadCpuProfile } from './types'; // We require the file because if we import it, it will be included in the bundle. // I guess tsc does not check file contents when it's imported. const THREAD_ID_STRING = String(threadId); const THREAD_NAME = isMainThread ? 'main' : 'worker'; const FORMAT_VERSION = '1'; +const CONTINUOUS_FORMAT_VERSION = '2'; // Os machine was backported to 16.18, but this was not reflected in the types // @ts-expect-error ignore missing @@ -32,7 +50,9 @@ const ARCH = os.arch(); * @param {ThreadCpuProfile | RawThreadCpuProfile} profile * @returns {boolean} */ -function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { +function isRawThreadCpuProfile( + profile: ThreadCpuProfile | RawThreadCpuProfile | ContinuousThreadCpuProfile | RawChunkCpuProfile, +): profile is RawThreadCpuProfile | RawChunkCpuProfile { return !('thread_metadata' in profile); } @@ -43,7 +63,9 @@ function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): * @param {ThreadCpuProfile | RawThreadCpuProfile} profile * @returns {ThreadCpuProfile} */ -export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { +export function enrichWithThreadInformation( + profile: ThreadCpuProfile | RawThreadCpuProfile | ContinuousThreadCpuProfile | RawChunkCpuProfile, +): ThreadCpuProfile | ContinuousThreadCpuProfile { if (!isRawThreadCpuProfile(profile)) { return profile; } @@ -57,7 +79,7 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea name: THREAD_NAME, }, }, - }; + } as ThreadCpuProfile | ContinuousThreadCpuProfile; } /** @@ -88,7 +110,6 @@ export function createProfilingEvent(client: Client, profile: RawThreadCpuProfil * @param {options} * @returns {Profile} */ - function createProfilePayload( client: Client, cpuProfile: RawThreadCpuProfile, @@ -146,7 +167,7 @@ function createProfilePayload( debug_meta: { images: applyDebugMetadata(client, cpuProfile.resources), }, - profile: enrichedThreadProfile, + profile: enrichedThreadProfile as ThreadCpuProfile, transaction: { name: transaction, id: event_id, @@ -158,6 +179,82 @@ function createProfilePayload( return profile; } +/** + * Create a profile chunk from raw thread profile + * @param {RawThreadCpuProfile} cpuProfile + * @param {options} + * @returns {Profile} + */ +function createProfileChunkPayload( + client: Client, + cpuProfile: RawChunkCpuProfile, + { + release, + environment, + start_timestamp, + trace_id, + profiler_id, + chunk_id, + }: { + release: string; + environment: string; + start_timestamp: number; + trace_id: string | undefined; + chunk_id: string; + profiler_id: string; + }, +): ProfileChunk { + // Log a warning if the profile has an invalid traceId (should be uuidv4). + // All profiles and transactions are rejected if this is the case and we want to + // warn users that this is happening if they enable debug flag + if (trace_id && trace_id.length !== 32) { + DEBUG_BUILD && logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); + } + + const enrichedThreadProfile = enrichWithThreadInformation(cpuProfile); + + const profile: ProfileChunk = { + chunk_id: chunk_id, + profiler_id: profiler_id, + timestamp: new Date(start_timestamp).toISOString(), + platform: 'node', + version: CONTINUOUS_FORMAT_VERSION, + release: release, + environment: environment, + measurements: cpuProfile.measurements, + debug_meta: { + images: applyDebugMetadata(client, cpuProfile.resources), + }, + profile: enrichedThreadProfile as ContinuousThreadCpuProfile, + }; + + return profile; +} + +/** + * Creates a profiling chunk envelope item, if the profile does not pass validation, returns null. + */ +export function createProfilingChunkEvent( + start_timestamp: number, + client: Client, + options: { release?: string; environment?: string }, + profile: RawChunkCpuProfile, + identifiers: { trace_id: string | undefined; chunk_id: string; profiler_id: string }, +): ProfileChunk | null { + if (!isValidProfileChunk(profile)) { + return null; + } + + return createProfileChunkPayload(client, profile, { + release: options.release ?? '', + environment: options.environment ?? '', + start_timestamp: start_timestamp, + trace_id: identifiers.trace_id ?? '', + chunk_id: identifiers.chunk_id, + profiler_id: identifiers.profiler_id, + }); +} + /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). * @param {unknown} rate @@ -210,6 +307,24 @@ export function isValidProfile(profile: RawThreadCpuProfile): profile is RawThre return true; } +/** + * Checks if the profile chunk is valid and can be sent to Sentry. + * @param profile + * @returns + */ +export function isValidProfileChunk(profile: RawChunkCpuProfile): profile is RawChunkCpuProfile { + if (profile.samples.length <= 1) { + DEBUG_BUILD && + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile chunk because it contains less than 2 samples'); + return false; + } + + return true; +} + /** * Adds items to envelope if they are not already present - mutates the envelope. * @param {Envelope} envelope @@ -262,6 +377,41 @@ export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[ return events; } +/** + * Creates event envelope headers for a profile chunk. This is separate from createEventEnvelopeHeaders util + * as the profile chunk does not conform to the sentry event type + */ +export function createEventEnvelopeHeaders( + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn?: DsnComponents, +): EventEnvelopeHeaders { + return { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; +} + +/** + * Creates a standalone profile_chunk envelope. + */ +export function makeProfileChunkEnvelope( + chunk: ProfileChunk, + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn?: DsnComponents, +): ProfileChunkEnvelope { + const profileChunkHeader: ProfileChunkItem[0] = { + type: 'profile_chunk', + }; + + return createEnvelope(createEventEnvelopeHeaders(sdkInfo, tunnel, dsn), [ + [profileChunkHeader, chunk], + ]); +} + const debugIdStackParserCache = new WeakMap>(); /** diff --git a/packages/profiling-node/test/cpu_profiler.test.ts b/packages/profiling-node/test/cpu_profiler.test.ts index 8f66a91cb5ef..be12e740510a 100644 --- a/packages/profiling-node/test/cpu_profiler.test.ts +++ b/packages/profiling-node/test/cpu_profiler.test.ts @@ -1,5 +1,6 @@ +import type { ContinuousThreadCpuProfile, ThreadCpuProfile } from '@sentry/types'; import { CpuProfilerBindings, PrivateCpuProfilerBindings } from '../src/cpu_profiler'; -import type { RawThreadCpuProfile, ThreadCpuProfile } from '../src/types'; +import type { RawThreadCpuProfile } from '../src/types'; // Required because we test a hypothetical long profile // and we cannot use advance timers as the c++ relies on @@ -18,13 +19,16 @@ const fibonacci = (n: number): number => { }; const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const profiled = async (name: string, fn: () => void) => { +const profiled = async (name: string, fn: () => void, format: 0 | 1 = 0) => { CpuProfilerBindings.startProfiling(name); await fn(); - return CpuProfilerBindings.stopProfiling(name); + return CpuProfilerBindings.stopProfiling(name, format); }; -const assertValidSamplesAndStacks = (stacks: ThreadCpuProfile['stacks'], samples: ThreadCpuProfile['samples']) => { +const assertValidSamplesAndStacks = ( + stacks: ThreadCpuProfile['stacks'], + samples: ThreadCpuProfile['samples'] | ContinuousThreadCpuProfile['samples'], +) => { expect(stacks.length).toBeGreaterThan(0); expect(samples.length).toBeGreaterThan(0); expect(stacks.length <= samples.length).toBe(true); @@ -68,16 +72,25 @@ describe('Private bindings', () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); expect(() => { - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, false); if (!profile) throw new Error('No profile'); }).not.toThrow(); }); + it('throws if invalid format is supplied', async () => { + PrivateCpuProfilerBindings.startProfiling('profiled-program'); + await wait(100); + expect(() => { + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', Number.MAX_SAFE_INTEGER, 0, false); + if (!profile) throw new Error('No profile'); + }).toThrow('StopProfiling expects a valid format type as second argument.'); + }); + it('collects resources', async () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, true); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, true); if (!profile) throw new Error('No profile'); expect(profile.resources.length).toBeGreaterThan(0); @@ -94,7 +107,7 @@ describe('Private bindings', () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, false); if (!profile) throw new Error('No profile'); expect(profile.resources.length).toBe(0); @@ -159,27 +172,27 @@ describe('Profiler bindings', () => { CpuProfilerBindings.startProfiling('same-title'); CpuProfilerBindings.startProfiling('same-title'); - const first = CpuProfilerBindings.stopProfiling('same-title'); - const second = CpuProfilerBindings.stopProfiling('same-title'); + const first = CpuProfilerBindings.stopProfiling('same-title', 0); + const second = CpuProfilerBindings.stopProfiling('same-title', 0); expect(first).not.toBe(null); expect(second).toBe(null); }); - it('weird cases', () => { + it('multiple calls with same title', () => { CpuProfilerBindings.startProfiling('same-title'); expect(() => { - CpuProfilerBindings.stopProfiling('same-title'); - CpuProfilerBindings.stopProfiling('same-title'); + CpuProfilerBindings.stopProfiling('same-title', 0); + CpuProfilerBindings.stopProfiling('same-title', 0); }).not.toThrow(); }); it('does not crash if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); }); it('does crash if name is invalid', () => { - expect(() => CpuProfilerBindings.stopProfiling('')).toThrow(); + expect(() => CpuProfilerBindings.stopProfiling('', 0)).toThrow(); // @ts-expect-error test invalid input expect(() => CpuProfilerBindings.stopProfiling(undefined)).toThrow(); // @ts-expect-error test invalid input @@ -189,8 +202,8 @@ describe('Profiler bindings', () => { }); it('does not throw if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); - expect(() => CpuProfilerBindings.stopProfiling('does not exist')).not.toThrow(); + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); + expect(() => CpuProfilerBindings.stopProfiling('does not exist', 0)).not.toThrow(); }); it('compiles with eager logging by default', async () => { @@ -202,6 +215,27 @@ describe('Profiler bindings', () => { expect(profile.profiler_logging_mode).toBe('eager'); }); + it('chunk format type', async () => { + const profile = await profiled( + 'non nullable stack', + async () => { + await wait(1000); + fibonacci(36); + await wait(1000); + }, + 1, + ); + + if (!profile) fail('Profile is null'); + + for (const sample of profile.samples) { + if (!('timestamp' in sample)) { + throw new Error(`Sample ${JSON.stringify(sample)} has no timestamp`); + } + expect(sample.timestamp).toBeDefined(); + } + }); + it('stacks are not null', async () => { const profile = await profiled('non nullable stack', async () => { await wait(1000); @@ -216,7 +250,7 @@ describe('Profiler bindings', () => { it('samples at ~99hz', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(100); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); if (!profile) fail('Profile is null'); @@ -240,7 +274,7 @@ describe('Profiler bindings', () => { it('collects memory footprint', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); const heap_usage = profile?.measurements['memory_footprint']; if (!heap_usage) { @@ -256,7 +290,7 @@ describe('Profiler bindings', () => { it('collects cpu usage', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); const cpu_usage = profile?.measurements['cpu_usage']; if (!cpu_usage) { @@ -272,7 +306,7 @@ describe('Profiler bindings', () => { it('does not overflow measurement buffer if profile runs longer than 30s', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(35000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); expect(profile).not.toBe(null); expect(profile?.measurements?.['cpu_usage']?.values.length).toBeLessThanOrEqual(300); expect(profile?.measurements?.['memory_footprint']?.values.length).toBeLessThanOrEqual(300); diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 040ed5297205..92d1018e18d4 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -35,7 +35,7 @@ describe('ProfilingIntegration', () => { getTransport: () => transport, } as unknown as NodeClient; - integration.setup(client); + integration?.setup?.(client); // eslint-disable-next-line @typescript-eslint/unbound-method expect(transport.send).not.toHaveBeenCalled(); @@ -54,6 +54,7 @@ describe('ProfilingIntegration', () => { getOptions: () => { return { _metadata: {}, + profilesSampleRate: 1, }; }, getDsn: () => { @@ -64,7 +65,7 @@ describe('ProfilingIntegration', () => { const spy = jest.spyOn(client, 'on'); - integration.setup(client); + integration?.setup?.(client); expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledWith('spanStart', expect.any(Function)); diff --git a/packages/profiling-node/test/spanProfileUtils.test.ts b/packages/profiling-node/test/spanProfileUtils.test.ts index 687b6ca60768..9cc2ae58a972 100644 --- a/packages/profiling-node/test/spanProfileUtils.test.ts +++ b/packages/profiling-node/test/spanProfileUtils.test.ts @@ -1,10 +1,13 @@ import * as Sentry from '@sentry/node'; import { getMainCarrier } from '@sentry/core'; +import type { NodeClientOptions } from '@sentry/node/build/types/types'; import type { Transport } from '@sentry/types'; import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils'; import { CpuProfilerBindings } from '../src/cpu_profiler'; -import { _nodeProfilingIntegration } from '../src/integration'; +import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration'; + +jest.setTimeout(10000); function makeClientWithHooks(): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); @@ -28,9 +31,49 @@ function makeClientWithHooks(): [Sentry.NodeClient, Transport] { return [client, client.getTransport() as Transport]; } +function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { + const integration = _nodeProfilingIntegration(); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + profilesSampleRate: undefined, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + }); + + return [client, client.getTransport() as Transport]; +} + +function makeClientOptions( + options: Omit, +): NodeClientOptions { + return { + stackParser: Sentry.defaultStackParser, + integrations: [_nodeProfilingIntegration()], + debug: true, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + ...options, + }; +} + const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -describe('spanProfileUtils', () => { +describe('automated span instrumentation', () => { beforeEach(() => { jest.useRealTimers(); // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited @@ -65,6 +108,7 @@ describe('spanProfileUtils', () => { Sentry.setCurrentClient(client); client.init(); + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -103,6 +147,7 @@ describe('spanProfileUtils', () => { Sentry.setCurrentClient(client); client.init(); + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -248,6 +293,21 @@ describe('spanProfileUtils', () => { }, }); }); + + it('automated span instrumentation does not support continuous profiling', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); }); it('does not crash if stop is called multiple times', async () => { @@ -270,6 +330,7 @@ describe('spanProfileUtils', () => { 'Error\n at filename3.js (filename3.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', }; + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -322,3 +383,290 @@ describe('spanProfileUtils', () => { }); }); }); + +describe('continuous profiling', () => { + beforeEach(() => { + jest.useFakeTimers(); + // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited + getMainCarrier().__SENTRY__ = {}; + GLOBAL_OBJ._sentryDebugIds = undefined as any; + }); + afterEach(() => { + const client = Sentry.getClient(); + const integration = client?.getIntegrationByName('ProfilingIntegration'); + + if (integration) { + integration._profiler.stop(); + } + + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.runAllTimers(); + delete getMainCarrier().__SENTRY__; + }); + + it('initializes the continuous profiler and binds the sentry client', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + expect(integration._profiler).toBeDefined(); + expect(integration._profiler['_client']).toBe(client); + }); + + it('starts a continuous profile', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('multiple calls to start abort previous profile', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + integration._profiler.start(); + expect(startProfilingSpy).toHaveBeenCalledTimes(2); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('restarts a new chunk after previous', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(5001); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).toHaveBeenCalledTimes(2); + }); + + it('stops a continuous profile after interval', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(5001); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('manullly stopping a chunk doesnt restart the profiler', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(1000); + + integration._profiler.stop(); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('continuous mode does not instrument spans', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('sends as profile_chunk envelope type', async () => { + // @ts-expect-error we just mock the return type and ignore the signature + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + ], + measurements: {}, + stacks: [[0]], + frames: [], + resources: [], + profiler_logging_mode: 'lazy', + }; + }); + + const [client, transport] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + jest.advanceTimersByTime(1000); + integration._profiler.stop(); + jest.advanceTimersByTime(1000); + + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0].type).toBe('profile_chunk'); + }); +}); + +describe('span profiling mode', () => { + it.each([ + ['profilesSampleRate=1', makeClientOptions({ profilesSampleRate: 1 })], + ['profilesSampler is defined', makeClientOptions({ profilesSampler: () => 1 })], + ])('%s', async (_label, options) => { + const logSpy = jest.spyOn(logger, 'log'); + const client = new Sentry.NodeClient({ + ...options, + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + tracesSampleRate: 1, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + integrations: [_nodeProfilingIntegration()], + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const transport = client.getTransport(); + + if (!transport) { + throw new Error('Transport not found'); + } + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + + if (!integration) { + throw new Error('Profiling integration not found'); + } + + integration._profiler.start(); + expect(logSpy).toHaveBeenLastCalledWith('[Profiling] Profiler was never attached to the client.'); + }); +}); +describe('continuous profiling mode', () => { + it.each([ + ['profilesSampleRate=0', makeClientOptions({ profilesSampleRate: 0 })], + ['profilesSampleRate=undefined', makeClientOptions({ profilesSampleRate: undefined })], + // @ts-expect-error test invalid value + ['profilesSampleRate=null', makeClientOptions({ profilesSampleRate: null })], + [ + 'profilesSampler is not defined and profilesSampleRate is not set', + makeClientOptions({ profilesSampler: undefined, profilesSampleRate: 0 }), + ], + ])('%s', async (_label, options) => { + const client = new Sentry.NodeClient({ + ...options, + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + tracesSampleRate: 1, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + integrations: [_nodeProfilingIntegration()], + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const transport = client.getTransport(); + + if (!transport) { + throw new Error('Transport not found'); + } + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + const callCount = startProfilingSpy.mock.calls.length; + expect(startProfilingSpy).toHaveBeenCalled(); + + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + expect(startProfilingSpy).toHaveBeenCalledTimes(callCount); + }); +}); diff --git a/packages/remix/package.json b/packages/remix/package.json index 511b0d6d975d..acf21ec24f8d 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -28,12 +28,12 @@ "exports": { "./package.json": "./package.json", ".": { + "types": "./build/types/index.types.d.ts", "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" }, - "node": "./build/cjs/index.server.js", - "types": "./build/types/index.types.d.ts" + "node": "./build/cjs/index.server.js" }, "./import": { "import": { diff --git a/packages/replay-internal/src/coreHandlers/handleBeforeSendEvent.ts b/packages/replay-internal/src/coreHandlers/handleBeforeSendEvent.ts index 297d0bc2bbe3..f7434f595693 100644 --- a/packages/replay-internal/src/coreHandlers/handleBeforeSendEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleBeforeSendEvent.ts @@ -21,7 +21,8 @@ export function handleBeforeSendEvent(replay: ReplayContainer): BeforeSendEventC } function handleHydrationError(replay: ReplayContainer, event: ErrorEvent): void { - const exceptionValue = event.exception && event.exception.values && event.exception.values[0].value; + const exceptionValue = + event.exception && event.exception.values && event.exception.values[0] && event.exception.values[0].value; if (typeof exceptionValue !== 'string') { return; } diff --git a/packages/replay-internal/src/coreHandlers/performanceObserver.ts b/packages/replay-internal/src/coreHandlers/performanceObserver.ts index 45b843760e52..638ef53b05fb 100644 --- a/packages/replay-internal/src/coreHandlers/performanceObserver.ts +++ b/packages/replay-internal/src/coreHandlers/performanceObserver.ts @@ -1,7 +1,18 @@ -import { addLcpInstrumentationHandler, addPerformanceInstrumentationHandler } from '@sentry-internal/browser-utils'; - +import { + addClsInstrumentationHandler, + addFidInstrumentationHandler, + addInpInstrumentationHandler, + addLcpInstrumentationHandler, + addPerformanceInstrumentationHandler, +} from '@sentry-internal/browser-utils'; import type { ReplayContainer } from '../types'; -import { getLargestContentfulPaint } from '../util/createPerformanceEntries'; +import { + getCumulativeLayoutShift, + getFirstInputDelay, + getInteractionToNextPaint, + getLargestContentfulPaint, + webVitalHandler, +} from '../util/createPerformanceEntries'; /** * Sets up a PerformanceObserver to listen to all performance entry types. @@ -26,9 +37,10 @@ export function setupPerformanceObserver(replay: ReplayContainer): () => void { }); clearCallbacks.push( - addLcpInstrumentationHandler(({ metric }) => { - replay.replayPerformanceEntries.push(getLargestContentfulPaint(metric)); - }), + addLcpInstrumentationHandler(webVitalHandler(getLargestContentfulPaint, replay)), + addClsInstrumentationHandler(webVitalHandler(getCumulativeLayoutShift, replay)), + addFidInstrumentationHandler(webVitalHandler(getFirstInputDelay, replay)), + addInpInstrumentationHandler(webVitalHandler(getInteractionToNextPaint, replay)), ); // A callback to cleanup all handlers diff --git a/packages/replay-internal/src/types/performance.ts b/packages/replay-internal/src/types/performance.ts index 2fe87d24a9c8..5241c12d847a 100644 --- a/packages/replay-internal/src/types/performance.ts +++ b/packages/replay-internal/src/types/performance.ts @@ -96,12 +96,17 @@ export type ResourceData = Pick function to normalize data for event @@ -25,6 +26,42 @@ const ENTRY_TYPES: Record< navigation: createNavigationEntry, }; +export interface Metric { + /** + * The current value of the metric. + */ + value: number; + + /** + * The rating as to whether the metric value is within the "good", + * "needs improvement", or "poor" thresholds of the metric. + */ + rating: 'good' | 'needs-improvement' | 'poor'; + + /** + * Any performance entries relevant to the metric value calculation. + * The array may also be empty if the metric value was not based on any + * entries (e.g. a CLS value of 0 given no layout shifts). + */ + entries: PerformanceEntry[] | PerformanceEventTiming[]; +} + +interface LayoutShiftAttribution { + node?: Node; + previousRect: DOMRectReadOnly; + currentRect: DOMRectReadOnly; +} + +/** + * Handler creater for web vitals + */ +export function webVitalHandler( + getter: (metric: Metric) => ReplayPerformanceEntry, + replay: ReplayContainer, +): (data: { metric: Metric }) => void { + return ({ metric }) => void replay.replayPerformanceEntries.push(getter(metric)); +} + /** * Create replay performance entries from the browser performance entries. */ @@ -141,29 +178,65 @@ function createResourceEntry( } /** - * Add a LCP event to the replay based on an LCP metric. + * Add a LCP event to the replay based on a LCP metric. */ -export function getLargestContentfulPaint(metric: { - value: number; - entries: PerformanceEntry[]; -}): ReplayPerformanceEntry { - const entries = metric.entries; - const lastEntry = entries[entries.length - 1] as (PerformanceEntry & { element?: Element }) | undefined; - const element = lastEntry ? lastEntry.element : undefined; +export function getLargestContentfulPaint(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { element?: Node }) | undefined; + const node = lastEntry ? lastEntry.element : undefined; + return getWebVital(metric, 'largest-contentful-paint', node); +} +/** + * Add a CLS event to the replay based on a CLS metric. + */ +export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry { + // get first node that shifts + const firstEntry = metric.entries[0] as (PerformanceEntry & { sources?: LayoutShiftAttribution[] }) | undefined; + const node = firstEntry ? (firstEntry.sources ? firstEntry.sources[0].node : undefined) : undefined; + return getWebVital(metric, 'cumulative-layout-shift', node); +} + +/** + * Add a FID event to the replay based on a FID metric. + */ +export function getFirstInputDelay(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; + const node = lastEntry ? lastEntry.target : undefined; + return getWebVital(metric, 'first-input-delay', node); +} + +/** + * Add an INP event to the replay based on an INP metric. + */ +export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; + const node = lastEntry ? lastEntry.target : undefined; + return getWebVital(metric, 'interaction-to-next-paint', node); +} + +/** + * Add an web vital event to the replay based on the web vital metric. + */ +export function getWebVital( + metric: Metric, + name: string, + node: Node | undefined, +): ReplayPerformanceEntry { const value = metric.value; + const rating = metric.rating; const end = getAbsoluteTime(value); - const data: ReplayPerformanceEntry = { - type: 'largest-contentful-paint', - name: 'largest-contentful-paint', + const data: ReplayPerformanceEntry = { + type: 'web-vital', + name, start: end, end, data: { value, size: value, - nodeId: element ? record.mirror.getId(element) : undefined, + rating, + nodeId: node ? record.mirror.getId(node) : undefined, }, }; diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index 176de1c2d32e..f13d72feecf4 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -11,7 +11,13 @@ vi.mock('@sentry/utils', async () => ({ })); import { WINDOW } from '../../../src/constants'; -import { createPerformanceEntries, getLargestContentfulPaint } from '../../../src/util/createPerformanceEntries'; +import { + createPerformanceEntries, + getCumulativeLayoutShift, + getFirstInputDelay, + getInteractionToNextPaint, + getLargestContentfulPaint, +} from '../../../src/util/createPerformanceEntries'; import { PerformanceEntryNavigation } from '../../fixtures/performanceEntry/navigation'; describe('Unit | util | createPerformanceEntries', () => { @@ -66,17 +72,78 @@ describe('Unit | util | createPerformanceEntries', () => { it('works with an LCP metric', async () => { const metric = { value: 5108.299, + rating: 'good' as const, entries: [], }; const event = getLargestContentfulPaint(metric); expect(event).toEqual({ - type: 'largest-contentful-paint', + type: 'web-vital', name: 'largest-contentful-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, nodeId: undefined }, + data: { value: 5108.299, rating: 'good', size: 5108.299, nodeId: undefined }, + }); + }); + }); + + describe('getCumulativeLayoutShift', () => { + it('works with an CLS metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getCumulativeLayoutShift(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'cumulative-layout-shift', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + }); + }); + }); + + describe('getFirstInputDelay', () => { + it('works with an FID metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getFirstInputDelay(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'first-input-delay', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + }); + }); + }); + + describe('getInteractionToNextPaint', () => { + it('works with an INP metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getInteractionToNextPaint(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'interaction-to-next-paint', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, }); }); }); diff --git a/packages/solidjs/.eslintrc.js b/packages/solid/.eslintrc.js similarity index 100% rename from packages/solidjs/.eslintrc.js rename to packages/solid/.eslintrc.js diff --git a/packages/solidjs/LICENSE b/packages/solid/LICENSE similarity index 100% rename from packages/solidjs/LICENSE rename to packages/solid/LICENSE diff --git a/packages/solid/README.md b/packages/solid/README.md new file mode 100644 index 000000000000..06ef8f810e93 --- /dev/null +++ b/packages/solid/README.md @@ -0,0 +1,88 @@ +

    + + Sentry + +

    + +# Official Sentry SDK for Solid + +[![npm version](https://img.shields.io/npm/v/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) +[![npm dm](https://img.shields.io/npm/dm/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) +[![npm dt](https://img.shields.io/npm/dt/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) + +This SDK is considered **experimental and in an alpha state**. It may experience breaking changes. Please reach out on +[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. This +SDK currently only supports [Solid](https://www.solidjs.com/) and is not yet officially compatible with +[Solid Start](https://start.solidjs.com/). + +# Solid Router + +The Solid Router instrumentation uses the Solid Router library to create navigation spans to ensure you collect +meaningful performance data about the health of your page loads and associated requests. + +Add `Sentry.solidRouterBrowserTracingIntegration` instead of the regular `Sentry.browserTracingIntegration` and provide +the hooks it needs to enable performance tracing: + +`useBeforeLeave` from `@solidjs/router` +`useLocation` from `@solidjs/router` + +Make sure `Sentry.solidRouterBrowserTracingIntegration` is initialized by your `Sentry.init` call, before you wrap +`Router`. Otherwise, the routing instrumentation may not work properly. + +Wrap `Router`, `MemoryRouter` or `HashRouter` from `@solidjs/router` using `Sentry.withSentryRouterRouting`. This +creates a higher order component, which will enable Sentry to reach your router context. + +```js +import * as Sentry from '@sentry/solid'; +import { Route, Router, useBeforeLeave, useLocation } from '@solidjs/router'; + +Sentry.init({ + dsn: '__PUBLIC_DSN__', + integrations: [Sentry.solidRouterBrowserTracingIntegration({ useBeforeLeave, useLocation })], + tracesSampleRate: 1.0, // Capture 100% of the transactions +}); + +const SentryRouter = Sentry.withSentryRouterRouting(Router); + +render( + () => ( + + + ... + + ), + document.getElementById('root'), +); +``` + +# ErrorBoundary + +To automatically capture exceptions from inside a component tree and render a fallback component, wrap the native Solid +JS `ErrorBoundary` component with `Sentry.withSentryErrorBoundary`. + +```js +import * as Sentry from '@sentry/solid'; +import { ErrorBoundary } from 'solid-js'; + +Sentry.init({ + dsn: '__PUBLIC_DSN__', + tracesSampleRate: 1.0, // Capture 100% of the transactions +}); + +const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); + +render( + () => ( +
    Error: {err.message}
    }> + +
    + ), + document.getElementById('root'), +); +``` + +# Sourcemaps and Releases + +To generate and upload source maps of your Solid JS app bundle, check our guide +[how to configure your bundler](https://docs.sentry.io/platforms/javascript/guides/solid/sourcemaps/#uploading-source-maps) +to emit source maps. diff --git a/packages/solidjs/package.json b/packages/solid/package.json similarity index 90% rename from packages/solidjs/package.json rename to packages/solid/package.json index aa6930b6c4aa..05b00d189840 100644 --- a/packages/solidjs/package.json +++ b/packages/solid/package.json @@ -1,9 +1,9 @@ { - "name": "@sentry/solidjs", + "name": "@sentry/solid", "version": "8.8.0", - "description": "Official Sentry SDK for SolidJS", + "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", - "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidjs", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", "author": "Sentry", "license": "MIT", "engines": { @@ -54,6 +54,8 @@ "@solidjs/router": "^0.13.5", "@solidjs/testing-library": "0.8.5", "solid-js": "^1.8.11", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/user-event": "^14.5.2", "vite-plugin-solid": "^2.8.2" }, "scripts": { @@ -69,7 +71,7 @@ "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "ts-node ../../scripts/prepack.ts && npm pack ./build", "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build coverage sentry-solidjs-*.tgz", + "clean": "rimraf build coverage sentry-solid-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", "test": "vitest run", diff --git a/packages/solidjs/rollup.npm.config.mjs b/packages/solid/rollup.npm.config.mjs similarity index 100% rename from packages/solidjs/rollup.npm.config.mjs rename to packages/solid/rollup.npm.config.mjs diff --git a/packages/solidjs/src/debug-build.ts b/packages/solid/src/debug-build.ts similarity index 100% rename from packages/solidjs/src/debug-build.ts rename to packages/solid/src/debug-build.ts diff --git a/packages/solid/src/errorboundary.ts b/packages/solid/src/errorboundary.ts new file mode 100644 index 000000000000..8a1ad0efa902 --- /dev/null +++ b/packages/solid/src/errorboundary.ts @@ -0,0 +1,31 @@ +import { captureException } from '@sentry/browser'; +import type { Component, JSX } from 'solid-js'; +import { mergeProps, splitProps } from 'solid-js'; +import { createComponent } from 'solid-js/web'; + +type ErrorBoundaryProps = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallback: JSX.Element | ((err: any, reset: () => void) => JSX.Element); + children: JSX.Element; +}; + +/** + * A higher-order component to wrap Solid's ErrorBoundary to capture exceptions. + */ +export function withSentryErrorBoundary(ErrorBoundary: Component): Component { + const SentryErrorBoundary = (props: ErrorBoundaryProps): JSX.Element => { + const [local, others] = splitProps(props, ['fallback']); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fallback = (error: any, reset: () => void): JSX.Element => { + captureException(error); + + const f = local.fallback; + return typeof f === 'function' ? f(error, reset) : f; + }; + + return createComponent(ErrorBoundary, mergeProps({ fallback }, others)); + }; + + return SentryErrorBoundary; +} diff --git a/packages/solidjs/src/index.ts b/packages/solid/src/index.ts similarity index 74% rename from packages/solidjs/src/index.ts rename to packages/solid/src/index.ts index 77f17110f5e1..48aee7358776 100644 --- a/packages/solidjs/src/index.ts +++ b/packages/solid/src/index.ts @@ -3,3 +3,4 @@ export * from '@sentry/browser'; export { init } from './sdk'; export * from './solidrouter'; +export * from './errorboundary'; diff --git a/packages/solidjs/src/sdk.ts b/packages/solid/src/sdk.ts similarity index 80% rename from packages/solidjs/src/sdk.ts rename to packages/solid/src/sdk.ts index 7e33431a63b1..83a14a30bc2a 100644 --- a/packages/solidjs/src/sdk.ts +++ b/packages/solid/src/sdk.ts @@ -3,14 +3,14 @@ import { init as browserInit } from '@sentry/browser'; import { applySdkMetadata } from '@sentry/core'; /** - * Initializes the SolidJS SDK + * Initializes the Solid SDK */ export function init(options: BrowserOptions): void { const opts = { ...options, }; - applySdkMetadata(opts, 'solidjs'); + applySdkMetadata(opts, 'solid'); browserInit(opts); } diff --git a/packages/solidjs/src/solidrouter.ts b/packages/solid/src/solidrouter.ts similarity index 99% rename from packages/solidjs/src/solidrouter.ts rename to packages/solid/src/solidrouter.ts index d38b21313570..d22e7afb889a 100644 --- a/packages/solidjs/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -71,7 +71,7 @@ function handleNavigation(location: string): void { name: location, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidjs.solidrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, }); diff --git a/packages/solid/test/errorboundary.test.tsx b/packages/solid/test/errorboundary.test.tsx new file mode 100644 index 000000000000..f1e3f2c0e56a --- /dev/null +++ b/packages/solid/test/errorboundary.test.tsx @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type * as SentryBrowser from '@sentry/browser'; +import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { render } from '@solidjs/testing-library'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import { ErrorBoundary } from 'solid-js'; +import { BrowserClient, withSentryErrorBoundary } from '../src'; + +const mockCaptureException = vi.fn(); +vi.mock('@sentry/browser', async () => { + const actual = await vi.importActual('@sentry/browser'); + return { + ...actual, + captureException: (...args) => mockCaptureException(...args), + } as typeof SentryBrowser; +}); + +const user = userEvent.setup(); +const SentryErrorBoundary = withSentryErrorBoundary(ErrorBoundary); + +describe('withSentryErrorBoundary', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + + const client = createMockBrowserClient(); + setCurrentClient(client); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('calls `captureException` when an error occurs`', () => { + render(() => ( + Ooops, an error occurred.}> + + + )); + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenLastCalledWith(new ReferenceError('NonExistentComponent is not defined')); + }); + + it('renders the fallback component', async () => { + const { findByText } = render(() => ( + Ooops, an error occurred.}> + + + )); + + expect(await findByText('Ooops, an error occurred.')).toBeInTheDocument(); + }); + + it('passes the `error` and `reset` function to the fallback component', () => { + const mockFallback = vi.fn(); + + render(() => { + + + ; + }); + + expect(mockFallback).toHaveBeenCalledTimes(1); + expect(mockFallback).toHaveBeenCalledWith( + new ReferenceError('NonExistentComponent is not defined'), + expect.any(Function), + ); + }); + + it('calls `captureException` again after resetting', async () => { + const { findByRole } = render(() => ( + }> + + + )); + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenNthCalledWith(1, new ReferenceError('NonExistentComponent is not defined')); + + const button = await findByRole('button'); + await user.click(button); + + expect(mockCaptureException).toHaveBeenCalledTimes(2); + expect(mockCaptureException).toHaveBeenNthCalledWith(2, new ReferenceError('NonExistentComponent is not defined')); + }); + + it('renders children when there is no error', async () => { + const { queryByText } = render(() => ( + Oops, an error occurred.}> +
    Adopt a cat
    +
    + )); + + expect(await queryByText('Adopt a cat')).toBeInTheDocument(); + expect(await queryByText('Ooops, an error occurred')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/solidjs/test/sdk.test.ts b/packages/solid/test/sdk.test.ts similarity index 80% rename from packages/solidjs/test/sdk.test.ts rename to packages/solid/test/sdk.test.ts index 6b075b0099c4..1fa704f5cc2c 100644 --- a/packages/solidjs/test/sdk.test.ts +++ b/packages/solid/test/sdk.test.ts @@ -6,7 +6,7 @@ import { init as solidInit } from '../src/sdk'; const browserInit = vi.spyOn(SentryBrowser, 'init'); -describe('Initialize SolidJS SDk', () => { +describe('Initialize Solid SDk', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -19,8 +19,8 @@ describe('Initialize SolidJS SDk', () => { const expectedMetadata = { _metadata: { sdk: { - name: 'sentry.javascript.solidjs', - packages: [{ name: 'npm:@sentry/solidjs', version: SDK_VERSION }], + name: 'sentry.javascript.solid', + packages: [{ name: 'npm:@sentry/solid', version: SDK_VERSION }], version: SDK_VERSION, }, }, diff --git a/packages/solidjs/test/solidrouter.test.tsx b/packages/solid/test/solidrouter.test.tsx similarity index 99% rename from packages/solidjs/test/solidrouter.test.tsx rename to packages/solid/test/solidrouter.test.tsx index 4530eaa75871..a268a8ee50ed 100644 --- a/packages/solidjs/test/solidrouter.test.tsx +++ b/packages/solid/test/solidrouter.test.tsx @@ -139,7 +139,7 @@ describe('solidRouterBrowserTracingIntegration', () => { data: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidjs.solidrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', }), }), ); @@ -173,7 +173,7 @@ describe('solidRouterBrowserTracingIntegration', () => { data: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidjs.solidrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', }), }), ); diff --git a/packages/solidjs/tsconfig.json b/packages/solid/tsconfig.json similarity index 100% rename from packages/solidjs/tsconfig.json rename to packages/solid/tsconfig.json diff --git a/packages/solidjs/tsconfig.test.json b/packages/solid/tsconfig.test.json similarity index 65% rename from packages/solidjs/tsconfig.test.json rename to packages/solid/tsconfig.test.json index fc9e549d35ce..adecd5079938 100644 --- a/packages/solidjs/tsconfig.test.json +++ b/packages/solid/tsconfig.test.json @@ -5,8 +5,10 @@ "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["vitest/globals"] + "types": ["vitest/globals", "vite/client", "@testing-library/jest-dom"], // other package-specific, test-specific options + "jsx": "preserve", + "jsxImportSource": "solid-js" } } diff --git a/packages/solidjs/tsconfig.types.json b/packages/solid/tsconfig.types.json similarity index 100% rename from packages/solidjs/tsconfig.types.json rename to packages/solid/tsconfig.types.json diff --git a/packages/solidjs/vite.config.ts b/packages/solid/vite.config.ts similarity index 100% rename from packages/solidjs/vite.config.ts rename to packages/solid/vite.config.ts diff --git a/packages/solidjs/README.md b/packages/solidjs/README.md deleted file mode 100644 index 00d451763624..000000000000 --- a/packages/solidjs/README.md +++ /dev/null @@ -1,50 +0,0 @@ -

    - - Sentry - -

    - -# Official Sentry SDK for SolidJS - -This SDK is work in progress, and should not be used before officially released. - -# Solid Router - -The Solid Router instrumentation uses the Solid Router library to create navigation spans to ensure you collect -meaningful performance data about the health of your page loads and associated requests. - -Add `Sentry.solidRouterBrowserTracingIntegration` instead of the regular `Sentry.browserTracingIntegration` and provide -the hooks it needs to enable performance tracing: - -`useBeforeLeave` from `@solidjs/router` -`useLocation` from `@solidjs/router` - -Make sure `Sentry.solidRouterBrowserTracingIntegration` is initialized by your `Sentry.init` call, before you wrap -`Router`. Otherwise, the routing instrumentation may not work properly. - -Wrap `Router`, `MemoryRouter` or `HashRouter` from `@solidjs/router` using `Sentry.withSentryRouterRouting`. This -creates a higher order component, which will enable Sentry to reach your router context. - -```js -import * as Sentry from '@sentry/solidjs'; -import { Route, Router, useBeforeLeave, useLocation } from '@solidjs/router'; - -Sentry.init({ - dsn: '__PUBLIC_DSN__', - integrations: [Sentry.solidRouterBrowserTracingIntegration({ useBeforeLeave, useLocation })], - tracesSampleRate: 1.0, // Capture 100% of the transactions - debug: true, -}); - -const SentryRouter = Sentry.withSentryRouterRouting(Router); - -render( - () => ( - - - ... - - ), - document.getElementById('root'), -); -``` diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 05614778ecce..0ef4e41c238c 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -22,12 +22,12 @@ "exports": { "./package.json": "./package.json", ".": { + "types": "./build/types/index.types.d.ts", "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" }, - "node": "./build/cjs/index.server.js", - "types": "./build/types/index.types.d.ts" + "node": "./build/cjs/index.server.js" } }, "publishConfig": { diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index d7089fbb0225..29e8fc123b16 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -4,7 +4,7 @@ import type { ClientReport } from './clientreport'; import type { DsnComponents } from './dsn'; import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; -import type { Profile } from './profiling'; +import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; @@ -36,6 +36,7 @@ export type EnvelopeItemType = | 'attachment' | 'event' | 'profile' + | 'profile_chunk' | 'replay_event' | 'replay_recording' | 'check_in' @@ -79,8 +80,9 @@ type ClientReportItemHeaders = { type: 'client_report' }; type ReplayEventItemHeaders = { type: 'replay_event' }; type ReplayRecordingItemHeaders = { type: 'replay_recording'; length: number }; type CheckInItemHeaders = { type: 'check_in' }; -type StatsdItemHeaders = { type: 'statsd'; length: number }; type ProfileItemHeaders = { type: 'profile' }; +type ProfileChunkItemHeaders = { type: 'profile_chunk' }; +type StatsdItemHeaders = { type: 'statsd'; length: number }; type SpanItemHeaders = { type: 'span' }; export type EventItem = BaseEnvelopeItem; @@ -96,6 +98,7 @@ type ReplayRecordingItem = BaseEnvelopeItem; export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; +export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; @@ -116,13 +119,16 @@ export type ReplayEnvelope = [ReplayEnvelopeHeaders, [ReplayEventItem, ReplayRec export type CheckInEnvelope = BaseEnvelope; export type StatsdEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type ProfileChunkEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope | SessionEnvelope | ClientReportEnvelope + | ProfileChunkEnvelope | ReplayEnvelope | CheckInEnvelope | StatsdEnvelope | SpanEnvelope; + export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c90b7841f9ff..8fbfd37e95ab 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -45,6 +45,7 @@ export type { StatsdItem, StatsdEnvelope, ProfileItem, + ProfileChunkEnvelope, SpanEnvelope, SpanItem, } from './envelope'; @@ -69,7 +70,9 @@ export type { ThreadCpuStack, ThreadCpuFrame, ThreadCpuProfile, + ContinuousThreadCpuProfile, Profile, + ProfileChunk, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; export type { diff --git a/packages/types/src/profiling.ts b/packages/types/src/profiling.ts index 3650500fcd7b..5161b6b64b2e 100644 --- a/packages/types/src/profiling.ts +++ b/packages/types/src/profiling.ts @@ -12,6 +12,13 @@ export interface ThreadCpuSample { elapsed_since_start_ns: string; } +export interface ContinuousThreadCpuSample { + stack_id: StackId; + thread_id: ThreadId; + queue_address?: string; + timestamp: number; +} + export type ThreadCpuStack = FrameId[]; export type ThreadCpuFrame = { @@ -34,7 +41,37 @@ export interface ThreadCpuProfile { queue_metadata?: Record; } -export interface Profile { +export interface ContinuousThreadCpuProfile { + samples: ContinuousThreadCpuSample[]; + stacks: ThreadCpuStack[]; + frames: ThreadCpuFrame[]; + thread_metadata: Record; + queue_metadata?: Record; +} + +interface BaseProfile { + timestamp: string; + version: string; + release: string; + environment: string; + platform: string; + profile: T; + debug_meta?: { + images: DebugImage[]; + }; + measurements?: Record< + string, + { + unit: MeasurementUnit; + values: { + elapsed_since_start_ns: number; + value: number; + }[]; + } + >; +} + +export interface Profile extends BaseProfile { event_id: string; version: string; os: { @@ -86,3 +123,8 @@ export interface Profile { } >; } + +export interface ProfileChunk extends BaseProfile { + chunk_id: string; + profiler_id: string; +} diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 17c40bed92ad..8bf29788edf0 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -217,6 +217,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { client_report: 'internal', user_report: 'default', profile: 'profile', + profile_chunk: 'profile', replay_event: 'replay', replay_recording: 'replay', check_in: 'monitor', diff --git a/packages/utils/src/instrument/fetch.ts b/packages/utils/src/instrument/fetch.ts index 4c502334024a..24c435fdf07c 100644 --- a/packages/utils/src/instrument/fetch.ts +++ b/packages/utils/src/instrument/fetch.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandlerDataFetch } from '@sentry/types'; -import { fill } from '../object'; +import { isError } from '../is'; +import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; import { timestampInSeconds } from '../time'; import { GLOBAL_OBJ } from '../worldwide'; @@ -45,6 +46,15 @@ function instrumentFetch(): void { ...handlerData, }); + // We capture the stack right here and not in the Promise error callback because Safari (and probably other + // browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless. + + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your fetch call did not + // have a stack trace, so the SDK backfilled the stack trace so + // you can see which fetch call failed. + const virtualStackTrace = new Error().stack; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return originalFetch.apply(GLOBAL_OBJ, args).then( (response: Response) => { @@ -65,6 +75,16 @@ function instrumentFetch(): void { }; triggerHandlers('fetch', erroredHandlerData); + + if (isError(error) && error.stack === undefined) { + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your fetch call did not + // have a stack trace, so the SDK backfilled the stack trace so + // you can see which fetch call failed. + error.stack = virtualStackTrace; + addNonEnumerableProperty(error, 'framesToPop', 1); + } + // NOTE: If you are a Sentry user, and you are seeing this stack frame, // it means the sentry.javascript SDK caught an error invoking your application code. // This is expected behavior and NOT indicative of a bug with sentry.javascript. diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index ba42c8ba9fb5..b1d7163e48d1 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -83,8 +83,9 @@ export function instrumentVueRouter( if (to.name && options.routeLabel !== 'path') { spanName = to.name.toString(); transactionSource = 'custom'; - } else if (to.matched[0] && to.matched[0].path) { - spanName = to.matched[0].path; + } else if (to.matched.length > 0) { + const lastIndex = to.matched.length - 1; + spanName = to.matched[lastIndex].path; transactionSource = 'route'; } diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 8ff42d49e2b9..e835855ebd7a 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -43,6 +43,14 @@ const testRoutes: Record = { path: '/accounts/4', query: {}, }, + nestedRoute: { + matched: [{ path: '/' }, { path: '/categories' }, { path: '/categories/:categoryId' }], + params: { + categoryId: '1', + }, + path: '/categories/1', + query: {}, + }, namedRoute: { matched: [{ path: '/login' }], name: 'login-screen', @@ -85,6 +93,7 @@ describe('instrumentVueRouter()', () => { it.each([ ['normalRoute1', 'normalRoute2', '/accounts/:accountId', 'route'], + ['normalRoute1', 'nestedRoute', '/categories/:categoryId', 'route'], ['normalRoute2', 'namedRoute', 'login-screen', 'custom'], ['normalRoute2', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'], ])( @@ -122,6 +131,7 @@ describe('instrumentVueRouter()', () => { it.each([ ['initialPageloadRoute', 'normalRoute1', '/books/:bookId/chapter/:chapterId', 'route'], + ['initialPageloadRoute', 'nestedRoute', '/categories/:categoryId', 'route'], ['initialPageloadRoute', 'namedRoute', 'login-screen', 'custom'], ['initialPageloadRoute', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'], ])( diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index 119f9764fc60..f7c6d0fded5a 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -15,7 +15,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/vue', '@sentry/react', '@sentry/angular', - '@sentry/solidjs', + '@sentry/solid', '@sentry/svelte', '@sentry/profiling-node', '@sentry-internal/browser-utils', diff --git a/yarn.lock b/yarn.lock index 0463b8364fde..e83c8fe9f310 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,6 +63,11 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== +"@adobe/css-tools@^4.3.2": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.0.tgz#728c484f4e10df03d5a3acd0d8adcbbebff8ad63" + integrity sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ== + "@ampproject/remapping@2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -1088,6 +1093,14 @@ "@babel/highlight" "^7.24.2" picocolors "^1.0.0" +"@babel/code-frame@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + "@babel/compat-data@^7.13.0", "@babel/compat-data@^7.17.7", "@babel/compat-data@^7.19.4", "@babel/compat-data@^7.20.0": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30" @@ -1118,6 +1131,11 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98" integrity sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw== +"@babel/compat-data@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" + integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== + "@babel/core@7.18.10": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" @@ -1224,20 +1242,20 @@ semver "^6.3.1" "@babel/core@^7.23.3": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" - integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.7.tgz#b676450141e0b52a3d43bc91da86aa608f950ac4" + integrity sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g== dependencies: "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.24.2" - "@babel/generator" "^7.24.5" - "@babel/helper-compilation-targets" "^7.23.6" - "@babel/helper-module-transforms" "^7.24.5" - "@babel/helpers" "^7.24.5" - "@babel/parser" "^7.24.5" - "@babel/template" "^7.24.0" - "@babel/traverse" "^7.24.5" - "@babel/types" "^7.24.5" + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.24.7" + "@babel/helper-compilation-targets" "^7.24.7" + "@babel/helper-module-transforms" "^7.24.7" + "@babel/helpers" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/template" "^7.24.7" + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -1323,12 +1341,12 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" -"@babel/generator@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" - integrity sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA== +"@babel/generator@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" + integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== dependencies: - "@babel/types" "^7.24.5" + "@babel/types" "^7.24.7" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" @@ -1405,6 +1423,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz#4eb6c4a80d6ffeac25ab8cd9a21b5dfa48d503a9" + integrity sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg== + dependencies: + "@babel/compat-data" "^7.24.7" + "@babel/helper-validator-option" "^7.24.7" + browserslist "^4.22.2" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.19.0", "@babel/helper-create-class-features-plugin@^7.5.5": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b" @@ -1497,6 +1526,13 @@ resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== +"@babel/helper-environment-visitor@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" + integrity sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-explode-assignable-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096" @@ -1520,6 +1556,14 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" +"@babel/helper-function-name@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz#75f1e1725742f39ac6584ee0b16d94513da38dd2" + integrity sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA== + dependencies: + "@babel/template" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" @@ -1534,6 +1578,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-hoist-variables@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee" + integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-member-expression-to-functions@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" @@ -1562,13 +1613,21 @@ dependencies: "@babel/types" "^7.22.15" -"@babel/helper-module-imports@^7.24.1", "@babel/helper-module-imports@^7.24.3": +"@babel/helper-module-imports@^7.24.1": version "7.24.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== dependencies: "@babel/types" "^7.24.0" +"@babel/helper-module-imports@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz#f2f980392de5b84c3328fc71d38bd81bbb83042b" + integrity sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.6", "@babel/helper-module-transforms@^7.20.2": version "7.20.2" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz#ac53da669501edd37e658602a21ba14c08748712" @@ -1605,16 +1664,16 @@ "@babel/helper-split-export-declaration" "^7.22.6" "@babel/helper-validator-identifier" "^7.22.20" -"@babel/helper-module-transforms@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz#ea6c5e33f7b262a0ae762fd5986355c45f54a545" - integrity sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A== +"@babel/helper-module-transforms@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz#31b6c9a2930679498db65b685b1698bfd6c7daf8" + integrity sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ== dependencies: - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-module-imports" "^7.24.3" - "@babel/helper-simple-access" "^7.24.5" - "@babel/helper-split-export-declaration" "^7.24.5" - "@babel/helper-validator-identifier" "^7.24.5" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-module-imports" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" @@ -1650,6 +1709,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz#945681931a52f15ce879fd5b86ce2dae6d3d7f2a" integrity sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w== +"@babel/helper-plugin-utils@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" + integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== + "@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" @@ -1703,12 +1767,13 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-simple-access@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz#50da5b72f58c16b07fbd992810be6049478e85ba" - integrity sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ== +"@babel/helper-simple-access@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" + integrity sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg== dependencies: - "@babel/types" "^7.24.5" + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" "@babel/helper-skip-transparent-expression-wrappers@^7.18.9": version "7.18.9" @@ -1738,12 +1803,12 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-split-export-declaration@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" - integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== +"@babel/helper-split-export-declaration@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" + integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== dependencies: - "@babel/types" "^7.24.5" + "@babel/types" "^7.24.7" "@babel/helper-string-parser@^7.19.4": version "7.19.4" @@ -1765,10 +1830,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== -"@babel/helper-string-parser@^7.24.1": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" - integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== +"@babel/helper-string-parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" + integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" @@ -1780,10 +1845,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== -"@babel/helper-validator-identifier@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" - integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== +"@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== "@babel/helper-validator-option@^7.16.7", "@babel/helper-validator-option@^7.18.6": version "7.18.6" @@ -1800,6 +1865,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== +"@babel/helper-validator-option@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" + integrity sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw== + "@babel/helper-wrap-function@^7.18.9": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1" @@ -1864,14 +1934,13 @@ "@babel/traverse" "^7.24.1" "@babel/types" "^7.24.0" -"@babel/helpers@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.5.tgz#fedeb87eeafa62b621160402181ad8585a22a40a" - integrity sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q== +"@babel/helpers@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.7.tgz#aa2ccda29f62185acb5d42fb4a3a1b1082107416" + integrity sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg== dependencies: - "@babel/template" "^7.24.0" - "@babel/traverse" "^7.24.5" - "@babel/types" "^7.24.5" + "@babel/template" "^7.24.7" + "@babel/types" "^7.24.7" "@babel/highlight@^7.10.4", "@babel/highlight@^7.18.6": version "7.18.6" @@ -1920,6 +1989,16 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.2", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0": version "7.20.3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" @@ -1930,7 +2009,7 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.15.tgz#eec9f36d8eaf0948bb88c87a46784b5ee9fd0c89" integrity sha512-DI4a1oZuf8wC+oAJA9RW6ga3Zbe8RZFt7kD9i4qAspz3I/yHet1VvC3DiSy/fsUvv5pvJuNPh0LPOdCcqinDPg== -"@babel/parser@^7.21.8", "@babel/parser@^7.24.5": +"@babel/parser@^7.21.8": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== @@ -1960,6 +2039,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.4.tgz#234487a110d89ad5a3ed4a8a566c36b9453e8c88" integrity sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg== +"@babel/parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" + integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.4": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz#6125f0158543fb4edf1c22f322f3db67f21cb3e1" @@ -2295,11 +2379,11 @@ "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-jsx@^7.18.6": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz#3f6ca04b8c841811dbc3c5c5f837934e0d626c10" - integrity sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA== + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz#39a1fa4a7e3d3d7f34e2acc6be585b718d30e02d" + integrity sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ== dependencies: - "@babel/helper-plugin-utils" "^7.24.0" + "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-jsx@^7.22.5": version "7.22.5" @@ -3420,6 +3504,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.9.2": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" + integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@7.18.10", "@babel/template@^7.18.10", "@babel/template@^7.3.3": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" @@ -3447,6 +3538,15 @@ "@babel/parser" "^7.24.0" "@babel/types" "^7.24.0" +"@babel/template@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" + integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/traverse@^7.13.0", "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.22.10", "@babel/traverse@^7.23.0", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8" @@ -3495,19 +3595,19 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" - integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== - dependencies: - "@babel/code-frame" "^7.24.2" - "@babel/generator" "^7.24.5" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.24.5" - "@babel/parser" "^7.24.5" - "@babel/types" "^7.24.5" +"@babel/traverse@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" + integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.24.7" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-function-name" "^7.24.7" + "@babel/helper-hoist-variables" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" debug "^4.3.1" globals "^11.1.0" @@ -3565,13 +3665,13 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" -"@babel/types@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" - integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== +"@babel/types@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" + integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== dependencies: - "@babel/helper-string-parser" "^7.24.1" - "@babel/helper-validator-identifier" "^7.24.5" + "@babel/helper-string-parser" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" "@bcoe/v8-coverage@^0.2.3": @@ -6213,21 +6313,14 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/context-base/-/context-base-0.12.0.tgz#4906ae27359d3311e3dea1b63770a16f60848550" integrity sha512-UXwSsXo3F3yZ1dIBOG9ID8v2r9e+bqLWoizCtTb8rXtwF+N5TM7hzzvQz72o3nBU+zrI/D5e+OqAYK8ZgDd3DA== -"@opentelemetry/core@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.23.0.tgz#f2e7ada7f35750f3c1674aef1e52c879005c0731" - integrity sha512-hdQ/a9TMzMQF/BO8Cz1juA43/L5YGtCSiKoOHmrTEf7VMDAZgy8ucpWx3eQTnQ3gBloRcWtzvcrMZABC3PTSKQ== - dependencies: - "@opentelemetry/semantic-conventions" "1.23.0" - -"@opentelemetry/core@1.24.1", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.8.0": +"@opentelemetry/core@1.24.1": version "1.24.1" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.24.1.tgz#35ab9d2ac9ca938e0ffbdfa40c49c169ac8ba80d" integrity sha512-wMSGfsdmibI88K9wB498zXY04yThPexo8jvwNNlm542HZB7XrrMRBbAyKJqG8qDRJwIBdBrPMi4V9ZPW/sqrcg== dependencies: "@opentelemetry/semantic-conventions" "1.24.1" -"@opentelemetry/core@1.25.0", "@opentelemetry/core@^1.25.0": +"@opentelemetry/core@1.25.0", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.25.0", "@opentelemetry/core@^1.8.0": version "1.25.0" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.25.0.tgz#ad034f5c2669f589bd703bfbbaa38b51f8504053" integrity sha512-n0B3s8rrqGrasTgNkXLKXzN0fXo+6IYP7M5b7AMsrZM33f/y6DS6kJ0Btd7SespASWq8bgL3taLo0oe0vB52IQ== @@ -6274,10 +6367,10 @@ "@opentelemetry/semantic-conventions" "^1.22.0" "@types/connect" "3.4.36" -"@opentelemetry/instrumentation-express@0.40.0": - version "0.40.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.40.0.tgz#e2172b39e7abc218b29564aa59e92c1ab8ca8389" - integrity sha512-ahITgz2cFaMvqGDvxOdgxjgQyGmFccGMIoiwYpZQ+MJQt5qxvRZhau794/McdvtUp4LrK5OfvK1hQp4YsW2VGA== +"@opentelemetry/instrumentation-express@0.40.1": + version "0.40.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.40.1.tgz#b4c31a352691b060b330e4c028a8ef5472b89e27" + integrity sha512-+RKMvVe2zw3kIXRup9c1jFu3T4d0fs5aKy015TpiMyoCKX1UMu3Z0lfgYtuyiSTANvg5hZnDbWmQmqSPj9VTvg== dependencies: "@opentelemetry/core" "^1.8.0" "@opentelemetry/instrumentation" "^0.52.0" @@ -6338,10 +6431,10 @@ "@types/koa" "2.14.0" "@types/koa__router" "12.0.3" -"@opentelemetry/instrumentation-mongodb@0.44.0": - version "0.44.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.44.0.tgz#359ecc72a903f8f586f34d7a57b7e122037621a9" - integrity sha512-VPnmN5LZN8gWQ1znRz7mdZBly4h4G8Fsp8NJYqgM1CEoglX+O/Dj36zesZVSi1InPyDX2hGDTt6Qp3DFYjl7WA== +"@opentelemetry/instrumentation-mongodb@0.45.0": + version "0.45.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.45.0.tgz#d6373e30f3e83eba87f7e6e2ea72c1351467d6b5" + integrity sha512-xnZP9+ayeB1JJyNE9cIiwhOJTzNEsRhXVdLgfzmrs48Chhhk026mQdM5CITfyXSCfN73FGAIB8d91+pflJEfWQ== dependencies: "@opentelemetry/instrumentation" "^0.52.0" "@opentelemetry/sdk-metrics" "^1.9.1" @@ -6393,6 +6486,15 @@ "@types/pg" "8.6.1" "@types/pg-pool" "2.0.4" +"@opentelemetry/instrumentation-redis-4@0.40.0": + version "0.40.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.40.0.tgz#4a1bc9bebfb869de8d982b1a1a5b550bdb68d15b" + integrity sha512-0ieQYJb6yl35kXA75LQUPhHtGjtQU9L85KlWa7d4ohBbk/iQKZ3X3CFl5jC5vNMq/GGPB3+w3IxNvALlHtrp7A== + dependencies: + "@opentelemetry/instrumentation" "^0.52.0" + "@opentelemetry/redis-common" "^0.36.2" + "@opentelemetry/semantic-conventions" "^1.22.0" + "@opentelemetry/instrumentation@0.52.0", "@opentelemetry/instrumentation@^0.52.0": version "0.52.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.0.tgz#f8b790bfb1c61c27e0ba846bc6d0e377da195d1e" @@ -6434,25 +6536,17 @@ integrity sha512-hhTW8pFp9PSyosYzzuUL9rdm7HF97w3OCyElufFHyUnYnKkCBbu8ne2LyF/KSdI/xZ81ubxWZs78hX4S7pLq5g== "@opentelemetry/propagator-aws-xray@^1.3.1": - version "1.24.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-aws-xray/-/propagator-aws-xray-1.24.1.tgz#fd041d43f0eee7d482d272bc23688d42abe216d5" - integrity sha512-RzwoLe6QzsYGcpmxxDbbbgSpe3ncxSM4dtFHXh/rCYGjyq0nZGXKvk26mJtWZ4kQ3nuiIoqSZueIuGmt/mvOTA== + version "1.25.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-aws-xray/-/propagator-aws-xray-1.25.0.tgz#cbbe70cc45235e195d1f07c6e845df766b674b88" + integrity sha512-+honT9J/Xa6Mxk7AO/VlSUGaVSSQzqHr0wZDWrSunnc3eVbS5YTuv7UrcoHTED+AYziawTlx7ICeAX2VPc1M+w== dependencies: - "@opentelemetry/core" "1.24.1" + "@opentelemetry/core" "1.25.0" "@opentelemetry/redis-common@^0.36.2": version "0.36.2" resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz#906ac8e4d804d4109f3ebd5c224ac988276fdc47" integrity sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g== -"@opentelemetry/resources@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.23.0.tgz#4c71430f3e20c4d88b67ef5629759fae108485e5" - integrity sha512-iPRLfVfcEQynYGo7e4Di+ti+YQTAY0h5mQEUJcHlU9JOqpb4x965O6PZ+wMcwYVY63G96KtdS86YCM1BF1vQZg== - dependencies: - "@opentelemetry/core" "1.23.0" - "@opentelemetry/semantic-conventions" "1.23.0" - "@opentelemetry/resources@1.24.1", "@opentelemetry/resources@^1.8.0": version "1.24.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.24.1.tgz#5e2cb84814824f3b1e1017e6caeeee8402e0ad6e" @@ -6478,12 +6572,12 @@ "@opentelemetry/core" "^0.12.0" "@opentelemetry/sdk-metrics@^1.9.1": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.23.0.tgz#b4cf3cc86b6dedf5c438c67c829df7399bf64be1" - integrity sha512-4OkvW6+wST4h6LFG23rXSTf6nmTf201h9dzq7bE0z5R9ESEVLERZz6WXwE7PSgg1gdjlaznm1jLJf8GttypFDg== + version "1.25.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.0.tgz#0c954d580c17821ae4385d29447718df09e80b79" + integrity sha512-IF+Sv4VHgBr/BPMKabl+GouJIhEqAOexCHgXVTISdz3q9P9H/uA8ScCF+22gitQ69aFtESbdYOV+Fen5+avQng== dependencies: - "@opentelemetry/core" "1.23.0" - "@opentelemetry/resources" "1.23.0" + "@opentelemetry/core" "1.25.0" + "@opentelemetry/resources" "1.25.0" lodash.merge "^4.6.2" "@opentelemetry/sdk-trace-base@^1.22": @@ -6504,17 +6598,12 @@ "@opentelemetry/resources" "1.25.0" "@opentelemetry/semantic-conventions" "1.25.0" -"@opentelemetry/semantic-conventions@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.23.0.tgz#627f2721b960fe586b7f72a07912cb7699f06eef" - integrity sha512-MiqFvfOzfR31t8cc74CTP1OZfz7MbqpAnLCra8NqQoaHJX6ncIRTdYOQYBDQ2uFISDq0WY8Y9dDTWvsgzzBYRg== - -"@opentelemetry/semantic-conventions@1.24.1", "@opentelemetry/semantic-conventions@^1.17.0", "@opentelemetry/semantic-conventions@^1.22.0", "@opentelemetry/semantic-conventions@^1.23.0": +"@opentelemetry/semantic-conventions@1.24.1": version "1.24.1" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.24.1.tgz#d4bcebda1cb5146d47a2a53daaa7922f8e084dfb" integrity sha512-VkliWlS4/+GHLLW7J/rVBA00uXus1SWvwFvcUDxDwmFxYfg/2VI6ekwdXS28cjI8Qz2ky2BzG8OUHo+WeYIWqw== -"@opentelemetry/semantic-conventions@1.25.0", "@opentelemetry/semantic-conventions@^1.25.0": +"@opentelemetry/semantic-conventions@1.25.0", "@opentelemetry/semantic-conventions@^1.17.0", "@opentelemetry/semantic-conventions@^1.22.0", "@opentelemetry/semantic-conventions@^1.23.0", "@opentelemetry/semantic-conventions@^1.25.0": version "1.25.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.0.tgz#390eb4d42a29c66bdc30066af9035645e9bb7270" integrity sha512-M+kkXKRAIAiAP6qYyesfrC5TOmDpDVtsxuGfPcqd9B/iBrac+E14jYwrgm0yZBUIbIP2OnqC3j+UgkXLm1vxUQ== @@ -6634,6 +6723,40 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@redis/bloom@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" + integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg== + +"@redis/client@1.5.16": + version "1.5.16" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.16.tgz#1d5919077a06a4b935b0e4bef9e036eef1a10371" + integrity sha512-X1a3xQ5kEMvTib5fBrHKh6Y+pXbeKXqziYuxOUo1ojQNECg4M5Etd1qqyhMap+lFUOAh8S7UYevgJHOm4A+NOg== + dependencies: + cluster-key-slot "1.1.2" + generic-pool "3.9.0" + yallist "4.0.0" + +"@redis/graph@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea" + integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw== + +"@redis/json@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.6.tgz#b7a7725bbb907765d84c99d55eac3fcf772e180e" + integrity sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw== + +"@redis/search@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.6.tgz#33bcdd791d9ed88ab6910243a355d85a7fedf756" + integrity sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw== + +"@redis/time-series@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.5.tgz#a6d70ef7a0e71e083ea09b967df0a0ed742bc6ad" + integrity sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg== + "@remix-run/node@^1.4.3": version "1.5.1" resolved "https://registry.yarnpkg.com/@remix-run/node/-/node-1.5.1.tgz#1c367d4035baaef8f0ea66962a826456d62f0030" @@ -7851,6 +7974,20 @@ lz-string "^1.5.0" pretty-format "^27.0.2" +"@testing-library/jest-dom@^6.4.5": + version "6.4.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.5.tgz#badb40296477149136dabef32b572ddd3b56adf1" + integrity sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A== + dependencies: + "@adobe/css-tools" "^4.3.2" + "@babel/runtime" "^7.9.2" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + lodash "^4.17.21" + redent "^3.0.0" + "@testing-library/react-hooks@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" @@ -7878,6 +8015,11 @@ dependencies: "@testing-library/dom" "^8.1.0" +"@testing-library/user-event@^14.5.2": + version "14.5.2" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" + integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -8449,7 +8591,17 @@ dependencies: "@types/unist" "*" -"@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8", "@types/history@*": +"@types/history-4@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history-5@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history@*": version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== @@ -8595,13 +8747,6 @@ resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.8.tgz#84dbf2d020a9209a272058725e168f21d331a67e" integrity sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ== -"@types/md5@2.1.33": - version "2.1.33" - resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.1.33.tgz#8c8dba30df4ad0e92296424f08c4898dd808e8df" - integrity sha512-8+X960EtKLoSblhauxLKy3zzotagjoj3Jt1Tx9oaxUdZEPIBl+mkrUz6PNKpzJgkrKSN9YgkWTA29c0KnLshmA== - dependencies: - "@types/node" "*" - "@types/mdast@^3.0.0": version "3.0.13" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.13.tgz#b7ba6e52d0faeb9c493e32c205f3831022be4e1b" @@ -8815,7 +8960,15 @@ "@types/history" "^3" "@types/react" "*" -"@types/react-router-4@npm:@types/react-router@5.1.14", "@types/react-router-5@npm:@types/react-router@5.1.14": +"@types/react-router-4@npm:@types/react-router@5.1.14": + version "5.1.14" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" + integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== + dependencies: + "@types/history" "*" + "@types/react" "*" + +"@types/react-router-5@npm:@types/react-router@5.1.14": version "5.1.14" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== @@ -12081,11 +12234,6 @@ bson@^1.1.4: resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a" integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg== -btoa@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" - integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g== - buffer-crc32@^0.2.5, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -12821,7 +12969,7 @@ clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== -cluster-key-slot@^1.1.0: +cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== @@ -13632,6 +13780,11 @@ css-what@^6.0.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + cssdb@^7.0.0, cssdb@^7.1.0: version "7.11.2" resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-7.11.2.tgz#127a2f5b946ee653361a5af5333ea85a39df5ae5" @@ -14209,6 +14362,11 @@ dom-accessibility-api@^0.5.9: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz#102ee5f25eacce09bdf1cfa5a298f86da473be4b" integrity sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw== +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -17342,6 +17500,11 @@ generate-function@^2.3.1: dependencies: is-property "^1.0.2" +generic-pool@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4" + integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -24936,17 +25099,12 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" -playwright-core@1.40.1, playwright-core@^1.29.1: - version "1.40.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.40.1.tgz#442d15e86866a87d90d07af528e0afabe4c75c05" - integrity sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ== - -playwright-core@1.44.1: +playwright-core@1.44.1, playwright-core@^1.44.1: version "1.44.1" resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.44.1.tgz#53ec975503b763af6fc1a7aa995f34bc09ff447c" integrity sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA== -playwright@1.44.1: +playwright@1.44.1, playwright@^1.44.1: version "1.44.1" resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.44.1.tgz#5634369d777111c1eea9180430b7a184028e7892" integrity sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg== @@ -24955,15 +25113,6 @@ playwright@1.44.1: optionalDependencies: fsevents "2.3.2" -playwright@^1.31.1: - version "1.40.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.40.1.tgz#a11bf8dca15be5a194851dbbf3df235b9f53d7ae" - integrity sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw== - dependencies: - playwright-core "1.40.1" - optionalDependencies: - fsevents "2.3.2" - pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -26099,7 +26248,7 @@ react-is@^18.0.0: dependencies: "@remix-run/router" "1.0.2" -"react-router-6@npm:react-router@6.3.0", react-router@6.3.0: +"react-router-6@npm:react-router@6.3.0": version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== @@ -26114,6 +26263,13 @@ react-router-dom@^6.2.2: history "^5.2.0" react-router "6.3.0" +react-router@6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96" @@ -26348,6 +26504,18 @@ redeyed@~1.0.0: dependencies: esprima "~3.0.0" +"redis-4@npm:redis@^4.6.14": + version "4.6.14" + resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.14.tgz#599e49b65816c56a6683f6b19dc374c8e786d091" + integrity sha512-GrNg/e33HtsQwNXL7kJT+iNFPSwE1IPmd7wzV3j4f2z0EYxZfZE7FVTmUysgAtqQQtg5NXF5SNLR9OdO/UHOfw== + dependencies: + "@redis/bloom" "1.2.0" + "@redis/client" "1.5.16" + "@redis/graph" "1.1.1" + "@redis/json" "1.0.6" + "@redis/search" "1.1.6" + "@redis/time-series" "1.0.5" + redis-errors@^1.0.0, redis-errors@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" @@ -26405,6 +26573,11 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regenerator-transform@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" @@ -28432,7 +28605,7 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -28458,6 +28631,15 @@ string-width@^2.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -28553,7 +28735,14 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -31180,7 +31369,7 @@ workerpool@^6.4.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.0.tgz#f8d5cfb45fde32fa3b7af72ad617c3369567a462" integrity sha512-i3KR1mQMNwY2wx20ozq2EjISGtQWDIfV56We+yGJ5yDs8jTwQiLLaqHlkBHITlCuJnYlVRmXegxFxZg7gqI++A== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -31198,6 +31387,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -31349,16 +31547,16 @@ yalc@^1.0.0-pre.53: npm-packlist "^2.1.5" yargs "^16.1.1" +yallist@4.0.0, yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yallist@^3.0.0, yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - yam@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yam/-/yam-1.0.0.tgz#7f6c91dc0f5de75a031e6da6b3907c3d25ab0de5"