diff --git a/.claude/settings.json b/.claude/settings.json index 239ec50ef580..8a78c52fdd63 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,5 @@ { + "env": { "ENABLE_LSP_TOOL": "1" }, "permissions": { "allow": [ "Bash(find:*)", diff --git a/.craft.yml b/.craft.yml index dd6f1a7f3453..7e2ee3217533 100644 --- a/.craft.yml +++ b/.craft.yml @@ -95,6 +95,9 @@ targets: - name: npm id: '@sentry/bun' includeNames: /^sentry-bun-\d.*\.tgz$/ + - name: npm + id: '@sentry/elysia' + includeNames: /^sentry-elysia-\d.*\.tgz$/ - name: npm id: '@sentry/hono' includeNames: /^sentry-hono-\d.*\.tgz$/ @@ -245,4 +248,11 @@ targets: name: 'Sentry Effect SDK' sdkName: 'sentry.javascript.effect' packageUrl: 'https://www.npmjs.com/package/@sentry/effect' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/effect/' onlyIfPresent: /^sentry-effect-\d.*\.tgz$/ + 'npm:@sentry/elysia': + name: 'Sentry Elysia SDK' + sdkName: 'sentry.javascript.elysia' + packageUrl: 'https://www.npmjs.com/package/@sentry/elysia' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/elysia/' + onlyIfPresent: /^sentry-elysia-\d.*\.tgz$/ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 305e975b48fd..47edbfeed264 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -46,6 +46,7 @@ body: - '@sentry/cloudflare - hono' - '@sentry/deno' - '@sentry/effect' + - '@sentry/elysia' - '@sentry/ember' - '@sentry/gatsby' - '@sentry/google-cloud-serverless' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69523f544f2f..dea6b4802dc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -961,7 +961,7 @@ jobs: with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun - if: contains(fromJSON('["node-exports-test-app","nextjs-16-bun"]'), matrix.test-application) + if: contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 - name: Set up AWS SAM if: matrix.test-application == 'aws-serverless' diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index ef0f0344b8fc..323b17219b1a 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -57,7 +57,7 @@ jobs: "label": "Ember" }, "@sentry.gatsby": { - "label": "Gatbsy" + "label": "Gatsby" }, "@sentry.google-cloud-serverless": { "label": "Google Cloud Functions" diff --git a/.oxlintrc.json b/.oxlintrc.json index ef23a888fab8..e65745736664 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -66,7 +66,7 @@ "typescript/no-redundant-type-constituents": "off", "typescript/restrict-template-expressions": "off", "typescript/await-thenable": "warn", - "typescript/no-base-to-string": "warn" + "typescript/no-base-to-string": "off" } }, { diff --git a/.size-limit.js b/.size-limit.js index 750e7ce8f7fd..3e0902c0a57c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45 KB', + limit: '45.1 KB', }, // Vue SDK (ESM) { diff --git a/AGENTS.md b/AGENTS.md index bea38f66e2d9..d5bed0e89fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,19 @@ Monorepo with 40+ packages in `@sentry/*`, managed with Yarn workspaces and Nx. - After cloning: `yarn install && yarn build` - Never change Volta, Yarn, or package manager versions unless explicitly asked +### Code Intelligence + +Prefer LSP over Grep/Read for code navigation — it's faster, precise, and avoids reading entire files: + +- `workspaceSymbol` to find where something is defined +- `findReferences` to see all usages across the codebase +- `goToDefinition` / `goToImplementation` to jump to source +- `hover` for type info without reading the file + +Use Grep only when LSP isn't available or for text/pattern searches (comments, strings, config). + +After writing or editing code, check LSP diagnostics and fix errors before proceeding. + ## Package Manager Use **yarn**: `yarn install`, `yarn build:dev`, `yarn test`, `yarn lint` diff --git a/CHANGELOG.md b/CHANGELOG.md index eb998b2deffc..640448521586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.46.0 + +### Important Changes + +- **feat(elysia): `@sentry/elysia` - Alpha Release ([#19509](https://github.com/getsentry/sentry-javascript/pull/19509))** + + New Sentry SDK for the [Elysia](https://elysiajs.com/) web framework, supporting both Bun and Node.js runtimes. + + > **Note:** This is an alpha release. Please report any issues or feedback on [GitHub](https://github.com/getsentry/sentry-javascript/issues). + + **Features** + - **Automatic error capturing** — 5xx errors captured via global `onError` hook; 3xx/4xx ignored by default. Customizable with `shouldHandleError`. + - **Automatic tracing** — Lifecycle spans for every Elysia phase (Request, Parse, Transform, BeforeHandle, Handle, AfterHandle, MapResponse, AfterResponse, Error) with parameterized route names (e.g. `GET /users/:id`). + - **Distributed tracing** — `sentry-trace` and `baggage` headers propagated automatically on incoming/outgoing requests. + + **Usage** + + ```javascript + import * as Sentry from '@sentry/elysia'; + import { Elysia } from 'elysia'; + + Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0 }); + + const app = Sentry.withElysia(new Elysia()); + app.get('/', () => 'Hello World'); + app.listen(3000); + ``` + +### Other Changes + +- feat(nuxt): Conditionally use plugins based on Nitro version (v2/v3) ([#19955](https://github.com/getsentry/sentry-javascript/pull/19955)) +- fix(cloudflare): Forward `ctx` argument to `Workflow.do` user callback ([#19891](https://github.com/getsentry/sentry-javascript/pull/19891)) +- fix(cloudflare): Send correct events in local development ([#19900](https://github.com/getsentry/sentry-javascript/pull/19900)) +- fix(core): Do not overwrite user provided conversation id in Vercel ([#19903](https://github.com/getsentry/sentry-javascript/pull/19903)) +- fix(core): Preserve `.withResponse()` on Anthropic instrumentation ([#19935](https://github.com/getsentry/sentry-javascript/pull/19935)) +- fix(core): Send `internal_error` as span status for Vercel error spans ([#19921](https://github.com/getsentry/sentry-javascript/pull/19921)) +- fix(core): Truncate content array format in Vercel ([#19911](https://github.com/getsentry/sentry-javascript/pull/19911)) +- fix(deps): bump fast-xml-parser to 5.5.8 in @azure/core-xml chain ([#19918](https://github.com/getsentry/sentry-javascript/pull/19918)) +- fix(deps): bump socket.io-parser to 4.2.6 to fix CVE-2026-33151 ([#19880](https://github.com/getsentry/sentry-javascript/pull/19880)) +- fix(nestjs): Add `node` to nest metadata ([#19875](https://github.com/getsentry/sentry-javascript/pull/19875)) +- fix(serverless): Add node to metadata ([#19878](https://github.com/getsentry/sentry-javascript/pull/19878)) + +
+ Internal Changes + +- chore(ci): Fix "Gatbsy" typo in issue package label workflow ([#19905](https://github.com/getsentry/sentry-javascript/pull/19905)) +- chore(claude): Enable Claude Code Intelligence (LSP) ([#19930](https://github.com/getsentry/sentry-javascript/pull/19930)) +- chore(deps): bump mongodb-memory-server-global from 10.1.4 to 11.0.1 ([#19888](https://github.com/getsentry/sentry-javascript/pull/19888)) +- chore(deps-dev): bump @react-router/node from 7.13.0 to 7.13.1 ([#19544](https://github.com/getsentry/sentry-javascript/pull/19544)) +- chore(deps-dev): bump effect from 3.19.19 to 3.20.0 ([#19926](https://github.com/getsentry/sentry-javascript/pull/19926)) +- chore(deps-dev): bump qunit-dom from 3.2.1 to 3.5.0 ([#19546](https://github.com/getsentry/sentry-javascript/pull/19546)) +- chore(node-integration-tests): Remove unnecessary `file-type` dependency ([#19824](https://github.com/getsentry/sentry-javascript/pull/19824)) +- chore(remix): Replace glob with native recursive fs walk ([#19531](https://github.com/getsentry/sentry-javascript/pull/19531)) +- feat(deps): bump stacktrace-parser from 0.1.10 to 0.1.11 ([#19887](https://github.com/getsentry/sentry-javascript/pull/19887)) +- fix(craft): Add missing mainDocsUrl for @sentry/effect SDK ([#19860](https://github.com/getsentry/sentry-javascript/pull/19860)) +- fix(deps): bump next to 15.5.14 in nextjs-15 and nextjs-15-intl E2E test apps ([#19917](https://github.com/getsentry/sentry-javascript/pull/19917)) +- fix(deps): update lockfile to resolve h3@1.15.10 ([#19933](https://github.com/getsentry/sentry-javascript/pull/19933)) +- ref(core): Remove duplicate `buildMethodPath` utility from openai ([#19969](https://github.com/getsentry/sentry-javascript/pull/19969)) +- ref(elysia): Drop `@elysiajs/opentelemetry` dependency ([#19947](https://github.com/getsentry/sentry-javascript/pull/19947)) +- ref(nuxt): Extract core logic for storage/database to prepare for Nuxt v5 ([#19920](https://github.com/getsentry/sentry-javascript/pull/19920)) +- ref(nuxt): Extract handler patching to extra plugin for Nitro v2/v3 ([#19915](https://github.com/getsentry/sentry-javascript/pull/19915)) +- ref(sveltekit): Replace recast + @babel/parser with acorn ([#19533](https://github.com/getsentry/sentry-javascript/pull/19533)) +- test(astro): Re-enable server island tracing e2e test in Astro 6 ([#19872](https://github.com/getsentry/sentry-javascript/pull/19872)) +- test(cloudflare): Enable multi-worker tests for CF integration tests ([#19938](https://github.com/getsentry/sentry-javascript/pull/19938)) + +
+ +Work in this release was contributed by @roli-lpci. Thank you for your contributions! + ## 10.45.0 ### Important Changes diff --git a/README.md b/README.md index 5ac7fefc3b81..841a6380b5e2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ package. Please refer to the README and instructions of those SDKs for more deta for native crashes - [`@sentry/effect`](https://github.com/getsentry/sentry-javascript/tree/master/packages/effect): SDK for Effect (Alpha) - [`@sentry/bun`](https://github.com/getsentry/sentry-javascript/tree/master/packages/bun): SDK for Bun +- [`@sentry/elysia`](https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia): SDK for Elysia - [`@sentry/deno`](https://github.com/getsentry/sentry-javascript/tree/master/packages/deno): SDK for Deno - [`@sentry/cloudflare`](https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare): SDK for Cloudflare diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js new file mode 100644 index 000000000000..6a0a6c4cae5e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/subject.js @@ -0,0 +1,79 @@ +/** + * Test that verifies thenable objects with extra methods (like jQuery's jqXHR) + * preserve those methods when returned from Sentry.startSpan(). + * + * Example case: + * const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...)); + * jqXHR.abort(); // Should work and not throw an error because of missing abort() method + */ + +// Load jQuery from CDN +const script = document.createElement('script'); +script.src = 'https://code.jquery.com/jquery-3.7.1.min.js'; +script.integrity = 'sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo='; +script.crossOrigin = 'anonymous'; + +script.onload = function () { + runTest(); +}; + +script.onerror = function () { + window.jqXHRTestError = 'Failed to load jQuery'; + window.jqXHRMethodsPreserved = false; +}; + +document.head.appendChild(script); + +async function runTest() { + window.jqXHRAbortCalled = false; + window.jqXHRAbortResult = null; + window.jqXHRTestError = null; + + try { + if (!window.jQuery) { + throw new Error('jQuery not loaded'); + } + + const result = Sentry.startSpan({ name: 'test-jqxhr', op: 'http.client' }, () => { + // Make a real AJAX request with jQuery + return window.jQuery.ajax({ + url: 'https://httpbin.org/status/200', + method: 'GET', + timeout: 5000, + }); + }); + + const hasAbort = typeof result.abort === 'function'; + const hasReadyState = 'readyState' in result; + + if (hasAbort && hasReadyState) { + try { + result.abort(); + window.jqXHRAbortCalled = true; + window.jqXHRAbortResult = 'abort-successful'; + window.jqXHRMethodsPreserved = true; + } catch (e) { + console.log('abort() threw an error:', e); + window.jqXHRTestError = `abort() failed: ${e.message}`; + window.jqXHRMethodsPreserved = false; + } + } else { + window.jqXHRMethodsPreserved = false; + window.jqXHRTestError = 'jqXHR methods not preserved'; + } + + // Since we aborted the request, it should be rejected + try { + await result; + window.jqXHRPromiseResolved = true; // Unexpected + } catch (err) { + // Expected: aborted request rejects + window.jqXHRPromiseResolved = false; + window.jqXHRPromiseRejected = true; + } + } catch (error) { + console.error('Test error:', error); + window.jqXHRTestError = error.message; + window.jqXHRMethodsPreserved = false; + } +} diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts new file mode 100644 index 000000000000..44008a93a3e9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/thenable-with-extra-methods/test.ts @@ -0,0 +1,51 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('preserves extra methods on real jQuery jqXHR objects', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + const transactionPromise = waitForTransactionRequest(page); + + await page.goto(url); + + // Wait for jQuery to load + await page.waitForTimeout(1000); + + const methodsPreserved = await page.evaluate(() => (window as any).jqXHRMethodsPreserved); + expect(methodsPreserved).toBe(true); + + const abortCalled = await page.evaluate(() => (window as any).jqXHRAbortCalled); + expect(abortCalled).toBe(true); + + const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortResult); + expect(abortReturnValue).toBe('abort-successful'); + + const testError = await page.evaluate(() => (window as any).jqXHRTestError); + expect(testError).toBeNull(); + + const transaction = envelopeRequestParser(await transactionPromise); + expect(transaction.transaction).toBe('test-jqxhr'); + expect(transaction.spans).toBeDefined(); +}); + +sentryTest('aborted request rejects promise correctly', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + // Wait for jQuery to load + await page.waitForTimeout(1000); + + const promiseRejected = await page.evaluate(() => (window as any).jqXHRPromiseRejected); + expect(promiseRejected).toBe(true); + + const promiseResolved = await page.evaluate(() => (window as any).jqXHRPromiseResolved); + expect(promiseResolved).toBe(false); +}); diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index a9fb96b59505..b0b439eb122a 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -105,6 +105,7 @@ export function createRunner(...paths: string[]) { let envelopeCount = 0; const { resolve: setWorkerPort, promise: workerPortPromise } = deferredPromise(); let child: ReturnType | undefined; + let childSubWorker: ReturnType | undefined; /** Called after each expect callback to check if we're complete */ function expectCallbackCalled(): void { @@ -168,7 +169,7 @@ export function createRunner(...paths: string[]) { } createBasicSentryServer(newEnvelope) - .then(([mockServerPort, mockServerClose]) => { + .then(async ([mockServerPort, mockServerClose]) => { if (mockServerClose) { CLEANUP_STEPS.add(() => { mockServerClose(); @@ -181,6 +182,49 @@ export function createRunner(...paths: string[]) { ? ['inherit', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', 'ignore', 'ipc']; + const onChildError = (e: Error) => { + // eslint-disable-next-line no-console + console.error('Error starting child process:', e); + reject(e); + }; + + function onChildMessage(message: string, onReady?: (port: number) => void): void { + const msg = JSON.parse(message) as { event: string; port?: number }; + if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { + if (process.env.DEBUG) log('worker ready on port', msg.port); + onReady?.(msg.port); + } + } + + if (existsSync(join(testPath, 'wrangler-sub-worker.jsonc'))) { + childSubWorker = spawn( + 'wrangler', + [ + 'dev', + '--config', + join(testPath, 'wrangler-sub-worker.jsonc'), + '--show-interactive-dev-session', + 'false', + '--var', + `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, + '--port', + '0', + '--inspector-port', + '0', + ], + { stdio, signal }, + ); + + // Wait for the sub-worker to be ready before starting the main worker + await new Promise((resolveSubWorker, rejectSubWorker) => { + childSubWorker!.on('message', (msg: string) => onChildMessage(msg, () => resolveSubWorker())); + childSubWorker!.on('error', rejectSubWorker); + childSubWorker!.on('exit', code => { + rejectSubWorker(new Error(`Sub-worker exited with code ${code}`)); + }); + }); + } + child = spawn( 'wrangler', [ @@ -199,21 +243,12 @@ export function createRunner(...paths: string[]) { CLEANUP_STEPS.add(() => { child?.kill(); + childSubWorker?.kill(); }); - child.on('error', e => { - // eslint-disable-next-line no-console - console.error('Error starting child process:', e); - reject(e); - }); - - child.on('message', (message: string) => { - const msg = JSON.parse(message) as { event: string; port?: number }; - if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { - setWorkerPort(msg.port); - if (process.env.DEBUG) log('worker ready on port', msg.port); - } - }); + childSubWorker?.on('error', onChildError); + child.on('error', onChildError); + child.on('message', (msg: string) => onChildMessage(msg, setWorkerPort)); }) .catch(e => reject(e)); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts new file mode 100644 index 000000000000..06c79931b880 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const myWorker = { + async fetch(request: Request) { + return new Response('Hello from another worker!'); + }, +}; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + myWorker, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts new file mode 100644 index 000000000000..dc178759f51d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + ANOTHER_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const response = await env.ANOTHER_WORKER.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts new file mode 100644 index 000000000000..fd64c0d31d27 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('adds a trace to a worker via service binding', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..30bd95322560 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc @@ -0,0 +1,9 @@ +{ + "name": "cloudflare-service-binding-sub-worker", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc new file mode 100644 index 000000000000..69bc9f6e6e99 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "name": "cloudflare-worker-service-binding", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, + "services": [ + { + "binding": "ANOTHER_WORKER", + "service": "cloudflare-service-binding-sub-worker", + }, + ], +} diff --git a/dev-packages/e2e-tests/test-applications/astro-6/package.json b/dev-packages/e2e-tests/test-applications/astro-6/package.json index e97314a949b4..050ea8980e06 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6/package.json @@ -16,7 +16,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "latest || *", - "astro": "^6.0.0" + "astro": "^6.0.6" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts index 10910c01bd3f..829b44a035f3 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.serverIslands.test.ts @@ -1,10 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// Skipping this test FOR NOW because there's a known bug in Astro 6.0.2 that causes -// server-islands to not work correctly with the node adapter: -// https://github.com/withastro/astro/issues/15753 -test.describe.skip('tracing in static routes with server islands', () => { +test.describe('tracing in static routes with server islands', () => { test('only sends client pageload transaction and server island endpoint transaction', async ({ page }) => { const clientPageloadTxnPromise = waitForTransaction('astro-6', txnEvent => { return txnEvent.transaction === '/server-island'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json new file mode 100644 index 000000000000..160b8a9cdc03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -0,0 +1,38 @@ +{ + "name": "cloudflare-local-workers", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts new file mode 100644 index 000000000000..73abbd951b90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts new file mode 100644 index 000000000000..daca399a1034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/src/index.ts @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + const url = new URL(request.url); + switch (url.pathname) { + case '/storage/put': { + await this.ctx.storage.put('test-key', 'test-value'); + return new Response('Stored'); + } + case '/storage/get': { + const value = await this.ctx.storage.get('test-key'); + return new Response(`Got: ${value}`); + } + default: { + return new Response('Not found'); + } + } + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname.startsWith('/pass-to-object/')) { + const id = env.MY_DURABLE_OBJECT.idFromName('foo'); + const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub; + url.pathname = url.pathname.replace('/pass-to-object/', ''); + const response = await stub.fetch(new Request(url, request)); + await new Promise(resolve => setTimeout(resolve, 500)); + return response; + } + + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..47fc687cdc8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-local-workers', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts new file mode 100644 index 000000000000..557b6e5affb8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/index.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +/** + * This must be the only test in here. + * + * Both the Worker and the Durable Object initialize their own AsyncLocalStorage + * context. Wrangler dev is currently single-threaded locally, so when a previous + * test (e.g. a websocket test) already sets up ALS, that context carries over + * and masks bugs in our instrumentation - causing this test to pass when it + * should fail. + */ +test('Worker and Durable Object both send transactions when worker calls DO', async ({ baseURL }) => { + const workerTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /pass-to-object/storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const doTransactionPromise = waitForTransaction('cloudflare-local-workers', event => { + return event.transaction === 'GET /storage/get' && event.contexts?.trace?.op === 'http.server'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/storage/get`); + expect(response.status).toBe(200); + + const [workerTransaction, doTransaction] = await Promise.all([workerTransactionPromise, doTransactionPromise]); + + expect(workerTransaction.transaction).toBe('GET /pass-to-object/storage/get'); + expect(workerTransaction.contexts?.trace?.op).toBe('http.server'); + + expect(doTransaction.transaction).toBe('GET /storage/get'); + expect(doTransaction.contexts?.trace?.op).toBe('http.server'); + expect(doTransaction.spans?.some(span => span.op === 'db')).toBe(true); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json new file mode 100644 index 000000000000..80bfbd97acc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml new file mode 100644 index 000000000000..96788a17d4c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/wrangler.toml @@ -0,0 +1,111 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-local-workers" +main = "src/index.ts" +compatibility_date = "2024-07-25" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +[[durable_objects.bindings]] +name = "MY_DURABLE_OBJECT" +class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore b/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc b/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json new file mode 100644 index 000000000000..73689db97994 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/package.json @@ -0,0 +1,25 @@ +{ + "name": "elysia-bun-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/elysia": "latest || *", + "elysia": "^1.4.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs new file mode 100644 index 000000000000..44d3b834833d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'bun src/app.ts', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts new file mode 100644 index 000000000000..db6a5fff8bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts @@ -0,0 +1,142 @@ +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +const app = Sentry.withElysia(new Elysia()); + +// Simple success route +app.get('/test-success', () => ({ version: 'v1' })); + +// Parameterized route +app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param })); + +// Multiple params +app.get('/test-multi-param/:param1/:param2', ({ params }) => ({ + param1: params.param1, + param2: params.param2, +})); + +// Route that throws an error (will be caught by onError) +app.get('/test-exception/:id', ({ params }) => { + throw new Error(`This is an exception with id ${params.id}`); +}); + +// Route with a custom span +app.get('/test-transaction', () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + return { status: 'ok' }; +}); + +// Route with specific middleware via .guard or .use +app.group('/with-middleware', app => + app + .onBeforeHandle(() => { + // This is a route-specific middleware + }) + .get('/test', () => ({ middleware: true })), +); + +// Error with specific status code +app.post('/test-post-error', () => { + throw new Error('Post error'); +}); + +// Route that returns a non-500 error +app.get('/test-4xx', ({ set }) => { + set.status = 400; + return { error: 'Bad Request' }; +}); + +// Error that reaches the error handler with status still set to 200 (unusual, should still be captured) +app.get('/test-error-with-200-status', ({ set }) => { + set.status = 200; + throw new Error('Error with 200 status'); +}); + +// POST route that echoes body +app.post('/test-post', ({ body }) => ({ status: 'ok', body })); + +// Route that returns inbound headers (for propagation tests) +app.get('/test-inbound-headers/:id', ({ params, request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, id: params.id }; +}); + +// Outgoing fetch propagation +app.get('/test-outgoing-fetch/:id', async ({ params }) => { + const id = params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (allowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-allowed', async () => { + const response = await fetch(`http://localhost:3040/external-allowed`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (disallowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-disallowed', async () => { + const response = await fetch(`http://localhost:3040/external-disallowed`); + const data = await response.json(); + return data; +}); + +// Route that throws a string (not an Error object) +app.get('/test-string-error', () => { + // eslint-disable-next-line no-throw-literal + throw 'String error message'; +}); + +// Route for concurrent isolation tests — returns scope data in response +app.get('/test-isolation/:userId', async ({ params }) => { + Sentry.setUser({ id: params.userId }); + Sentry.setTag('user_id', params.userId); + + // Simulate async work to increase overlap between concurrent requests + await new Promise(resolve => setTimeout(resolve, 200)); + + return { + userId: params.userId, + isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id, + isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id, + }; +}); + +// Flush route for waiting on events +app.get('/flush', async () => { + await Sentry.flush(); + return { ok: true }; +}); + +app.listen(3030, () => { + console.log('Elysia app listening on port 3030'); +}); + +// Second app for external propagation tests +const app2 = new Elysia(); + +app2.get('/external-allowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-allowed' }; +}); + +app2.get('/external-disallowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-disallowed' }; +}); + +app2.listen(3040, () => { + console.log('External app listening on port 3040'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs new file mode 100644 index 000000000000..7519e8a3f650 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'elysia-bun', +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts new file mode 100644 index 000000000000..8bd544e6f9c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await request.get(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual( + expect.objectContaining({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Error event includes request metadata', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await request.get(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/test-exception/456'), + headers: expect.any(Object), + }), + ); +}); + +test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { + const transactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-4xx'; + }); + + const response = await request.get(`${baseURL}/test-4xx`); + // Wait for the transaction to ensure the request was processed + await transactionPromise; + + expect(response.status()).toBe(400); +}); + +test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status'; + }); + + await request.get(`${baseURL}/test-error-with-200-status`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures POST route errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Post error'; + }); + + await request.post(`${baseURL}/test-post-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures thrown string errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun', event => { + return !event.type && event.exception?.values?.[0]?.value === 'String error message'; + }); + + await request.get(`${baseURL}/test-string-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + type: 'auto.http.elysia.on_error', + handled: false, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts new file mode 100644 index 000000000000..3bdc1cc2e99d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/isolation.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +// The Elysia integration currently does not fork isolation scopes per request, +// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests. +// This test documents the expected behavior once per-request isolation is implemented. +test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => { + // Fire 3 concurrent requests with different user IDs + const [response1, response2, response3] = await Promise.all([ + fetch(`${baseURL}/test-isolation/user-1`), + fetch(`${baseURL}/test-isolation/user-2`), + fetch(`${baseURL}/test-isolation/user-3`), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + // Each response should have its own user ID — no leaking between requests + expect(data1.userId).toBe('user-1'); + expect(data1.isolationScopeUserId).toBe('user-1'); + expect(data1.isolationScopeTag).toBe('user-1'); + + expect(data2.userId).toBe('user-2'); + expect(data2.isolationScopeUserId).toBe('user-2'); + expect(data2.isolationScopeTag).toBe('user-2'); + + expect(data3.userId).toBe('user-3'); + expect(data3.isolationScopeUserId).toBe('user-3'); + expect(data3.isolationScopeTag).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts new file mode 100644 index 000000000000..c07dea3c9dc6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-success`); + + const sentryTrace = response.headers.get('sentry-trace'); + const baggage = response.headers.get('baggage'); + + expect(sentryTrace).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-[01]/); + expect(baggage).toContain('sentry-environment=qa'); + expect(baggage).toContain('sentry-trace_id='); +}); + +// Bun's native fetch does not emit undici diagnostics channels, +// so the nativeNodeFetchIntegration cannot inject sentry-trace/baggage headers. +// These tests document the desired behavior and will pass once Bun adds support +// for undici diagnostics channels or an alternative propagation mechanism is added. + +test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = randomUUID(); + + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-inbound-headers/:id' + ); + }); + + const outboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + expect(traceId).toEqual(expect.any(String)); + + // Verify sentry-trace header was propagated to the inbound request + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-1$`)); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + // Both transactions should share the same trace ID + expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); +}); + +test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-allowed'); + expect(data.headers?.['sentry-trace']).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + expect(data.headers?.baggage).toBeDefined(); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + await inboundTransactionPromise; + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts new file mode 100644 index 000000000000..b0b21fb9227f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a successful route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-success', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-param/:param' + ); + }); + + await request.get(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-param/:param'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + ); + }); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction for an errored route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-exception/:id' + ); + }); + + await request.get(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await request.get(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + const testSpan = spans.find(span => span.description === 'test-span'); + const childSpan = spans.find(span => span.description === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + parent_span_id: testSpan?.span_id, + }), + ); +}); + +test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + // The Handle span should be present as a request handler + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'Handle', + op: 'request_handler.elysia', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + // Use a route with middleware so there are child handler spans + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // No spans should exist — we name them 'anonymous' instead + const unknownSpans = spans.filter(span => span.description === ''); + expect(unknownSpans).toHaveLength(0); + + // Anonymous handler spans should be named 'anonymous' + const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); + expect(anonymousSpans.length).toBeGreaterThan(0); + + // Named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // BeforeHandle span should be present from the route-specific middleware + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'BeforeHandle', + op: 'middleware.elysia', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Captures request metadata for POST requests', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const response = await request.post(`${baseURL}/test-post`, { + data: { foo: 'bar', other: 1 }, + headers: { 'Content-Type': 'application/json' }, + }); + const resBody = await response.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/test-post'), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json b/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json new file mode 100644 index 000000000000..869427e44d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore b/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc b/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json new file mode 100644 index 000000000000..dda646fab480 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -0,0 +1,26 @@ +{ + "name": "elysia-node-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/elysia": "latest || *", + "elysia": "latest", + "@elysiajs/node": "^1.4.5" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "24.11.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs new file mode 100644 index 000000000000..7b08eab8ba80 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'node src/app.ts', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts new file mode 100644 index 000000000000..375ca9a29c6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts @@ -0,0 +1,143 @@ +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; +import { node } from '@elysiajs/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +const app = Sentry.withElysia(new Elysia({ adapter: node() })); + +// Simple success route +app.get('/test-success', () => ({ version: 'v1' })); + +// Parameterized route +app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param })); + +// Multiple params +app.get('/test-multi-param/:param1/:param2', ({ params }) => ({ + param1: params.param1, + param2: params.param2, +})); + +// Route that throws an error (will be caught by onError) +app.get('/test-exception/:id', ({ params }) => { + throw new Error(`This is an exception with id ${params.id}`); +}); + +// Route with a custom span +app.get('/test-transaction', () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + return { status: 'ok' }; +}); + +// Route with specific middleware via .guard or .use +app.group('/with-middleware', app => + app + .onBeforeHandle(() => { + // This is a route-specific middleware + }) + .get('/test', () => ({ middleware: true })), +); + +// Error with specific status code +app.post('/test-post-error', () => { + throw new Error('Post error'); +}); + +// Route that returns a non-500 error +app.get('/test-4xx', ({ set }) => { + set.status = 400; + return { error: 'Bad Request' }; +}); + +// Error that reaches the error handler with status still set to 200 (unusual, should still be captured) +app.get('/test-error-with-200-status', ({ set }) => { + set.status = 200; + throw new Error('Error with 200 status'); +}); + +// POST route that echoes body +app.post('/test-post', ({ body }) => ({ status: 'ok', body })); + +// Route that returns inbound headers (for propagation tests) +app.get('/test-inbound-headers/:id', ({ params, request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, id: params.id }; +}); + +// Outgoing fetch propagation +app.get('/test-outgoing-fetch/:id', async ({ params }) => { + const id = params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (allowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-allowed', async () => { + const response = await fetch(`http://localhost:3040/external-allowed`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (disallowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-disallowed', async () => { + const response = await fetch(`http://localhost:3040/external-disallowed`); + const data = await response.json(); + return data; +}); + +// Route that throws a string (not an Error object) +app.get('/test-string-error', () => { + // eslint-disable-next-line no-throw-literal + throw 'String error message'; +}); + +// Route for concurrent isolation tests — returns scope data in response +app.get('/test-isolation/:userId', async ({ params }) => { + Sentry.setUser({ id: params.userId }); + Sentry.setTag('user_id', params.userId); + + // Simulate async work to increase overlap between concurrent requests + await new Promise(resolve => setTimeout(resolve, 200)); + + return { + userId: params.userId, + isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id, + isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id, + }; +}); + +// Flush route for waiting on events +app.get('/flush', async () => { + await Sentry.flush(); + return { ok: true }; +}); + +app.listen(3030, () => { + console.log('Elysia app listening on port 3030'); +}); + +// Second app for external propagation tests +const app2 = new Elysia({ adapter: node() }); + +app2.get('/external-allowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-allowed' }; +}); + +app2.get('/external-disallowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-disallowed' }; +}); + +app2.listen(3040, () => { + console.log('External app listening on port 3040'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs new file mode 100644 index 000000000000..85dfff25b5a6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'elysia-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts new file mode 100644 index 000000000000..aac83b65e703 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await request.get(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual( + expect.objectContaining({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Error event includes request metadata', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await request.get(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/test-exception/456'), + headers: expect.any(Object), + }), + ); +}); + +test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { + const transactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-4xx'; + }); + + const response = await request.get(`${baseURL}/test-4xx`); + // Wait for the transaction to ensure the request was processed + await transactionPromise; + + expect(response.status()).toBe(400); +}); + +test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status'; + }); + + await request.get(`${baseURL}/test-error-with-200-status`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures POST route errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Post error'; + }); + + await request.post(`${baseURL}/test-post-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures thrown string errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'String error message'; + }); + + await request.get(`${baseURL}/test-string-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + type: 'auto.http.elysia.on_error', + handled: false, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts new file mode 100644 index 000000000000..3bdc1cc2e99d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/isolation.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +// The Elysia integration currently does not fork isolation scopes per request, +// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests. +// This test documents the expected behavior once per-request isolation is implemented. +test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => { + // Fire 3 concurrent requests with different user IDs + const [response1, response2, response3] = await Promise.all([ + fetch(`${baseURL}/test-isolation/user-1`), + fetch(`${baseURL}/test-isolation/user-2`), + fetch(`${baseURL}/test-isolation/user-3`), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + // Each response should have its own user ID — no leaking between requests + expect(data1.userId).toBe('user-1'); + expect(data1.isolationScopeUserId).toBe('user-1'); + expect(data1.isolationScopeTag).toBe('user-1'); + + expect(data2.userId).toBe('user-2'); + expect(data2.isolationScopeUserId).toBe('user-2'); + expect(data2.isolationScopeTag).toBe('user-2'); + + expect(data3.userId).toBe('user-3'); + expect(data3.isolationScopeUserId).toBe('user-3'); + expect(data3.isolationScopeTag).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts new file mode 100644 index 000000000000..14bdbdf7ca6b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-success`); + + const sentryTrace = response.headers.get('sentry-trace'); + const baggage = response.headers.get('baggage'); + + expect(sentryTrace).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-[01]/); + expect(baggage).toContain('sentry-environment=qa'); + expect(baggage).toContain('sentry-trace_id='); +}); + +// Bun's native fetch does not emit undici diagnostics channels, +// so the nativeNodeFetchIntegration cannot inject sentry-trace/baggage headers. +// These tests document the desired behavior and will pass once Bun adds support +// for undici diagnostics channels or an alternative propagation mechanism is added. + +test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = randomUUID(); + + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-inbound-headers/:id' + ); + }); + + const outboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + expect(traceId).toEqual(expect.any(String)); + + // Verify sentry-trace header was propagated to the inbound request + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-1$`)); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + // Both transactions should share the same trace ID + expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); +}); + +test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-allowed'); + expect(data.headers?.['sentry-trace']).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + expect(data.headers?.baggage).toBeDefined(); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + await inboundTransactionPromise; + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts new file mode 100644 index 000000000000..941341c3422a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a successful route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-success', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-param/:param' + ); + }); + + await request.get(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-param/:param'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + ); + }); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction for an errored route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-exception/:id' + ); + }); + + await request.get(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await request.get(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + const testSpan = spans.find(span => span.description === 'test-span'); + const childSpan = spans.find(span => span.description === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + parent_span_id: testSpan?.span_id, + }), + ); +}); + +test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + // The Handle span should be present as a request handler + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'Handle', + op: 'request_handler.elysia', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + // Use a route with middleware so there are child handler spans + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // No spans should exist — we name them 'anonymous' instead + const unknownSpans = spans.filter(span => span.description === ''); + expect(unknownSpans).toHaveLength(0); + + // Anonymous handler spans should be named 'anonymous' + const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); + expect(anonymousSpans.length).toBeGreaterThan(0); + + // Named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // BeforeHandle span should be present from the route-specific middleware + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'BeforeHandle', + op: 'middleware.elysia', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Captures request metadata for POST requests', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const response = await request.post(`${baseURL}/test-post`, { + data: { foo: 'bar', other: 1 }, + headers: { 'Content-Type': 'application/json' }, + }); + const resBody = await response.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/test-post'), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json new file mode 100644 index 000000000000..869427e44d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index 9e18defda67b..ca609897ff4c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.13", + "next": "15.5.14", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index fc876dc41ba7..9263605b5672 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.13", + "next": "15.5.14", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 17c6f714c499..f195206fb5b2 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -41,6 +41,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Astro 'setupFastifyErrorHandler', + 'withElysia', ], }, { @@ -75,6 +76,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'withElysia', ], }, { @@ -84,6 +86,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'withElysia', ], }, { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue new file mode 100644 index 000000000000..b686436bee17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue new file mode 100644 index 000000000000..7d9cce216273 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue new file mode 100644 index 000000000000..089d77a2eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -0,0 +1,18 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue new file mode 100644 index 000000000000..57a583eb43b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue new file mode 100644 index 000000000000..fb41b62b3308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue new file mode 100644 index 000000000000..e702eca86715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue new file mode 100644 index 000000000000..780adc07de53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue new file mode 100644 index 000000000000..25b423a4c442 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue new file mode 100644 index 000000000000..24918924f4a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue new file mode 100644 index 000000000000..d0d8e7241968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue new file mode 100644 index 000000000000..019404aaf460 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue new file mode 100644 index 000000000000..41daf0460b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..a1831f1e8e76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash @@ -0,0 +1,46 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID || kill $DEV_PID + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts new file mode 100644 index 000000000000..bdef334cfa88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -0,0 +1,47 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + imports: { autoImport: false }, + + routeRules: { + '/rendering-modes/client-side-only-page': { ssr: false }, + '/rendering-modes/isr-cached-page': { isr: true }, + '/rendering-modes/isr-1h-cached-page': { isr: 3600 }, + '/rendering-modes/swr-cached-page': { swr: true }, + '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, + '/rendering-modes/pre-rendered-page': { prerender: true }, + }, + + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json new file mode 100644 index 000000000000..ad5b209a6b22 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -0,0 +1,38 @@ +{ + "name": "nuxt-5", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "//": [ + "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", + "TODO: remove nitro from dependencies" + ], + "dependencies": { + "@pinia/nuxt": "^0.11.3", + "@sentry/nuxt": "latest || *", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts new file mode 100644 index 000000000000..b86690ca086c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts new file mode 100644 index 000000000000..900df0811faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nuxt'; +import { /* usePinia,*/ useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + /* Sentry.piniaIntegration(usePinia(), { + actionTransformer: action => `${action}.transformed`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), + */ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts new file mode 100644 index 000000000000..26519911072b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts new file mode 100644 index 000000000000..1f537ec4fee7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts @@ -0,0 +1,86 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { defineCachedFunction, defineCachedHandler } from 'nitro/cache'; + +// Test cachedFunction +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = defineCachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts new file mode 100644 index 000000000000..8fbe09098e8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts @@ -0,0 +1,104 @@ +import { defineHandler } from 'nitro'; +import { useDatabase } from 'nitro/database'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts new file mode 100644 index 000000000000..6e17444c30bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts @@ -0,0 +1,72 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { useDatabase } from 'nitro/database'; + +export default defineHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts new file mode 100644 index 000000000000..6ac58ac0ebee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Simple API endpoint that will trigger all server middleware + return { + message: 'Server middleware test endpoint', + path: event.path, + method: event.method, + headers: { + 'x-first-middleware': event.res?.headers.get('x-first-middleware'), + 'x-second-middleware': event.res?.headers.get('x-second-middleware'), + 'x-auth-middleware': event.res?.headers.get('x-auth-middleware'), + }, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..8bc4cff56610 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts new file mode 100644 index 000000000000..3422c275abe0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(_e => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts new file mode 100644 index 000000000000..23b89ce2c287 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(event => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..eb41287ad23d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts new file mode 100644 index 000000000000..992f00fee4df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts new file mode 100644 index 000000000000..e0ad305d2b9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts new file mode 100644 index 000000000000..d50d5d435912 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts new file mode 100644 index 000000000000..9d86cbafcbbd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts new file mode 100644 index 000000000000..01a184dfcc54 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-second-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts new file mode 100644 index 000000000000..7216e9fc7560 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -0,0 +1,13 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + // Check if we should throw an error + const query = getQuery(event); + if (query.throwError === 'true') { + throw new Error('Auth middleware error'); + } + + // Set a header to indicate this middleware ran + event.res?.headers.set('x-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts new file mode 100644 index 000000000000..726cfaba8c10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts @@ -0,0 +1,37 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + onRequest: async event => { + // Set a header to indicate the onRequest hook ran + event.res?.headers.set('x-hooks-onrequest', 'executed'); + + // Check if we should throw an error in onRequest + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + + handler: async event => { + // Set a header to indicate the main handler ran + event.res?.headers.set('x-hooks-handler', 'executed'); + + // Check if we should throw an error in handler + const query = getQuery(event); + if (query.throwHandlerError === 'true') { + throw new Error('Handler error'); + } + }, + + onBeforeResponse: async (event, response) => { + // Set a header to indicate the onBeforeResponse hook ran + event.res?.headers.set('x-hooks-onbeforeresponse', 'executed'); + + // Check if we should throw an error in onBeforeResponse + const query = getQuery(event); + if (query.throwOnBeforeResponseError === 'true') { + throw new Error('OnBeforeResponse hook error'); + } + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts new file mode 100644 index 000000000000..f0bac6fb3113 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts @@ -0,0 +1,48 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + // Array of onRequest handlers + onRequest: [ + async event => { + event.res?.headers.set('x-array-onrequest-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest0Error === 'true') { + throw new Error('OnRequest[0] hook error'); + } + }, + async event => { + event.res?.headers.set('x-array-onrequest-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest1Error === 'true') { + throw new Error('OnRequest[1] hook error'); + } + }, + ], + + handler: async event => { + event.res?.headers.set('x-array-handler', 'executed'); + }, + + // Array of onBeforeResponse handlers + onBeforeResponse: [ + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse0Error === 'true') { + throw new Error('OnBeforeResponse[0] hook error'); + } + }, + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse1Error === 'true') { + throw new Error('OnBeforeResponse[1] hook error'); + } + }, + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs new file mode 100644 index 000000000000..1bff06b86eef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-5', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts new file mode 100644 index 000000000000..7a660b88d714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts new file mode 100644 index 000000000000..9257bbc0e8a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts new file mode 100644 index 000000000000..331b41d90ccf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-5', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts new file mode 100644 index 000000000000..93f9935d048f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts new file mode 100644 index 000000000000..2c6a1be53662 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('captures error thrown in NuxtErrorBoundary', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in Error Boundary'; + }); + + await page.goto(`/client-error`); + await page.locator('#error-in-error-boundary').click(); + + const error = await errorPromise; + + const expectedBreadcrumb = { + category: 'console', + message: 'Additional functionality in NuxtErrorBoundary', + }; + + const matchingBreadcrumb = error.breadcrumbs.find( + (breadcrumb: { category: string; message: string }) => + breadcrumb.category === expectedBreadcrumb.category && breadcrumb.message === expectedBreadcrumb.message, + ); + + expect(matchingBreadcrumb).toBeTruthy(); + expect(matchingBreadcrumb?.category).toBe(expectedBreadcrumb.category); + expect(matchingBreadcrumb?.message).toBe(expectedBreadcrumb.message); + + expect(error.transaction).toEqual('/client-error'); + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown in Error Boundary', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from nuxt-5 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts new file mode 100644 index 000000000000..163dfd28c80a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts new file mode 100644 index 000000000000..3c314b80b59c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -0,0 +1,333 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +// TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that +test.describe.skip('Server Middleware Instrumentation', () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to the API endpoint that will trigger all server middleware + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); + + const serverTxnEvent = await serverTxnEventPromise; + + // Verify that we have spans for each middleware + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + expect(middlewareSpans).toHaveLength(11); + + // Check for specific middleware spans + const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); + const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); + const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); + const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const arrayHooksHandlerSpan = middlewareSpans.find( + span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', + ); + + expect(firstMiddlewareSpan).toBeDefined(); + expect(secondMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan).toBeDefined(); + expect(hooksOnRequestSpan).toBeDefined(); + expect(arrayHooksHandlerSpan).toBeDefined(); + + // Verify each span has the correct attributes + [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { + expect(span).toEqual( + expect.objectContaining({ + op: 'middleware.nuxt', + data: expect.objectContaining({ + 'sentry.op': 'middleware.nuxt', + 'sentry.origin': 'auto.middleware.nuxt', + 'sentry.source': 'custom', + 'http.request.method': 'GET', + 'http.route': '/api/middleware-test', + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); + + // Verify spans have different span IDs (each middleware gets its own span) + const spanIds = middlewareSpans.map(span => span.span_id); + const uniqueSpanIds = new Set(spanIds); + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + expect(uniqueSpanIds.size).toBe(11); + + // Verify spans share the same trace ID + const traceIds = middlewareSpans.map(span => span.trace_id); + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(1); + }); + + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + await request.get('/api/middleware-test'); + const serverTxnEvent = await serverTxnEventPromise; + + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // All middleware spans should be children of the main transaction + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + }); + + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + }); + + // Make request with query param to trigger error in auth middleware + const response = await request.get('/api/middleware-test?throwError=true'); + + // The request should fail due to the middleware error + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the auth middleware span + const authMiddlewareSpan = serverTxnEvent.spans?.find( + span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '03.auth', + ); + + expect(authMiddlewareSpan).toBeDefined(); + + // Verify the span has error status + expect(authMiddlewareSpan?.status).toBe('internal_error'); + + // Verify the error event is associated with the correct transaction + expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + + // Verify the error has the correct mechanism + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: 'Auth middleware error', + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), + }), + ); + }); + + test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the hooks middleware + const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + + // Should have spans for onRequest, handler, and onBeforeResponse + expect(hooksSpans).toHaveLength(3); + + // Find specific hook spans + const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const onBeforeResponseSpan = hooksSpans.find( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onRequestSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + expect(onBeforeResponseSpan).toBeDefined(); + + // Verify span names include hook types + expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(handlerSpan?.description).toBe('04.hooks'); + expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + + // Verify all spans have correct middleware name (without hook suffix) + [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + }); + + // Verify hook-specific attributes + expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + + // Verify no index attributes for single hooks + expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should create spans with index attributes for array hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with array hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the array hooks middleware + const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + + // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans + expect(arrayHooksSpans).toHaveLength(5); + + // Find onRequest array spans + const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + expect(onRequestSpans).toHaveLength(2); + + // Find onBeforeResponse array spans + const onBeforeResponseSpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + expect(onBeforeResponseSpans).toHaveLength(2); + + // Find handler span + const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + expect(handlerSpan).toBeDefined(); + + // Verify index attributes for onRequest array + const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest1Span).toBeDefined(); + + // Verify index attributes for onBeforeResponse array + const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onBeforeResponse0Span).toBeDefined(); + expect(onBeforeResponse1Span).toBeDefined(); + + // Verify span names for array handlers + expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + + // Verify handler has no index + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should handle errors in onRequest hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; + }); + + // Make request with query param to trigger error in onRequest + const response = await request.get('/api/middleware-test?throwOnRequestError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onRequest span that should have error status + const onRequestSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + ); + + expect(onRequestSpan).toBeDefined(); + expect(onRequestSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); + }); + + test('should handle errors in onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; + }); + + // Make request with query param to trigger error in onBeforeResponse + const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onBeforeResponse span that should have error status + const onBeforeResponseSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onBeforeResponseSpan).toBeDefined(); + expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); + }); + + test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; + }); + + // Make request with query param to trigger error in second onRequest handler + const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the second onRequest span that should have error status + const onRequest1Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 1, + ); + + expect(onRequest1Span).toBeDefined(); + expect(onRequest1Span?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); + + // Verify the first onRequest handler still executed successfully + const onRequest0Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 0, + ); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest0Span?.status).not.toBe('internal_error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts new file mode 100644 index 000000000000..fa1529187286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +// TODO: Pinia does not yet support Nuxt 5, so this test is skipped for now. +test.skip('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + cart: { rawItems: ['item'] }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..c6ff331a2780 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts new file mode 100644 index 000000000000..b0d9af9142da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts new file mode 100644 index 000000000000..7c7d51af4d4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts @@ -0,0 +1,208 @@ +import { expect, test, type Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Rendering Modes with Cached HTML', () => { + test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); + }); + + test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); + }); + + test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR-cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + }); + + test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + }); +}); + +/** + * Tests that tracing meta-tags change with multiple requests on ISR-cached pages + * This utility handles the common pattern of: + * 1. Making two requests to an ISR-cached page + * 2. Verifying tracing meta-tags are present and change between requests + * 3. Verifying distributed tracing works correctly for both requests + * 4. Verifying trace IDs are different between requests + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/isr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'ISR Cached Page') + */ +export async function testChangingTracingMetaTagsOnISRPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; + + // === 2. Request === + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise2, + serverTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + + await test.step('Test distributed trace from 1. request', () => { + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + + expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); + expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); + expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + }); + + await test.step('Test distributed trace from 2. request', () => { + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + + expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); + expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); + expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + }); + + await test.step('Test that trace IDs from subsequent requests are different', () => { + // Different trace IDs for the server transactions + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); + expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + }); +} + +/** + * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) + * This utility handles the common pattern of: + * 1. Making two requests to a cached page + * 2. Verifying no tracing meta-tags are present + * 3. Verifying only the first request creates a server transaction + * 4. Verifying traces are not distributed + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * @returns Object containing transaction events for additional custom assertions + */ +export async function testExcludeTracingMetaTagsOnCachedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + // Only the 1. request creates a server transaction + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + // Verify no baggage and sentry-trace meta-tags are present on first request + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + // === 2. Request === + + await page.goto(routePath); + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + let serverTxnEvent2 = undefined; + const serverTxnEventPromise2 = Promise.race([ + waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), + ]); + + try { + serverTxnEvent2 = await serverTxnEventPromise2; + throw new Error('Second server transaction should not have been sent'); + } catch (error) { + expect(error.message).toBe('No second server transaction expected'); + } + + const [clientTxnEvent2] = await Promise.all([ + clientTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; + const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + + await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + }); + + await test.step('1. Server Transaction and all Client Transactions are defined', () => { + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent2TraceId).toBeDefined(); + expect(serverTxnEvent2).toBeUndefined(); + expect(serverTxnEvent2TraceId).toBeUndefined(); + }); + + await test.step('Trace is not distributed', () => { + // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) + expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); + expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts new file mode 100644 index 000000000000..d4d4b141fa16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + 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: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts new file mode 100644 index 000000000000..ebd367d96031 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-5', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); +}); + +// TODO: Make test work with Nuxt 5 +test.skip('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts new file mode 100644 index 000000000000..e136d5635a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/') ?? false; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index beb758aca018..9d726fdf772f 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -80,6 +80,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/elysia': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/ember': access: $all publish: $all diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e33ef4e6b42c..f171d14f0ee4 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -64,7 +64,7 @@ "knex": "^2.5.1", "lru-memoizer": "2.3.0", "mongodb": "^3.7.3", - "mongodb-memory-server-global": "^10.1.4", + "mongodb-memory-server-global": "^11.0.1", "mongoose": "^6.13.6", "mysql": "^2.18.1", "mysql2": "^3.19.1", @@ -91,7 +91,6 @@ "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", "eslint-plugin-regexp": "^1.15.0", - "file-type": "^21.3.1", "globby": "11", "react": "^18.3.1", "zod": "^3.24.1" diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs new file mode 100644 index 000000000000..c7e0a4f5a9ce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-with-response.mjs @@ -0,0 +1,199 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import Anthropic from '@anthropic-ai/sdk'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + const model = req.body.model; + + res.set('request-id', 'req_withresponse_test'); + + if (req.body.stream) { + res.set('content-type', 'text/event-stream'); + res.flushHeaders(); + + const events = [ + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_stream_withresponse', type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } } })}\n\n`, + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Streaming with response!' } })}\n\n`, + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`, + ]; + + let i = 0; + const interval = setInterval(() => { + if (i < events.length) { + res.write(events[i]); + i++; + } else { + clearInterval(interval); + res.end(); + } + }, 10); + return; + } + + res.send({ + id: 'msg_withresponse', + type: 'message', + model: model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Testing .withResponse() method!', + }, + ], + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Test 1: Verify .withResponse() method is preserved and works correctly + const result = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test withResponse' }], + }); + + // Verify .withResponse() method exists and can be called + if (typeof result.withResponse !== 'function') { + throw new Error('.withResponse() method does not exist'); + } + + // Call .withResponse() and verify structure + const withResponseResult = await result.withResponse(); + + // Verify expected properties are present + if (!withResponseResult.data) { + throw new Error('.withResponse() did not return data'); + } + if (!withResponseResult.response) { + throw new Error('.withResponse() did not return response'); + } + if (withResponseResult.request_id === undefined) { + throw new Error('.withResponse() did not return request_id'); + } + + // Verify returned data structure matches expected Anthropic response + const { data } = withResponseResult; + if (data.id !== 'msg_withresponse') { + throw new Error(`Expected data.id to be 'msg_withresponse', got '${data.id}'`); + } + if (data.model !== 'claude-3-haiku-20240307') { + throw new Error(`Expected data.model to be 'claude-3-haiku-20240307', got '${data.model}'`); + } + if (data.content[0].text !== 'Testing .withResponse() method!') { + throw new Error( + `Expected data.content[0].text to be 'Testing .withResponse() method!', got '${data.content[0].text}'`, + ); + } + + // Verify response is a Response object with correct headers + if (!(withResponseResult.response instanceof Response)) { + throw new Error('response is not a Response object'); + } + if (withResponseResult.response.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${withResponseResult.response.headers.get('request-id')}'`, + ); + } + + // Verify request_id matches the header + if (withResponseResult.request_id !== 'req_withresponse_test') { + throw new Error(`Expected request_id 'req_withresponse_test', got '${withResponseResult.request_id}'`); + } + + // Test 2: Verify .asResponse() method works + const result2 = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test asResponse' }], + }); + + // Verify .asResponse() method exists and can be called + if (typeof result2.asResponse !== 'function') { + throw new Error('.asResponse() method does not exist'); + } + + // Call .asResponse() and verify it returns raw Response + const rawResponse = await result2.asResponse(); + + // Verify response is a Response object with correct headers + if (!(rawResponse instanceof Response)) { + throw new Error('.asResponse() did not return a Response object'); + } + + // Verify response has correct status + if (rawResponse.status !== 200) { + throw new Error(`Expected status 200, got ${rawResponse.status}`); + } + + // Verify response headers + if (rawResponse.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${rawResponse.headers.get('request-id')}'`, + ); + } + + // Test 3: Verify .withResponse() works with streaming (stream: true) + const streamResult = client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Test stream withResponse' }], + stream: true, + }); + + if (typeof streamResult.withResponse !== 'function') { + throw new Error('.withResponse() method does not exist on streaming result'); + } + + const streamWithResponse = await streamResult.withResponse(); + + if (!streamWithResponse.data) { + throw new Error('streaming .withResponse() did not return data'); + } + if (!streamWithResponse.response) { + throw new Error('streaming .withResponse() did not return response'); + } + if (streamWithResponse.request_id === undefined) { + throw new Error('streaming .withResponse() did not return request_id'); + } + + if (!(streamWithResponse.response instanceof Response)) { + throw new Error('streaming response is not a Response object'); + } + if (streamWithResponse.response.headers.get('request-id') !== 'req_withresponse_test') { + throw new Error( + `Expected request-id header 'req_withresponse_test', got '${streamWithResponse.response.headers.get('request-id')}'`, + ); + } + + // Consume the stream to allow span to complete + for await (const _ of streamWithResponse.data) { + void _; + } + }); + + // Wait for the stream event handler to finish + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 2c72ec7daadd..fb520e5e09b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -359,6 +359,37 @@ describe('Anthropic integration', () => { }); }); + createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { + const chatSpan = (responseId: string) => + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'claude-3-haiku-20240307', + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: responseId, + }), + description: 'chat claude-3-haiku-20240307', + op: 'gen_ai.chat', + status: 'ok', + }); + + test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + chatSpan('msg_withresponse'), + chatSpan('msg_withresponse'), + chatSpan('msg_stream_withresponse'), + ]), + }, + }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: false', async () => { await createRunner() diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs new file mode 100644 index 000000000000..4933d3bfb9c2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +Sentry.setConversationId('conv-a'); + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Hello!', + providerMetadata: { + openai: { responseId: 'resp_should_not_overwrite' }, + }, + }), + }), + prompt: 'Say hello', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 7907544a6d11..6b0b0a45fcf8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -923,4 +923,31 @@ describe('Vercel AI integration', () => { await createRunner().expect({ transaction: expectedTransaction }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('does not overwrite conversation id set via Sentry.setConversationId with responseId from provider metadata', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'gen_ai.invoke_agent', + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv-a', + }), + }), + expect.objectContaining({ + op: 'gen_ai.generate_text', + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv-a', + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs index 9bc70d48d4c3..266b62cd7742 100644 --- a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs +++ b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs @@ -28,6 +28,7 @@ async function getMeasurements(instrumentFile, autocannonCommand = 'yarn test:ge await killAppProcess(); return result; } catch (error) { + //oxlint-disable-next-line restrict-template-expressions log(`Error running autocannon: ${error}`); await killAppProcess(); throw error; diff --git a/package.json b/package.json index 42edbcaf8879..ccb2f051319a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "packages/deno", "packages/effect", "packages/ember", + "packages/elysia", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", "packages/feedback", diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 25180a41f6e6..fe069d7ff6f5 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -109,7 +109,7 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } - applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource); + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless', 'node'], sdkSource); return initWithoutDefaultIntegrations(opts); } diff --git a/packages/aws-serverless/test/sdk.test.ts b/packages/aws-serverless/test/sdk.test.ts index c0f508eb3859..aa16049e2e2b 100644 --- a/packages/aws-serverless/test/sdk.test.ts +++ b/packages/aws-serverless/test/sdk.test.ts @@ -618,6 +618,10 @@ describe('AWSLambda', () => { name: 'npm:@sentry/aws-serverless', version: expect.any(String), }, + { + name: 'npm:@sentry/node', + version: expect.any(String), + }, ], version: expect.any(String), }, diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 7b79fa9383d6..6f9c06fdf88b 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -105,7 +105,7 @@ export function init(userOptions: BunOptions = {}): NodeClient | undefined { const options = { ...userOptions, platform: 'javascript', - runtime: { name: 'bun', version: Bun.version }, + runtime: { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index ebadd6393298..e7f036971f4a 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -48,6 +48,12 @@ export function makeFlushLock(context: ExecutionContext): FlushLock { * @returns A promise that resolves when flush and dispose are complete */ export async function flushAndDispose(client: Client | undefined, timeout = 2000): Promise { - await flush(timeout); - client?.dispose(); + if (!client) { + await flush(timeout); + + return; + } + + await client.flush(timeout); + client.dispose(); } diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 3c40c86ff867..6515a330ca99 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -67,24 +67,27 @@ class WrappedWorkflowStep implements WorkflowStep { private _step: WorkflowStep, ) {} - public async do>(name: string, callback: () => Promise): Promise; + public async do>( + name: string, + callback: (...args: unknown[]) => Promise, + ): Promise; public async do>( name: string, config: WorkflowStepConfig, - callback: () => Promise, + callback: (...args: unknown[]) => Promise, ): Promise; public async do>( name: string, configOrCallback: WorkflowStepConfig | (() => Promise), - maybeCallback?: () => Promise, + maybeCallback?: (...args: unknown[]) => Promise, ): Promise { // Capture the current scope, so parent span (e.g., a startSpan surrounding step.do) is preserved const scopeForStep = getCurrentScope(); - const userCallback = (maybeCallback || configOrCallback) as () => Promise; + const userCallback = (maybeCallback || configOrCallback) as (...args: unknown[]) => Promise; const config = typeof configOrCallback === 'function' ? undefined : configOrCallback; - const instrumentedCallback: () => Promise = async () => { + const instrumentedCallback = async (...args: unknown[]): Promise => { return startSpan( { op: 'function.step.do', @@ -101,7 +104,7 @@ class WrappedWorkflowStep implements WorkflowStep { }, async span => { try { - const result = await userCallback(); + const result = await userCallback(...args); span.setStatus({ code: 1 }); return result; } catch (error) { diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts index 34714711c682..2a2b68aab02d 100644 --- a/packages/cloudflare/test/flush.test.ts +++ b/packages/cloudflare/test/flush.test.ts @@ -1,6 +1,8 @@ import { type ExecutionContext } from '@cloudflare/workers-types'; +import * as sentryCore from '@sentry/core'; +import { type Client } from '@sentry/core'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { makeFlushLock } from '../src/flush'; +import { flushAndDispose, makeFlushLock } from '../src/flush'; describe('Flush buffer test', () => { const waitUntilPromises: Promise[] = []; @@ -28,3 +30,35 @@ describe('Flush buffer test', () => { await expect(lock.ready).resolves.toBeUndefined(); }); }); + +describe('flushAndDispose', () => { + it('should flush and dispose the client when provided', async () => { + const mockClient = { + flush: vi.fn().mockResolvedValue(true), + dispose: vi.fn(), + } as unknown as Client; + + await flushAndDispose(mockClient, 3000); + + expect(mockClient.flush).toHaveBeenCalledWith(3000); + expect(mockClient.dispose).toHaveBeenCalled(); + }); + + it('should fall back to global flush when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalledWith(2000); + flushSpy.mockRestore(); + }); + + it('should not call dispose when no client is provided', async () => { + const flushSpy = vi.spyOn(sentryCore, 'flush').mockResolvedValue(true); + + await flushAndDispose(undefined); + + expect(flushSpy).toHaveBeenCalled(); + flushSpy.mockRestore(); + }); +}); diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index b460e6bfee5a..f21bee8612a8 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -6,14 +6,16 @@ import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); +const MOCK_STEP_CTX = { attempt: 1 }; + const mockStep: WorkflowStep = { do: vi .fn() .mockImplementation( async ( _name: string, - configOrCallback: WorkflowStepConfig | (() => Promise), - maybeCallback?: () => Promise, + configOrCallback: WorkflowStepConfig | ((...args: unknown[]) => Promise), + maybeCallback?: (...args: unknown[]) => Promise, ) => { let count = 0; @@ -22,9 +24,9 @@ const mockStep: WorkflowStep = { try { if (typeof configOrCallback === 'function') { - return await configOrCallback(); + return await configOrCallback(MOCK_STEP_CTX); } else { - return await (maybeCallback ? maybeCallback() : Promise.resolve()); + return await (maybeCallback ? maybeCallback(MOCK_STEP_CTX) : Promise.resolve()); } } catch { await new Promise(resolve => setTimeout(resolve, 1000)); @@ -427,6 +429,26 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { ]); }); + test('Forwards step context (ctx) to user callback', async () => { + const callbackSpy = vi.fn().mockResolvedValue({ ok: true }); + + class CtxTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('ctx step', callbackSpy); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, CtxTestWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as CtxTestWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + await workflow.run(event, mockStep); + + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(MOCK_STEP_CTX); + }); + test('Step.do span becomes child of surrounding custom span', async () => { class ParentChildWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index c831bd01a6bb..6c530da521c5 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -4,11 +4,16 @@ import { isInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; +const mocks = vi.hoisted(() => ({ + flush: vi.fn().mockResolvedValue(true), +})); + function createMockClient(hasTransport: boolean = true) { return { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: mocks.flush, getTransport: vi.fn().mockReturnValue(hasTransport ? { send: vi.fn() } : undefined), }; } @@ -263,8 +268,7 @@ describe('wrapMethodWithSentry', () => { await wrapped(); expect(waitUntil).toHaveBeenCalled(); - // flushAndDispose calls flush internally - expect(sentryCore.flush).toHaveBeenCalledWith(2000); + expect(mocks.flush).toHaveBeenCalledWith(2000); }); it('handles missing waitUntil gracefully', async () => { @@ -346,6 +350,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue(undefined), } as unknown as sentryCore.Client; @@ -377,6 +382,7 @@ describe('wrapMethodWithSentry', () => { getOptions: () => ({}), on: vi.fn(), dispose: vi.fn(), + flush: vi.fn().mockResolvedValue(true), getTransport: vi.fn().mockReturnValue({ send: vi.fn() }), } as unknown as sentryCore.Client; diff --git a/packages/core/src/asyncContext/stackStrategy.ts b/packages/core/src/asyncContext/stackStrategy.ts index 87dc534fc636..36c1d2127530 100644 --- a/packages/core/src/asyncContext/stackStrategy.ts +++ b/packages/core/src/asyncContext/stackStrategy.ts @@ -1,6 +1,7 @@ import type { Client } from '../client'; import { getDefaultCurrentScope, getDefaultIsolationScope } from '../defaultScopes'; import { Scope } from '../scope'; +import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike'; import { isThenable } from '../utils/is'; import { getMainCarrier, getSentryCarrier } from './../carrier'; import type { AsyncContextStrategy } from './types'; @@ -52,17 +53,11 @@ export class AsyncContextStack { } if (isThenable(maybePromiseResult)) { - // @ts-expect-error - isThenable returns the wrong type - return maybePromiseResult.then( - res => { - this._popScope(); - return res; - }, - e => { - this._popScope(); - throw e; - }, - ); + return chainAndCopyPromiseLike( + maybePromiseResult as PromiseLike> & Record, + () => this._popScope(), + () => this._popScope(), + ) as T; } this._popScope(); diff --git a/packages/core/src/tracing/ai/messageTruncation.ts b/packages/core/src/tracing/ai/messageTruncation.ts index 16df3c298466..779cf332855b 100644 --- a/packages/core/src/tracing/ai/messageTruncation.ts +++ b/packages/core/src/tracing/ai/messageTruncation.ts @@ -14,15 +14,20 @@ type ContentMessage = { content: string; }; +/** + * One block inside OpenAI / Anthropic `content: [...]` arrays (text, image_url, etc.). + */ +type ContentArrayBlock = { + [key: string]: unknown; + type: string; +}; + /** * Message format used by OpenAI and Anthropic APIs for media. */ type ContentArrayMessage = { [key: string]: unknown; - content: { - [key: string]: unknown; - type: string; - }[]; + content: ContentArrayBlock[]; }; /** @@ -47,6 +52,11 @@ type MediaPart = { content: string; }; +/** + * One element of an array-based message: OpenAI/Anthropic `content[]` or Google `parts`. + */ +type ArrayMessageItem = TextPart | MediaPart | ContentArrayBlock; + /** * Calculate the UTF-8 byte length of a string. */ @@ -95,31 +105,33 @@ function truncateTextByBytes(text: string, maxBytes: number): string { } /** - * Extract text content from a Google GenAI message part. - * Parts are either plain strings or objects with a text property. + * Extract text content from a message item. + * Handles plain strings and objects with a text property. * * @returns The text content */ -function getPartText(part: TextPart | MediaPart): string { - if (typeof part === 'string') { - return part; +function getItemText(item: ArrayMessageItem): string { + if (typeof item === 'string') { + return item; + } + if ('text' in item && typeof item.text === 'string') { + return item.text; } - if ('text' in part) return part.text; return ''; } /** - * Create a new part with updated text content while preserving the original structure. + * Create a new item with updated text content while preserving the original structure. * - * @param part - Original part (string or object) + * @param item - Original item (string or object) * @param text - New text content - * @returns New part with updated text + * @returns New item with updated text */ -function withPartText(part: TextPart | MediaPart, text: string): TextPart { - if (typeof part === 'string') { +function withItemText(item: ArrayMessageItem, text: string): ArrayMessageItem { + if (typeof item === 'string') { return text; } - return { ...part, text }; + return { ...item, text }; } /** @@ -176,56 +188,78 @@ function truncateContentMessage(message: ContentMessage, maxBytes: number): unkn } /** - * Truncate a message with `parts: [...]` format (Google GenAI). - * Keeps as many complete parts as possible, only truncating the first part if needed. + * Extracts the array items and their key from an array-based message. + * Returns `null` key if neither `parts` nor `content` is a valid array. + */ +function getArrayItems(message: PartsMessage | ContentArrayMessage): { + key: 'parts' | 'content' | null; + items: ArrayMessageItem[]; +} { + if ('parts' in message && Array.isArray(message.parts)) { + return { key: 'parts', items: message.parts }; + } + if ('content' in message && Array.isArray(message.content)) { + return { key: 'content', items: message.content }; + } + return { key: null, items: [] }; +} + +/** + * Truncate a message with an array-based format. + * Handles both `parts: [...]` (Google GenAI) and `content: [...]` (OpenAI/Anthropic multimodal). + * Keeps as many complete items as possible, only truncating the first item if needed. * - * @param message - Message with parts array + * @param message - Message with parts or content array * @param maxBytes - Maximum byte limit * @returns Array with truncated message, or empty array if it doesn't fit */ -function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { - const { parts } = message; +function truncateArrayMessage(message: PartsMessage | ContentArrayMessage, maxBytes: number): unknown[] { + const { key, items } = getArrayItems(message); - // Calculate overhead by creating empty text parts - const emptyParts = parts.map(part => withPartText(part, '')); - const overhead = jsonBytes({ ...message, parts: emptyParts }); + if (key === null || items.length === 0) { + return []; + } + + // Calculate overhead by creating empty text items + const emptyItems = items.map(item => withItemText(item, '')); + const overhead = jsonBytes({ ...message, [key]: emptyItems }); let remainingBytes = maxBytes - overhead; if (remainingBytes <= 0) { return []; } - // Include parts until we run out of space - const includedParts: (TextPart | MediaPart)[] = []; + // Include items until we run out of space + const includedItems: ArrayMessageItem[] = []; - for (const part of parts) { - const text = getPartText(part); + for (const item of items) { + const text = getItemText(item); const textSize = utf8Bytes(text); if (textSize <= remainingBytes) { - // Part fits: include it as-is - includedParts.push(part); + // Item fits: include it as-is + includedItems.push(item); remainingBytes -= textSize; - } else if (includedParts.length === 0) { - // First part doesn't fit: truncate it + } else if (includedItems.length === 0) { + // First item doesn't fit: truncate it const truncated = truncateTextByBytes(text, remainingBytes); if (truncated) { - includedParts.push(withPartText(part, truncated)); + includedItems.push(withItemText(item, truncated)); } break; } else { - // Subsequent part doesn't fit: stop here + // Subsequent item doesn't fit: stop here break; } } /* c8 ignore start * for type safety only, algorithm guarantees SOME text included */ - if (includedParts.length <= 0) { + if (includedItems.length <= 0) { return []; } else { /* c8 ignore stop */ - return [{ ...message, parts: includedParts }]; + return [{ ...message, [key]: includedItems }]; } } @@ -258,13 +292,8 @@ function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { return truncateContentMessage(message, maxBytes); } - if (isContentArrayMessage(message)) { - // Content array messages are returned as-is without truncation - return [message]; - } - - if (isPartsMessage(message)) { - return truncatePartsMessage(message, maxBytes); + if (isContentArrayMessage(message) || isPartsMessage(message)) { + return truncateArrayMessage(message, maxBytes); } // Unknown message format: cannot truncate safely diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 46602a54553a..38e4d831db3f 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -1,8 +1,10 @@ /** * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) */ +import { captureException } from '../../exports'; import { getClient } from '../../currentScopes'; import type { Span } from '../../types-hoist/span'; +import { isThenable } from '../../utils/is'; import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, @@ -172,3 +174,81 @@ export function extractSystemInstructions(messages: unknown[] | unknown): { return { systemInstructions, filteredMessages }; } + +/** + * Creates a wrapped version of .withResponse() that replaces the data field + * with the instrumented result while preserving metadata (response, request_id). + */ +async function createWithResponseWrapper( + originalWithResponse: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection + // If instrumentedPromise rejects first, we still need this handled + const safeOriginalWithResponse = originalWithResponse.catch(error => { + captureException(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + throw error; + }); + + const instrumentedResult = await instrumentedPromise; + const originalWrapper = await safeOriginalWithResponse; + + // Combine instrumented result with original metadata + if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { + return { + ...originalWrapper, + data: instrumentedResult, + }; + } + return instrumentedResult; +} + +/** + * Wraps a promise-like object to preserve additional methods (like .withResponse()) + * that AI SDK clients (OpenAI, Anthropic) attach to their APIPromise return values. + * + * Standard Promise methods (.then, .catch, .finally) are routed to the instrumented + * promise to preserve Sentry's span instrumentation, while custom SDK methods are + * forwarded to the original promise to maintain the SDK's API surface. + */ +export function wrapPromiseWithMethods( + originalPromiseLike: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // If the original result is not thenable, return the instrumented promise + if (!isThenable(originalPromiseLike)) { + return instrumentedPromise; + } + + // Create a proxy that forwards Promise methods to instrumentedPromise + // and preserves additional methods from the original result + return new Proxy(originalPromiseLike, { + get(target: object, prop: string | symbol): unknown { + // For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag), + // use instrumentedPromise to preserve Sentry instrumentation. + // For custom methods (like .withResponse()), use the original target. + const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag; + const source = useInstrumentedPromise ? instrumentedPromise : target; + + const value = Reflect.get(source, prop) as unknown; + + // Special handling for .withResponse() to preserve instrumentation + // .withResponse() returns { data: T, response: Response, request_id: string } + if (prop === 'withResponse' && typeof value === 'function') { + return function wrappedWithResponse(this: unknown): unknown { + const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); + return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType); + }; + } + + return typeof value === 'function' ? value.bind(source) : value; + }, + }) as Promise; +} diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index f677fe5eb90f..693ecbd23ff8 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -3,7 +3,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; -import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -28,6 +27,7 @@ import { getSpanOperation, resolveAIRecordingOptions, setTokenUsageAttributes, + wrapPromiseWithMethods, } from '../ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -218,21 +218,30 @@ function handleStreamingRequest( // messages.stream() always returns a sync MessageStream, even with stream: true param if (isStreamRequested && !isStreamingMethod) { - return startSpanManual(spanConfig, async span => { - try { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - const result = await originalMethod.apply(context, args); - return instrumentAsyncIterableStream( - result as AsyncIterable, - span, - options.recordOutputs ?? false, - ) as unknown as R; - } catch (error) { - return handleStreamingError(error, span, methodPath); + let originalResult!: Promise; + + const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => { + originalResult = originalMethod.apply(context, args) as Promise; + + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); } + + return (async () => { + try { + const result = await originalResult; + return instrumentAsyncIterableStream( + result as AsyncIterable, + span, + options.recordOutputs ?? false, + ) as unknown as R; + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + })(); }); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); } else { return startSpanManual(spanConfig, span => { try { @@ -285,19 +294,26 @@ function instrumentMethod( ); } - return startSpan( + let originalResult!: Promise; + + const instrumentedPromise = startSpan( { name: `${operationName} ${model}`, op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, span => { + originalResult = target.apply(context, args) as Promise; + if (options.recordInputs && params) { addPrivateRequestAttributes(span, params); } - return handleCallbackErrors( - () => target.apply(context, args), + return originalResult.then( + result => { + addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); + return result; + }, error => { captureException(error, { mechanism: { @@ -308,12 +324,13 @@ function instrumentMethod( }, }, }); + throw error; }, - () => {}, - result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), ); }, ); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); }, }) as (...args: T) => R | Promise; } diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 484128810b01..d5ee3f53af86 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -5,7 +5,6 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { debug } from '../../utils/debug-logger'; -import { isThenable } from '../../utils/is'; import { GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, @@ -18,7 +17,13 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, OPENAI_OPERATIONS, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getTruncatedJsonString, resolveAIRecordingOptions } from '../ai/utils'; +import { + extractSystemInstructions, + getTruncatedJsonString, + resolveAIRecordingOptions, + wrapPromiseWithMethods, + buildMethodPath, +} from '../ai/utils'; import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, @@ -33,7 +38,6 @@ import { addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, - buildMethodPath, extractRequestParameters, getOperationName, getSpanOperation, @@ -172,75 +176,6 @@ function addRequestAttributes(span: Span, params: Record, opera } } -/** - * Creates a wrapped version of .withResponse() that replaces the data field - * with the instrumented result while preserving metadata (response, request_id). - */ -async function createWithResponseWrapper( - originalWithResponse: Promise, - instrumentedPromise: Promise, -): Promise { - // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection - // If instrumentedPromise rejects first, we still need this handled - const safeOriginalWithResponse = originalWithResponse.catch(error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai', - }, - }); - throw error; - }); - - const instrumentedResult = await instrumentedPromise; - const originalWrapper = await safeOriginalWithResponse; - - // Combine instrumented result with original metadata - if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { - return { - ...originalWrapper, - data: instrumentedResult, - }; - } - return instrumentedResult; -} - -/** - * Wraps a promise-like object to preserve additional methods (like .withResponse()) - */ -function wrapPromiseWithMethods(originalPromiseLike: Promise, instrumentedPromise: Promise): Promise { - // If the original result is not thenable, return the instrumented promise - // Should not happen with current OpenAI SDK instrumented methods, but just in case. - if (!isThenable(originalPromiseLike)) { - return instrumentedPromise; - } - - // Create a proxy that forwards Promise methods to instrumentedPromise - // and preserves additional methods from the original result - return new Proxy(originalPromiseLike, { - get(target: object, prop: string | symbol): unknown { - // For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag), - // use instrumentedPromise to preserve Sentry instrumentation. - // For custom methods (like .withResponse()), use the original target. - const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag; - const source = useInstrumentedPromise ? instrumentedPromise : target; - - const value = Reflect.get(source, prop) as unknown; - - // Special handling for .withResponse() to preserve instrumentation - // .withResponse() returns { data: T, response: Response, request_id: string } - if (prop === 'withResponse' && typeof value === 'function') { - return function wrappedWithResponse(this: unknown): unknown { - const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); - return createWithResponseWrapper(originalWithResponse, instrumentedPromise); - }; - } - - return typeof value === 'function' ? value.bind(source) : value; - }, - }) as Promise; -} - /** * Instrument a method with Sentry spans * Following Sentry AI Agents Manual Instrumentation conventions @@ -300,7 +235,7 @@ function instrumentMethod( })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise); + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); } // Non-streaming @@ -332,7 +267,7 @@ function instrumentMethod( ); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise); + return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); }; } diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 3338d4524d75..f89b786b5a3c 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -69,13 +69,6 @@ export function shouldInstrument(methodPath: string): methodPath is Instrumented return INSTRUMENTED_METHODS.includes(methodPath as InstrumentedMethod); } -/** - * Build method path from current traversal - */ -export function buildMethodPath(currentPath: string, prop: string): string { - return currentPath ? `${currentPath}.${prop}` : prop; -} - /** * Check if response is a Chat Completion object */ diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 17e26b7b6bac..5458ace456c5 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -266,6 +266,12 @@ function processEndedVercelAiSpan(span: SpanJSON): void { return; } + // The Vercel AI SDK sets span status to raw error message strings. + // Any such value should be normalized to a SpanStatusType value. We pick internal_error as it is the most generic. + if (span.status && span.status !== 'ok') { + span.status = 'internal_error'; + } + renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); @@ -470,7 +476,9 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { 'gen_ai.usage.output_tokens.prediction_rejected', openaiMetadata.rejectedPredictionTokens, ); - setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); + if (!attributes['gen_ai.conversation.id']) { + setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); + } } if (providerMetadataObject.anthropic) { diff --git a/packages/core/src/utils/chain-and-copy-promiselike.ts b/packages/core/src/utils/chain-and-copy-promiselike.ts new file mode 100644 index 000000000000..4d8db088d22e --- /dev/null +++ b/packages/core/src/utils/chain-and-copy-promiselike.ts @@ -0,0 +1,55 @@ +const isActualPromise = (p: unknown) => + p instanceof Promise && !(p as unknown as ChainedPromiseLike)[kChainedCopy]; + +type ChainedPromiseLike = PromiseLike & { + [kChainedCopy]: true; +}; +const kChainedCopy = Symbol('chained PromiseLike'); + +/** + * Copy the properties from a decorated promiselike object onto its chained + * actual promise. + */ +export const chainAndCopyPromiseLike = >( + original: T, + onSuccess: (value: V) => void, + onError: (e: unknown) => void, +): T => { + const chained = original.then( + value => { + onSuccess(value); + return value; + }, + err => { + onError(err); + throw err; + }, + ) as T; + + // if we're just dealing with "normal" Promise objects, return the chain + return isActualPromise(chained) && isActualPromise(original) ? chained : copyProps(original, chained); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const copyProps = >(original: T, chained: T): T => { + let mutated = false; + //oxlint-disable-next-line guard-for-in + for (const key in original) { + if (key in chained) continue; + mutated = true; + const value = original[key]; + if (typeof value === 'function') { + Object.defineProperty(chained, key, { + value: (...args: unknown[]) => value.apply(original, args), + enumerable: true, + configurable: true, + writable: true, + }); + } else { + (chained as Record)[key] = value; + } + } + + if (mutated) Object.assign(chained, { [kChainedCopy]: true }); + return chained; +}; diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index 1a09e23a40aa..4fa0b036c101 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -1,3 +1,4 @@ +import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike'; import { isThenable } from '../utils/is'; /* eslint-disable */ @@ -62,7 +63,12 @@ export function handleCallbackErrors< * Maybe handle a promise rejection. * This expects to be given a value that _may_ be a promise, or any other value. * If it is a promise, and it rejects, it will call the `onError` callback. - * Other than this, it will generally return the given value as-is. + * + * For thenable objects with extra methods (like jQuery's jqXHR), + * this function preserves those methods by wrapping the original thenable in a Proxy + * that intercepts .then() calls to apply error handling while forwarding all other + * properties to the original object. + * This allows code like `startSpan(() => $.ajax(...)).abort()` to work correctly. */ function maybeHandlePromiseRejection( value: MaybePromise, @@ -71,21 +77,19 @@ function maybeHandlePromiseRejection( onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { - // @ts-expect-error - the isThenable check returns the "wrong" type here - return value.then( - res => { + return chainAndCopyPromiseLike( + value as MaybePromise & PromiseLike> & Record, + result => { onFinally(); - onSuccess(res); - return res; + onSuccess(result as Awaited); }, - e => { - onError(e); + err => { + onError(err); onFinally(); - throw e; }, - ); + ) as MaybePromise; } - + // Non-thenable value - call callbacks immediately and return as-is onFinally(); onSuccess(value); return value; diff --git a/packages/core/test/lib/tracing/ai-message-truncation.test.ts b/packages/core/test/lib/tracing/ai-message-truncation.test.ts index c7f8e0043622..f1f318e02128 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/core/test/lib/tracing/ai-message-truncation.test.ts @@ -547,5 +547,66 @@ describe('message truncation utilities', () => { }, ]); }); + + it('truncates content array message when first text item does not fit', () => { + const messages = [ + { + role: 'user', + content: [{ type: 'text', text: `2 ${humongous}` }], + }, + ]; + const result = truncateGenAiMessages(messages); + const truncLen = + 20_000 - + 2 - + JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: '' }], + }).length; + expect(result).toStrictEqual([ + { + role: 'user', + content: [{ type: 'text', text: `2 ${humongous}`.substring(0, truncLen) }], + }, + ]); + }); + + it('drops subsequent content array items that do not fit', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: 'text', text: `1 ${big}` }, + { type: 'image_url', url: 'https://example.com/img.png' }, + { type: 'text', text: `2 ${big}` }, + { type: 'text', text: `3 ${big}` }, + { type: 'text', text: `4 ${giant}` }, + { type: 'text', text: `5 ${giant}` }, + ], + }, + ]; + const result = truncateGenAiMessages(messages); + expect(result).toStrictEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: `1 ${big}` }, + { type: 'image_url', url: 'https://example.com/img.png' }, + { type: 'text', text: `2 ${big}` }, + { type: 'text', text: `3 ${big}` }, + ], + }, + ]); + }); + + it('drops content array message if overhead is too large', () => { + const messages = [ + { + some_other_field: humongous, + content: [{ type: 'text', text: 'hello' }], + }, + ]; + expect(truncateGenAiMessages(messages)).toStrictEqual([]); + }); }); }); diff --git a/packages/core/test/lib/tracing/ai/utils.test.ts b/packages/core/test/lib/tracing/ai/utils.test.ts index 28f98c846619..43f16e70c0f2 100644 --- a/packages/core/test/lib/tracing/ai/utils.test.ts +++ b/packages/core/test/lib/tracing/ai/utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '../../../../src'; -import { resolveAIRecordingOptions } from '../../../../src/tracing/ai/utils'; +import { resolveAIRecordingOptions, wrapPromiseWithMethods } from '../../../../src/tracing/ai/utils'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('resolveAIRecordingOptions', () => { @@ -38,3 +38,84 @@ describe('resolveAIRecordingOptions', () => { expect(resolveAIRecordingOptions({ recordInputs: false })).toEqual({ recordInputs: false, recordOutputs: true }); }); }); + +describe('wrapPromiseWithMethods', () => { + /** + * Creates a mock APIPromise that mimics the behavior of OpenAI/Anthropic SDK APIPromise. + * The returned object is a thenable with extra methods like .withResponse() and .asResponse(). + */ + function createMockAPIPromise(value: T, metadata: { response: object; request_id: string }) { + const resolvedPromise = Promise.resolve(value); + const apiPromise = Object.assign(resolvedPromise, { + withResponse: () => + Promise.resolve({ + data: value, + response: metadata.response, + request_id: metadata.request_id, + }), + asResponse: () => Promise.resolve(metadata.response), + }); + return apiPromise; + } + + it('routes .then() to instrumentedPromise', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const result = await wrapped; + expect(result).toBe('instrumented-data'); + }); + + it('routes .withResponse() to original and swaps data with instrumented result', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const withResponseResult = await (wrapped as typeof original).withResponse(); + expect(withResponseResult).toEqual({ + data: 'instrumented-data', + response: { status: 200 }, + request_id: 'req_123', + }); + }); + + it('routes .asResponse() to original', async () => { + const mockResponse = { status: 200, headers: new Map() }; + const original = createMockAPIPromise('original-data', { + response: mockResponse, + request_id: 'req_123', + }); + const instrumented = Promise.resolve('instrumented-data'); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + const response = await (wrapped as typeof original).asResponse(); + expect(response).toBe(mockResponse); + }); + + it('returns instrumentedPromise when original is not thenable', async () => { + const instrumented = Promise.resolve('instrumented-data'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapped = wrapPromiseWithMethods(null as any, instrumented, 'auto.ai.test'); + + const result = await wrapped; + expect(result).toBe('instrumented-data'); + }); + + it('propagates errors from instrumentedPromise', async () => { + const original = createMockAPIPromise('original-data', { + response: { status: 200 }, + request_id: 'req_123', + }); + const instrumented = Promise.reject(new Error('instrumented-error')); + const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + + await expect(wrapped).rejects.toThrow('instrumented-error'); + }); +}); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 271cd669d56c..6be1bf0577f3 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -210,6 +210,20 @@ describe('startSpan', () => { }); }); + describe('AsyncContext withScope promise integrity behavior', () => { + it('preserves custom thenable methods', async () => { + const jqXHR = { + then: Promise.resolve(1).then.bind(Promise.resolve(1)), + abort: vi.fn(), + }; + expect(jqXHR instanceof Promise).toBe(false); + const result = startSpan({ name: 'test' }, () => jqXHR); + expect(typeof result.abort).toBe('function'); + result.abort(); + expect(jqXHR.abort).toHaveBeenCalled(); + }); + }); + it('returns a non recording span if tracing is disabled', () => { const options = getDefaultTestClientOptions({}); client = new TestClient(options); diff --git a/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts b/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts new file mode 100644 index 000000000000..dbba343eca42 --- /dev/null +++ b/packages/core/test/lib/tracing/vercel-ai-span-status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; +import type { SpanJSON } from '../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('vercel-ai span status normalization', () => { + function processSpan(status: string): string | undefined { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); + const client = new TestClient(options); + client.init(); + addVercelAiProcessors(client); + + const span: SpanJSON = { + description: 'test', + span_id: 'test-span-id', + trace_id: 'test-trace-id', + start_timestamp: 1000, + timestamp: 2000, + origin: 'auto.vercelai.otel', + status, + data: {}, + }; + + const eventProcessor = client['_eventProcessors'].find(p => p.id === 'VercelAiEventProcessor'); + const processedEvent = eventProcessor!({ type: 'transaction' as const, spans: [span] }, {}); + return (processedEvent as { spans?: SpanJSON[] })?.spans?.[0]?.status; + } + + it('normalizes raw error message status to internal_error', () => { + expect(processSpan("FileNotFoundError: The file '/nonexistent/file.txt' does not exist")).toBe('internal_error'); + }); + + it('preserves ok status', () => { + expect(processSpan('ok')).toBe('ok'); + }); +}); diff --git a/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts new file mode 100644 index 000000000000..2f4415940dc8 --- /dev/null +++ b/packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { chainAndCopyPromiseLike } from '../../../src/utils/chain-and-copy-promiselike'; + +describe('chain and copy promiselike objects', () => { + it('does no copying for normal promises', async () => { + const p = new Promise(res => res(1)); + Object.assign(p, { newProperty: true }); + let success = false; + let error = false; + const q = chainAndCopyPromiseLike( + p, + () => { + success = true; + }, + () => { + error = true; + }, + ); + expect(await q).toBe(1); + //@ts-expect-error - this is not a normal prop on Promises + expect(q.newProperty).toBe(undefined); + expect(success).toBe(true); + expect(error).toBe(false); + }); + + it('copies properties of non-Promise then-ables', async () => { + class FakePromise { + value: T; + constructor(value: T) { + this.value = value; + } + then(fn: (value: T) => unknown) { + const newVal = fn(this.value); + return new FakePromise(newVal); + } + } + const p = new FakePromise(1) as PromiseLike; + Object.assign(p, { newProperty: true }); + let success = false; + let error = false; + const q = chainAndCopyPromiseLike( + p, + () => { + success = true; + }, + () => { + error = true; + }, + ); + expect(await q).toBe(1); + //@ts-expect-error - this is not a normal prop on FakePromises + expect(q.newProperty).toBe(true); + expect(success).toBe(true); + expect(error).toBe(false); + }); +}); diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index 25cd873ace08..65a55bcc9ef6 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { buildMethodPath } from '../../../src/tracing/ai/utils'; import { - buildMethodPath, getOperationName, getSpanOperation, isChatCompletionChunk, diff --git a/packages/effect/package.json b/packages/effect/package.json index c5ddd8a3a7fe..bf3455bcc9be 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -71,7 +71,7 @@ }, "devDependencies": { "@effect/vitest": "^0.23.9", - "effect": "^3.19.19" + "effect": "^3.20.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index 590502fb657e..1874fe9b0f53 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; -import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION, SentrySpan } from '@sentry/core'; +import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; import { Effect, Layer, Logger, LogLevel } from 'effect'; import { afterEach, beforeEach, vi } from 'vitest'; import * as sentryClient from '../src/index.client'; diff --git a/packages/elysia/.oxlintrc.json b/packages/elysia/.oxlintrc.json new file mode 100644 index 000000000000..d38bbcf5c769 --- /dev/null +++ b/packages/elysia/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"] +} diff --git a/packages/elysia/LICENSE b/packages/elysia/LICENSE new file mode 100644 index 000000000000..0ecae617386e --- /dev/null +++ b/packages/elysia/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/elysia/README.md b/packages/elysia/README.md new file mode 100644 index 000000000000..7565ad83ff40 --- /dev/null +++ b/packages/elysia/README.md @@ -0,0 +1,38 @@ +

+ + Sentry + +

+ +> NOTICE: This package is in alpha state and may be subject to breaking changes. + +# Official Sentry SDK for Elysia + +[![npm version](https://img.shields.io/npm/v/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) +[![npm dm](https://img.shields.io/npm/dm/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) +[![npm dt](https://img.shields.io/npm/dt/@sentry/elysia.svg)](https://www.npmjs.com/package/@sentry/elysia) + +> **Alpha**: This SDK is in alpha stage and may have breaking changes in future releases. + +## Usage + +```javascript +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; + +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +}); + +const app = Sentry.withElysia(new Elysia()) + .get('/', () => 'Hello World') + .listen(3000); +``` + +## Links + + + +- [Sentry.io](https://sentry.io/?utm_source=github&utm_medium=npm_elysia) +- [Sentry Discord Server](https://discord.gg/Ww9hbqr) diff --git a/packages/elysia/package.json b/packages/elysia/package.json new file mode 100644 index 000000000000..86d60bfe822f --- /dev/null +++ b/packages/elysia/package.json @@ -0,0 +1,81 @@ +{ + "name": "@sentry/elysia", + "version": "10.45.0", + "description": "Official Sentry SDK for Elysia", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + } + } + }, + "typesVersions": { + "<5.0": { + "build/types/index.d.ts": [ + "build/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/bun": "10.45.0", + "@sentry/core": "10.45.0" + }, + "peerDependencies": { + "elysia": "^1.4.0" + }, + "devDependencies": { + "bun-types": "^1.2.9", + "elysia": "^1.4.0" + }, + "peerDependenciesMeta": { + "elysia": { + "optional": false + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-elysia-*.tgz", + "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", + "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware", + "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", + "test": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/elysia/rollup.npm.config.mjs b/packages/elysia/rollup.npm.config.mjs new file mode 100644 index 000000000000..6aa756423128 --- /dev/null +++ b/packages/elysia/rollup.npm.config.mjs @@ -0,0 +1,3 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants(makeBaseNPMConfig({})); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts new file mode 100644 index 000000000000..8a5c5e622de1 --- /dev/null +++ b/packages/elysia/src/index.ts @@ -0,0 +1,195 @@ +// Re-export everything from @sentry/bun +export { + addEventProcessor, + addBreadcrumb, + addIntegration, + captureException, + captureEvent, + captureMessage, + captureCheckIn, + captureFeedback, + startSession, + captureSession, + endSession, + withMonitor, + createTransport, + getClient, + isInitialized, + isEnabled, + generateInstrumentOnce, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getTraceData, + getTraceMetaTags, + setCurrentClient, + Scope, + SDK_VERSION, + setContext, + setConversationId, + setExtra, + setExtras, + setTag, + setTags, + setUser, + getSpanStatusFromHttpCode, + setHttpStatus, + withScope, + withIsolationScope, + makeNodeTransport, + NodeClient, + defaultStackParser, + lastEventId, + flush, + close, + getSentryRelease, + createGetModuleFromFilename, + createLangChainCallbackHandler, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, + // eslint-disable-next-line deprecation/deprecation + anrIntegration, + // eslint-disable-next-line deprecation/deprecation + disableAnrDetectionForCallback, + consoleIntegration, + httpIntegration, + httpServerIntegration, + httpServerSpansIntegration, + nativeNodeFetchIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + openAIIntegration, + langChainIntegration, + langGraphIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + fsIntegration, + functionToStringIntegration, + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration, + eventFiltersIntegration, + linkedErrorsIntegration, + setMeasurement, + getActiveSpan, + startSpan, + startInactiveSpan, + startSpanManual, + startNewTrace, + suppressTracing, + withActiveSpan, + getRootSpan, + getSpanDescendants, + continueTrace, + getAutoPerformanceIntegrations, + cron, + parameterize, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + dataloaderIntegration, + expressIntegration, + expressErrorHandler, + setupExpressErrorHandler, + fastifyIntegration, + setupFastifyErrorHandler, + firebaseIntegration, + koaIntegration, + setupKoaErrorHandler, + connectIntegration, + setupConnectErrorHandler, + genericPoolIntegration, + graphqlIntegration, + knexIntegration, + kafkaIntegration, + lruMemoizerIntegration, + mongoIntegration, + mongooseIntegration, + mysqlIntegration, + mysql2Integration, + redisIntegration, + tediousIntegration, + postgresIntegration, + postgresJsIntegration, + prismaIntegration, + processSessionIntegration, + hapiIntegration, + setupHapiErrorHandler, + honoIntegration, + setupHonoErrorHandler, + spotlightIntegration, + initOpenTelemetry, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + trpcMiddleware, + updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, + instrumentOpenAiClient, + instrumentAnthropicAiClient, + instrumentGoogleGenAIClient, + instrumentLangGraph, + instrumentStateGraphCompile, + zodErrorsIntegration, + profiler, + amqplibIntegration, + anthropicAIIntegration, + googleGenAIIntegration, + vercelAIIntegration, + logger, + consoleLoggingIntegration, + createConsolaReporter, + createSentryWinstonTransport, + wrapMcpServerWithSentry, + featureFlagsIntegration, + launchDarklyIntegration, + growthbookIntegration, + buildLaunchDarklyFlagUsedHandler, + openFeatureIntegration, + OpenFeatureIntegrationHook, + statsigIntegration, + unleashIntegration, + metrics, + bunServerIntegration, + makeFetchTransport, +} from '@sentry/bun'; + +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + Span, + StackFrame, + Stacktrace, + Thread, + User, + FeatureFlagsIntegration, + Metric, + ExclusiveEventHintOrCaptureContext, + CaptureContext, +} from '@sentry/core'; + +export { + captureConsoleIntegration, + dedupeIntegration, + extraErrorDataIntegration, + rewriteFramesIntegration, +} from '@sentry/core'; + +export type { ElysiaOptions } from './types'; + +// Elysia-specific exports +export { withElysia } from './withElysia'; +export { getDefaultIntegrations, init } from './sdk'; diff --git a/packages/elysia/src/sdk.ts b/packages/elysia/src/sdk.ts new file mode 100644 index 000000000000..f8d8dd9d8a35 --- /dev/null +++ b/packages/elysia/src/sdk.ts @@ -0,0 +1,60 @@ +import * as os from 'node:os'; +import { + bunServerIntegration, + getDefaultIntegrations as getBunDefaultIntegrations, + makeFetchTransport, +} from '@sentry/bun'; +import type { Integration, Options } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { init as initNode, type NodeClient } from '@sentry/bun'; +import type { ElysiaOptions } from './types'; + +/** Get the default integrations for the Elysia SDK. */ +export function getDefaultIntegrations(_options: Options): Integration[] { + // Filter out bunServerIntegration + // Elysia already produces an HTTP server span, so we don't need Bun's competing root span. + return getBunDefaultIntegrations(_options).filter(i => i.name !== bunServerIntegration().name); +} + +/** + * Get the runtime name and version. + */ +function getRuntime(): { name: string; version: string } { + if (typeof Bun !== 'undefined') { + return { name: 'bun', version: Bun.version }; + } + + return { name: 'node', version: process.version }; +} + +/** + * Initializes the Sentry Elysia SDK. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/elysia'; + * + * Sentry.init({ + * dsn: '__DSN__', + * tracesSampleRate: 1.0, + * }); + * ``` + */ +export function init(userOptions: ElysiaOptions = {}): NodeClient | undefined { + const options = { + ...userOptions, + platform: 'javascript', + runtime: getRuntime(), + serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), + }; + + applySdkMetadata(userOptions, 'elysia', ['elysia', options.runtime.name]); + + options.transport = options.transport || makeFetchTransport; + + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = getDefaultIntegrations(options); + } + + return initNode(options); +} diff --git a/packages/elysia/src/types.ts b/packages/elysia/src/types.ts new file mode 100644 index 000000000000..78b4afd14e87 --- /dev/null +++ b/packages/elysia/src/types.ts @@ -0,0 +1,6 @@ +import type { BunOptions } from '@sentry/bun'; + +/** + * Configuration options for the Sentry Elysia SDK. + */ +export type ElysiaOptions = BunOptions; diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts new file mode 100644 index 000000000000..da3600ad317d --- /dev/null +++ b/packages/elysia/src/withElysia.ts @@ -0,0 +1,316 @@ +import type { Span } from '@sentry/core'; +import { + captureException, + continueTrace, + getActiveSpan, + getIsolationScope, + getRootSpan, + getTraceData, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setHttpStatus, + startInactiveSpan, + startSpanManual, + updateSpanName, + winterCGRequestToRequestData, + withIsolationScope, +} from '@sentry/core'; +import type { Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia'; + +interface ElysiaHandlerOptions { + shouldHandleError?: (context: ErrorContext) => boolean; +} + +const ELYSIA_ORIGIN = 'auto.http.elysia'; + +/** + * Map Elysia lifecycle phase names to Sentry span ops. + */ +const ELYSIA_LIFECYCLE_OP_MAP: Record = { + Request: 'middleware.elysia', + Parse: 'middleware.elysia', + Transform: 'middleware.elysia', + BeforeHandle: 'middleware.elysia', + Handle: 'request_handler.elysia', + AfterHandle: 'middleware.elysia', + MapResponse: 'middleware.elysia', + AfterResponse: 'middleware.elysia', + Error: 'middleware.elysia', +}; + +function isBun(): boolean { + return typeof Bun !== 'undefined'; +} + +/** + * Per-request storage for the root span reference. + * .wrap() captures the root span and .trace() reads it. + * This is necessary because Elysia's .trace() callbacks may run in a different + * async context where getActiveSpan() returns undefined. + */ +const rootSpanForRequest = new WeakMap(); + +const instrumentedApps = new WeakSet(); + +/** + * Updates the root span and isolation scope with the parameterized route name. + */ +function updateRouteTransactionName(request: Request, method: string, route: string): void { + const transactionName = `${method} ${route}`; + + // Try the stored root span first (reliable across async contexts), + // then fall back to getActiveSpan() for cases where async context is preserved. + const rootSpan = rootSpanForRequest.get(request); + if (rootSpan) { + updateSpanName(rootSpan, transactionName); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } else { + const activeSpan = getActiveSpan(); + if (activeSpan) { + const root = getRootSpan(activeSpan); + updateSpanName(root, transactionName); + root.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + } + + getIsolationScope().setTransactionName(transactionName); +} + +function defaultShouldHandleError(context: ErrorContext): boolean { + const status = context.set.status; + if (status === undefined) { + return true; + } + const statusCode = parseInt(String(status), 10); + if (Number.isNaN(statusCode)) { + return true; + } + // Capture server errors (5xx) and unusual status codes (<= 299 in an error handler). + // 3xx and 4xx are not captured by default (client errors / redirects). + return statusCode >= 500 || statusCode <= 299; +} + +/** + * Instruments a single Elysia lifecycle phase by creating a Sentry span for it, + * and child spans for each individual handler within the phase. + * + * @param rootSpan - The root server span to parent lifecycle spans under. + * Must be passed explicitly because Elysia's .trace() listener callbacks run + * in a different async context where getActiveSpan() returns undefined. + */ +function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, rootSpan: Span | undefined): void { + const op = ELYSIA_LIFECYCLE_OP_MAP[phaseName]; + if (!op) { + return; + } + + void listener(process => { + const phaseSpan = startInactiveSpan({ + name: phaseName, + parentSpan: rootSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + }, + }); + + // Create child spans for individual handlers within this phase. + // Named functions get their name, arrow functions get 'anonymous'. + if (process.total > 0) { + void process.onEvent(child => { + const handlerName = child.name || 'anonymous'; + const childSpan = startInactiveSpan({ + name: handlerName, + parentSpan: phaseSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + }, + }); + + void child.onStop(() => { + childSpan.end(); + }); + }); + } + + void process.onStop(() => { + phaseSpan.end(); + }); + }); +} + +/** + * Integrate Sentry with an Elysia app for error handling, request context, + * and tracing. Returns the app instance for chaining. + * + * Should be called at the **start** of the chain before defining routes. + * + * @param app The Elysia instance + * @param options Configuration options + * @returns The same Elysia instance for chaining + * + * @example + * ```javascript + * import * as Sentry from '@sentry/elysia'; + * import { Elysia } from 'elysia'; + * + * Sentry.withElysia(new Elysia()) + * .get('/', () => 'Hello World') + * .listen(3000); + * ``` + */ +export function withElysia(app: T, options: ElysiaHandlerOptions = {}): T { + if (instrumentedApps.has(app)) { + return app; + } + instrumentedApps.add(app); + + // Use .wrap() to capture or create the root span for each request. + // This is necessary because Elysia's .trace() callbacks run in a different + // async context where getActiveSpan() returns undefined. By storing the root + // span in a WeakMap keyed by the Request object, we can retrieve it in .trace(). + // HigherOrderFunction type is not exported from elysia's main entry point, + // so we type the callback parameters directly. + app.wrap((fn: (...args: unknown[]) => unknown, request: Request) => { + if (isBun()) { + // On Bun there is no HTTP instrumentation, so we create a root span ourselves. + // Scope setup must happen inside the returned function so that it's active + // when Elysia calls the handler (not during .wrap() registration). + return (...args: unknown[]) => { + return withIsolationScope(() => { + return continueTrace( + { + sentryTrace: request.headers.get('sentry-trace') || '', + baggage: request.headers.get('baggage'), + }, + () => { + return startSpanManual( + { + op: 'http.server', + name: `${request.method} ${new URL(request.url).pathname}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }, + rootSpan => { + rootSpanForRequest.set(request, rootSpan); + try { + const result = fn(...args); + if (result instanceof Promise) { + return result.then( + res => { + rootSpanForRequest.delete(request); + rootSpan.end(); + return res; + }, + err => { + rootSpanForRequest.delete(request); + rootSpan.end(); + throw err; + }, + ); + } + rootSpanForRequest.delete(request); + rootSpan.end(); + return result; + } catch (err) { + rootSpanForRequest.delete(request); + rootSpan.end(); + throw err; + } + }, + ); + }, + ); + }); + }; + } + + // On Node.js, the HTTP instrumentation already creates a root span. + // We just capture its reference so .trace() can use it. + const activeSpan = getActiveSpan(); + if (activeSpan) { + rootSpanForRequest.set(request, getRootSpan(activeSpan)); + } + return fn; + }); + + // Use .trace() ONLY for span creation. The trace API is observational — + // callbacks fire after phases complete, so they can't reliably mutate + // response headers or capture errors. All SDK logic stays in real hooks. + const traceHandler: TraceHandler = lifecycle => { + const rootSpan = rootSpanForRequest.get(lifecycle.context.request); + + const phases: [string, TraceListener][] = [ + ['Request', lifecycle.onRequest], + ['Parse', lifecycle.onParse], + ['Transform', lifecycle.onTransform], + ['BeforeHandle', lifecycle.onBeforeHandle], + ['Handle', lifecycle.onHandle], + ['AfterHandle', lifecycle.onAfterHandle], + ['MapResponse', lifecycle.onMapResponse], + ['AfterResponse', lifecycle.onAfterResponse], + ['Error', lifecycle.onError], + ]; + + for (const [phaseName, listener] of phases) { + if (listener) { + instrumentLifecyclePhase(phaseName, listener, rootSpan); + } + } + }; + + app.trace({ as: 'global' }, traceHandler); + + // SDK logic uses real lifecycle hooks — these show up as handler spans + // in the trace (named sentryOnRequest etc.), but that's the correct + // tradeoff: the trace API can't reliably mutate state or capture errors. + + app.onRequest(function sentryOnRequest(context) { + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(context.request), + }); + }); + + app.onAfterHandle({ as: 'global' }, function sentryOnAfterHandle(context) { + if (context.route) { + updateRouteTransactionName(context.request, context.request.method, context.route); + } + + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + context.set.headers['sentry-trace'] = traceData['sentry-trace']; + } + if (traceData.baggage) { + context.set.headers['baggage'] = traceData.baggage; + } + }); + + app.onError({ as: 'global' }, function sentryOnError(context) { + if (context.route) { + updateRouteTransactionName(context.request, context.request.method, context.route); + } + + // Set error status on root span + const rootSpan = rootSpanForRequest.get(context.request); + if (rootSpan) { + const statusCode = parseInt(String(context.set.status), 10); + setHttpStatus(rootSpan, Number.isNaN(statusCode) ? 500 : statusCode); + } + + const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + if (shouldHandleError(context)) { + captureException(context.error, { + mechanism: { + type: 'auto.http.elysia.on_error', + handled: false, + }, + }); + } + }); + + return app; +} diff --git a/packages/elysia/test/sdk.test.ts b/packages/elysia/test/sdk.test.ts new file mode 100644 index 000000000000..d27011f0e416 --- /dev/null +++ b/packages/elysia/test/sdk.test.ts @@ -0,0 +1,124 @@ +import type { Integration } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockApplySdkMetadata = vi.fn(); +const mockInitNode = vi.fn(); +const mockGetBunDefaultIntegrations = vi.fn(() => [] as Integration[]); +const mockMakeFetchTransport = vi.fn(); + +vi.mock('@sentry/core', async importActual => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + applySdkMetadata: mockApplySdkMetadata, + }; +}); + +vi.mock('@sentry/bun', () => ({ + init: mockInitNode, + getDefaultIntegrations: mockGetBunDefaultIntegrations, + makeFetchTransport: mockMakeFetchTransport, + bunServerIntegration: () => ({ name: 'BunServer', setupOnce: vi.fn() }), +})); + +// Must import after mocks are set up +// @ts-expect-error - dynamic import +const { init, getDefaultIntegrations } = await import('../src/sdk'); + +describe('init', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sets SDK metadata to elysia', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockApplySdkMetadata).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }), + 'elysia', + ['elysia', 'node'], + ); + }); + + it('calls initNode with the options', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + platform: 'javascript', + }), + ); + }); + + it('uses makeFetchTransport by default', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + transport: mockMakeFetchTransport, + }), + ); + }); + + it('allows overriding transport', () => { + const customTransport = vi.fn(); + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', transport: customTransport }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + transport: customTransport, + }), + ); + }); + + it('sets default integrations from bun and filters out BunServer', () => { + const mockIntegration = { name: 'MockIntegration', setupOnce: vi.fn() }; + const bunServerMock = { name: 'BunServer', setupOnce: vi.fn() }; + mockGetBunDefaultIntegrations.mockReturnValueOnce([mockIntegration, bunServerMock]); + + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + defaultIntegrations: [mockIntegration], + }), + ); + }); + + it('does not override user-provided defaultIntegrations', () => { + const userIntegrations = [{ name: 'UserIntegration', setupOnce: vi.fn() }]; + + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', defaultIntegrations: userIntegrations }); + + expect(mockInitNode).toHaveBeenCalledWith( + expect.objectContaining({ + defaultIntegrations: userIntegrations, + }), + ); + expect(mockGetBunDefaultIntegrations).not.toHaveBeenCalled(); + }); + + it('detects runtime correctly', () => { + init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' }); + + const calledOptions = mockInitNode.mock.calls[0]![0]; + // In vitest (Node), Bun is not defined, so runtime should be node + expect(calledOptions.runtime.name).toBe('node'); + expect(calledOptions.runtime.version).toBe(process.version); + }); +}); + +describe('getDefaultIntegrations', () => { + it('returns bun default integrations without BunServer', () => { + const mockIntegration = { name: 'MockIntegration', setupOnce: vi.fn() }; + const bunServerMock = { name: 'BunServer', setupOnce: vi.fn() }; + mockGetBunDefaultIntegrations.mockReturnValueOnce([mockIntegration, bunServerMock]); + + const integrations = getDefaultIntegrations({}); + + expect(integrations).toEqual([mockIntegration]); + expect(mockGetBunDefaultIntegrations).toHaveBeenCalledWith({}); + }); +}); diff --git a/packages/elysia/test/withElysia.test.ts b/packages/elysia/test/withElysia.test.ts new file mode 100644 index 000000000000..3f73d9e5d835 --- /dev/null +++ b/packages/elysia/test/withElysia.test.ts @@ -0,0 +1,178 @@ +import type { ErrorContext } from 'elysia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture handlers registered by withElysia +let onAfterHandleHandler: (context: unknown) => void; +let onErrorHandler: (context: unknown) => void; + +function createMockApp() { + const app: Record = {}; + app.use = vi.fn().mockReturnValue(app); + app.wrap = vi.fn().mockReturnValue(app); + app.trace = vi.fn().mockReturnValue(app); + app.onRequest = vi.fn(() => app); + app.onAfterHandle = vi.fn((_opts: unknown, handler: (context: unknown) => void) => { + onAfterHandleHandler = handler; + return app; + }); + app.onError = vi.fn((_opts: unknown, handler: (context: unknown) => void) => { + onErrorHandler = handler; + return app; + }); + return app; +} + +let mockApp: ReturnType; + +const mockCaptureException = vi.fn(); +const mockGetIsolationScope = vi.fn(() => ({ + setSDKProcessingMetadata: vi.fn(), + setTransactionName: vi.fn(), +})); +const mockGetClient = vi.fn(() => ({ + on: vi.fn(), +})); +const mockGetTraceData = vi.fn(() => ({ + 'sentry-trace': 'abc123-def456-1', + baggage: 'sentry-environment=test,sentry-trace_id=abc123', +})); + +vi.mock('@sentry/core', async importActual => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + captureException: (...args: unknown[]) => mockCaptureException(...args), + getIsolationScope: () => mockGetIsolationScope(), + getClient: () => mockGetClient(), + getTraceData: () => mockGetTraceData(), + }; +}); + +// @ts-expect-error - dynamic import after mocks +const { withElysia } = await import('../src/withElysia'); + +describe('withElysia', () => { + beforeEach(() => { + mockApp = createMockApp(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('registers .wrap(), .trace(), and lifecycle hooks', () => { + // @ts-expect-error - mock app + withElysia(mockApp); + expect(mockApp.wrap).toHaveBeenCalledWith(expect.any(Function)); + expect(mockApp.trace).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); + expect(mockApp.onRequest).toHaveBeenCalled(); + expect(mockApp.onAfterHandle).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); + expect(mockApp.onError).toHaveBeenCalledWith({ as: 'global' }, expect.any(Function)); + }); + + it('returns the app instance for chaining', () => { + // @ts-expect-error - mock app + const result = withElysia(mockApp); + expect(result).toBe(mockApp); + }); + + describe('response trace headers', () => { + it('injects sentry-trace and baggage into response headers', () => { + // @ts-expect-error - mock app + withElysia(mockApp); + const headers: Record = {}; + onAfterHandleHandler({ set: { headers } }); + + expect(headers['sentry-trace']).toBe('abc123-def456-1'); + expect(headers['baggage']).toBe('sentry-environment=test,sentry-trace_id=abc123'); + }); + + it('does not set headers when trace data is empty', () => { + mockGetTraceData.mockReturnValueOnce({}); + // @ts-expect-error - mock app + withElysia(mockApp); + const headers: Record = {}; + onAfterHandleHandler({ set: { headers } }); + + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['baggage']).toBeUndefined(); + }); + }); + + describe('defaultShouldHandleError', () => { + function triggerError(status: number | string | undefined): void { + // @ts-expect-error - mock app + withElysia(mockApp); + onErrorHandler({ + route: '/test', + request: { method: 'GET' }, + error: new Error('test'), + set: { status }, + } as unknown as ErrorContext); + } + + it('captures errors with status >= 500', () => { + triggerError(500); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with status 503', () => { + triggerError(503); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with undefined status', () => { + triggerError(undefined); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('captures errors with status <= 299 (unusual in error handler)', () => { + triggerError(200); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('does not capture 4xx errors', () => { + triggerError(400); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not capture 404 errors', () => { + triggerError(404); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not capture 3xx responses', () => { + triggerError(302); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('handles string status codes', () => { + triggerError('500'); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('does not capture string 4xx status codes', () => { + triggerError('400'); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); + + describe('custom shouldHandleError', () => { + it('uses custom shouldHandleError when provided', () => { + const customShouldHandle = vi.fn(() => false); + // @ts-expect-error - mock app + withElysia(mockApp, { shouldHandleError: customShouldHandle }); + + onErrorHandler({ + route: '/test', + request: { method: 'GET' }, + error: new Error('test'), + set: { status: 500 }, + } as unknown as ErrorContext); + + expect(customShouldHandle).toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/elysia/tsconfig.json b/packages/elysia/tsconfig.json new file mode 100644 index 000000000000..dcbef254b942 --- /dev/null +++ b/packages/elysia/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + // package-specific options + "types": ["bun-types"] + } +} diff --git a/packages/elysia/tsconfig.test.json b/packages/elysia/tsconfig.test.json new file mode 100644 index 000000000000..4cbbffaccbbc --- /dev/null +++ b/packages/elysia/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["bun-types"] + + // other package-specific, test-specific options + } +} diff --git a/packages/elysia/tsconfig.types.json b/packages/elysia/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/elysia/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/ember/package.json b/packages/ember/package.json index 9d1821b625d6..6311e478c5f1 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -75,7 +75,7 @@ "eslint-plugin-qunit": "8.0.0", "loader.js": "~4.7.0", "qunit": "~2.22.0", - "qunit-dom": "~3.2.1", + "qunit-dom": "~3.5.0", "sinon": "21.0.1", "webpack": "~5.104.1" }, diff --git a/packages/google-cloud-serverless/src/sdk.ts b/packages/google-cloud-serverless/src/sdk.ts index 1161ab60300e..6eb80aed2f64 100644 --- a/packages/google-cloud-serverless/src/sdk.ts +++ b/packages/google-cloud-serverless/src/sdk.ts @@ -29,7 +29,7 @@ export function init(options: NodeOptions = {}): NodeClient | undefined { ...options, }; - applySdkMetadata(opts, 'google-cloud-serverless'); + applySdkMetadata(opts, 'google-cloud-serverless', ['google-cloud-serverless', 'node']); return initNode(opts); } diff --git a/packages/google-cloud-serverless/test/sdk.test.ts b/packages/google-cloud-serverless/test/sdk.test.ts index 5b41bc4abb21..553911ead646 100644 --- a/packages/google-cloud-serverless/test/sdk.test.ts +++ b/packages/google-cloud-serverless/test/sdk.test.ts @@ -31,6 +31,10 @@ describe('init()', () => { name: 'npm:@sentry/google-cloud-serverless', version: expect.any(String), }, + { + name: 'npm:@sentry/node', + version: expect.any(String), + }, ], version: expect.any(String), }, diff --git a/packages/nestjs/src/sdk.ts b/packages/nestjs/src/sdk.ts index 733cb935003c..f72735af45e7 100644 --- a/packages/nestjs/src/sdk.ts +++ b/packages/nestjs/src/sdk.ts @@ -18,7 +18,7 @@ export function init(options: NodeOptions | undefined = {}): NodeClient | undefi ...options, }; - applySdkMetadata(opts, 'nestjs'); + applySdkMetadata(opts, 'nestjs', ['nestjs', 'node']); const client = nodeInit(opts); diff --git a/packages/nestjs/test/sdk.test.ts b/packages/nestjs/test/sdk.test.ts index 1692c9be6fdd..5def451d6415 100644 --- a/packages/nestjs/test/sdk.test.ts +++ b/packages/nestjs/test/sdk.test.ts @@ -20,7 +20,10 @@ describe('Initialize Nest SDK', () => { _metadata: { sdk: { name: 'sentry.javascript.nestjs', - packages: [{ name: 'npm:@sentry/nestjs', version: SDK_VERSION }], + packages: [ + { name: 'npm:@sentry/nestjs', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], version: SDK_VERSION, }, }, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 166ecede87d5..afdd4994ca6a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -88,7 +88,7 @@ "@sentry/vercel-edge": "10.45.0", "@sentry/webpack-plugin": "^5.1.0", "rollup": "^4.35.0", - "stacktrace-parser": "^0.1.10" + "stacktrace-parser": "^0.1.11" }, "devDependencies": { "eslint-plugin-react": "^7.31.11", diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index 31624e3bffc6..2d933373578d 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -138,7 +138,6 @@ describe('dropMiddlewareTunnelRequests', () => { describe('skipOpenTelemetrySetup', () => { it('does not process spans when skipOpenTelemetrySetup is true', async () => { const core = await import('@sentry/core'); - const originalGetClient = core.getClient; vi.spyOn(core, 'getClient').mockReturnValueOnce({ getOptions: () => ({ skipOpenTelemetrySetup: true }), } as any); diff --git a/packages/node-core/src/integrations/systemError.ts b/packages/node-core/src/integrations/systemError.ts index f1fd3f4db0dc..14c0c23ffa54 100644 --- a/packages/node-core/src/integrations/systemError.ts +++ b/packages/node-core/src/integrations/systemError.ts @@ -46,7 +46,7 @@ export const systemErrorIntegration = defineIntegration((options: Options = {}) const error = hint.originalException; const errorContext: SystemErrorContext = { - ...error, + ...(error as SystemErrorContext), }; if (!client.getOptions().sendDefaultPii && options.includePaths !== true) { diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index ff1ac4d757d1..322dc969ff0d 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -45,7 +45,13 @@ "access": "public" }, "peerDependencies": { - "nuxt": ">=3.7.0 || 4.x" + "nuxt": ">=3.7.0 || 4.x || 5.x", + "nitro": "2.x || 3.x" + }, + "peerDependenciesMeta": { + "nitro": { + "optional": true + } }, "dependencies": { "@nuxt/kit": "^3.13.2", @@ -56,11 +62,13 @@ "@sentry/node-core": "10.45.0", "@sentry/rollup-plugin": "^5.1.1", "@sentry/vite-plugin": "^5.1.0", - "@sentry/vue": "10.45.0" + "@sentry/vue": "10.45.0", + "local-pkg": "^1.1.2" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", "@nuxt/nitro-server": "^3.21.1", + "nitro": "^3.0.260311-beta", "nuxi": "^3.25.1", "nuxt": "3.17.7", "vite": "^5.4.11" diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 55656e103738..0c1e43031742 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,7 +15,7 @@ import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; -import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; +import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -28,7 +28,7 @@ export default defineNuxtModule({ }, }, defaults: {}, - setup(moduleOptionsParam, nuxt) { + async setup(moduleOptionsParam, nuxt) { if (moduleOptionsParam?.enabled === false) { return; } @@ -78,21 +78,33 @@ export default defineNuxtModule({ } const serverConfigFile = findDefaultSdkInitFile('server', nuxt); + const isNitroV3 = (await getNitroMajorVersion()) >= 3; if (serverConfigFile) { + if (isNitroV3) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + } + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server', }); + + // Preps the middleware instrumentation module. + addMiddlewareImports(); + addStorageInstrumentation(nuxt, !isNitroV3); + addDatabaseInstrumentation(nuxt.options.nitro, !isNitroV3, moduleOptions); } if (clientConfigFile || serverConfigFile) { setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } - addOTelCommonJSImportAlias(nuxt); + addOTelCommonJSImportAlias(nuxt, isNitroV3); const pagesDataTemplate = addTemplate({ filename: 'sentry--nuxt-pages-data.mjs', @@ -114,13 +126,6 @@ export default defineNuxtModule({ }; }); - // Preps the the middleware instrumentation module. - if (serverConfigFile) { - addMiddlewareImports(); - addStorageInstrumentation(nuxt); - addDatabaseInstrumentation(nuxt.options.nitro, moduleOptions); - } - // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { const tsConfig = options.tsConfig as { include?: string[] }; @@ -146,7 +151,7 @@ export default defineNuxtModule({ return; } - if (serverConfigFile) { + if (serverConfigFile && !isNitroV3) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/runtime/plugins/database-legacy.server.ts b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts new file mode 100644 index 000000000000..fc9dca7c964c --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts @@ -0,0 +1,13 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useDatabase } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; + +/** + * Nitro plugin that instruments database calls for Nuxt v3/v4 (Nitro v2) + */ +export default (() => { + createDatabasePlugin(useDatabase, databaseConfig as Record); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index ffdc1fceba18..71c4b6011dae 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -1,232 +1,13 @@ -import { - addBreadcrumb, - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - type Span, - SPAN_STATUS_ERROR, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { Database, PreparedStatement } from 'db0'; -import type { NitroAppPlugin } from 'nitropack'; -import { useDatabase } from 'nitropack/runtime'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useDatabase } from 'nitro/database'; // @ts-expect-error - This is a virtual module import { databaseConfig } from '#sentry/database-config.mjs'; -import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; - -type MaybeInstrumentedDatabase = Database & { - __sentry_instrumented__?: boolean; -}; - -/** - * Keeps track of prepared statements that have been patched. - */ -const patchedStatement = new WeakSet(); +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; /** - * The Sentry origin for the database plugin. - */ -const SENTRY_ORIGIN = 'auto.db.nuxt'; - -/** - * Creates a Nitro plugin that instruments the database calls. + * Nitro plugin that instruments database calls for Nuxt v5+ (Nitro v3+) */ export default (() => { - try { - const _databaseConfig = databaseConfig as Record; - const databaseInstances = Object.keys(databaseConfig); - debug.log('[Nitro Database Plugin]: Instrumenting databases...'); - - for (const instance of databaseInstances) { - debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); - const db = useDatabase(instance); - instrumentDatabase(db, _databaseConfig[instance]); - } - - debug.log('[Nitro Database Plugin]: Databases instrumented.'); - } catch (error) { - // During build time, we can't use the useDatabase function, so we just log an error. - if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { - debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); - return; - } - - debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); - } + createDatabasePlugin(useDatabase, databaseConfig as Record); }) satisfies NitroAppPlugin; - -/** - * Instruments a database instance with Sentry. - */ -function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { - if (db.__sentry_instrumented__) { - debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); - return; - } - - const metadata: DatabaseSpanData = { - 'db.system.name': config?.connector ?? db.dialect, - ...getDatabaseSpanData(config), - }; - - db.prepare = new Proxy(db.prepare, { - apply(target, thisArg, args: Parameters) { - const [query] = args; - - return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); - }, - }); - - // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly - // So we have to patch it manually, and would mean we would have less info in the spans. - // https://github.com/unjs/db0/blob/main/src/database.ts#L64 - db.sql = new Proxy(db.sql, { - apply(target, thisArg, args: Parameters) { - const query = args[0]?.[0] ?? ''; - const opts = createStartSpanOptions(query, metadata); - - return startSpan( - opts, - handleSpanStart(() => target.apply(thisArg, args)), - ); - }, - }); - - db.exec = new Proxy(db.exec, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(args[0], metadata), - handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), - ); - }, - }); - - db.__sentry_instrumented__ = true; -} - -/** - * Instruments a DB prepared statement with Sentry. - * - * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` - * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. - */ -function instrumentPreparedStatement( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.bind = new Proxy(statement.bind, { - apply(target, thisArg, args: Parameters) { - return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); - }, - }); - - return instrumentPreparedStatementQueries(statement, query, data); -} - -/** - * Patches the query methods of a DB prepared statement with Sentry. - */ -function instrumentPreparedStatementQueries( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - if (patchedStatement.has(statement)) { - return statement; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.get = new Proxy(statement.get, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.run = new Proxy(statement.run, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.all = new Proxy(statement.all, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - patchedStatement.add(statement); - - return statement; -} - -/** - * Creates a span start callback handler - */ -function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { - return async (span: Span) => { - try { - const result = await fn(); - if (breadcrumbOpts) { - createBreadcrumb(breadcrumbOpts.query); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: SENTRY_ORIGIN, - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }; -} - -function createBreadcrumb(query: string): void { - addBreadcrumb({ - category: 'query', - message: query, - data: { - 'db.query.text': query, - }, - }); -} - -/** - * Creates a start span options object. - */ -function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { - return { - name: query, - attributes: { - 'db.query.text': query, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - ...data, - }, - }; -} diff --git a/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts b/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts new file mode 100644 index 000000000000..3cfa5ad0e13a --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/handler-legacy.server.ts @@ -0,0 +1,7 @@ +import type { EventHandler } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { patchEventHandler } from '../utils/patchEventHandler'; + +export default (nitroApp => { + nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/handler.server.ts b/packages/nuxt/src/runtime/plugins/handler.server.ts new file mode 100644 index 000000000000..a919786e39c7 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/handler.server.ts @@ -0,0 +1,13 @@ +import type { EventHandler } from 'nitro/h3'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; +import { patchEventHandler } from '../utils/patchEventHandler'; + +/** + * This plugin patches the h3 event handler for Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + if (nitroApp?.h3?.handler) { + // oxlint-disable-next-line @typescript-eslint/unbound-method + nitroApp.h3.handler = patchEventHandler(nitroApp.h3.handler); + } +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 5625c2f571b2..7ea91e36cf25 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,11 +1,5 @@ -import { - debug, - flushIfServerless, - getDefaultIsolationScope, - getIsolationScope, - withIsolationScope, -} from '@sentry/core'; -import type { EventHandler, H3Event } from 'h3'; +import { debug } from '@sentry/core'; +import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; @@ -13,19 +7,24 @@ import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); - nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - const headers = event.node.res?.getHeaders() || {}; + // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined + const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; + + // h3 v2 (Nuxt 5): response headers are on event.res.headers + const isPreRenderedPage = + Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') || + // fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value. + // oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access + !!(event as any).res?.headers?.has?.('x-nitro-prerender'); - const isPreRenderedPage = Object.keys(headers).includes('x-nitro-prerender'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isSWRCachedPage = event?.context?.cache?.options.swr as boolean | undefined; + // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access + const isSWRCachedPage = event?.context?.cache?.options?.swr as boolean | undefined; if (!isPreRenderedPage && !isSWRCachedPage) { addSentryTracingMetaTags(html.head); @@ -37,26 +36,3 @@ export default (nitroApp => { } }); }) satisfies NitroAppPlugin; - -function patchEventHandler(handler: EventHandler): EventHandler { - return new Proxy(handler, { - async apply(handlerTarget, handlerThisArg, handlerArgs: Parameters) { - const isolationScope = getIsolationScope(); - const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; - - debug.log( - `Patched h3 event handler. ${ - isolationScope === newIsolationScope ? 'Using existing' : 'Created new' - } isolation scope.`, - ); - - return withIsolationScope(newIsolationScope, async () => { - try { - return await handlerTarget.apply(handlerThisArg, handlerArgs); - } finally { - await flushIfServerless(); - } - }); - }, - }); -} diff --git a/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts new file mode 100644 index 000000000000..77b6a390d0e9 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts @@ -0,0 +1,12 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useStorage } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; +import { createStoragePlugin } from '../utils/instrumentStorage'; + +/** + * Nitro plugin that instruments storage driver calls for Nuxt v3/v4 (Nitro v2) + */ +export default (async _nitroApp => { + await createStoragePlugin(useStorage, userStorageMounts as string[]); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts index 1c5a3fd678d4..f9763b51d47b 100644 --- a/packages/nuxt/src/runtime/plugins/storage.server.ts +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -1,314 +1,12 @@ -import { - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - type SpanAttributes, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { NitroAppPlugin } from 'nitropack'; -import { useStorage } from 'nitropack/runtime'; -import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; -import type { Driver, Storage } from 'unstorage'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useStorage } from 'nitro/storage'; // @ts-expect-error - This is a virtual module import { userStorageMounts } from '#sentry/storage-config.mjs'; - -type MaybeInstrumented = T & { - __sentry_instrumented__?: boolean; -}; - -type MaybeInstrumentedDriver = MaybeInstrumented; - -type DriverMethod = keyof Driver; +import { createStoragePlugin } from '../utils/instrumentStorage'; /** - * Methods that should have a attribute to indicate a cache hit. - */ -const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); - -/** - * Creates a Nitro plugin that instruments the storage driver. + * Nitro plugin that instruments storage driver calls for Nuxt v5+ (Nitro v3+) */ export default (async _nitroApp => { - // This runs at runtime when the Nitro server starts - const storage = useStorage(); - // Mounts are suffixed with a colon, so we need to add it to the set items - const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); - - debug.log('[storage] Starting to instrument storage drivers...'); - - // Adds cache mount to handle Nitro's cache calls - // Nitro uses the mount to cache functions and event handlers - // https://nitro.build/guide/cache - userMounts.add('cache:'); - // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. - // Either way, we need to instrument the root mount as well. - userMounts.add(''); - - // Get all mounted storage drivers - const mounts = storage.getMounts(); - for (const mount of mounts) { - // Skip excluded mounts and root mount - if (!userMounts.has(mount.base)) { - continue; - } - - instrumentDriver(mount.driver, mount.base); - } - - // Wrap the mount method to instrument future mounts - storage.mount = wrapStorageMount(storage); + await createStoragePlugin(useStorage, userStorageMounts as string[]); }) satisfies NitroAppPlugin; - -/** - * Instruments a driver by wrapping all method calls using proxies. - */ -function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { - // Already instrumented, skip... - if (driver.__sentry_instrumented__) { - debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); - - return driver; - } - - debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); - - // List of driver methods to instrument - // get/set/remove are aliases and already use their {method}Item methods - const methodsToInstrument: DriverMethod[] = [ - 'hasItem', - 'getItem', - 'getItemRaw', - 'getItems', - 'setItem', - 'setItemRaw', - 'setItems', - 'removeItem', - 'getKeys', - 'clear', - ]; - - for (const methodName of methodsToInstrument) { - const original = driver[methodName]; - // Skip if method doesn't exist on this driver - if (typeof original !== 'function') { - continue; - } - - // Replace with instrumented - driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); - } - - // Mark as instrumented - driver.__sentry_instrumented__ = true; - - return driver; -} - -/** - * Creates an instrumented method for the given method. - */ -function createMethodWrapper( - original: (...args: unknown[]) => unknown, - methodName: DriverMethod, - driver: Driver, - mountBase: string, -): (...args: unknown[]) => unknown { - return new Proxy(original, { - async apply(target, thisArg, args) { - const options = createSpanStartOptions(methodName, driver, mountBase, args); - - debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); - - return startSpan(options, async span => { - try { - const result = await target.apply(thisArg, args); - span.setStatus({ code: SPAN_STATUS_OK }); - - if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }); - }, - }); -} - -/** - * Wraps the storage mount method to instrument the driver. - */ -function wrapStorageMount(storage: Storage): Storage['mount'] { - const original: MaybeInstrumented = storage.mount; - if (original.__sentry_instrumented__) { - return original; - } - - function mountWithInstrumentation(base: string, driver: Driver): Storage { - debug.log(`[storage] Instrumenting mount: "${base}"`); - - const instrumentedDriver = instrumentDriver(driver, base); - - return original(base, instrumentedDriver); - } - - mountWithInstrumentation.__sentry_instrumented__ = true; - - return mountWithInstrumentation; -} -/** - * Normalizes the method name to snake_case to be used in span names or op. - */ -function normalizeMethodName(methodName: string): string { - return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); -} - -/** - * Checks if the value is empty, used for cache hit detection. - */ -function isEmptyValue(value: unknown): value is null | undefined { - return value === null || value === undefined; -} - -/** - * Creates the span start options for the storage method. - */ -function createSpanStartOptions( - methodName: keyof Driver, - driver: Driver, - mountBase: string, - args: unknown[], -): StartSpanOptions { - const keys = getCacheKeys(args?.[0], mountBase); - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], - 'db.operation.name': methodName, - 'db.collection.name': mountBase.replace(/:$/, ''), - 'db.system.name': driver.name ?? 'unknown', - }; - - return { - name: keys.join(', '), - attributes, - }; -} - -/** - * Gets a normalized array of cache keys. - */ -function getCacheKeys(key: unknown, prefix: string): string[] { - // Handles an array of keys - if (Array.isArray(key)) { - return key.map(k => normalizeKey(k, prefix)); - } - - return [normalizeKey(key, prefix)]; -} - -/** - * Normalizes the key to a string for `cache.key` attribute. - */ -function normalizeKey(key: unknown, prefix: string): string { - if (typeof key === 'string') { - return `${prefix}${key}`; - } - - // Handles an object with a key property - if (typeof key === 'object' && key !== null && 'key' in key) { - return `${prefix}${key.key}`; - } - - return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; -} - -const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; - -/** - * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. - * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. - * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. - * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. - */ -function isCacheHit(key: string, value: unknown): boolean { - try { - const isEmpty = isEmptyValue(value); - // Empty value means no cache hit either way - // Or if key doesn't match the cached function or handler patterns, we can return the empty value check - if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { - return !isEmpty; - } - - return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); - } catch { - // this is a best effort, so we return false if we can't validate the cache entry - return false; - } -} - -/** - * Validates the cache entry. - */ -function validateCacheEntry( - key: string, - entry: CacheEntry | CacheEntry, -): boolean { - if (isEmptyValue(entry.value)) { - return false; - } - - // Date.now is used by Nitro internally, so safe to use here. - // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 - if (Date.now() > (entry.expires || 0)) { - return false; - } - - /** - * Pulled from Nitro's cache entry validation - * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 - */ - if (isResponseCacheEntry(key, entry)) { - if (entry.value.status >= 400) { - return false; - } - - if (entry.value.body === undefined) { - return false; - } - - if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { - return false; - } - } - - return true; -} - -/** - * Checks if the cache entry is a response cache entry. - */ -function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { - return key.startsWith('nitro:handlers:'); -} diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 2c3526b951c9..5a8e9c3db701 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,6 +1,5 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; -import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; @@ -69,7 +68,8 @@ export function reportNuxtError(options: { if (instance?.$props) { const sentryClient = getClient(); - const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & VueOptions) : null; + // `attachProps` is defined in the Vue integration options, but the type is not exported from @sentry/vue, as it's only used internally. + const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & { attachProps: boolean }) : null; // `attachProps` is enabled by default and props should only not be attached if explicitly disabled (see DEFAULT_CONFIG in `vueIntegration`). // oxlint-disable-next-line typescript/no-unsafe-member-access diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts index e5d9c8dc7cec..d69368c92e1e 100644 --- a/packages/nuxt/src/runtime/utils/database-span-data.ts +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -1,14 +1,28 @@ import type { ConnectorName } from 'db0'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; export interface DatabaseSpanData { [key: string]: string | number | undefined; } +/** + * A minimal database connection configuration type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `DatabaseConnectionConfig` from both packages. + */ +export interface DatabaseConnectionConfig { + connector?: ConnectorName; + options?: { + host?: string; + port?: number; + dataDir?: string; + name?: string; + [key: string]: unknown; + }; +} + /** * Extracts span attributes from the database configuration. */ -export function getDatabaseSpanData(config?: DatabaseConfig): Partial { +export function getDatabaseSpanData(config?: DatabaseConnectionConfig): Partial { try { if (!config?.connector) { // Default to SQLite if no connector is configured diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts new file mode 100644 index 000000000000..9f7d320fe390 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -0,0 +1,232 @@ +import { + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + SPAN_STATUS_ERROR, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates the Nitro database plugin setup by instrumenting the configured database instances. + * + * Called from the version-specific plugin entry points (database.server.ts / database-legacy.server.ts) + * which supply the correct `useDatabase` import for their respective Nitro version. + */ +export function createDatabasePlugin( + useDatabase: (name: string) => Database, + databaseConfig: Record, +): void { + try { + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +} + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConnectionConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler. + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts new file mode 100644 index 000000000000..e51666aba79b --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -0,0 +1,340 @@ +import { + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + type SpanAttributes, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Driver, Storage } from 'unstorage'; + +/** + * A minimal cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `CacheEntry` from both packages. + */ +interface CacheEntry { + value?: T; + expires?: number; +} + +/** + * A minimal response cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `ResponseCacheEntry` from both packages. + */ +interface ResponseCacheEntry { + status?: number; + body?: unknown; + headers?: Record; +} + +/** + * The Nitro-specific Storage interface that extends unstorage's Storage with `getMounts`. + * Both nitropack and nitro expose a storage with this shape at runtime. + */ +interface NitroStorage extends Storage { + getMounts(): Array<{ base: string; driver: Driver }>; +} + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have an attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates the Nitro storage plugin setup by instrumenting all relevant storage drivers. + * + * The `useStorage` parameter is typed as `() => unknown` because nitropack (Nitro v2) and nitro (Nitro v3+) define `StorageValue` differently. + * Cast to `NitroStorage` is safe since all Nitro versions expose `getMounts()` at runtime. + */ +export async function createStoragePlugin(useStorage: () => unknown, userStorageMounts: string[]): Promise { + // This runs at runtime when the Nitro server starts + const storage = useStorage() as NitroStorage; + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set(userStorageMounts.map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +} + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver on future mounts. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} + +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: unknown, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || typeof key !== 'string' || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if ((entry.value.status ?? 0) >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers?.etag === 'undefined' || entry.value.headers?.['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/runtime/utils/patchEventHandler.ts b/packages/nuxt/src/runtime/utils/patchEventHandler.ts new file mode 100644 index 000000000000..41625012c10c --- /dev/null +++ b/packages/nuxt/src/runtime/utils/patchEventHandler.ts @@ -0,0 +1,35 @@ +import { + debug, + flushIfServerless, + getDefaultIsolationScope, + getIsolationScope, + withIsolationScope, +} from '@sentry/core'; + +/** + * Patches the H3 event handler of Nitro. + * + * Uses a TypeScript generic type to ensure the returned handler type fits different versions of Nitro. + */ +export function patchEventHandler(handler: H3EventHandler): H3EventHandler { + return new Proxy(handler, { + async apply(handlerTarget, handlerThisArg, handlerArgs: unknown) { + const isolationScope = getIsolationScope(); + const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; + + debug.log( + `Patched h3 event handler. ${ + isolationScope === newIsolationScope ? 'Using existing' : 'Created new' + } isolation scope.`, + ); + + return withIsolationScope(newIsolationScope, async () => { + try { + return await handlerTarget.apply(handlerThisArg, handlerArgs); + } finally { + await flushIfServerless(); + } + }); + }, + }); +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index c806a8f662c2..b025157339b3 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -7,7 +7,11 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Sets up the database instrumentation. */ -export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: SentryNuxtModuleOptions): void { +export function addDatabaseInstrumentation( + nitro: NitroConfig, + isLegacyNitro: boolean, + moduleOptions?: SentryNuxtModuleOptions, +): void { if (!nitro.experimental?.database) { // We cannot use DEBUG_BUILD here because it is a runtime flag, so it is not available for build time scripts // So we have to pass in the module options to the build time script @@ -38,5 +42,9 @@ export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: S }, }); - addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + if (isLegacyNitro) { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); + } else { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + } } diff --git a/packages/nuxt/src/vite/sentryVitePlugin.ts b/packages/nuxt/src/vite/sentryVitePlugin.ts index f301a17c9423..31ef7c97465d 100644 --- a/packages/nuxt/src/vite/sentryVitePlugin.ts +++ b/packages/nuxt/src/vite/sentryVitePlugin.ts @@ -1,8 +1,7 @@ import type { Nuxt } from '@nuxt/schema'; -import { sentryVitePlugin } from '@sentry/vite-plugin'; import type { ConfigEnv, Plugin, UserConfig } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { extractNuxtSourceMapSetting, getPluginOptions, validateDifferentSourceMapSettings } from './sourceMaps'; +import { extractNuxtSourceMapSetting, validateDifferentSourceMapSettings } from './sourceMaps'; /** * Creates a Vite plugin that adds the Sentry Vite plugin and validates source map settings. diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index f4f4004d1b50..393f8b12e59b 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -5,7 +5,7 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Prepares the storage config export to be used in the runtime storage instrumentation. */ -export function addStorageInstrumentation(nuxt: Nuxt): void { +export function addStorageInstrumentation(nuxt: Nuxt, isLegacyNitro: boolean): void { const moduleDirResolver = createResolver(import.meta.url); const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); @@ -17,5 +17,9 @@ export function addStorageInstrumentation(nuxt: Nuxt): void { }, }); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + if (isLegacyNitro) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + } } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 6b1092a952bc..86eafaae2c9b 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -3,6 +3,24 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Gets the major version of the installed nitro package. + * Returns 2 as the default if nitro is not found or the version cannot be determined. + */ +export async function getNitroMajorVersion(): Promise { + try { + const { getPackageInfo } = await import('local-pkg'); + const info = await getPackageInfo('nitro'); + if (info?.version) { + const major = parseInt(info.version.split('.')[0] ?? '2', 10); + return isNaN(major) ? 2 : major; + } + } catch { + // If local-pkg is unavailable or nitro is not found, default to v2 + } + return 2; +} + /** * Find the default SDK init file for the given type (client or server). * The sentry.server.config file is prioritized over the instrument.server file. @@ -190,8 +208,8 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string * * @see https://nuxt.com/docs/guide/concepts/esm#aliasing-libraries */ -export function addOTelCommonJSImportAlias(nuxt: Nuxt): void { - if (!nuxt.options.dev) { +export function addOTelCommonJSImportAlias(nuxt: Nuxt, isNitroV3 = false): void { + if (!nuxt.options.dev || isNitroV3) { return; } diff --git a/packages/nuxt/test/vite/databaseConfig.test.ts b/packages/nuxt/test/vite/databaseConfig.test.ts index 4d95fc7a4df0..e987ab3984a5 100644 --- a/packages/nuxt/test/vite/databaseConfig.test.ts +++ b/packages/nuxt/test/vite/databaseConfig.test.ts @@ -34,7 +34,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', @@ -45,7 +45,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: false }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -53,7 +53,7 @@ describe('addDatabaseInstrumentation', () => { it('should not log debug message when moduleOptions is undefined', () => { const nitroConfig: NitroConfig = {}; - addDatabaseInstrumentation(nitroConfig, undefined); + addDatabaseInstrumentation(nitroConfig, false, undefined); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -62,7 +62,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = {}; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -71,7 +71,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = { experimental: { database: false } }; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index be33cf500397..e0b4956219c3 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -112,7 +112,7 @@ describe('setupSourceMaps hooks', () => { it.each([ { label: 'server (SSR) build', buildConfig: { build: { ssr: true }, plugins: [] } }, { label: 'client build', buildConfig: { build: { ssr: false }, plugins: [] } }, - ])('adds sentry vite plugin to vite config for $label in production', async ({ buildConfig }) => { + ])('adds sentry vite plugin to vite config for $label in production', async () => { const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); const { mockAddVitePlugin, getCapturedPlugins } = createMockAddVitePlugin(); diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 4911a06b6f2f..2be73259305a 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -427,4 +427,14 @@ describe('addOTelCommonJSImportAlias', () => { expect(nuxtMock.options.alias).toBeUndefined(); }); + + it('does not add alias when in Nitro v3+ (Rolldown incompatibility)', () => { + const nuxtMock: Nuxt = { + options: { dev: true }, + } as unknown as Nuxt; + + addOTelCommonJSImportAlias(nuxtMock, true); + + expect(nuxtMock.options.alias).toBeUndefined(); + }); }); diff --git a/packages/nuxt/tsconfig.json b/packages/nuxt/tsconfig.json index de9c931f2cd1..5766b891dd95 100644 --- a/packages/nuxt/tsconfig.json +++ b/packages/nuxt/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { // package-specific options - "module": "esnext" + "module": "esnext", + "moduleResolution": "bundler" } } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 22175d6f4bca..626c0eeb1240 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@react-router/dev": "^7.13.0", - "@react-router/node": "^7.13.0", + "@react-router/node": "^7.13.1", "react": "^18.3.1", "react-router": "^7.13.0", "vite": "^6.1.0" diff --git a/packages/remix/package.json b/packages/remix/package.json index 5360d8acd014..7008e0a642dc 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -72,7 +72,6 @@ "@sentry/core": "10.45.0", "@sentry/node": "10.45.0", "@sentry/react": "10.45.0", - "glob": "^13.0.6", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/scripts/deleteSourcemaps.js b/packages/remix/scripts/deleteSourcemaps.js index 82a00b5b0f92..47ec7f8bfd20 100644 --- a/packages/remix/scripts/deleteSourcemaps.js +++ b/packages/remix/scripts/deleteSourcemaps.js @@ -2,13 +2,49 @@ const fs = require('fs'); const path = require('path'); -const { globSync } = require('glob'); +/** + * Recursively walks a directory and returns relative paths of all files + * matching the given extension. + * + * Uses manual recursion instead of `fs.readdirSync({ recursive: true, withFileTypes: true })` + * to avoid a bug in Node 18.17–18.18 where `withFileTypes` returns incorrect `parentPath` values + * when combined with `recursive: true`. + * + * @param {string} rootDir - The root directory to start walking from. + * @param {string} extension - The file extension to match (e.g. '.map'). + * @returns {string[]} Relative file paths from rootDir. + */ +function walkDirectory(rootDir, extension) { + const results = []; + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith(extension)) { + results.push(path.relative(rootDir, fullPath)); + } + } + } + + walk(rootDir); + return results; +} function deleteSourcemaps(buildPath) { console.info(`[sentry] Deleting sourcemaps from ${buildPath}`); // Delete all .map files in the build folder and its subfolders - const mapFiles = globSync('**/*.map', { cwd: buildPath }); + const mapFiles = walkDirectory(buildPath, '.map'); mapFiles.forEach(file => { fs.unlinkSync(path.join(buildPath, file)); diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts index fd8440f3578d..d9197c3315bb 100644 --- a/packages/remix/src/server/serverTimingTracePropagation.ts +++ b/packages/remix/src/server/serverTimingTracePropagation.ts @@ -1,4 +1,3 @@ -import type { Span } from '@sentry/core'; import { debug, getTraceData, isNodeEnv } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; import { isCloudflareEnv } from '../utils/utils'; diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 28ef147b57d7..40652b48b905 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -39,9 +39,6 @@ "@vanilla-extract/css": "1.13.0", "@vanilla-extract/integration": "6.2.4", "@types/mime": "^3.0.0", - "@sentry/remix/glob": "<10.4.3", - "jackspeak": "<3.4.1", - "**/path-scurry/lru-cache": "10.2.0", "vite": "^6.0.0" }, "engines": { diff --git a/packages/remix/test/server/serverTimingTracePropagation.test.ts b/packages/remix/test/server/serverTimingTracePropagation.test.ts index 7e9852e97c6b..0543146277df 100644 --- a/packages/remix/test/server/serverTimingTracePropagation.test.ts +++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts @@ -1,14 +1,15 @@ -import { getActiveSpan, getTraceData, isNodeEnv, spanToBaggageHeader, spanToTraceHeader } from '@sentry/core'; +import { getActiveSpan, getTraceData, isNodeEnv } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { generateSentryServerTimingHeader, injectServerTimingHeaderValue, } from '../../src/server/serverTimingTracePropagation'; +import type { Span } from '@sentry/core'; const mockSpan = { spanId: 'test-span-id', spanContext: () => ({ traceId: '12345678901234567890123456789012' }), -}; +} as unknown as Span; const mockRootSpan = { spanId: 'root-span-id', spanContext: () => ({ traceId: '12345678901234567890123456789012' }), diff --git a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts index 9b6f5f602e63..cb6c0d49c528 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferArray.ts @@ -73,12 +73,10 @@ export class EventBufferArray implements EventBuffer { /** @inheritdoc */ public getEarliestTimestamp(): number | null { - const timestamp = this.events.map(event => event.timestamp).sort()[0]; - - if (!timestamp) { - return null; + let ts: number | null = null; + for (const { timestamp } of this.events) { + if (ts === null || timestamp < ts) ts = timestamp; } - - return timestampToMs(timestamp); + return ts === null ? ts : timestampToMs(ts); } } diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 8b92f0330875..2f54131a2a3e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -47,18 +47,17 @@ } }, "dependencies": { - "@babel/parser": "7.26.9", "@sentry/cloudflare": "10.45.0", "@sentry/core": "10.45.0", "@sentry/node": "10.45.0", "@sentry/svelte": "10.45.0", "@sentry/vite-plugin": "^5.1.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "acorn": "^8.14.0", "magic-string": "~0.30.0", - "recast": "0.23.11", "sorcery": "1.0.0" }, "devDependencies": { - "@babel/types": "^7.26.3", "@sveltejs/kit": "^2.53.3", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index 58862e452ddc..2a1301ce00d9 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -1,10 +1,11 @@ +import { tsPlugin } from '@sveltejs/acorn-typescript'; +import * as acorn from 'acorn'; import * as fs from 'fs'; import * as path from 'path'; -import * as recast from 'recast'; import type { Plugin } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; -import { parser } from './recastTypescriptParser'; -import t = recast.types.namedTypes; + +const AcornParser = acorn.Parser.extend(tsPlugin()); export type AutoInstrumentSelection = { /** @@ -123,23 +124,21 @@ export async function canWrapLoad(id: string, debug: boolean): Promise const code = (await fs.promises.readFile(id, 'utf8')).toString(); - const ast = recast.parse(code, { - parser, - }); - - const program = (ast as { program?: t.Program }).program; - - if (!program) { + let program: acorn.Program; + try { + program = AcornParser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + }); + } catch { // eslint-disable-next-line no-console debug && console.log(`Skipping wrapping ${id} because it doesn't contain valid JavaScript or TypeScript`); return false; } const hasLoadDeclaration = program.body - .filter( - (statement): statement is recast.types.namedTypes.ExportNamedDeclaration => - statement.type === 'ExportNamedDeclaration', - ) + .filter((statement): statement is acorn.ExportNamedDeclaration => statement.type === 'ExportNamedDeclaration') .find(exportDecl => { // find `export const load = ...` if (exportDecl.declaration?.type === 'VariableDeclaration') { @@ -160,11 +159,8 @@ export async function canWrapLoad(id: string, debug: boolean): Promise return exportDecl.specifiers.find(specifier => { return ( (specifier.exported.type === 'Identifier' && specifier.exported.name === 'load') || - // Type casting here because somehow the 'exportExtensions' plugin isn't reflected in the possible types - // This plugin adds support for exporting something as a string literal (see comment above) - // Doing this to avoid adding another babel plugin dependency - ((specifier.exported.type as 'StringLiteral' | '') === 'StringLiteral' && - (specifier.exported as unknown as t.StringLiteral).value === 'load') + // ESTree/acorn represents `export { x as "load" }` with a Literal node (not Babel's StringLiteral) + (specifier.exported.type === 'Literal' && specifier.exported.value === 'load') ); }); } diff --git a/packages/sveltekit/src/vite/recastTypescriptParser.ts b/packages/sveltekit/src/vite/recastTypescriptParser.ts deleted file mode 100644 index ca37439ddae9..000000000000 --- a/packages/sveltekit/src/vite/recastTypescriptParser.ts +++ /dev/null @@ -1,91 +0,0 @@ -// This babel parser config is taken from recast's typescript parser config, specifically from these two files: -// see: https://github.com/benjamn/recast/blob/master/parsers/_babel_options.ts -// see: https://github.com/benjamn/recast/blob/master/parsers/babel-ts.ts -// -// Changes: -// - we don't add the 'jsx' plugin, to correctly parse TypeScript angle bracket type assertions -// (see https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) -// - minor import and export changes -// - merged the two files linked above into one for simplicity - -// Date of access: 2025-03-04 -// Commit: https://github.com/benjamn/recast/commit/ba5132174894b496285da9d001f1f2524ceaed3a - -// Recast license: - -// Copyright (c) 2012 Ben Newman - -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to -// permit persons to whom the Software is furnished to do so, subject to -// the following conditions: - -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import type { ParserPlugin } from '@babel/parser'; -import { parse as babelParse } from '@babel/parser'; -import type { Options } from 'recast'; - -export const parser: Options['parser'] = { - parse: (source: string) => - babelParse(source, { - strictMode: false, - allowImportExportEverywhere: true, - allowReturnOutsideFunction: true, - startLine: 1, - tokens: true, - plugins: [ - 'typescript', - 'asyncGenerators', - 'bigInt', - 'classPrivateMethods', - 'classPrivateProperties', - 'classProperties', - 'classStaticBlock', - 'decimal', - 'decorators-legacy', - 'doExpressions', - 'dynamicImport', - 'exportDefaultFrom', - 'exportNamespaceFrom', - 'functionBind', - 'functionSent', - 'importAssertions', - 'exportExtensions' as ParserPlugin, - 'importMeta', - 'nullishCoalescingOperator', - 'numericSeparator', - 'objectRestSpread', - 'optionalCatchBinding', - 'optionalChaining', - [ - 'pipelineOperator', - { - proposal: 'minimal', - }, - ], - [ - 'recordAndTuple', - { - syntaxType: 'hash', - }, - ], - 'throwExpressions', - 'topLevelAwait', - 'v8intrinsic', - ], - sourceType: 'module', - }), -}; diff --git a/yarn.lock b/yarn.lock index 1237ed9f3caa..b4340f8ab7d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1914,13 +1914,6 @@ "@babel/template" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/parser@7.26.9": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.9.tgz#d9e78bee6dc80f9efd8f2349dcfbbcdace280fd5" - integrity sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A== - dependencies: - "@babel/types" "^7.26.9" - "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.25.4", "@babel/parser@^7.26.7", "@babel/parser@^7.27.7", "@babel/parser@^7.28.0", "@babel/parser@^7.28.4", "@babel/parser@^7.28.5", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" @@ -2981,7 +2974,7 @@ "@babel/types" "^7.29.0" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.23.6", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.26.3", "@babel/types@^7.26.8", "@babel/types@^7.26.9", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.7", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.19", "@babel/types@^7.23.6", "@babel/types@^7.24.7", "@babel/types@^7.25.4", "@babel/types@^7.26.8", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.7", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== @@ -3374,25 +3367,25 @@ lodash "^4.17.21" resolve "^1.20.0" -"@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" - integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== +"@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" + integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== dependencies: - "@emnapi/wasi-threads" "1.1.0" + "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" -"@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" - integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== +"@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.7.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" + integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== +"@emnapi/wasi-threads@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz#a19d9772cc3d195370bf6e2a805eec40aa75e18e" + integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== dependencies: tslib "^2.4.0" @@ -5350,10 +5343,10 @@ resolved "https://registry.yarnpkg.com/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz#577c0c25d8aae9f69a97738b7b0d03d1471cdc49" integrity sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng== -"@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.1.9": - version "1.4.5" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz#0f53a6c5a350fbe4bfa12cc80b69e8d358f1bbc0" - integrity sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw== +"@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.3.0": + version "1.4.6" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz#2edf5819fa0e69d86059f44d1fe57ae9d7817c12" + integrity sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g== dependencies: sparse-bitfield "^3.0.3" @@ -5375,6 +5368,15 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2" + integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== + dependencies: + "@emnapi/core" "^1.7.1" + "@emnapi/runtime" "^1.7.1" + "@tybys/wasm-util" "^0.10.1" + "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -6569,6 +6571,11 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.76.0.tgz#3dbef82283f871c9cb59325c9daf4f740d11a6e9" integrity sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA== +"@oxc-project/types@=0.120.0": + version "0.120.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.120.0.tgz#af521b0e689dd0eaa04fe4feef9b68d98b74783d" + integrity sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg== + "@oxc-project/types@^0.76.0": version "0.76.0" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.76.0.tgz#89ae800d774ccb344278fc17ab6c15348da8b995" @@ -7141,13 +7148,20 @@ valibot "^1.2.0" vite-node "^3.2.2" -"@react-router/node@7.13.0", "@react-router/node@^7.13.0": +"@react-router/node@7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@react-router/node/-/node-7.13.0.tgz#8146a95ab894a0035702e2f65cd069e834e25488" integrity sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A== dependencies: "@mjackson/node-fetch-server" "^0.2.0" +"@react-router/node@^7.13.1": + version "7.13.1" + resolved "https://registry.yarnpkg.com/@react-router/node/-/node-7.13.1.tgz#aadedf2fb37afe63b9ac268d818e5eec4f5437ab" + integrity sha512-IWPPf+Q3nJ6q4bwyTf5leeGUfg8GAxSN1RKj5wp9SK915zKK+1u4TCOfOmr8hmC6IW1fcjKV0WChkM0HkReIiw== + dependencies: + "@mjackson/node-fetch-server" "^0.2.0" + "@redis/bloom@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" @@ -7272,10 +7286,87 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/pluginutils@^1.0.0-beta.9": - version "1.0.0-rc.4" - resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz#267b477af268a082861c861e47f6a787dff59cc4" - integrity sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ== +"@rolldown/binding-android-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz#0bbd3380f49a6d0dc96c9b32fb7dad26ae0dfaa7" + integrity sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz#a30b051784fbb13635e652ba4041c6ce7a4ce7ab" + integrity sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w== + +"@rolldown/binding-darwin-x64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz#2d9dea982d5be90b95b6d8836ff26a4b0959d94b" + integrity sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz#4efc3aca43ae4dfb90729eeca6e84ef6e6b38c4a" + integrity sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz#4a19a5d24537e925b25e9583b6cd575b2ad9fa27" + integrity sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz#01a41e5e905838353ae9a3da10dc8242dcd61453" + integrity sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz#bd059e5f83471de29ce35b0ba254995d8091ca40" + integrity sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz#fe726a540631015f269a989c0cfb299283190390" + integrity sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz#825ced028bad3f1fa9ce83b1f3dac76e0424367f" + integrity sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz#b700dae69274aa3d54a16ca5e00e30f47a089119" + integrity sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz#eb875660ad68a2348acab36a7005699e87f6e9dd" + integrity sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz#72aa24b412f83025087bcf83ce09634b2bd93c5c" + integrity sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz#7f3303a96c5dc01d1f4c539b1dcbc16392c6f17d" + integrity sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA== + dependencies: + "@napi-rs/wasm-runtime" "^1.1.1" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz#3419144a04ad12c69c48536b01fc21ac9d87ecf4" + integrity sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.10": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz#09bee46e6a32c6086beeabc3da12e67be714f882" + integrity sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w== + +"@rolldown/pluginutils@1.0.0-rc.10", "@rolldown/pluginutils@^1.0.0-beta.9": + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz#eed997f37f928a3300bbe2161f42687d8a3ae759" + integrity sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg== "@rollup/plugin-alias@^5.0.0": version "5.1.1" @@ -8651,10 +8742,10 @@ "@supabase/realtime-js" "2.11.2" "@supabase/storage-js" "2.7.1" -"@sveltejs/acorn-typescript@^1.0.5": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" - integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== +"@sveltejs/acorn-typescript@^1.0.5", "@sveltejs/acorn-typescript@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa" + integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA== "@sveltejs/kit@^2.53.3": version "2.53.3" @@ -9026,7 +9117,7 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== -"@tybys/wasm-util@^0.10.0": +"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== @@ -9659,11 +9750,11 @@ "@types/node" "*" "@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": - version "22.10.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" - integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== + version "25.4.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.4.0.tgz#f25d8467984d6667cc4c1be1e2f79593834aaedb" + integrity sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw== dependencies: - undici-types "~6.20.0" + undici-types "~7.18.0" "@types/node@^14.8.0": version "14.18.63" @@ -9911,10 +10002,10 @@ resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== -"@types/whatwg-url@^11.0.2": - version "11.0.5" - resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz#aaa2546e60f0c99209ca13360c32c78caf2c409f" - integrity sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ== +"@types/whatwg-url@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-13.0.0.tgz#2b11e32772fd321c0dedf4d655953ea8ce587b2a" + integrity sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q== dependencies: "@types/webidl-conversions" "*" @@ -12816,10 +12907,10 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@*, bson@^6.10.3: - version "6.10.3" - resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.3.tgz#5f9a463af6b83e264bedd08b236d1356a30eda47" - integrity sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ== +bson@*, bson@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-7.2.0.tgz#1a496a42d9ff130b9f3ab8efd465459c758c747f" + integrity sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ== bson@^1.1.4: version "1.1.6" @@ -13882,7 +13973,7 @@ cookie@^0.7.1, cookie@^0.7.2, cookie@~0.7.1, cookie@~0.7.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -cookie@^1.0.1, cookie@^1.0.2: +cookie@^1.0.1, cookie@^1.0.2, cookie@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== @@ -14075,6 +14166,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: dependencies: uncrypto "^0.1.3" +crossws@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.4.4.tgz#d62574bcc6de75f0e45fe08b5133d9ba8436a30c" + integrity sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -14359,7 +14455,7 @@ debug@2, debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, de dependencies: ms "2.0.0" -debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: +debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -15030,10 +15126,10 @@ effect@3.16.12: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" -effect@^3.19.19: - version "3.19.19" - resolved "https://registry.yarnpkg.com/effect/-/effect-3.19.19.tgz#643a5a4b7445cc924a28270bc6cd1a5c8facd27e" - integrity sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg== +effect@^3.20.0: + version "3.20.0" + resolved "https://registry.yarnpkg.com/effect/-/effect-3.20.0.tgz#827752d2c90f0a12562f1fdac3bf0197d067fd6a" + integrity sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw== dependencies: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" @@ -15050,6 +15146,16 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== +elysia@^1.4.0: + version "1.4.27" + resolved "https://registry.yarnpkg.com/elysia/-/elysia-1.4.27.tgz#709f07f54c0d0400aab8a18bac4d94223203ec45" + integrity sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg== + dependencies: + cookie "^1.1.1" + exact-mirror "^0.2.7" + fast-decode-uri-component "^1.0.1" + memoirist "^0.4.0" + ember-auto-import@^2.5.0, ember-auto-import@^2.7.2: version "2.8.1" resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.8.1.tgz#03977e87ce178e6f9e4f89809185ff8f0fee9fcb" @@ -15722,6 +15828,15 @@ env-paths@^2.2.0: resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== +env-runner@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/env-runner/-/env-runner-0.1.6.tgz#b2acc95c00bc9a00457d7ad5220f10bd75595b2d" + integrity sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA== + dependencies: + crossws "^0.4.4" + httpxy "^0.3.1" + srvx "^0.11.9" + err-code@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" @@ -16838,6 +16953,11 @@ events@^3.0.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +exact-mirror@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/exact-mirror/-/exact-mirror-0.2.7.tgz#ee8e75c362a67ca0e07cb13fea92b61adaabfa29" + integrity sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg== + exec-sh@^0.3.2, exec-sh@^0.3.4: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -17107,6 +17227,11 @@ fast-content-type-parse@^3.0.0: resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== +fast-decode-uri-component@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" + integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -17201,7 +17326,14 @@ fast-uri@^3.0.1: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== -fast-xml-parser@5.3.6, fast-xml-parser@^5.0.7: +fast-xml-builder@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" + integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== + dependencies: + path-expression-matcher "^1.1.3" + +fast-xml-parser@5.3.6: version "5.3.6" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== @@ -17215,6 +17347,15 @@ fast-xml-parser@^4.4.1: dependencies: strnum "^1.0.5" +fast-xml-parser@^5.0.7: + version "5.5.8" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" + integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== + dependencies: + fast-xml-builder "^1.1.4" + path-expression-matcher "^1.2.0" + strnum "^2.2.0" + fastq@^1.6.0: version "1.19.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" @@ -17277,7 +17418,7 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.3.2, file-type@^21.3.1: +file-type@21.3.2: version "21.3.2" resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== @@ -17551,7 +17692,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.11, follow-redirects@^1.15.9: +follow-redirects@^1.0.0, follow-redirects@^1.15.11: version "1.15.11" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== @@ -18380,9 +18521,9 @@ h3@1.15.3: uncrypto "^0.1.3" h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5: - version "1.15.5" - resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.5.tgz#e2f28d4a66a249973bb050eaddb06b9ab55506f8" - integrity sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg== + version "1.15.10" + resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.10.tgz#defe650df7b70cf585d2020c4146fb580cfb0d42" + integrity sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg== dependencies: cookie-es "^1.2.2" crossws "^0.3.5" @@ -18394,6 +18535,14 @@ h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5: ufo "^1.6.3" uncrypto "^0.1.3" +h3@^2.0.1-rc.16: + version "2.0.1-rc.17" + resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.17.tgz#86fb5a5261a38f59e0fb3384581e345285be3b61" + integrity sha512-9rPJs68qMj7HJH78z7uSIAw6rl3EElLdVSirTeAf6B5ogwiFVIr9AKMMS4u00Gp8DYIPnnjtw3ZWN7EkYcPBrQ== + dependencies: + rou3 "^0.8.1" + srvx "^0.11.12" + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -18975,7 +19124,7 @@ https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" -https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5: +https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -18993,6 +19142,11 @@ httpxy@^0.1.7: resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.1.7.tgz#02d02e57eda10e8b5c0e3f9f10860e3d7a5991a4" integrity sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ== +httpxy@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.3.1.tgz#da1bb1a4a26cb44d7835a9297c845a0e06372083" + integrity sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw== + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -21548,6 +21702,11 @@ memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" +memoirist@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/memoirist/-/memoirist-0.4.0.tgz#7677aa70f8c2f7f0791f8af1b689495c8dbc906d" + integrity sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg== + memory-pager@^1.0.2: version "1.5.0" resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" @@ -22296,39 +22455,39 @@ mongodb-connection-string-url@^2.6.0: "@types/whatwg-url" "^8.2.1" whatwg-url "^11.0.0" -mongodb-connection-string-url@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz#e223089dfa0a5fa9bf505f8aedcbc67b077b33e7" - integrity sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== +mongodb-connection-string-url@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz#347b664cd9e6ddff10d5c1c6010d6d8dbfe9272d" + integrity sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ== dependencies: - "@types/whatwg-url" "^11.0.2" - whatwg-url "^14.1.0 || ^13.0.0" + "@types/whatwg-url" "^13.0.0" + whatwg-url "^14.1.0" -mongodb-memory-server-core@10.1.4: - version "10.1.4" - resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-10.1.4.tgz#aaeab3dfb13a1495dedd2f4af1eee815792b8fb9" - integrity sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA== +mongodb-memory-server-core@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-11.0.1.tgz#952709a231acad192c3cd8b9795b15cdf2a04ff6" + integrity sha512-IcIb2S9Xf7Lmz43Z1ZujMqNg7PU5Q7yn+4wOnu7l6pfeGPkEmlqzV1hIbroVx8s4vXhPB1oMGC1u8clW7aj3Xw== dependencies: async-mutex "^0.5.0" camelcase "^6.3.0" - debug "^4.3.7" + debug "^4.4.3" find-cache-dir "^3.3.2" - follow-redirects "^1.15.9" - https-proxy-agent "^7.0.5" - mongodb "^6.9.0" + follow-redirects "^1.15.11" + https-proxy-agent "^7.0.6" + mongodb "^7.0.0" new-find-package-json "^2.0.0" - semver "^7.6.3" + semver "^7.7.3" tar-stream "^3.1.7" - tslib "^2.7.0" - yauzl "^3.1.3" + tslib "^2.8.1" + yauzl "^3.2.0" -mongodb-memory-server-global@^10.1.4: - version "10.1.4" - resolved "https://registry.yarnpkg.com/mongodb-memory-server-global/-/mongodb-memory-server-global-10.1.4.tgz#b902ae141775dcea5530482d2ddb45418df79d86" - integrity sha512-YMe7XVkGfwyD7Uwn9G8jejMxy0+sP/2o3D4QU5AvmsioJGppT+z+tk6dxzKhi2xc7TRnY+CTGX6OrqscYiDHyg== +mongodb-memory-server-global@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-global/-/mongodb-memory-server-global-11.0.1.tgz#dd7649fee1200506b47e2ba822f6010b3372086a" + integrity sha512-UC5ESWzGVlgdKWQMr6C0M/6lhLGSq+J+hz1dGDlqk0giOEXK4FW4Y0blhqLiaZ95xgk2sWZ6Ek2sLooW0hmjlA== dependencies: - mongodb-memory-server-core "10.1.4" - tslib "^2.7.0" + mongodb-memory-server-core "11.0.1" + tslib "^2.8.1" mongodb@4.17.2: version "4.17.2" @@ -22355,14 +22514,14 @@ mongodb@^3.7.3: optionalDependencies: saslprep "^1.0.0" -mongodb@^6.9.0: - version "6.13.1" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.13.1.tgz#924319f957a22efda45a96d38c08a594fd7929fa" - integrity sha512-gdq40tX8StmhP6akMp1pPoEVv+9jTYFSrga/g23JxajPAQhH39ysZrHGzQCSd9PEOnuEQEdjIWqxO7ZSwC0w7Q== +mongodb@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-7.1.0.tgz#c13640259d30cdf9c3936aaa003f0d0c0a34fa9b" + integrity sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg== dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.3" - mongodb-connection-string-url "^3.0.0" + "@mongodb-js/saslprep" "^1.3.0" + bson "^7.1.1" + mongodb-connection-string-url "^7.0.0" mongoose@^6.13.6: version "6.13.9" @@ -22643,6 +22802,11 @@ next@14.2.35: "@next/swc-win32-ia32-msvc" "14.2.33" "@next/swc-win32-x64-msvc" "14.2.33" +nf3@^0.3.11: + version "0.3.13" + resolved "https://registry.yarnpkg.com/nf3/-/nf3-0.3.13.tgz#9dfbc08158c9f12583ebf82bd89c97dc362b7df1" + integrity sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw== + ng-packagr@^14.2.2: version "14.3.0" resolved "https://registry.yarnpkg.com/ng-packagr/-/ng-packagr-14.3.0.tgz#517a7c343aa125a7d631097fede16941949fb503" @@ -22688,6 +22852,26 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nitro@^3.0.260311-beta: + version "3.0.260311-beta" + resolved "https://registry.yarnpkg.com/nitro/-/nitro-3.0.260311-beta.tgz#46860d42e6a412c7ea361fae525427c01b6ded3d" + integrity sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ== + dependencies: + consola "^3.4.2" + crossws "^0.4.4" + db0 "^0.3.4" + env-runner "^0.1.6" + h3 "^2.0.1-rc.16" + hookable "^6.0.1" + nf3 "^0.3.11" + ocache "^0.1.2" + ofetch "^2.0.0-alpha.3" + ohash "^2.0.11" + rolldown "^1.0.0-rc.8" + srvx "^0.11.9" + unenv "^2.0.0-rc.24" + unstorage "^2.0.0-alpha.6" + nitropack@^2.11.10, nitropack@^2.11.13, nitropack@^2.13.1: version "2.13.1" resolved "https://registry.yarnpkg.com/nitropack/-/nitropack-2.13.1.tgz#70be1b14eb0d2fed9c670fe7cfff3741c384ecf2" @@ -23426,6 +23610,13 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +ocache@^0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/ocache/-/ocache-0.1.4.tgz#d4a71be84ceaeb5685cc0128c197d44713dda9a7" + integrity sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ== + dependencies: + ohash "^2.0.11" + ofetch@^1.4.1, ofetch@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/ofetch/-/ofetch-1.5.1.tgz#5c43cc56e03398b273014957060344254505c5c7" @@ -24094,6 +24285,11 @@ path-exists@^5.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== +path-expression-matcher@^1.1.3, path-expression-matcher@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" + integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== + path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -25503,10 +25699,10 @@ quick-temp@^0.1.2, quick-temp@^0.1.3, quick-temp@^0.1.5, quick-temp@^0.1.8: rimraf "^2.5.4" underscore.string "~3.3.4" -qunit-dom@~3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/qunit-dom/-/qunit-dom-3.2.1.tgz#650707b818d5a889ac1923a5cdbdc0d9daf4db7a" - integrity sha512-+qSm8zQ7hPA9NijmTDVsUFNGEFP/K+DTymjlsU01O3NhkGtb9rsZRztJXwaiAlmVSX4vSzjydPxpZCRhpWIq4A== +qunit-dom@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/qunit-dom/-/qunit-dom-3.5.0.tgz#f10222b3a918300e0d9ead6d4a2938c97f063087" + integrity sha512-eemLM5bflWafzmBnwlYbjf9NrjEkV2j7NO7mTvsMzQBJbEaq2zFvUFDtHV9JaK0TT5mgRZt034LCUewYGmjjjQ== dependencies: dom-element-descriptors "^0.5.1" @@ -25831,17 +26027,6 @@ real-require@^0.2.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== -recast@0.23.11, recast@^0.23.4: - version "0.23.11" - resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" - integrity sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA== - dependencies: - ast-types "^0.16.1" - esprima "~4.0.0" - source-map "~0.6.1" - tiny-invariant "^1.3.3" - tslib "^2.0.1" - recast@^0.18.1: version "0.18.10" resolved "https://registry.yarnpkg.com/recast/-/recast-0.18.10.tgz#605ebbe621511eb89b6356a7e224bff66ed91478" @@ -25862,6 +26047,17 @@ recast@^0.20.5: source-map "~0.6.1" tslib "^2.0.1" +recast@^0.23.4: + version "0.23.11" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" + integrity sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA== + dependencies: + ast-types "^0.16.1" + esprima "~4.0.0" + source-map "~0.6.1" + tiny-invariant "^1.3.3" + tslib "^2.0.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -26516,6 +26712,30 @@ roarr@^7.0.4: safe-stable-stringify "^2.4.1" semver-compare "^1.0.0" +rolldown@^1.0.0-rc.8: + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.10.tgz#41c55e52d833c52c90131973047250548e35f2bf" + integrity sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA== + dependencies: + "@oxc-project/types" "=0.120.0" + "@rolldown/pluginutils" "1.0.0-rc.10" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.0.0-rc.10" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.10" + "@rolldown/binding-darwin-x64" "1.0.0-rc.10" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.10" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.10" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.10" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.10" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.10" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.10" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.10" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.10" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.10" + rollup-plugin-cleanup@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/rollup-plugin-cleanup/-/rollup-plugin-cleanup-3.2.1.tgz#8cbc92ecf58babd7c210051929797f137bbf777c" @@ -26626,6 +26846,11 @@ rou3@^0.7.12: resolved "https://registry.yarnpkg.com/rou3/-/rou3-0.7.12.tgz#cac17425c04abddba854a42385cabfe0b971a179" integrity sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg== +rou3@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/rou3/-/rou3-0.8.1.tgz#d18c9dae42bdd9cd4fffa77bc6731d5cfe92129a" + integrity sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA== + router@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" @@ -27480,12 +27705,12 @@ socket.io-adapter@~2.5.2: ws "~8.17.1" socket.io-parser@~4.2.4: - version "4.2.4" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" - integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + version "4.2.6" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f" + integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg== dependencies: "@socket.io/component-emitter" "~3.1.0" - debug "~4.3.1" + debug "~4.4.1" socket.io@^4.5.4: version "4.8.1" @@ -27828,10 +28053,10 @@ sqlstring@2.3.1: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= -srvx@^0.11.2: - version "0.11.4" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.4.tgz#0d1dd962c2320f84fc7872f2500b21c84c3d1b97" - integrity sha512-m/2p87bqWZ94xpRN06qNBwh0xq/D0dXajnvPDSHFqrTogxuTWYNP1UHz6Cf+oY7D+NPLY35TJAp4ESIKn0WArQ== +srvx@^0.11.12, srvx@^0.11.2, srvx@^0.11.9: + version "0.11.12" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.12.tgz#ed59866cd0cec580b119e161ead3fecd2a546fee" + integrity sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA== ssri@^9.0.0: version "9.0.1" @@ -27855,10 +28080,10 @@ stackframe@^1.3.4: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== -stacktrace-parser@^0.1.10: - version "0.1.10" - resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz#29fb0cae4e0d0b85155879402857a1639eb6051a" - integrity sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg== +stacktrace-parser@^0.1.11: + version "0.1.11" + resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz#c7c08f9b29ef566b9a6f7b255d7db572f66fabc4" + integrity sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg== dependencies: type-fest "^0.7.1" @@ -28209,10 +28434,10 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== -strnum@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a" - integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ== +strnum@^2.1.2, strnum@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.1.tgz#d28f896b4ef9985212494ce8bcf7ca304fad8368" + integrity sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg== strtok3@^10.3.4: version "10.3.4" @@ -28939,10 +29164,10 @@ tr46@^4.1.1: dependencies: punycode "^2.3.0" -tr46@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" - integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== +tr46@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca" + integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== dependencies: punycode "^2.3.1" @@ -29076,7 +29301,7 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.7.0, tslib@^2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -29393,10 +29618,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" - integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== undici@7.18.2: version "7.18.2" @@ -29758,6 +29983,11 @@ unstorage@^1.16.0, unstorage@^1.17.4: ofetch "^1.5.1" ufo "^1.6.3" +unstorage@^2.0.0-alpha.6: + version "2.0.0-alpha.7" + resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-2.0.0-alpha.7.tgz#803ea90176683bf2175bb01065cb07df6d65280a" + integrity sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog== + untildify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0" @@ -30633,12 +30863,12 @@ whatwg-url@^12.0.0, whatwg-url@^12.0.1: tr46 "^4.1.1" webidl-conversions "^7.0.0" -"whatwg-url@^14.1.0 || ^13.0.0": - version "14.1.1" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.1.1.tgz#ce71e240c61541315833b5cdafd139a479e47058" - integrity sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ== +whatwg-url@^14.1.0: + version "14.2.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== dependencies: - tr46 "^5.0.0" + tr46 "^5.1.0" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -31136,7 +31366,7 @@ yarn-deduplicate@6.0.2: semver "^7.5.0" tslib "^2.5.0" -yauzl@^3.1.3: +yauzl@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.2.1.tgz#d35befb9a0fdd328da41926be895ade2de14dbe7" integrity sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==