diff --git a/.agents/skills/write-tests/SKILL.md b/.agents/skills/write-tests/SKILL.md new file mode 100644 index 000000000000..94fa4dee89df --- /dev/null +++ b/.agents/skills/write-tests/SKILL.md @@ -0,0 +1,407 @@ +--- +name: write-tests +description: > + Write high-quality unit tests (Vitest) and E2E tests (Playwright) following senior test-engineering + practices. Use this skill whenever asked to write tests, add test coverage, create test cases, + fix failing tests, add missing assertions, test a new feature, write specs, or cover edge cases. + Also trigger when the user says "write tests for", "add tests", "test this", "cover this", + "needs tests", "add E2E test", "add unit test", "test coverage", or when reviewing code and + noticing missing test coverage. +--- + +# Write Tests + +Tests are not production code. They are documentation — each one is a tiny executable spec that says +"this system does X." A reader should grasp the intent in seconds. A failure should point to exactly +one broken behavior, not leave you going through a 40-line test body. + +## Workflow + +Follow these steps in order before writing any test code. + +1. **Decide the framework.** Testing a function's return value, side effects, or module interactions + → Vitest (lives under `packages//test/`). Testing that a real HTTP request to a running app + produces the correct Sentry envelope → Playwright (lives under + `dev-packages/e2e-tests/test-applications//tests/`). + +2. **Read 2–3 existing test files** in the target `test/` directory. Specifically note: + - Which `vi.mock` style they use (string path or import form) + - What cleanup they do in `beforeEach` (`clearAllMocks` vs `restoreAllMocks`) + - How they import the module under test (`../../src/...` vs `@sentry/...`) + - The `describe`/`it` nesting depth and naming style + - What setup functions are called together — does the function under test require companion + initialization? (e.g., does `patchRoute` also need `patchAppUse` to work correctly?) + + Match what you find. Consistency within a package matters more than idealized best practice. + +3. **Check for existing test utilities** before writing helpers from scratch: + - `packages/core/test/mocks/` — `TestClient`, `getDefaultTestClientOptions`, fake transports + - `packages/core/test/testutils.ts` — `clearGlobalScope()`, version gating + - `dev-packages/test-utils/` — `waitForTransaction`, `waitForError`, `waitForSession`, + `getPlaywrightConfig`, mock Sentry server, event proxy + - `dev-packages/node-integration-tests/utils/` — `createEsmAndCjsTests`, assertion helpers + +4. **Identify the behaviors that matter most** — edge cases, error paths, boundary conditions. + Don't aim for quantity; aim for the tests that would catch real regressions. + +--- + +## Core principles + +### Fewer tests, better tests + +The goal is not to maximize test count. A large suite of shallow happy-path tests gives a false +sense of coverage — they pass on every change, including changes that introduce bugs. A smaller +suite that targets edge cases, error paths, and boundary conditions catches far more regressions. + +Before writing a test, ask: "If this test didn't exist, what bug could ship?" If you can't answer +that concretely, the test probably isn't worth writing. Prioritize: + +- **Edge cases and boundaries** — the off-by-one, the empty array, the `null` input +- **Error paths** — does the function fail gracefully or silently swallow the error? +- **Integration seams** — where two modules or systems interact (e.g., middleware calling `next()`) +- **Behavior that previously broke** — regression tests for known bugs + +Don't waste tests on: trivial getters/setters, pure delegation to well-tested libraries, +TypeScript type constraints (the compiler already checks those), or re-testing the same behavior +that a higher-level test already covers. + +### Arrange → Act → Assert + +Structure every test with the AAA pattern, separated by blank lines. The whitespace makes the +phases obvious — no labels or comments needed. + +```typescript +it('skips errors already captured by middleware', () => { + const error = new Error('already captured'); + Object.defineProperty(error, '__sentry_captured__', { value: true }); + + responseHandler(createMockContext(500, error)); + + expect(mockCaptureException).not.toHaveBeenCalled(); +}); +``` + +### One behavior, one reason to fail + +Each test makes exactly one behavioral claim. Multiple `expect` calls are fine when they assert on +different facets of the _same_ outcome. But if you're checking two unrelated behaviors, those are +two tests. No conditional logic, no branching, no try/catch — a test is a straight line. + +### Assert behavior, not implementation + +If someone refactored the internals but the function still returned the correct result, would this +test break? If yes, you're testing wiring, not behavior. + +```typescript +// Bad: asserts nothing meaningful +it('handles the request', async () => { + expect(() => handler(mockReq)).not.toThrow(); +}); + +// Good: asserts on the observable outcome +it('sets transaction name from route path', () => { + responseHandler(createMockContext(200)); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); +}); +``` + +### Precise assertions + +Default to exact matching. `toMatchObject`, `expect.objectContaining`, and `expect.arrayContaining` +silently ignore fields that matter. This has caused real bugs to ship in this codebase. + +**Use `toEqual` by default.** The same applies to `toHaveBeenCalledWith` — spell out every +argument rather than wrapping in `objectContaining`. This is the single most common place where +loose assertions creep in: + +```typescript +// Bad: silently ignores any missing or extra properties in the call +expect(startSpan).toHaveBeenCalledWith(expect.objectContaining({ name: 'middleware', op: 'middleware.hono' })); + +// Good: exact match on the full argument — if the shape changes, the test catches it +expect(startSpan).toHaveBeenCalledWith({ + name: 'middleware', + op: 'middleware.hono', + onlyIfParent: true, + parentSpan: fakeRootSpan, + attributes: { 'sentry.op': 'middleware.hono', 'sentry.origin': 'auto.middleware.hono' }, +}); +``` + +The only valid reasons to use `toMatchObject` or `objectContaining` are: **(1)** the object is +generated by a framework or third-party library and contains fields you don't control (timestamps, +random IDs, internal framework state), or **(2)** the object has 10+ fields and the test only +cares about 2–3 of them (in which case individual `.toBe()` checks on those fields are still +preferred). If you wrote the object being asserted, you can spell it out — use `toEqual`. + +When you do fall back, prefer individual `.toBe()` checks over `objectContaining`: + +```typescript +expect(event.transaction).toBe('GET /users/:id'); +expect(event.contexts?.trace?.op).toBe('http.server'); +``` + +**Every `toContain` / `toContainEqual` needs a `toHaveLength` companion.** Without it, the +assertion passes even if the array has unexpected extra items: + +```typescript +// Bad: doesn't notice extra unexpected spans +expect(spanNames).toContain('authMiddleware'); + +// Good: locks down both content and count +expect(spanNames).toHaveLength(1); +expect(spanNames).toContain('authMiddleware'); +``` + +**Use exported constants, not magic numbers.** If the code under test uses named constants like +`SPAN_STATUS_OK`, reference those same constants in assertions. If the constant's value ever +changes, tests using magic numbers silently pass with wrong expectations. + +### Naming + +Names should be concise, descriptive, and read as correct English. Lead with the verb. + +| Quality | Example | +| -------- | ----------------------------------------------------------------------------------------- | +| **Good** | `'captures error when context.error is set'` | +| **Good** | `'does not re-capture errors already captured by wrapMiddlewareWithSpan'` | +| **Good** | `'returns empty array when no items match'` | +| **Bad** | `'should correctly return the formatted price string when given a valid positive number'` | +| **Bad** | `'test error handling'` / `'works correctly'` | + +Drop "should" — it adds words without adding meaning. + +--- + +## Input quality + +### Use realistic data + +```typescript +// Weak +const url = 'http://test'; + +// Strong — exercises URL parsing, path handling, query strings +const url = 'https://api.example.com/users/42?include=profile&format=json'; +``` + +### Boundary Value Analysis + +If the valid range is 1–100, test -2, -1, 0, 1, 2, 99, 100, 101, Number.POSITIVE_INFINITY. Bugs cluster at boundaries — off-by-one +errors, inclusive/exclusive confusion, type coercion. + +### Test the unhappy path as hard as the happy path + +- **Empty inputs:** `''`, `[]`, `{}`, `undefined`, `null` +- **Falsy-but-valid:** `0`, `false`, `''`, `NaN` — these trip up loose truthiness checks +- **Error conditions:** network failure, malformed input, missing required fields, timeout +- **Concurrency:** what if called twice simultaneously? What if called after cleanup? + +Each edge case gets its own test with a descriptive name. + +--- + +## Writing Vitest tests + +### File structure + +- Name test files `*.test.ts`, mirroring the source path: `src/shared/patchRoute.ts` → + `test/shared/patchRoute.test.ts`. +- Import the module under test from its source path (`../../src/...`). But when importing from a _different_ package + (e.g., `@sentry/core` in a `@sentry/node` test), use the package name — that's a real dependency, not the code under test. +- For browser-environment tests: `/** @vitest-environment jsdom */` at top of file. + +### Mocking + +**Prefer spies and stubs over full module mocks.** A spy observes behavior without replacing the +system under test. A full mock replaces it — and now you're testing your mock, not your code. + +```typescript +const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); +sentry(app); +expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not initialized')); +``` + +**When you need `vi.mock`:** If the package's existing tests use string paths +(`vi.mock('../../src/utils')`), match that style. If you're creating the first test file for a +package, prefer the import form for type safety: + +```typescript +vi.mock(import('../../src/utils'), async importOriginal => { + const actual = await importOriginal(); + return { ...actual, helperFn: vi.fn() }; +}); +``` + +**Always restore mocks.** This repo does _not_ set `restoreMocks: true` globally — you are +responsible for cleanup. Leaked mocks cause mysterious failures in unrelated tests. Use whatever +cleanup the existing tests in your package use. If creating the first test file, use: + +```typescript +beforeEach(() => { + vi.restoreAllMocks(); +}); +``` + +### Error testing + +Use the library's built-in matchers. Never use try/catch in tests. + +```typescript +expect(() => parseConfig(null)).toThrow('config is required'); +await expect(asyncOp()).rejects.toThrow(TimeoutError); +``` + +For async callbacks where you need to verify an assertion actually ran, use `expect.assertions(n)`. + +### Parameterized tests (Vitest) + +Use `it.each` or `it.for` for data-driven cases — not raw `for` loops. `it.each` integrates with +the test runner, gives better output, and each case runs independently. + +```typescript +it.each([ + { input: 0, expected: 'zero' }, + { input: 1, expected: 'one' }, + { input: -1, expected: 'negative' }, +])('classifies $input as $expected', ({ input, expected }) => { + expect(classify(input)).toBe(expected); +}); +``` + +### Test isolation + +Tests must never depend on execution order or share mutable state. For this codebase, many tests +need to reset global Sentry state: + +```typescript +beforeEach(() => { + clearGlobalScope(); + getCurrentScope().clear(); + getIsolationScope().clear(); +}); +``` + +### Grouping + +1-2 levels of `describe` is usually enough. Deeper nesting makes tests harder to find and read. + +```typescript +describe('patchRoute', () => { + describe('sub-app middleware wrapping', () => { + it('wraps .use() middleware handlers', async () => { ... }); + it('does not wrap sole route handlers', async () => { ... }); + }); +}); +``` + +--- + +## Writing Playwright E2E tests + +### When to write E2E tests + +Write E2E tests when you need to verify that the SDK correctly instruments a real application. +Unit tests can't catch integration bugs between the SDK and a framework's request lifecycle. +Also use the `/e2e` skill for running E2E tests. + +### File structure + +- Tests live in `dev-packages/e2e-tests/test-applications//tests/*.test.ts`. +- Shared constants (like `APP_NAME`) go in `tests/constants.ts`. +- Each test app has a `playwright.config.ts` using `getPlaywrightConfig` from + `@sentry-internal/test-utils`. + +### The waitFor pattern + +Set up a promise for the expected Sentry event, trigger the action, then await and assert. + +```typescript +test('captures transaction for GET /users/:id', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /users/:id'; + }); + + const response = await fetch(`${baseURL}/users/42`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe('GET /users/:id'); +}); +``` + +**The predicate must be specific enough to match only your event.** A vague predicate can match an +unrelated event from a parallel test, causing flaky passes or hangs. + +### Asserting on spans + +Prefer asserting on the exact span count alongside individual field checks: + +```typescript +const spans = transaction.spans || []; +expect(spans).toHaveLength(2); + +const middlewareSpan = spans.find(s => s.description === 'middlewareA'); +expect(middlewareSpan?.op).toBe('middleware.hono'); +expect(middlewareSpan?.origin).toBe('auto.middleware.hono'); +expect(middlewareSpan?.status).toBe('ok'); +``` + +### Error event assertions + +Check both the exception value and the mechanism. The mechanism tells you _how_ the error was +captured — that's the SDK's actual responsibility: + +```typescript +const errorEvent = await errorPromise; +expect(errorEvent.exception?.values?.[0]?.value).toBe('connection refused'); + +const mechanism = errorEvent.exception?.values?.[0]?.mechanism; +expect(mechanism?.handled).toBe(false); +expect(mechanism?.type).toBe('auto.http.hono.context_error'); +``` + +### Parameterized E2E tests + +For Playwright tests (unlike Vitest), `for...of` loops are the established codebase convention. +Use `for...of` (not `.forEach()`) so Playwright's test registration works correctly: + +```typescript +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('captures named middleware span', async ({ baseURL }) => { + // ... + }); + }); +} +``` + +### Common pitfalls + +- **Proxy name mismatch:** `APP_NAME` must match `proxyServerName` in `start-event-proxy.mjs`. +- **Flaky predicates:** Add enough specificity (path, method, unique marker) to disambiguate. +- **Forgetting `await`:** The `waitFor*` helpers return a promise. Without `await`, the test passes + vacuously and the assertion never runs. + +--- + +## Checklist + +Before you're done, verify each test against these criteria: + +- [ ] Catches a real potential bug — not just confirming the happy path works +- [ ] Single, clear reason it could fail +- [ ] Description reads as a behavior specification (no "should", no "works correctly") +- [ ] No dependency on other tests' execution or state +- [ ] Mocks and spies are restored (via `beforeEach`) +- [ ] Edge cases covered: empty inputs, boundaries, error paths, null/undefined +- [ ] Realistic test data (not `"foo"`, `"test"`, `123`) +- [ ] No try/catch for error testing — `toThrow` / `rejects.toThrow` only +- [ ] Assertions use `toEqual` by default; `toHaveBeenCalledWith` spells out full arguments +- [ ] Array lookups (`toContain`, `toContainEqual`) paired with `toHaveLength` +- [ ] Uses exported constants (e.g., `SPAN_STATUS_OK`) instead of magic numbers +- [ ] Passes in isolation (`vitest run ` or single Playwright test) +- [ ] Matches the existing conventions of the package's test directory diff --git a/.craft.yml b/.craft.yml index 7e2ee3217533..eb42b5cc5de4 100644 --- a/.craft.yml +++ b/.craft.yml @@ -139,6 +139,9 @@ targets: - name: npm id: '@sentry/react-router' includeNames: /^sentry-react-router-\d.*\.tgz$/ + - name: npm + id: '@sentry/nitro' + includeNames: /^sentry-nitro-\d.*\.tgz$/ ## 7. Other Packages ## 7.1 @@ -256,3 +259,9 @@ targets: packageUrl: 'https://www.npmjs.com/package/@sentry/elysia' mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/elysia/' onlyIfPresent: /^sentry-elysia-\d.*\.tgz$/ + 'npm:@sentry/nitro': + name: 'Sentry Nitro SDK' + sdkName: 'sentry.javascript.nitro' + packageUrl: 'https://www.npmjs.com/package/@sentry/nitro' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/nitro/' + onlyIfPresent: /^sentry-nitro-\d.*\.tgz$/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3bb7aa3860ff..b754ae45db84 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,48 @@ -packages/replay-internal @getsentry/replay-sdk-web -packages/replay-worker @getsentry/replay-sdk-web -packages/replay-canvas @getsentry/replay-sdk-web -packages/feedback @getsentry/feedback-sdk -dev-packages/browser-integration-tests/suites/replay @getsentry/replay-sdk-web +# Browser, replay, feedback, and related packages +/packages/browser/ @getsentry/team-javascript-sdks-browser +/packages/browser-utils/ @getsentry/team-javascript-sdks-browser +/packages/replay-internal/ @getsentry/team-javascript-sdks-browser +/packages/replay-worker/ @getsentry/team-javascript-sdks-browser +/packages/replay-canvas/ @getsentry/team-javascript-sdks-browser +/packages/feedback/ @getsentry/team-javascript-sdks-browser +/dev-packages/browser-integration-tests/ @getsentry/team-javascript-sdks-browser + +# Node/server runtimes and related packages +/packages/node/ @getsentry/team-javascript-sdks-server +/packages/node-core/ @getsentry/team-javascript-sdks-server +/packages/node-native/ @getsentry/team-javascript-sdks-server +/packages/profiling-node/ @getsentry/team-javascript-sdks-server +/packages/opentelemetry/ @getsentry/team-javascript-sdks-server +/packages/deno/ @getsentry/team-javascript-sdks-server +/packages/bun/ @getsentry/team-javascript-sdks-server +/packages/cloudflare/ @getsentry/team-javascript-sdks-server +/packages/aws-serverless/ @getsentry/team-javascript-sdks-server +/packages/google-cloud-serverless/ @getsentry/team-javascript-sdks-server +/packages/vercel-edge/ @getsentry/team-javascript-sdks-server +/dev-packages/node-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/node-core-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/cloudflare-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/bun-integration-tests/ @getsentry/team-javascript-sdks-server + +# Framework integration packages +/packages/angular/ @getsentry/team-javascript-sdks-framework +/packages/astro/ @getsentry/team-javascript-sdks-framework +/packages/effect/ @getsentry/team-javascript-sdks-framework +/packages/elysia/ @getsentry/team-javascript-sdks-framework +/packages/ember/ @getsentry/team-javascript-sdks-framework +/packages/gatsby/ @getsentry/team-javascript-sdks-framework +/packages/hono/ @getsentry/team-javascript-sdks-framework +/packages/nestjs/ @getsentry/team-javascript-sdks-framework +/packages/nextjs/ @getsentry/team-javascript-sdks-framework +/packages/nitro/ @getsentry/team-javascript-sdks-framework +/packages/nuxt/ @getsentry/team-javascript-sdks-framework +/packages/react/ @getsentry/team-javascript-sdks-framework +/packages/react-router/ @getsentry/team-javascript-sdks-framework +/packages/remix/ @getsentry/team-javascript-sdks-framework +/packages/solid/ @getsentry/team-javascript-sdks-framework +/packages/solidstart/ @getsentry/team-javascript-sdks-framework +/packages/svelte/ @getsentry/team-javascript-sdks-framework +/packages/sveltekit/ @getsentry/team-javascript-sdks-framework +/packages/tanstackstart/ @getsentry/team-javascript-sdks-framework +/packages/tanstackstart-react/ @getsentry/team-javascript-sdks-framework +/packages/vue/ @getsentry/team-javascript-sdks-framework diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 47edbfeed264..499244434f82 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -52,6 +52,7 @@ body: - '@sentry/google-cloud-serverless' - '@sentry/nestjs' - '@sentry/nextjs' + - '@sentry/nitro' - '@sentry/nuxt' - '@sentry/react' - '@sentry/react-router' diff --git a/.github/actions/nx-affected-list/action.yml b/.github/actions/nx-affected-list/action.yml new file mode 100644 index 000000000000..314386659540 --- /dev/null +++ b/.github/actions/nx-affected-list/action.yml @@ -0,0 +1,40 @@ +name: 'Nx Affected List' +description: 'Outputs a space-separated list of Nx projects affected by changes between base and head commits.' + +inputs: + base: + description: 'Base commit SHA' + required: false + head: + description: 'Head commit SHA' + required: false + +outputs: + affected: + description: 'Space-separated list of affected project names' + value: ${{ steps.affected.outputs.affected }} + +runs: + using: 'composite' + steps: + - name: Get affected Nx projects + id: affected + shell: bash + env: + INPUT_BASE: ${{ inputs.base }} + INPUT_HEAD: ${{ inputs.head }} + run: | + set -euo pipefail + extra_args=() + if [ -n "${INPUT_BASE:-}" ]; then extra_args+=(--base="$INPUT_BASE"); fi + if [ -n "${INPUT_HEAD:-}" ]; then extra_args+=(--head="$INPUT_HEAD"); fi + + # Fail the step on nx/git errors so empty output cannot skip integration jobs silently. + AFFECTED=$(./node_modules/.bin/nx show projects --affected "${extra_args[@]}" | tr '\n' ' ' | xargs) + echo "affected=$AFFECTED" >> "$GITHUB_OUTPUT" + + if [ -n "$AFFECTED" ]; then + echo "Affected projects: $AFFECTED" + else + echo "No affected projects found" + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index add193a29d3b..ffcfe94821b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,6 @@ concurrency: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - # WARNING: this disables cross os caching as ~ and - # github.workspace evaluate to differents paths CACHED_DEPENDENCY_PATHS: | ${{ github.workspace }}/node_modules ${{ github.workspace }}/packages/*/node_modules @@ -38,25 +36,23 @@ env: # DEPENDENCY_CACHE_KEY: can't be set here because we don't have access to yarn.lock - # WARNING: this disables cross os caching as ~ and - # github.workspace evaluate to differents paths - # packages/utils/cjs and packages/utils/esm: Symlinks to the folders inside of `build`, needed for tests - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dev-packages/*/build - ${{ github.workspace }}/packages/*/build - ${{ github.workspace }}/packages/*/lib - ${{ github.workspace }}/packages/ember/*.d.ts - ${{ github.workspace }}/packages/gatsby/*.d.ts - - BUILD_CACHE_TARBALL_KEY: tarball-${{ github.event.inputs.commit || github.sha }} - - # GH will use the first restore-key it finds that matches - # So it will start by looking for one from the same branch, else take the newest one it can find elsewhere - # We want to prefer the cache from the current develop branch, if we don't find any on the current branch - NX_CACHE_RESTORE_KEYS: | - nx-Linux-${{ github.ref }}-${{ github.event.inputs.commit || github.sha }} - nx-Linux-${{ github.ref }} - nx-Linux + # build-output artifact paths are computed in job_build (step output from + # yarn ci:print-build-artifact-paths — Nx merged outputs for build:transpile, and build:types). + + # upload-artifact globs drop the path through the first `*` (see upload-artifact + # README). Tarballs therefore land in the artifact as /*.tgz; download + # build-tarball-output into packages/ so they resolve to packages//*.tgz. + TARBALL_ARTIFACT_GLOB: packages/*/*.tgz + TARBALL_ARTIFACT_DOWNLOAD_PATH: ${{ github.workspace }}/packages + + BUILD_LAYER_PATH: ${{ github.workspace }}/packages/aws-serverless/build/aws/dist-serverless + + # Same glob / download split as TARBALL_ARTIFACT_*: upload-artifact strips the path through + # the first `*`, so bundle trees are stored as /build/bundles/...; download into packages/. + BUNDLE_ARTIFACT_GLOB: packages/*/build/bundles + BUNDLE_ARTIFACT_DOWNLOAD_PATH: ${{ github.workspace }}/packages + + NX_CACHE_KEY: nx-Linux-${{ github.head_ref || github.ref }}-${{ github.run_id }} # https://bsky.app/profile/joyeecheung.bsky.social/post/3lhy6o54fo22h # Apparently some of our CI failures are attributable to a corrupt v8 cache, causing v8 failures with: "Check failed: current == end_slot_index.". @@ -66,6 +62,7 @@ env: jobs: job_get_metadata: uses: ./.github/workflows/ci-metadata.yml + name: Get CI Metadata with: head_commit: ${{ github.event.inputs.commit || github.sha }} permissions: @@ -103,41 +100,58 @@ jobs: id: install_dependencies - name: Check for Affected Nx Projects - uses: dkhunt27/action-nx-affected-list@v6.1 + uses: ./.github/actions/nx-affected-list id: checkForAffected if: github.event_name == 'pull_request' with: base: ${{ github.event.pull_request.base.sha }} head: ${{ env.HEAD_COMMIT }} - - name: NX cache - uses: actions/cache@v5 - # Disable cache when: - # - on release branches - # - when PR has `ci-skip-cache` label or on nightly builds - if: | - needs.job_get_metadata.outputs.is_release == 'false' && - needs.job_get_metadata.outputs.force_skip_cache == 'false' - with: - path: .nxcache - key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT || github.sha }} - # On develop branch, we want to _store_ the cache (so it can be used by other branches), but never _restore_ from it - restore-keys: - ${{needs.job_get_metadata.outputs.is_base_branch == 'false' && env.NX_CACHE_RESTORE_KEYS || - 'nx-never-restore'}} - - name: Build packages - run: yarn build + run: yarn build:ci + + - name: Compute build artifact paths from Nx + id: nx_build_paths + run: | + { + echo 'paths<> "$GITHUB_OUTPUT" + + - name: Store NX cache + uses: actions/cache/save@v5 + # Only cache this per-PR to speed up CI. + if: github.event_name == 'pull_request' + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: build-output - path: ${{ env.CACHED_BUILD_PATHS }} + path: ${{ steps.nx_build_paths.outputs.paths }} retention-days: 4 compression-level: 6 overwrite: true + - name: Determine which test applications should be run + id: matrix + run: + yarn --silent ci:build-matrix --base=${{ (github.event_name == 'pull_request' && + github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT + working-directory: dev-packages/e2e-tests + + - name: Determine which optional E2E test applications should be run + id: matrix-optional + run: + yarn --silent ci:build-matrix-optional --base=${{ (github.event_name == 'pull_request' && + github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT + working-directory: dev-packages/e2e-tests + outputs: dependency_cache_key: ${{ steps.install_dependencies.outputs.cache_key }} changed_node_integration: @@ -164,6 +178,92 @@ jobs: changed_browser_integration: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry-internal/browser-integration-tests') }} + changed_aws_serverless: + ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, + '@sentry/aws-serverless') }} + e2e-matrix: ${{ steps.matrix.outputs.matrix }} + e2e-matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} + + job_build_layer: + name: Build Lambda layer + needs: [job_get_metadata, job_build] + if: + needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix, + 'aws-serverless') || github.event_name != 'pull_request' + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v6 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Build Lambda layer + run: yarn build:layer + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} + retention-days: 4 + compression-level: 6 + overwrite: true + + job_build_bundles: + name: Build bundles + needs: [job_get_metadata, job_build] + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v6 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Build bundles + run: yarn build:bundle + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_GLOB }} + retention-days: 4 + compression-level: 6 + overwrite: true job_check_branches: name: Check PR branches @@ -182,7 +282,7 @@ jobs: job_size_check: name: Size Check - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] timeout-minutes: 15 runs-on: ubuntu-24.04 if: @@ -201,6 +301,11 @@ jobs: uses: ./.github/actions/restore-cache with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} - name: Check bundle sizes uses: ./dev-packages/size-limit-gh-action with: @@ -298,7 +403,7 @@ jobs: job_artifacts: name: Upload Artifacts - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs] runs-on: ubuntu-24.04 # Build artifacts are only needed for releasing workflow. if: needs.job_get_metadata.outputs.is_release == 'true' @@ -316,8 +421,23 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Pack tarballs - run: yarn build:tarball + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 + with: + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} + + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + + - name: Restore build layer artifacts + uses: actions/download-artifact@v7 + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} - name: Archive artifacts uses: actions/upload-artifact@v7 @@ -478,7 +598,7 @@ jobs: name: Playwright ${{ matrix.bundle }}${{ matrix.project && matrix.project != 'chromium' && format(' {0}', matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04-large-js timeout-minutes: 25 @@ -543,6 +663,12 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + - name: Install Playwright uses: ./.github/actions/install-playwright with: @@ -581,7 +707,7 @@ jobs: job_browser_loader_tests: name: PW ${{ matrix.bundle }} Tests - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -611,6 +737,12 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + - name: Install Playwright uses: ./.github/actions/install-playwright with: @@ -662,7 +794,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Check for dts files that reference stuff in the temporary build folder run: | - if grep -r --include "*.d.ts" --exclude-dir ".nxcache" 'import("@sentry(-internal)?/[^/]*/build' .; then + if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then echo "Found illegal TypeScript import statement." exit 1 fi @@ -834,25 +966,17 @@ jobs: cd packages/remix yarn test:integration:ci - job_e2e_prepare: - name: Prepare E2E tests + job_build_tarballs: + name: Build tarballs # We want to run this if: # - The build job was successful, not skipped if: | always() && needs.job_build.result == 'success' needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04-large-js + runs-on: ubuntu-24.04 timeout-minutes: 15 - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} steps: - - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v6 - if: github.event_name == 'pull_request' - with: - ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v6 with: @@ -861,48 +985,39 @@ jobs: uses: actions/setup-node@v6 with: node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + - name: Restore caches uses: ./.github/actions/restore-cache with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: NX cache - uses: actions/cache/restore@v5 - with: - path: .nxcache - key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} - # On develop branch, we want to _store_ the cache (so it can be used by other branches), but never _restore_ from it - restore-keys: ${{ env.NX_CACHE_RESTORE_KEYS }} - name: Build tarballs run: yarn build:tarball - - name: Stores tarballs in cache - uses: actions/cache/save@v5 + - name: Upload tarball artifacts + uses: actions/upload-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} - - - name: Determine which E2E test applications should be run - id: matrix - run: - yarn --silent ci:build-matrix --base=${{ (github.event_name == 'pull_request' && - github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT - working-directory: dev-packages/e2e-tests - - - name: Determine which optional E2E test applications should be run - id: matrix-optional - run: - yarn --silent ci:build-matrix-optional --base=${{ (github.event_name == 'pull_request' && - github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT - working-directory: dev-packages/e2e-tests + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_GLOB }} + if-no-files-found: error + retention-days: 4 + compression-level: 6 + overwrite: true job_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test # We need to add the `always()` check here because the previous step has this as well :( # See: https://github.com/actions/runner/issues/2205 if: - always() && needs.job_e2e_prepare.result == 'success' && needs.job_e2e_prepare.outputs.matrix != '{"include":[]}' - needs: [job_get_metadata, job_build, job_e2e_prepare] + always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}' + needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs] runs-on: ubuntu-24.04 timeout-minutes: 15 env: @@ -916,13 +1031,13 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' strategy: fail-fast: false - matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix) }} + matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 9.15.9 - name: Set up Node @@ -950,16 +1065,18 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Restore tarball cache - uses: actions/cache/restore@v5 - id: restore-tarball-cache + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} - - name: Build tarballs if not cached - if: steps.restore-tarball-cache.outputs.cache-hit != 'true' - run: yarn build:tarball + - name: Restore build layer artifacts + uses: actions/download-artifact@v7 + if: matrix.test-application == 'aws-serverless' || matrix.test-application == 'aws-serverless-layer' + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} - name: Prepare e2e tests run: yarn test:prepare @@ -1030,10 +1147,10 @@ jobs: # We need to add the `always()` check here because the previous step has this as well :( # See: https://github.com/actions/runner/issues/2205 if: - always() && needs.job_get_metadata.outputs.is_release != 'true' && needs.job_e2e_prepare.result == 'success' && - needs.job_e2e_prepare.outputs.matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || + always() && needs.job_get_metadata.outputs.is_release != 'true' && needs.job_build_tarballs.result == 'success' && + needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' - needs: [job_get_metadata, job_build, job_e2e_prepare] + needs: [job_get_metadata, job_build, job_build_tarballs] runs-on: ubuntu-24.04 timeout-minutes: 15 env: @@ -1047,14 +1164,14 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' strategy: fail-fast: false - matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix-optional) }} + matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 9.15.9 - name: Set up Node @@ -1066,16 +1183,11 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Restore tarball cache - uses: actions/cache/restore@v5 - id: restore-tarball-cache + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} - - - name: Build tarballs if not cached - if: steps.restore-tarball-cache.outputs.cache-hit != 'true' - run: yarn build:tarball + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} - name: Prepare E2E tests run: yarn test:prepare @@ -1131,6 +1243,9 @@ jobs: needs: [ job_build, + job_build_bundles, + job_build_layer, + job_build_tarballs, job_browser_unit_tests, job_bun_unit_tests, job_deno_unit_tests, diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml new file mode 100644 index 000000000000..d837fc254bf2 --- /dev/null +++ b/.github/workflows/bump-size-limits.yml @@ -0,0 +1,106 @@ +name: 'Auto-bump size-limit thresholds' + +on: + schedule: + - cron: '0 9 * * 5' # Friday 09:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +env: + CACHED_DEPENDENCY_PATHS: | + ${{ github.workspace }}/node_modules + ${{ github.workspace }}/packages/*/node_modules + ${{ github.workspace }}/dev-packages/*/node_modules + ~/.cache/mongodb-binaries/ + +concurrency: + group: bump-size-limits + cancel-in-progress: false + +jobs: + bump: + name: Bump size-limit thresholds + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} + + - name: Checkout develop + uses: actions/checkout@v6 + with: + ref: develop + token: ${{ steps.app-token.outputs.token }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Build packages + run: yarn build + + - name: Run bumper + # Capture stdout AND exit code without failing the step on exit-2 (no-op). + # The script writes .size-limit.js in place; create-pull-request handles + # commit/branch/PR — if there's no diff, it skips opening a PR. + run: | + set +e + node scripts/bump-size-limits.mjs > /tmp/bump-summary.md + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then + echo "::error::bump script failed with exit code $code" + cat /tmp/bump-summary.md || true + exit "$code" + fi + cat /tmp/bump-summary.md + + - name: Create or update PR + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: 'chore(size-limit): auto-bump weekly drift' + title: 'chore(size-limit): weekly auto-bump' + body-path: /tmp/bump-summary.md + branch: bot/bump-size-limits + base: develop + labels: 'Dev: CI' + add-paths: '.size-limit.js' + delete-branch: true + + - name: Open or comment on failure issue + if: failure() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title='Weekly size-limit auto-bump failure' + existing=$(gh issue list --search "in:title \"$title\"" --state open --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1) + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "Auto-bump workflow failed again: $RUN_URL" + else + body=$(cat < ({ + dsn: env.SENTRY_DSN, + enableRpcTracePropagation: true, + }), + handler, + ); + + // Durable Object + export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + env => ({ + dsn: env.SENTRY_DSN, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, + ); + ``` + +- **feat(hono)!: Change setup for `@sentry/hono/node` (`init` in external file) ([#20497](https://github.com/getsentry/sentry-javascript/pull/20497))** + + To improve Node.js instrumentation, the `sentry()` middleware exported from `@sentry/hono/node` no longer accepts configuration options. + Instead, you must configure the SDK by calling `Sentry.init()` in a dedicated instrumentation file that runs before your application code (read more in the [Hono SDK readme](https://github.com/getsentry/sentry-javascript/blob/develop/packages/hono/README.md): + + ```ts + // instrument.mjs (or instrument.ts) + import * as Sentry from '@sentry/hono/node'; + + Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, + }); + ``` + +- **feat(nitro): Add `@sentry/nitro` SDK ([#19224](https://github.com/getsentry/sentry-javascript/pull/19224))** + + A new `@sentry/nitro` package provides first-class Sentry support for [Nitro](https://nitro.build/) applications, with HTTP handler and error instrumentation, middleware tracing, request isolation, and build-time source map uploading via `withSentryConfig`. + Read more in the [Nitro SDK docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) and the [Nitro SDK readme](https://github.com/getsentry/sentry-javascript/blob/develop/packages/nitro/README.md). + +### Other Changes + +- deps(minimatch): Upgrade patch version to use new `brace-expansion` peer-dep ([#20198](https://github.com/getsentry/sentry-javascript/pull/20198)) +- docs: Add deprecation notices to `bin` scripts ([#20570](https://github.com/getsentry/sentry-javascript/pull/20570)) +- feat(astro): Drop prerendered http.server filter via `ignoreSpans` ([#20513](https://github.com/getsentry/sentry-javascript/pull/20513)) +- feat(aws-serverless): Validate extension tunnel DSN against `SENTRY_DSN` ([#20528](https://github.com/getsentry/sentry-javascript/pull/20528)) +- feat(browser): Add `ingest_settings` to span v2 envelope payload ([#20411](https://github.com/getsentry/sentry-javascript/pull/20411)) +- feat(browser): Add support for streamed spans in `httpContextIntegration` ([#20464](https://github.com/getsentry/sentry-javascript/pull/20464)) +- feat(core): Backfill otel attributes on streamed spans ([#20439](https://github.com/getsentry/sentry-javascript/pull/20439)) +- feat(core): clear up integrations on dispose ([#20407](https://github.com/getsentry/sentry-javascript/pull/20407)) +- feat(core): Instrument langgraph createReactAgent ([#20344](https://github.com/getsentry/sentry-javascript/pull/20344)) +- feat(core): Support attribute matching in `ignoreSpans` ([#20512](https://github.com/getsentry/sentry-javascript/pull/20512)) +- feat(feedback): allow error messages to be customized ([#20474](https://github.com/getsentry/sentry-javascript/pull/20474)) +- feat(hono): Support middleware spans defined in app groups ([#20465](https://github.com/getsentry/sentry-javascript/pull/20465)) +- feat(nextjs): Filter unwanted segments when span streaming is enabled ([#20384](https://github.com/getsentry/sentry-javascript/pull/20384)) +- feat(nextjs): Migrate edge event processors to span-first APIs ([#20551](https://github.com/getsentry/sentry-javascript/pull/20551)) +- feat(nextjs): Migrate server event processors to span-first APIs ([#20527](https://github.com/getsentry/sentry-javascript/pull/20527)) +- feat(nextjs): Set global attribute for turbopack usage ([#20558](https://github.com/getsentry/sentry-javascript/pull/20558)) +- feat(nitro): Nitro SDK ([#19224](https://github.com/getsentry/sentry-javascript/pull/19224)) +- feat(react-router): Clean up bogus `*` http.route attribute on segment spans ([#20471](https://github.com/getsentry/sentry-javascript/pull/20471)) +- feat(react-router): Drop low-quality transactions via `ignoreSpans` ([#20514](https://github.com/getsentry/sentry-javascript/pull/20514)) +- feat(sveltekit): Support span streaming in `svelteKitSpansEnhancement` integration ([#20496](https://github.com/getsentry/sentry-javascript/pull/20496)) +- feat(tanstackstart-react): Add dynamic tunnel route helper and generator ([#20264](https://github.com/getsentry/sentry-javascript/pull/20264)) +- fix: update prisma v7 spans descriptions ([#20456](https://github.com/getsentry/sentry-javascript/pull/20456)) +- fix(core): Avoid parse-time SyntaxError on Safari <16.4 in postgresjs ([#20498](https://github.com/getsentry/sentry-javascript/pull/20498)) +- fix(core): Ensure `isSentryRequest` handles subdomains properly ([#20530](https://github.com/getsentry/sentry-javascript/pull/20530)) +- fix(core): Ensure ip address headers are stripped when lower case ([#20484](https://github.com/getsentry/sentry-javascript/pull/20484)) +- fix(core): Filter more cookie names for PII ([#20485](https://github.com/getsentry/sentry-javascript/pull/20485)) +- fix(core): Use symbol for normalization checks ([#20486](https://github.com/getsentry/sentry-javascript/pull/20486)) +- fix(hono): Distinguish `.use()` middleware in sub-apps from `.all()` handlers ([#20554](https://github.com/getsentry/sentry-javascript/pull/20554)) +- fix(nextjs): Ensure we do not match tunnel endpoints too broadly ([#20488](https://github.com/getsentry/sentry-javascript/pull/20488)) +- fix(opentelemetry): Add conditional browser export to avoid node deps ([#20556](https://github.com/getsentry/sentry-javascript/pull/20556)) +- fix(replay): Avoid main-thread blocking in WorkerHandler under event bursts ([#20548](https://github.com/getsentry/sentry-javascript/pull/20548)) +- fix(replay): Ensure `maskAttributes` works with `maskAllText=false` ([#20491](https://github.com/getsentry/sentry-javascript/pull/20491)) +- fix(supabase): Consider `sendDefaultPii` for supabase integration ([#20490](https://github.com/getsentry/sentry-javascript/pull/20490)) + +
+ Internal Changes + +- chore: Add size limit reports on PRs for Cloudflare ([#20055](https://github.com/getsentry/sentry-javascript/pull/20055)) +- chore: Update CODEOWNERS ([#20559](https://github.com/getsentry/sentry-javascript/pull/20559)) +- chore(build): Opt-out of nx analytics ([#20487](https://github.com/getsentry/sentry-javascript/pull/20487)) +- chore(ci): Automatically bump size limit every week ([#20531](https://github.com/getsentry/sentry-javascript/pull/20531)) +- chore(ci): Bump pnpm/action-setup to v5 and pin to commit SHA ([#20462](https://github.com/getsentry/sentry-javascript/pull/20462)) +- chore(ci): Do not report flaky test issues if we cannot find a test name ([#20589](https://github.com/getsentry/sentry-javascript/pull/20589)) +- chore(ci): Streamline CI setup to split bundle, layer, tarball generation ([#20396](https://github.com/getsentry/sentry-javascript/pull/20396)) +- chore(ci): Vendor nx-affected-list action, drop dkhunt27 dependency ([#20463](https://github.com/getsentry/sentry-javascript/pull/20463)) +- chore(e2e): Add vue and vue-router to nuxt-4 canary build step to fix rollup resolution ([#20519](https://github.com/getsentry/sentry-javascript/pull/20519)) +- chore(e2e): Remove @tanstack/start-plugin-core override ([#20518](https://github.com/getsentry/sentry-javascript/pull/20518)) +- chore(size-limit): weekly auto-bump ([#20572](https://github.com/getsentry/sentry-javascript/pull/20572)) +- chore(skill): Add skill for writing unit and E2E tests ([#20561](https://github.com/getsentry/sentry-javascript/pull/20561)) +- chore(test): Reduce unneeded `idleTimeout` test config ([#20467](https://github.com/getsentry/sentry-javascript/pull/20467)) +- ci(size-bump): Fix path in size-limit auto-bump workflow ([#20566](https://github.com/getsentry/sentry-javascript/pull/20566)) +- fix(e2e/tanstackstart-react): pin @tanstack/start-plugin-core to unblock CI ([#20482](https://github.com/getsentry/sentry-javascript/pull/20482)) +- fix(tests): Remove nitro canary test job ([#20473](https://github.com/getsentry/sentry-javascript/pull/20473)) +- ref(browser): Use `safeSetSpanJSONAttributes` in cultureContext integration ([#20481](https://github.com/getsentry/sentry-javascript/pull/20481)) +- test(browser): Unflake some more tests ([#20591](https://github.com/getsentry/sentry-javascript/pull/20591)) +- test(nextjs): Pin `eslint-config-next` package to major ([#20552](https://github.com/getsentry/sentry-javascript/pull/20552)) +- test(node): Fix flaky ANR test ([#20592](https://github.com/getsentry/sentry-javascript/pull/20592)) +- test(node): Fix flaky worker thread integration test ([#20588](https://github.com/getsentry/sentry-javascript/pull/20588)) +- test(node): Unflake postgres tests ([#20593](https://github.com/getsentry/sentry-javascript/pull/20593)) +- test(node): Update timeout for cron integration tests ([#20586](https://github.com/getsentry/sentry-javascript/pull/20586)) +- test(supabase): Stop supabase before initializing ([#20563](https://github.com/getsentry/sentry-javascript/pull/20563)) +- test(tanstack): Prefix test labels ([#20569](https://github.com/getsentry/sentry-javascript/pull/20569)) + +
+ ## 10.50.0 ### Important Changes diff --git a/README.md b/README.md index 841a6380b5e2..71ae65bbe406 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ package. Please refer to the README and instructions of those SDKs for more deta - [`@sentry/gatsby`](https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby): SDK for Gatsby - [`@sentry/nestjs`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs): SDK for NestJS - [`@sentry/nextjs`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs): SDK for Next.js +- [`@sentry/nitro`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro): SDK for Nitro - [`@sentry/remix`](https://github.com/getsentry/sentry-javascript/tree/master/packages/remix): SDK for Remix - [`@sentry/tanstackstart-react`](https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react): SDK for TanStack Start React - [`@sentry/aws-serverless`](https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless): SDK diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 59e83fa7cca1..2eb1ae9ebfb5 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -61,6 +61,8 @@ "@playwright/test": "~1.56.0", "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.50.0", + "@sentry-internal/replay": "10.50.0", + "@sentry/opentelemetry": "10.50.0", "@supabase/supabase-js": "2.49.3", "axios": "1.15.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js new file mode 100644 index 000000000000..c69a872adc77 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration(), Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts new file mode 100644 index 000000000000..cb1e88072a9f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('httpContextIntegration captures url, user-agent, and referer', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url, { referer: 'https://sentry.io/' }); + + const spans = await spansPromise; + + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + expect(pageloadSpan!.attributes?.['url.full']).toEqual({ type: 'string', value: expect.any(String) }); + expect(pageloadSpan!.attributes?.['http.request.header.user_agent']).toEqual({ + type: 'string', + value: expect.any(String), + }); + expect(pageloadSpan!.attributes?.['http.request.header.referer']).toEqual({ + type: 'string', + value: 'https://sentry.io/', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js index 0f600426009f..30928883d2d8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js @@ -9,6 +9,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], tracesSampleRate: 1.0, + sendDefaultPii: true, }); // Simulate database operations diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js new file mode 100644 index 000000000000..d8c94f36fdd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js new file mode 100644 index 000000000000..8d51286de101 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js @@ -0,0 +1,13 @@ +import * as SentryOpenTelemetry from '@sentry/opentelemetry'; +import * as Sentry from '@sentry/browser'; + +// Verify that generally all imports can be resolved +// oxlint-disable-next-line no-console +for (const key in SentryOpenTelemetry) { + console.log(key, SentryOpenTelemetry[key]); +} + +// Verify that it console.errors if calling node-only thing +new SentryOpenTelemetry.SentryAsyncLocalStorageContextManager(); + +Sentry.captureException(new Error('test')); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts new file mode 100644 index 000000000000..79a45bd8a1ed --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../utils/helpers'; + +sentryTest('Should allow importing from @sentry/opentelemetry package', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE; + + if (bundle && bundle.includes('bundle')) { + sentryTest.skip(); + return; + } + + const consoleMessages: string[] = []; + page.on('console', msg => { + consoleMessages.push(msg.text()); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForErrorRequestOnUrl(page, url); + const eventData = envelopeRequestParser(req); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0].value).toBe('test'); + + expect(consoleMessages).toContainEqual('SentryAsyncLocalStorageContextManager is not supported in the browser'); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index e05685ca4868..de4bddd69f57 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -39,16 +39,15 @@ sentryTest( } const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); - await page.goto(url); - const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( page, 1, - { envelopeType: 'profile_chunk' }, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, properFullEnvelopeRequestParser, ); - const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; const envelopeItemHeader = profileChunkEnvelopeItem[0]; const envelopeItemPayload = profileChunkEnvelopeItem[1]; diff --git a/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js b/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js index 8c0a0cd9fca4..9bb07ba0cc11 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js @@ -4,6 +4,11 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 3000, childSpanTimeout: 3000 })], + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 3000, + childSpanTimeout: 3000, + }), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts index dfb1098bc650..9880598b9294 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts @@ -21,10 +21,10 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - const errorEventsPromise = getMultipleSentryEnvelopeRequests(page, 2); + await page.goto(url); + await runScriptInSandbox(page, { content: ` function run() { diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 7a70c832558f..1f1e44e97c43 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -56,6 +56,8 @@ sentryTest( [ { content_type: 'application/vnd.sentry.items.span.v2+json', item_count: 4, type: 'span' }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: expect.any(Array), }, ], @@ -179,6 +181,14 @@ sentryTest( type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js index 635210f2252c..9a11fe3682bb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, instrumentPageLoad: false, instrumentNavigation: false, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts index fe48966ddd6e..bf54209f053a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts @@ -21,10 +21,10 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - const errorEventsPromise = getMultipleSentryEnvelopeRequests(page, 2); + await page.goto(url); + await runScriptInSandbox(page, { content: ` throw new Error('Error during pageload'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js index 7eff1a54e9ff..8ec045483e0c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, _experiments: { enableHTTPTimings: true, }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js index e32d09a13fab..575e3fa65693 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, _experiments: { enableHTTPTimings: true, }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js index 409d1e4e7906..cd7948aadbc1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js @@ -7,7 +7,6 @@ Sentry.init({ integrations: [ Sentry.browserTracingIntegration({ ignorePerformanceApiSpans: ['measure-ignore', /mark-i/], - idleTimeout: 9000, }), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js index 385e9ed6b6cf..768ed5defbe8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, _experiments: { enableInteractions: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts index f1b0882d2325..383ecade3530 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts @@ -52,6 +52,14 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'idleTimeout', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js index 846538e7f3f0..6a7a77dff8b7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, _experiments: { enableInteractions: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js index ee197adaa33c..d3c861a8ae26 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: false, enableLongAnimationFrame: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js index f00d680435bb..019eece018f6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: false, enableLongAnimationFrame: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js index 965613d5464e..3c004b952f80 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), Sentry.spanStreamingIntegration(), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js index e1b3f6b13b01..0339c3fa3dbf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js index 1f6cc0a8f463..2be5f32985d7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: false, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js index 4be408ceab7e..f16f7af33b5c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: false, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js index ca1bf10dcddd..382115cbc8b9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: true, enableLongAnimationFrame: true, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: true, + enableLongAnimationFrame: true, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js index 3e3eedaf49b7..b0dc0a8d7ff5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: true, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js index d81b8932803c..382115cbc8b9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: true, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js index f6e5ce777e06..9f36511d8ff5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongAnimationFrame: false, instrumentPageLoad: false, instrumentNavigation: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js index 5986089e5aa4..ccf669361e37 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongAnimationFrame: false, instrumentPageLoad: false, instrumentNavigation: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js index 965613d5464e..3c004b952f80 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), Sentry.spanStreamingIntegration(), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js index e1b3f6b13b01..0339c3fa3dbf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js index 484350c14fcf..977ff9c6425e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongAnimationFrame: false, }), Sentry.spanStreamingIntegration(), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js index 319dfaadd4a8..47e9fd0b92d8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongAnimationFrame: false, }), ], diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js index 0e35db50764f..723707736f2e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: true, enableLongAnimationFrame: false, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js index 06caf2c2c239..fcce12c3dca2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js @@ -4,7 +4,11 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 2000, detectRedirects: false })], + integrations: [ + Sentry.browserTracingIntegration({ + detectRedirects: false, + }), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index 520a3d330bb9..c2dcad317a3f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -81,6 +81,14 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, 'device.processor_count': { type: expect.stringMatching(/^(integer)|(double)$/), value: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js index 9627bfc003e7..207473572df5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, onRequestSpanEnd(span, { headers }) { if (headers) { span.setAttribute('hook.called.response-type', headers.get('x-response-type')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js index 2c85bd05b765..680dfd7304df 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, onRequestSpanStart(span, { headers }) { if (headers) { span.setAttribute('hook.called.headers', headers.get('foo')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index 6b09fcd0097d..2344e28c67d4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -74,6 +74,14 @@ sentryTest( type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, // formerly known as 'hardwareConcurrency' 'device.processor_count': { type: expect.stringMatching(/^(integer)|(double)$/), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js index 70c0b30a03a5..3a4c2cb1cdf4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js @@ -7,7 +7,6 @@ Sentry.init({ integrations: [ Sentry.browserTracingIntegration({ ignoreResourceSpans: ['resource.script'], - idleTimeout: 9000, }), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js new file mode 100644 index 000000000000..a6a4e1f4740b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + ignoreSpans: [{ attributes: { 'http.status_code': 200 } }], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js new file mode 100644 index 000000000000..741f4077d2ca --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js @@ -0,0 +1,11 @@ +// This segment span matches ignoreSpans via attributes — segment + child should be dropped +Sentry.startSpan({ name: 'health-check', attributes: { 'http.status_code': 200 } }, () => { + Sentry.startSpan({ name: 'child-of-ignored' }, () => {}); +}); + +setTimeout(() => { + // This segment span does NOT match — segment + child should be sent + Sentry.startSpan({ name: 'normal-segment', attributes: { 'http.status_code': 500 } }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); +}, 1000); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts new file mode 100644 index 000000000000..903e2d4e9e2b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('attribute-matching ignoreSpans drops the trace', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + observeStreamedSpan(page, span => { + if (span.name === 'health-check' || span.name === 'child-of-ignored') { + throw new Error('Ignored span found'); + } + return false; + }); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment')); + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(url); + + expect((await spansPromise)?.length).toBe(2); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 2, reason: 'ignored' }]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js index 645668376b36..0878cd4e9ad6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -1,10 +1,10 @@ -// This segment span matches ignoreSpans — should NOT produce a transaction +// This segment span matches ignoreSpans — segment + child should be dropped Sentry.startSpan({ name: 'ignore-segment' }, () => { Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {}); }); setTimeout(() => { - // This segment span does NOT match — should produce a transaction + // This segment span does NOT match — segment + child should be sent Sentry.startSpan({ name: 'normal-segment' }, () => { Sentry.startSpan({ name: 'child-span' }, () => {}); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts index 1cb951621e21..6f4b885e71d1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts @@ -15,14 +15,14 @@ async function createSessionWithLatency(page: Page, latency: number) { await session.send('Network.emulateNetworkConditions', { offline: false, latency: latency, - downloadThroughput: (25 * 1024) / 8, + downloadThroughput: (100 * 1024) / 8, uploadThroughput: (5 * 1024) / 8, }); return session; } -sentryTest('should capture a `connection.rtt` metric.', async ({ getLocalTestUrl, page }) => { +sentryTest('should capture a `connection.rtt` metric. xxx', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js index ad1d8832b228..83076460599f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js @@ -4,10 +4,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js index f4df5dbe13e8..77a2ced629d8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js @@ -23,11 +23,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration({})], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js index f3e6fa567911..179f899527e8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js @@ -11,10 +11,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js index 32fbb07fbbae..dce8cd2508fd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js @@ -6,12 +6,11 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, + idleTimeout: 5000, _experiments: { enableStandaloneClsSpans: true, }, }), ], tracesSampleRate: 1, - debug: true, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js new file mode 100644 index 000000000000..361367d4b8a4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 5000, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js index 1044a4b68bda..9a26371a9461 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, enableInp: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js index 895e6f60ff42..9451c499ae82 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, enableInp: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js index 469f44076e73..8be8d8420f85 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js @@ -5,6 +5,11 @@ window._testBaseTimestamp = performance.timeOrigin / 1000; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 4000 }), Sentry.spanStreamingIntegration()], + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 5000, + }), + Sentry.spanStreamingIntegration(), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js index 8da426e106b8..d09eeab5f565 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js @@ -6,7 +6,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, + idleTimeout: 5000, _experiments: { enableStandaloneLcpSpans: true, }, diff --git a/dev-packages/browser-integration-tests/utils/generatePage.ts b/dev-packages/browser-integration-tests/utils/generatePage.ts index 3bba565fd147..a74cd7dfe7bb 100644 --- a/dev-packages/browser-integration-tests/utils/generatePage.ts +++ b/dev-packages/browser-integration-tests/utils/generatePage.ts @@ -38,11 +38,13 @@ export async function generatePage( compiler.run(err => { if (err) { reject(err); + return; } compiler.close(err => { if (err) { reject(err); + return; } resolve(); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 72d93bdabf33..ff45418add73 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -147,6 +147,10 @@ export const LOADER_CONFIGS: Record; * so that the compiled versions aren't included */ function generateSentryAlias(): Record { + if (!useBundleOrLoader) { + return {}; + } + const rootPackageJson = JSON.parse(fs.readFileSync(ROOT_PACKAGE_JSON_PATH, 'utf8')) as { workspaces: string[] }; const packageNames = rootPackageJson.workspaces .filter(workspace => !workspace.startsWith('dev-packages/')) @@ -189,7 +193,10 @@ class SentryScenarioGenerationPlugin { } public apply(compiler: Compiler): void { - compiler.options.resolve.alias = generateSentryAlias(); + const sentryAlias = generateSentryAlias(); + if (Object.keys(sentryAlias).length > 0) { + compiler.options.resolve.alias = sentryAlias; + } compiler.options.externals = useBundleOrLoader ? { // To help Webpack resolve Sentry modules in `import` statements in cases where they're provided in bundles rather than in `node_modules` diff --git a/dev-packages/browser-integration-tests/webpack.config.ts b/dev-packages/browser-integration-tests/webpack.config.ts index ddf31bc897c4..6aa2f6cfb0cf 100644 --- a/dev-packages/browser-integration-tests/webpack.config.ts +++ b/dev-packages/browser-integration-tests/webpack.config.ts @@ -3,7 +3,11 @@ import type { Configuration } from 'webpack'; const config = function (userConfig: Record): Configuration { return { ...userConfig, + target: 'web', mode: 'none', + resolve: { + conditionNames: ['webpack', 'import', 'require', 'browser', 'default'], + }, module: { rules: [ { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts index 74ce2cbbdac4..659b04a3f488 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -6,14 +6,38 @@ interface Env { TEST_DURABLE_OBJECT: DurableObjectNamespace; } +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// This class mimics a real-world DO with private fields/methods and multiple public methods class TestDurableObjectBase extends DurableObject { + // Private field used by RPC methods - tests that private fields work with instrumentation + #greeting = 'Hello'; + public constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); } - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + // RPC method that uses a private field - this would throw TypeError if the Proxy + // doesn't correctly bind `this` to the original object async sayHello(name: string): Promise { - return `Hello, ${name}`; + return `${this.#greeting}, ${name}`; + } + + // RPC method that modifies a private field + async setGreeting(greeting: string): Promise { + this.#greeting = greeting; + } + + // Other public methods that are not called - should not interfere with RPC + async getStatus(): Promise { + return 'OK'; + } + + async processData(data: Record): Promise> { + return { ...data, processed: true }; + } + + async multiply(a: number, b: number): Promise { + return a * b; } } @@ -21,7 +45,7 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), TestDurableObjectBase, ); @@ -36,6 +60,13 @@ export default { return new Response(greeting); } + // Test endpoint that modifies and reads a private field via RPC + if (request.url.includes('custom-greeting')) { + await stub.setGreeting('Howdy'); + const greeting = await stub.sayHello('partner'); + return new Response(greeting); + } + return new Response('Usual response'); }, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index e86508c0f101..4e9e65f22118 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; it('traces a durable object method', async ({ signal }) => { @@ -25,3 +26,74 @@ it('traces a durable object method', async ({ signal }) => { await runner.makeRequest('get', '/hello'); await runner.completed(); }); + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// The RPC receiver does not implement the method error on consecutive calls +it('handles consecutive RPC calls without throwing "RPC receiver does not implement method" error', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // First request - this always worked + const response1 = await runner.makeRequest('get', '/hello'); + expect(response1).toBe('Hello, world'); + + // Second consecutive request - this used to fail with: + // "The RPC receiver does not implement the method 'sayHello'" + const response2 = await runner.makeRequest('get', '/hello'); + expect(response2).toBe('Hello, world'); + + await runner.completed(); +}); + +// Regression test: RPC methods that access private fields should work correctly. +// When enableRpcTracePropagation wraps the DO in a Proxy, calling methods through +// the Proxy must ensure `this` refers to the original object (not the Proxy), +// otherwise private field access throws: "Cannot read private member from an object +// whose class did not declare it" +it('allows RPC methods to access private class fields', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'setGreeting', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // This calls setGreeting (writes private field) then sayHello (reads private field) + // Would throw TypeError if `this` is the Proxy instead of the original object + const response = await runner.makeRequest('get', '/custom-greeting'); + expect(response).toBe('Howdy, partner'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..eb21c2918155 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } +} + +// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + // enableRpcTracePropagation: false (default) + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..cba40af5a43d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => { + let receivedTransactions: string[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Should only receive the worker HTTP transaction, not the DO RPC transaction + 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 /rpc/hello', + }), + ); + receivedTransactions.push(transactionEvent.transaction as string); + }) + .start(signal); + + // The RPC call should still work, just not be instrumented + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Verify we only got the worker transaction, no RPC transaction + expect(receivedTransactions).toEqual(['GET /rpc/hello']); + expect(receivedTransactions).not.toContain('sayHello'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..0711a1d68d37 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts new file mode 100644 index 000000000000..8c6ab60fbdd5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts new file mode 100644 index 000000000000..f86348ab6fbc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object via RPC method call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + 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: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .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 /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + 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: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..3f909c489513 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..f9a6fd2ed8ff --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts new file mode 100644 index 000000000000..3465449ba2fe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts new file mode 100644 index 000000000000..a6f5818b8489 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts @@ -0,0 +1,103 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker to durable object (3 levels deep)', async ({ signal }) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + 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 /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker HTTP server transaction (from service binding fetch) + 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 /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main Worker -> Sub Worker -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..063d8e9224ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-worker-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..ddf9c607d906 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-worker-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-worker-worker-do-rpc-sub", + }, + ], +} diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 15de0fd49ee0..2ff4b0665cd3 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -198,7 +198,7 @@ try { ``` Test apps in the folder `test-applications` will be automatically picked up by CI in the job `job_e2e_tests` (in `.github/workflows/build.yml`). -The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.ts`. +The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.mjs`. For each test app, CI checks its dependencies (and devDependencies) to see if any of them have changed in the current PR (based on nx affected projects). For example, if something is changed in the browser package, only E2E test apps that depend on browser will run, while others will be skipped. diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.mjs similarity index 73% rename from dev-packages/e2e-tests/lib/getTestMatrix.ts rename to dev-packages/e2e-tests/lib/getTestMatrix.mjs index 86a4bda3e701..b8be7af5f528 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -1,42 +1,21 @@ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import { sync as globSync } from 'glob'; -import * as path from 'path'; -import { dirname } from 'path'; -import { parseArgs } from 'util'; - -interface MatrixInclude { - /** The test application (directory) name. */ - 'test-application': string; - /** Optional override for the build command to run. */ - 'build-command'?: string; - /** Optional override for the assert command to run. */ - 'assert-command'?: string; - /** Optional label for the test run. If not set, defaults to value of `test-application`. */ - label?: string; -} +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; -interface PackageJsonSentryTestConfig { - /** If this is true, the test app is optional. */ - optional?: boolean; - /** Variant configs that should be run in non-optional test runs. */ - variants?: Partial[]; - /** Variant configs that should be run in optional test runs. */ - optionalVariants?: Partial[]; - /** Skip this test app for matrix generation. */ - skip?: boolean; -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** - * This methods generates a matrix for the GitHub Actions workflow to run the E2E tests. - * It checks which test applications are affected by the current changes in the PR and then generates a matrix + * Generates a matrix for the GitHub Actions workflow to run the E2E tests. + * Checks which test applications are affected by the current changes in the PR and then generates a matrix * including all test apps that have at least one dependency that was changed in the PR. * If no `--base=xxx` is provided, it will output all test applications. * * If `--optional=true` is set, it will generate a matrix of optional test applications only. * Otherwise, these will be skipped. */ -function run(): void { +function run() { const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -52,9 +31,7 @@ function run(): void { // eslint-disable-next-line no-console console.error(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`); - const testApplications = globSync('*/package.json', { - cwd: `${__dirname}/../test-applications`, - }).map(filePath => dirname(filePath)); + const testApplications = discoverTestApplicationDirs(); // For GitHub Action debugging (using stderr the 'matrix=...' output is not polluted) // eslint-disable-next-line no-console @@ -67,7 +44,7 @@ function run(): void { : testApplications; const optionalMode = optional === 'true'; - const includes: MatrixInclude[] = []; + const includes = []; includedTestApplications.forEach(testApp => { addIncludesForTestApp(testApp, includes, { optionalMode }); @@ -78,11 +55,18 @@ function run(): void { console.log(`matrix=${JSON.stringify({ include: includes })}`); } -function addIncludesForTestApp( - testApp: string, - includes: MatrixInclude[], - { optionalMode }: { optionalMode: boolean }, -): void { +/** Direct children of `test-applications/` that contain a `package.json` (replaces glob one-segment + package.json). */ +function discoverTestApplicationDirs() { + const appsRoot = path.join(__dirname, '..', 'test-applications'); + return fs + .readdirSync(appsRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .filter(name => fs.existsSync(path.join(appsRoot, name, 'package.json'))) + .sort(); +} + +function addIncludesForTestApp(testApp, includes, { optionalMode }) { const packageJson = getPackageJson(testApp); const shouldSkip = packageJson.sentryTest?.skip || false; @@ -108,7 +92,7 @@ function addIncludesForTestApp( }); } -function getSentryDependencies(appName: string): string[] { +function getSentryDependencies(appName) { const packageJson = getPackageJson(appName); const dependencies = { @@ -119,11 +103,7 @@ function getSentryDependencies(appName: string): string[] { return Object.keys(dependencies).filter(key => key.startsWith('@sentry')); } -function getPackageJson(appName: string): { - dependencies?: { [key: string]: string }; - devDependencies?: { [key: string]: string }; - sentryTest?: PackageJsonSentryTestConfig; -} { +function getPackageJson(appName) { const fullPath = path.resolve(__dirname, '..', 'test-applications', appName, 'package.json'); if (!fs.existsSync(fullPath)) { @@ -133,19 +113,14 @@ function getPackageJson(appName: string): { return JSON.parse(fs.readFileSync(fullPath, 'utf8')); } -run(); - -function getAffectedTestApplications( - testApplications: string[], - { base = 'develop', head }: { base?: string; head?: string }, -): string[] { +function getAffectedTestApplications(testApplications, { base = 'develop', head }) { const additionalArgs = [`--base=${base}`]; if (head) { additionalArgs.push(`--head=${head}`); } - let affectedProjects: string[] = []; + let affectedProjects = []; try { affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) .toString() @@ -201,7 +176,7 @@ function getAffectedTestApplications( return Array.from(testAppsToRun); } -function getChangedTestApps(base: string, head?: string): false | Set { +function getChangedTestApps(base, head) { const changedFiles = execSync(`git diff --name-only ${base}${head ? `..${head}` : ''} -- .`, { encoding: 'utf-8', }) @@ -214,7 +189,7 @@ function getChangedTestApps(base: string, head?: string): false | Set { // eslint-disable-next-line no-console console.error(`Changed files since ${base}${head ? `..${head}` : ''}: ${JSON.stringify(changedFiles)}`); - const changedTestApps: Set = new Set(); + const changedTestApps = new Set(); const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; for (const file of changedFiles) { @@ -233,3 +208,5 @@ function getChangedTestApps(base: string, head?: string): false | Set { return changedTestApps; } + +run(); diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 9e5cc91c58ea..b142f8c8ccd7 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -12,8 +12,8 @@ "test:prepare": "ts-node prepare.ts", "test:validate": "ts-node validate-packed-tarball-setup.ts", "clean": "rimraf tmp node_modules packed && yarn clean:test-applications && yarn clean:pnpm", - "ci:build-matrix": "ts-node ./lib/getTestMatrix.ts", - "ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true", + "ci:build-matrix": "node ./lib/getTestMatrix.mjs", + "ci:build-matrix-optional": "node ./lib/getTestMatrix.mjs --optional=true", "ci:copy-to-temp": "ts-node ./ciCopyToTemp.ts", "ci:pnpm-overrides": "ts-node ./ciPnpmOverrides.ts", "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,.astro,.output,pnpm-lock.yaml,.last-run.json,test-results,.angular,event-dumps}", diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js new file mode 100644 index 000000000000..5a25387cfe10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.SENTRY_DSN ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js new file mode 100644 index 000000000000..c4751d2aa1fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-no-dsn-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index 8475ee0a328a..5d35a9f6fcc1 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -75,10 +75,13 @@ export class LocalLambdaStack extends Stack { Layers: [{ Ref: this.sentryLayer.logicalId }], Environment: { Variables: { - SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, + // We only set SENTRY_DSN if not running TunnelNoDsn, because there + // we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing. + TUNNEL_TEST_DSN: dsn, + ...(lambdaDir !== 'TunnelNoDsn' ? { SENTRY_DSN: dsn } : {}), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index c32dbfea7435..560f676cfd07 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -1,7 +1,21 @@ -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { waitForTransaction, waitForError, waitForRequest } from '@sentry-internal/test-utils'; import { InvokeCommand } from '@aws-sdk/client-lambda'; import { test, expect } from './lambda-fixtures'; +interface TunnelInvokeResult { + attemptedDsn?: string; + status: number; + responseBody: string; +} + +function parseLambdaPayload(payload: Uint8Array | undefined): TunnelInvokeResult { + if (!payload) { + throw new Error('Missing Lambda payload'); + } + + return JSON.parse(Buffer.from(payload).toString('utf8')) as TunnelInvokeResult; +} + test.describe('Lambda layer', () => { test('tracing in CJS works', async ({ lambdaClient }) => { const transactionEventPromise = waitForTransaction('aws-serverless-layer', transactionEvent => { @@ -242,4 +256,67 @@ test.describe('Lambda layer', () => { }), ); }); + + test('extension tunnel validates DSN allowlist and rejects invalid envelopes', async ({ lambdaClient }) => { + const matchingMarker = `extension-tunnel-matching-${Date.now()}`; + const matchingRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(matchingMarker); + }); + + const matchingResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + marker: matchingMarker, + }), + }), + ); + const matchingResult = parseLambdaPayload(matchingResponse.Payload); + expect(matchingResult.status).toBe(200); + await matchingRequestPromise; + + const mismatchedResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + // Keep host/project/port valid but change public key, so DSN stays valid and fails allowlist match. + dsn: String(matchingResult.attemptedDsn).replace('://public@', '://unauthorized@'), + }), + }), + ); + const mismatchedResult = parseLambdaPayload(mismatchedResponse.Payload); + expect(mismatchedResult.status).toBe(403); + expect(mismatchedResult.responseBody).toContain('DSN not allowed'); + + const missingDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + omitDsn: true, + }), + }), + ); + const missingDsnResult = parseLambdaPayload(missingDsnResponse.Payload); + expect(missingDsnResult.status).toBe(400); + expect(missingDsnResult.responseBody).toContain('missing DSN'); + }); + + test('extension tunnel forwards requests when SENTRY_DSN is missing', async ({ lambdaClient }) => { + const marker = `extension-tunnel-no-sentry-dsn-${Date.now()}`; + const noDsnRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(marker); + }); + + const noDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnelNoDsn', + Payload: JSON.stringify({ + marker, + }), + }), + ); + const noDsnResult = parseLambdaPayload(noDsnResponse.Payload); + expect(noDsnResult.status).toBe(200); + await noDsnRequestPromise; + }); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 53519a1bd80c..ba07bb7db4ca 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "dev:node": "node --import tsx/esm --import @sentry/node/preload src/entry.node.ts", + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", "dev:bun": "bun src/entry.bun.ts", "build": "wrangler deploy --dry-run", "test:build": "pnpm install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 74a21e10a349..d478c0734cb3 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -1,8 +1,5 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -type Runtime = 'cloudflare' | 'node' | 'bun'; - -const RUNTIME = (process.env.RUNTIME || 'cloudflare') as Runtime; +import { RUNTIME, type Runtime } from './tests/constants'; const testEnv = process.env.TEST_ENV; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts index eb2c669c6806..898a92e08be4 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -3,17 +3,9 @@ import { sentry } from '@sentry/hono/node'; import { serve } from '@hono/node-server'; import { addRoutes } from './routes'; -const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); +const app = new Hono(); -app.use( - // @ts-expect-error - Env is not yet in type - sentry(app, { - dsn: process.env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); +app.use(sentry(app)); addRoutes(app); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts new file mode 100644 index 000000000000..82f2a3864125 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/hono/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts index 656fea319579..49ca50c591bf 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -1,10 +1,81 @@ import { Hono } from 'hono'; +import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; -const testMiddleware = new Hono(); +const middlewareRoutes = new Hono(); -testMiddleware.get('/named', c => c.json({ middleware: 'named' })); -testMiddleware.get('/anonymous', c => c.json({ middleware: 'anonymous' })); -testMiddleware.get('/multi', c => c.json({ middleware: 'multi' })); -testMiddleware.get('/error', c => c.text('should not reach')); +middlewareRoutes.get('/named', c => c.json({ middleware: 'named' })); +middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); +middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); +middlewareRoutes.get('/error', c => c.text('should not reach')); -export { testMiddleware }; +// Self-contained sub-app registering its own middleware via .use() +const subAppWithMiddleware = new Hono(); + +subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); +}); +subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); +subAppWithMiddleware.use('/error/*', failingMiddleware); + +// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute. +subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { + return c.json({ handler: 'all' }); +}); + +subAppWithMiddleware.route('/', middlewareRoutes); + +// Sub-app with inline middleware for different registration styles. +// patchRoute wraps non-last handlers per method+path group as middleware. +const subAppWithInlineMiddleware = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware[method]( + '/direct', + async function inlineMiddleware(_c, next) { + await next(); + }, + c => c.text(`${method} direct response`), + ); + + subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) { + await next(); + }); + subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`)); +}); + +// .all(): .all('/path', mw, handler) +subAppWithInlineMiddleware.all( + '/all', + async function inlineMiddlewareAll(_c, next) { + await next(); + }, + c => c.text('all response'), +); +subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) { + await next(); +}); +subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response')); + +// .on() registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware.on( + method, + '/on', + async function inlineMiddlewareOn(_c, next) { + await next(); + }, + c => c.text(`${method} on response`), + ); + + subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) { + await next(); + }); + subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`)); +}); + +export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts new file mode 100644 index 000000000000..e32662fb3b18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts @@ -0,0 +1,55 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const routePatterns = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method (sync handlers) +METHODS.forEach(method => { + routePatterns[method]('/', c => c.text(`${method} response`)); +}); + +// Async handler +routePatterns.get('/async', async c => { + await new Promise(resolve => setTimeout(resolve, 10)); + return c.text('async response'); +}); + +// .all() registration +routePatterns.all('/all', c => c.text('all handler response')); + +// .on() registration +METHODS.forEach(method => { + routePatterns.on(method, '/on', c => c.text(`${method} on response`)); +}); + +// Error routes for direct method registration +METHODS.forEach(method => { + routePatterns[method]('/500', () => { + throw new HTTPException(500, { message: 'response 500' }); + }); + routePatterns[method]('/401', () => { + throw new HTTPException(401, { message: 'response 401' }); + }); + routePatterns[method]('/402', () => { + throw new HTTPException(402, { message: 'response 402' }); + }); + routePatterns[method]('/403', () => { + throw new HTTPException(403, { message: 'response 403' }); + }); +}); + +// Error routes for .all() +routePatterns.all('/all/500', () => { + throw new HTTPException(500, { message: 'response 500' }); +}); + +// Error routes for .on() +METHODS.forEach(method => { + routePatterns.on(method, '/on/500', () => { + throw new HTTPException(500, { message: 'response 500' }); + }); +}); + +export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index 65d30787de64..f6efc6dde03c 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,7 +1,8 @@ import type { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import { testMiddleware } from './route-groups/test-middleware'; -import { middlewareA, middlewareB, failingMiddleware } from './middleware'; +import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; +import { routePatterns } from './route-groups/test-route-patterns'; export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): void { app.get('/', c => { @@ -24,9 +25,7 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v throw new HTTPException(code, { message: `HTTPException ${code}` }); }); - // === Middleware === - // Middleware is registered on the main app (the patched instance) via `app.use()` - // TODO: In the future, we may want to support middleware registration on sub-apps (route groups) + // Root-app middleware: registered on the patched main app instance app.use('/test-middleware/named/*', middlewareA); app.use('/test-middleware/anonymous/*', async (c, next) => { c.header('X-Custom', 'anonymous'); @@ -34,6 +33,14 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v }); app.use('/test-middleware/multi/*', middlewareA, middlewareB); app.use('/test-middleware/error/*', failingMiddleware); + app.route('/test-middleware', middlewareRoutes); - app.route('/test-middleware', testMiddleware); + // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching + app.route('/test-subapp-middleware', subAppWithMiddleware); + + // Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware + app.route('/test-inline-middleware', subAppWithInlineMiddleware); + + // Route patterns: HTTP methods, .all(), .on(), sync/async, errors + app.route('/test-routes', routePatterns); } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts new file mode 100644 index 000000000000..5295914a7805 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'cloudflare' | 'node' | 'bun'; + +export const RUNTIME = (process.env.RUNTIME || 'cloudflare') as Runtime; + +export const APP_NAME = 'hono-4'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index e85958e8328b..832204237946 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; - -const APP_NAME = 'hono-4'; +import { APP_NAME } from './constants'; test('captures error thrown in route handler', async ({ baseURL }) => { const errorWaiter = waitForError(APP_NAME, event => { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index a03398798756..e8431bed67ce 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -1,143 +1,220 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { type SpanJSON } from '@sentry/core'; - -const APP_NAME = 'hono-4'; - -test('creates a span for named middleware', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/named'; +import { APP_NAME } from './constants'; + +const SCENARIOS = [ + { + name: 'root app middleware', + prefix: '/test-middleware', + }, + { + name: 'sub-app middleware (route group)', + prefix: '/test-subapp-middleware', + }, +] as const; + +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('creates a span for named middleware', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/named`; + }); + + const response = await fetch(`${baseURL}${prefix}/named`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + const middlewareSpan = spans.find( + (span: { description?: string; op?: string }) => + span.op === 'middleware.hono' && span.description === 'middlewareA', + ); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + description: 'middlewareA', + op: 'middleware.hono', + origin: 'auto.middleware.hono', + status: 'ok', + }), + ); + + // @ts-expect-error timestamp is defined + const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; + expect(durationMs).toBeGreaterThanOrEqual(49); + }); + + test('creates a span for anonymous middleware', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/anonymous`; + }); + + const response = await fetch(`${baseURL}${prefix}/anonymous`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: '', + op: 'middleware.hono', + origin: 'auto.middleware.hono', + status: 'ok', + }), + ); + }); + + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/multi`; + }); + + const response = await fetch(`${baseURL}${prefix}/multi`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + + expect(middlewareSpans).toHaveLength(2); + expect(middlewareSpans[0]?.description).toBe('middlewareA'); + expect(middlewareSpans[1]?.description).toBe('middlewareB'); + + expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + + // middlewareA has a 50ms delay, middlewareB has a 60ms delay + // @ts-expect-error timestamp is defined + const aDurationMs = (middlewareSpans[0]?.timestamp - middlewareSpans[0]?.start_timestamp) * 1000; + // @ts-expect-error timestamp is defined + const bDurationMs = (middlewareSpans[1]?.timestamp - middlewareSpans[1]?.start_timestamp) * 1000; + expect(aDurationMs).toBeGreaterThanOrEqual(49); + expect(bDurationMs).toBeGreaterThanOrEqual(59); + }); + + test('captures error thrown in middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Middleware error'; + }); + + const response = await fetch(`${baseURL}${prefix}/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.middleware.hono', + }), + ); + }); + + test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/error/*`; + }); + + await fetch(`${baseURL}${prefix}/error`); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + const failingSpan = spans.find( + (span: SpanJSON) => span.op === 'middleware.hono' && span.status === 'internal_error', + ); + + expect(failingSpan).toBeDefined(); + expect(failingSpan?.status).toBe('internal_error'); + }); + + test('includes request data on error events from middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + }); + + await fetch(`${baseURL}${prefix}/error`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${prefix}/error`), + }), + ); + }); }); +} - const response = await fetch(`${baseURL}/test-middleware/named`); - expect(response.status).toBe(200); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - const middlewareSpan = spans.find( - (span: { description?: string; op?: string }) => - span.op === 'middleware.hono' && span.description === 'middlewareA', - ); - - expect(middlewareSpan).toEqual( - expect.objectContaining({ - description: 'middlewareA', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); - - // The middleware has a 50ms delay, so the span duration should be at least 50ms (0.05s) - // @ts-expect-error timestamp is defined - const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; - expect(durationMs).toBeGreaterThanOrEqual(49); -}); - -test('creates a span for anonymous middleware', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/anonymous'; - }); - - const response = await fetch(`${baseURL}/test-middleware/anonymous`); - expect(response.status).toBe(200); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - description: '', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); -}); - -test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/multi'; - }); - - const response = await fetch(`${baseURL}/test-middleware/multi`); - expect(response.status).toBe(200); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; +test.describe('.all() handler in sub-app', () => { + test('does not create middleware span for .all() route handler', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return ( + event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' + ); + }); - // Sort spans because they are in a different order in Node/Bun (OTel-based) - const middlewareSpans = spans - .filter((span: SpanJSON) => span.op === 'middleware.hono' && span.origin === 'auto.middleware.hono') - .sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); - expect(middlewareSpans).toHaveLength(2); - expect(middlewareSpans[0]?.description).toBe('middlewareA'); - expect(middlewareSpans[1]?.description).toBe('middlewareB'); + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); - // Both middleware spans share the same parent (siblings, not nested) - expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + const transaction = await transactionPromise; + const spans = transaction.spans || []; - // middlewareA has a 50ms delay, middlewareB has a 60ms delay - // @ts-expect-error timestamp is defined - const middlewareADuration = (middlewareSpans[0]?.timestamp - middlewareSpans[0]?.start_timestamp) * 1000; - // @ts-expect-error timestamp is defined - const middlewareBDuration = (middlewareSpans[1]?.timestamp - middlewareSpans[1]?.start_timestamp) * 1000; - expect(middlewareADuration).toBeGreaterThanOrEqual(49); - expect(middlewareBDuration).toBeGreaterThanOrEqual(59); -}); - -test('captures error thrown in middleware', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; + // No middleware is called for this route, so there should be no spans. + expect(spans).toEqual([]); }); - - const response = await fetch(`${baseURL}/test-middleware/error`); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.middleware.hono', - }), - ); }); -test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/error/*'; - }); - - await fetch(`${baseURL}/test-middleware/error`); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - const failingSpan = spans.find( - (span: { description?: string; op?: string }) => - span.op === 'middleware.hono' && span.description === 'failingMiddleware', - ); - - expect(failingSpan).toBeDefined(); - expect(failingSpan?.status).toBe('internal_error'); - expect(failingSpan?.origin).toBe('auto.middleware.hono'); -}); - -test('includes request data on error events from middleware', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; - }); - - await fetch(`${baseURL}/test-middleware/error`); - - const errorEvent = await errorPromise; - expect(errorEvent.request).toEqual( - expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/test-middleware/error'), - }), - ); +const INLINE_PREFIX = '/test-inline-middleware'; + +const REGISTRATION_STYLES = [ + { name: 'direct method (.get())', path: '/direct' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +const MIDDLEWARE_STYLES = [ + { name: 'inline', path: '' }, + { name: 'separately registered', path: '/separately' }, +] as const; + +test.describe('inline middleware spans (sub-app)', () => { + for (const { name: regName, path: regPath } of REGISTRATION_STYLES) { + for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) { + test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => { + const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`; + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + + const EXPECTED_DESCRIPTIONS: Record> = { + '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, + '/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' }, + '/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' }, + }; + const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + description: expectedDescription, + op: 'middleware.hono', + origin: 'auto.middleware.hono', + status: 'ok', + }), + ); + }); + } + } }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts new file mode 100644 index 000000000000..fd6579fe3b17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const PREFIX = '/test-routes'; + +const REGISTRATION_STYLES = [ + { name: 'direct method', path: '' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +test.describe('HTTP methods', () => { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + test(`sends transaction for ${method}`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}`, { method }); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe(`${method} ${PREFIX}`); + }); + } +}); + +test.describe('route registration styles', () => { + for (const { name, path } of REGISTRATION_STYLES) { + test(`${name} sends transaction`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}${path}`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`); + }); + } + + for (const { name, path } of [ + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, + ]) { + test(`${name} responds to POST`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`); + }); + } +}); + +test('async handler sends transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`; + }); + + const response = await fetch(`${baseURL}${PREFIX}/async`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); +}); + +test.describe('500 HTTPException capture', () => { + for (const { name, path } of REGISTRATION_STYLES) { + test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => { + const fullPath = `${PREFIX}${path}/500`; + + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath); + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); + } + + test('captures 500 error with POST method', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + event.exception?.values?.[0]?.value === 'response 500' && + !!event.request?.url?.includes(`${PREFIX}/500`) && + event.request?.method === 'POST' + ); + }); + + const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' }); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); +}); + +test.describe('4xx HTTPException capture', () => { + for (const code of [401, 402, 403]) { + test(`captures ${code} HTTPException`, async ({ baseURL }) => { + const fullPath = `${PREFIX}/${code}`; + + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath); + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(code); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 58c73c6a8369..1c33943f38f8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; - -const APP_NAME = 'hono-4'; +import { APP_NAME } from './constants'; test('sends a transaction for the index route', async ({ baseURL }) => { const transactionWaiter = waitForTransaction(APP_NAME, event => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 3f3907a77bed..c1070677f383 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -39,7 +39,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 14334483d116..59f192d9bd1b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -31,7 +31,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5", "wrangler": "^4.61.0" }, @@ -43,7 +43,9 @@ { "build-command": "pnpm test:build-latest", "label": "nextjs-16-cf-workers (latest)" - }, + } + ], + "optionalVariants": [ { "build-command": "pnpm test:build-canary", "label": "nextjs-16-cf-workers (canary)" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index ee74ff6e9259..0821c63d43f5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -40,7 +40,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 1e417a48fd1f..944102e188b3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -41,7 +41,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/index.html b/dev-packages/e2e-tests/test-applications/nitro-3/index.html new file mode 100644 index 000000000000..4e9315ac391e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/index.html @@ -0,0 +1,11 @@ + + + + + Nitro E2E Test + + +

Nitro E2E Test App

+ + + diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs new file mode 100644 index 000000000000..53b80d309a5b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nitro'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json new file mode 100644 index 000000000000..ab92769115d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -0,0 +1,29 @@ +{ + "name": "nitro-3", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' node .output/server/index.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/browser": "latest || *", + "@sentry/nitro": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *", + "nitro": "^3.0.260415-beta", + "rolldown": "latest", + "vite": "latest" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts new file mode 100644 index 000000000000..a9fca21eecfb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { status: 'ok' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts new file mode 100644 index 000000000000..170efb1977ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + throw new Error('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts new file mode 100644 index 000000000000..a8c2cd7a99f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts @@ -0,0 +1,10 @@ +import { getDefaultIsolationScope, setTag } from '@sentry/core'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + setTag('my-isolated-tag', true); + // Check if the tag leaked into the default (global) isolation scope + setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); + + throw new Error('Isolation test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts new file mode 100644 index 000000000000..687c6f3f1e9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts @@ -0,0 +1,16 @@ +import { startSpan } from '@sentry/nitro'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + startSpan({ name: 'db.select', op: 'db' }, () => { + // simulate a select query + }); + + startSpan({ name: 'db.insert', op: 'db' }, () => { + startSpan({ name: 'db.serialize', op: 'serialize' }, () => { + // simulate serializing data before insert + }); + }); + + return { status: 'ok', nesting: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts new file mode 100644 index 000000000000..ef67525b36ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(event => { + const id = event.req.url; + return { id }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts new file mode 100644 index 000000000000..b488b371310d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { status: 'ok', transaction: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts new file mode 100644 index 000000000000..92d8f80c3756 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -0,0 +1,10 @@ +import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; + +export default defineHandler(event => { + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); + + const query = getQuery(event); + if (query['middleware-error'] === '1') { + throw new Error('Middleware error'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts new file mode 100644 index 000000000000..d27d0ba1763a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +// Let's us test trace propagation +Sentry.init({ + environment: 'qa', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: 'http://localhost:3031/', // proxy server + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs new file mode 100644 index 000000000000..928e68908661 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nitro-3', +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts new file mode 100644 index 000000000000..8e419ac9ba62 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends an error event to Sentry', async ({ request }) => { + const errorEventPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error'); + }); + + await request.get('/api/test-error'); + + const errorEvent = await errorEventPromise; + + // Nitro wraps thrown errors in an HTTPError with .cause, producing a chained exception + expect(errorEvent.exception?.values).toHaveLength(2); + + // The innermost exception (values[0]) is the original thrown error + expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.function.nitro.captureErrorHook', + }), + ); + + // The outermost exception (values[1]) is the HTTPError wrapper + expect(errorEvent.exception?.values?.[1]?.type).toBe('HTTPError'); + expect(errorEvent.exception?.values?.[1]?.value).toBe('This is a test error'); +}); + +test('Does not send 404 errors to Sentry', async ({ request }) => { + let errorReceived = false; + + void waitForError('nitro-3', event => { + if (!event.type) { + errorReceived = true; + return true; + } + return false; + }); + + await request.get('/api/non-existent-route'); + + expect(errorReceived).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts new file mode 100644 index 000000000000..7234fa0948ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Isolation scope prevents tag leaking between requests', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-isolation/:id'; + }); + + const errorPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error'); + }); + + await request.get('/api/test-isolation/1').catch(() => { + // noop - route throws + }); + + const transactionEvent = await transactionEventPromise; + const error = await errorPromise; + + // Assert that isolation scope works properly + expect(error.tags?.['my-isolated-tag']).toBe(true); + expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); + expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts new file mode 100644 index 000000000000..eec281d28f98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Creates middleware spans for requests', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-transaction'; + }); + + const response = await request.get('/api/test-transaction'); + + expect(response.headers()['x-sentry-test-middleware']).toBe('executed'); + + const transactionEvent = await transactionEventPromise; + + // h3 middleware spans have origin auto.http.nitro.h3 and op middleware.nitro + const h3MiddlewareSpans = transactionEvent.spans?.filter( + span => span.origin === 'auto.http.nitro.h3' && span.op === 'middleware.nitro', + ); + expect(h3MiddlewareSpans?.length).toBeGreaterThanOrEqual(1); +}); + +test('Captures errors thrown in middleware with error status on span', async ({ request }) => { + const errorEventPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'Middleware error'); + }); + + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-transaction' && event?.contexts?.trace?.status === 'internal_error'; + }); + + await request.get('/api/test-transaction?middleware-error=1'); + + const errorEvent = await errorEventPromise; + expect(errorEvent.exception?.values?.some(v => v.value === 'Middleware error')).toBe(true); + + const transactionEvent = await transactionEventPromise; + + // The transaction span should have error status + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts new file mode 100644 index 000000000000..090f8af36fb2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Span nesting: all spans share the same trace_id', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + const traceId = event.contexts?.trace?.trace_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + + // Every child span must belong to the same trace + for (const span of event.spans ?? []) { + expect(span.trace_id).toBe(traceId); + } +}); + +test('Span nesting: h3 middleware spans are children of the srvx request span', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + + // All h3 middleware spans should be children of the srvx span + const h3Spans = event.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); + expect(h3Spans?.length).toBeGreaterThanOrEqual(1); + + for (const span of h3Spans ?? []) { + expect(span.parent_span_id).toBe(srvxSpan!.span_id); + } +}); + +test('Span nesting: manual startSpan calls inside route handler are children of the srvx request span', async ({ + request, +}) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span — this is the parent of all h3 and manual spans + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + const srvxSpanId = srvxSpan!.span_id; + + // Find the manually created db spans + const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); + const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); + expect(dbSelectSpan).toBeDefined(); + expect(dbInsertSpan).toBeDefined(); + + // FIXME: Once nitro's h3 tracing plugin emits a separate span for route handlers (type: "route"), + // the db spans should be children of the h3 route handler span, not the srvx span directly. + // Currently nitro bypasses h3's ~routes for file-based routing, so h3 only emits middleware spans. + // Both db spans should be children of the srvx request span + expect(dbSelectSpan!.parent_span_id).toBe(srvxSpanId); + expect(dbInsertSpan!.parent_span_id).toBe(srvxSpanId); + + // Both db spans should be siblings (same parent) + expect(dbSelectSpan!.parent_span_id).toBe(dbInsertSpan!.parent_span_id); + + // The serialize span should be nested inside the db.insert span + const serializeSpan = event.spans?.find(span => span.op === 'serialize' && span.description === 'db.serialize'); + expect(serializeSpan).toBeDefined(); + expect(serializeSpan!.parent_span_id).toBe(dbInsertSpan!.span_id); +}); + +// FIXME: Nitro's file-based routing bypasses h3's ~routes, so h3's tracing plugin never wraps +// route handlers with type: "route". Once this is fixed upstream or we add our own wrapping, +// uncomment these tests to verify the h3 route handler span exists and is the parent of manual spans. +// +// test('Span nesting: h3 route handler span is a child of the srvx request span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); +// expect(srvxSpan).toBeDefined(); +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// expect(h3HandlerSpan!.parent_span_id).toBe(srvxSpan!.span_id); +// }); +// +// test('Span nesting: manual startSpan calls are children of the h3 route handler span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// +// const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); +// const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); +// expect(dbSelectSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// expect(dbInsertSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// }); + +test('Span nesting: middleware spans start before manual spans in the span tree', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Middleware spans should start before the manual db spans + const middlewareSpans = event.spans?.filter(span => span.op === 'middleware.nitro') ?? []; + const dbSpans = event.spans?.filter(span => span.op === 'db') ?? []; + + expect(middlewareSpans.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.length).toBeGreaterThanOrEqual(1); + + const earliestMiddlewareStart = Math.min(...middlewareSpans.map(s => s.start_timestamp)); + const earliestDbStart = Math.min(...dbSpans.map(s => s.start_timestamp)); + + // Middleware should start before the db spans + expect(earliestMiddlewareStart).toBeLessThanOrEqual(earliestDbStart); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts new file mode 100644 index 000000000000..705521ad759d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Propagates server trace to client pageload via Server-Timing headers', async ({ page }) => { + const clientTxnPromise = waitForTransaction('nitro-3', event => { + return event?.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const clientTxn = await clientTxnPromise; + + expect(clientTxn.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxn.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(clientTxn.contexts?.trace?.op).toBe('pageload'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts new file mode 100644 index 000000000000..48de9c4349df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction event for a successful route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-transaction'; + }); + + await request.get('/api/test-transaction'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /api/test-transaction', + type: 'transaction', + }), + ); + + // srvx.request creates a span for the request + const srvxSpans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.srvx'); + expect(srvxSpans?.length).toBeGreaterThanOrEqual(1); + + // h3 creates a child span for the route handler + const h3Spans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); + expect(h3Spans?.length).toBeGreaterThanOrEqual(1); +}); + +test('Sets correct HTTP status code on transaction', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-transaction'; + }); + + await request.get('/api/test-transaction'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.response.status_code': 200, + }), + ); + + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Uses parameterized route for transaction name', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-param/:id'; + }); + + await request.get('/api/test-param/123'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /api/test-param/:id', + transaction_info: expect.objectContaining({ source: 'route' }), + type: 'transaction', + }), + ); + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.route': '/api/test-param/:id', + }), + ); +}); + +test('Sets Server-Timing response headers for trace propagation', async ({ request }) => { + const response = await request.get('/api/test-transaction'); + const headers = response.headers(); + + expect(headers['server-timing']).toBeDefined(); + expect(headers['server-timing']).toContain('sentry-trace;desc="'); + expect(headers['server-timing']).toContain('baggage;desc="'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json new file mode 100644 index 000000000000..b9a951fbebb1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./*"] + } + }, + "include": ["src/**/*.ts", "routes/**/*.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts new file mode 100644 index 000000000000..d488f8298777 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -0,0 +1,15 @@ +import { withSentryConfig } from '@sentry/nitro'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + nitro( + // FIXME: Nitro plugin has a type issue + // @ts-expect-error + withSentryConfig({ + serverDir: './server', + }), + ), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 02477111483d..403e9b20a3a0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -14,7 +14,7 @@ "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 nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm add vue vue-router && pnpm install --force && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index 731081b54f52..1c5bb472d162 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes export default [ index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), ...prefix('errors', [ route('client', 'routes/errors/client.tsx'), route('client/:client-param', 'routes/errors/client-param.tsx'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0e5351a5704f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('low-quality transaction filter', () => { + test('does not send a server transaction for /__manifest? requests', async ({ page }) => { + const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions before we assert + await page.evaluate(() => fetch('/__sentry-flush')); + + const targetIsManifest = (t: (typeof serverTxns)[number]) => + typeof t.contexts?.trace?.data?.['http.target'] === 'string' && + (t.contexts.trace.data['http.target'] as string).includes('/__manifest'); + expect(serverTxns.some(targetIsManifest)).toBe(false); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 4b9d9062dca3..c5c86c0d31ae 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "clean": "npx rimraf node_modules pnpm-lock.yaml .next", - "start-local-supabase": "supabase init --force --workdir . && supabase start -o env && supabase db reset", + "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && supabase init --force --workdir . && supabase start -o env && supabase db reset", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", "test:assert": "pnpm test:prod" diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index cfb66b372420..40ce1462fcfd 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -57,37 +57,16 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', const transactionEvent = await pageloadTransactionPromise; - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', - data: expect.objectContaining({ - 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], - 'db.system': 'postgresql', - 'sentry.op': 'db', - 'sentry.origin': 'auto.db.supabase', - }), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.db.supabase', - }), - ); - - expect(transactionEvent.spans).toContainEqual({ + // Client uses default sendDefaultPii: false — URL filters and bodies are not attached to spans/breadcrumbs. + const redactedSelectSpan = expect.objectContaining({ + description: '[redacted] from(todos)', + op: 'db', data: expect.objectContaining({ 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], 'db.system': 'postgresql', 'sentry.op': 'db', 'sentry.origin': 'auto.db.supabase', }), - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -97,20 +76,26 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', origin: 'auto.db.supabase', }); + expect(transactionEvent.spans).toContainEqual(redactedSelectSpan); + + const selectSpan = transactionEvent.spans?.find( + (s: { description?: string }) => s.description === '[redacted] from(todos)', + ); + expect(selectSpan).toBeDefined(); + expect(selectSpan!.data).not.toHaveProperty('db.query'); + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.select', - message: 'select(*) filter(order, asc) from(todos)', - data: expect.any(Object), + message: '[redacted] from(todos)', }); expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.insert', - message: 'insert(...) select(*) from(todos)', - data: expect.any(Object), + message: 'insert(...) [redacted] from(todos)', }); }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 6d431226dbfc..0525acfad587 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,8 +9,17 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", + "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", + "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert:proxy": "pnpm test", + "test:assert": "pnpm test:assert:proxy", + "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", @@ -35,5 +44,29 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "tanstackstart-react (tunnel-generated)", + "build-command": "pnpm test:build:tunnel-generated", + "assert-command": "pnpm test:assert:tunnel-generated" + }, + { + "label": "tanstackstart-react (tunnel-static)", + "build-command": "pnpm test:build:tunnel-static", + "assert-command": "pnpm test:assert:tunnel-static" + }, + { + "label": "tanstackstart-react (tunnel-custom)", + "build-command": "pnpm test:build:tunnel-custom", + "assert-command": "pnpm test:assert:tunnel-custom" + }, + { + "label": "tanstackstart-react (tunnel-object)", + "build-command": "pnpm test:build:tunnel-object", + "assert-command": "pnpm test:assert:tunnel-object" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts new file mode 100644 index 000000000000..6e7d31c7a4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts @@ -0,0 +1,2 @@ +declare const __APP_DSN__: string; +declare const __APP_TUNNEL__: string | undefined; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx index b1c6f7727a26..9a39b6f35c42 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx @@ -11,13 +11,13 @@ export const getRouter = () => { if (!router.isServer) { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __APP_DSN__, integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, release: 'e2e-test', - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __APP_TUNNEL__, }); } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts new file mode 100644 index 000000000000..1409123f0402 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/tanstackstart-react'; +import { createFileRoute } from '@tanstack/react-router'; + +const USE_CUSTOM_TUNNEL_ROUTE = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const TUNNEL_DSN = 'http://public@localhost:3031/1337'; + +// Example of a manually defined tunnel endpoint without relying on the +// managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`. +// If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's +// `Sentry.init()` call so browser events are sent to the same endpoint. +export const Route = createFileRoute('/custom-monitor')({ + server: Sentry.createSentryTunnelRoute({ + allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts index 04d93e550824..a49b77a293b1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => { const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts index dffab8ea2aa3..ab31ce5e022a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ page, }) => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index 5186514d277a..f5e70a676432 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends a server function transaction with auto-instrumentation', async ({ page }) => { const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts new file mode 100644 index 000000000000..27f200b8ef62 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const tunnelRouteMode = + process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const expectedTunnelPathMatcher = + tunnelRouteMode === 'static' + ? '/monitor' + : tunnelRouteMode === 'custom' + ? '/custom-monitor' + : tunnelRouteMode === 'object' + ? '/object-monitor' + : /^\/[a-z0-9]{8}$/; + +test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); + +test('Sends client-side errors through the configured tunnel route', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto('/'); + const pageOrigin = new URL(page.url()).origin; + + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + const managedTunnelResponsePromise = page.waitForResponse(response => { + const responseUrl = new URL(response.url()); + + return ( + responseUrl.origin === pageOrigin && + response.request().method() === 'POST' && + (typeof expectedTunnelPathMatcher === 'string' + ? responseUrl.pathname === expectedTunnelPathMatcher + : expectedTunnelPathMatcher.test(responseUrl.pathname)) + ); + }); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + const managedTunnelResponse = await managedTunnelResponsePromise; + const managedTunnelUrl = new URL(managedTunnelResponse.url()); + const errorEvent = await errorEventPromise; + + expect(managedTunnelResponse.status()).toBe(200); + expect(managedTunnelUrl.origin).toBe(pageOrigin); + + if (typeof expectedTunnelPathMatcher === 'string') { + expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); + } else { + expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); + expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + } + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); + expect(errorEvent.transaction).toBe('/'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index a2b39609717d..2385d8aa5e93 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -5,10 +5,44 @@ import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; +const useManagedTunnelRoute = tunnelRouteMode !== 'off'; +const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const appDsn = + useManagedTunnelRoute || useCustomTunnelRoute + ? 'http://public@localhost:3031/1337' + : 'https://public@dsn.ingest.sentry.io/1337'; + +const appTunnel = useManagedTunnelRoute + ? undefined + : useCustomTunnelRoute + ? '/custom-monitor' + : 'http://localhost:3031/'; + +function resolveTunnelRouteOption() { + switch (tunnelRouteMode) { + case 'dynamic': + return true; + case 'static': + return '/monitor'; + case 'object': + return { path: '/object-monitor', allowedDsns: [appDsn] }; + default: + return undefined; + } +} + +const tunnelRoute = resolveTunnelRouteOption(); + export default defineConfig({ server: { port: 3000, }, + define: { + __APP_DSN__: JSON.stringify(appDsn), + __APP_TUNNEL__: appTunnel === undefined ? 'undefined' : JSON.stringify(appTunnel), + }, plugins: [ tsConfigPaths(), tanstackStart(), @@ -20,6 +54,7 @@ export default defineConfig({ project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, debug: true, + tunnelRoute, }), ], }); diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml new file mode 100644 index 000000000000..8878490df729 --- /dev/null +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -0,0 +1,294 @@ +# Taken from https://github.com/babel/babel/blob/624c78d99e8f42b2543b8943ab1b62bd71cf12d8/scripts/integration-tests/verdaccio-config.yml + +# +# This is the default config file. It allows all users to do anything, +# so don't use it on production systems. +# +# Look here for more config file examples: +# https://github.com/verdaccio/verdaccio/tree/master/conf +# + +# Repo-local storage (relative to this file). Absolute /verdaccio/... matches Docker-only templates and is not writable on typical dev machines. +storage: ./storage/data + +# https://verdaccio.org/docs/configuration#authentication +auth: + htpasswd: + file: ./storage/htpasswd + +# https://verdaccio.org/docs/configuration#uplinks +# a list of other known repositories we can talk to +uplinks: + npmjs: + url: https://registry.npmjs.org/ + +# Learn how to protect your packages +# https://verdaccio.org/docs/protect-your-dependencies/ +# https://verdaccio.org/docs/configuration#packages +packages: + # To not use a proxy (e.g. npm) but instead use verdaccio for package hosting we need to define rules here without the + # `proxy` field. Sadly we can't use a wildcard like "@sentry/*" because we have some dependencies (@sentry/cli, + # @sentry/webpack-plugin) that fall under that wildcard but don't live in this repository. If we were to use that + # wildcard, we would get a 404 when attempting to install them, since they weren't uploaded to verdaccio, and also + # don't have a proxy configuration. + + '@sentry/angular': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/astro': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/browser': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/bun': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/core': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/cloudflare': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/deno': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/effect': + access: $all + publish: $all + 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 + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/gatsby': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/hono': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nestjs': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nextjs': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node-core': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node-native': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/opentelemetry': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/profiling-node': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/react': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/react-router': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/remix': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/aws-serverless': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/google-cloud-serverless': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/solid': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/solidstart': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/svelte': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/sveltekit': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/tanstackstart': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/tanstackstart-react': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/types': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/vercel-edge': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/vue': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nuxt': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/wasm': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nitro': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry-internal/*': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@*/*': + # scoped packages + access: $all + publish: $all + unpublish: $all + proxy: npmjs + + '**': + # allow all users (including non-authenticated users) to read and + # publish all packages + # + # you can specify usernames/groupnames (depending on your auth plugin) + # and three keywords: "$all", "$anonymous", "$authenticated" + access: $all + + # allow all known users to publish/publish packages + # (anyone can register by default, remember?) + publish: $all + unpublish: $all + proxy: npmjs + +# https://verdaccio.org/docs/configuration#server +# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections. +# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout. +# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. +server: + keepAliveTimeout: 60 + +middlewares: + audit: + enabled: false + +# https://verdaccio.org/docs/logger +# log settings +log: { type: stdout, format: pretty, level: http } +#experiments: +# # support for npm token command +# token: false diff --git a/dev-packages/node-core-integration-tests/suites/anr/test.ts b/dev-packages/node-core-integration-tests/suites/anr/test.ts index c9a81ccb5db0..406830c9b299 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-core-integration-tests/suites/anr/test.ts @@ -2,6 +2,17 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; +/** Avoid flakes on slow CI: fixed sleeps can fire before the child process has finished exiting. */ +async function waitForChildExit(childHasExited: () => boolean, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (!childHasExited()) { + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out waiting for child process to exit'); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + const ANR_EVENT = { // Ensure we have context contexts: { @@ -178,7 +189,7 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => test('should exit', async () => { const runner = createRunner(__dirname, 'should-exit.js').start(); - await new Promise(resolve => setTimeout(resolve, 5_000)); + await waitForChildExit(() => runner.childHasExited()); expect(runner.childHasExited()).toBe(true); }); @@ -186,7 +197,7 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => test('should exit forced', async () => { const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - await new Promise(resolve => setTimeout(resolve, 5_000)); + await waitForChildExit(() => runner.childHasExited()); expect(runner.childHasExited()).toBe(true); }); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index 5c8cd915bc12..6fe6838844de 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -27,4 +27,4 @@ cron.start(); setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index a9febf9efbe4..078cc0997221 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('cron instrumentation', async () => { +test('cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts index 48107d0a4b1e..fb69a5de2482 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts @@ -33,4 +33,4 @@ const task = cronWithCheckIn.schedule( setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts index 990af6028235..61cfe7bf2943 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-cron instrumentation', async () => { +test('node-cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts index 5a670d9e6cf2..b64b2d814b67 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -53,4 +53,4 @@ const task2 = cronWithCheckIn.schedule( setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts index ea044ca22ec6..0f0ef2e268ad 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-cron instrumentation', async () => { +test('node-cron instrumentation', { timeout: 30_000 }, async () => { let firstErrorTraceId: string | undefined; await createRunner(__dirname, 'scenario.ts') diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts index f42675f25306..2bb67cc0047c 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts @@ -25,4 +25,4 @@ const job = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * * *', () => setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts index 2b46e04d50a4..08905b77e45f 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-schedule instrumentation', async () => { +test('node-schedule instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs index 508cbe487e91..77c35dce5589 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs @@ -1 +1,8 @@ -// Sentry is initialized by the @sentry/hono/node middleware in scenario.mjs +import * as Sentry from '@sentry/hono/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs index 92a08fcb5bb5..d27dc20bbc30 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs @@ -1,17 +1,11 @@ import { serve } from '@hono/node-server'; import { sentry } from '@sentry/hono/node'; -import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import { Hono } from 'hono'; const app = new Hono(); -app.use( - sentry(app, { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1.0, - transport: loggingTransport, - }), -); +app.use(sentry(app)); app.get('/', c => { return c.text('Hello from Hono on Node!'); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 8dd49d126b67..c6729e55c209 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -177,43 +177,50 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { .completed(); }); - test('worker thread', async () => { + test('worker thread', { timeout: 60_000 }, async () => { const instrument = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'worker-main.mjs') .withMockSentryServer() .withFlags('--import', instrument) .expect({ event: event => { - const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string; + const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string | undefined; expect(crashedThread).toBeDefined(); + const expectedEvent = ANR_EVENT(); expect(event).toMatchObject({ - ...ANR_EVENT(), + ...expectedEvent, + // We compare this separately below + threads: expect.any(Object), exception: { ...EXCEPTION(crashedThread), }, - threads: { - values: [ - { - id: '0', - name: 'main', - crashed: false, - current: true, - main: true, - stacktrace: { - frames: expect.any(Array), - }, - }, - { - id: crashedThread, - name: `worker-${crashedThread}`, - crashed: true, - current: true, - main: false, - }, - ], - }, }); + + const threadValues = event.threads?.values ?? []; + expect(threadValues).toHaveLength(2); + // Any order is fine, we just check that both are present + expect(threadValues).toContainEqual( + expect.objectContaining({ + id: '0', + name: 'main', + crashed: false, + current: true, + main: true, + stacktrace: { + frames: expect.any(Array), + }, + }), + ); + expect(threadValues).toContainEqual( + expect.objectContaining({ + id: crashedThread, + name: `worker-${crashedThread}`, + crashed: true, + current: true, + main: false, + }), + ); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs index dfd664fbf01f..a8927c19950c 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs @@ -2,4 +2,4 @@ import { longWork } from './long-work.js'; setTimeout(() => { longWork(); -}, 5000); +}, 10_000); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts new file mode 100644 index 000000000000..3fe49e76fb35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +async function run(): Promise { + await Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + }); + + await Sentry.flush(); +} + +void run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts new file mode 100644 index 000000000000..c943957c8ae6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -0,0 +1,29 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('infers sentry.op for streamed outgoing fetch spans', async () => { + expect.assertions(2); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + expect(true).toBe(true); + }) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const httpClientSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && item.attributes['sentry.op'].value === 'http.client', + ); + + expect(httpClientSpan).toBeDefined(); + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs new file mode 100644 index 000000000000..4b86f31cb860 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts new file mode 100644 index 000000000000..7ebd70673b96 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -0,0 +1,34 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('httpIntegration-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('infers sentry.op, name, and source for streamed server spans', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && + item.attributes['sentry.op'].value === 'http.server', + ); + + expect(serverSpan).toBeDefined(); + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.name).toBe('GET /test'); + expect(serverSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'route' }); + expect(serverSpan?.attributes?.['sentry.span.source']).toEqual({ type: 'string', value: 'route' }); + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs new file mode 100644 index 000000000000..7cf67b82fc11 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'http.method': 'POST' } }], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs new file mode 100644 index 000000000000..116c27711ef7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs @@ -0,0 +1,25 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/keep', (_req, res) => { + res.send({ status: 'kept' }); + setTimeout(() => { + // flush to avoid waiting for the span buffer timeout to send spans + // but defer it to the next tick to let the SDK finish the http.server span first. + Sentry.flush(); + }); +}); + +app.post('/drop', (_req, res) => { + res.send({ status: 'dropped' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts new file mode 100644 index 000000000000..22eac608d616 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts @@ -0,0 +1,44 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('filtering segment spans by attribute with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('segment spans matching an attribute filter are dropped including all children', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /keep' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + .start(); + + const dropRes = await runner.makeRequest('post', '/drop'); + expect((dropRes as { status: string }).status).toBe('dropped'); + + const keepRes = await runner.makeRequest('get', '/keep'); + expect((keepRes as { status: string }).status).toBe('kept'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs new file mode 100644 index 000000000000..78b0a0cba38c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs @@ -0,0 +1,65 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + res.json({ + id: 'msg_react_agent_123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Paris is the capital of France.', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 10, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + const agent = createReactAgent({ llm, tools: [], name: 'helpful_assistant' }); + + await agent.invoke({ + messages: [new SystemMessage('You are a helpful assistant.'), new HumanMessage('What is the capital of France?')], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs new file mode 100644 index 000000000000..f499d9eff5f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs @@ -0,0 +1,122 @@ +import { tool } from '@langchain/core/tools'; +import { ChatAnthropic } from '@langchain/anthropic'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { HumanMessage } from '@langchain/core/messages'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { z } from 'zod'; + +let callCount = 0; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + callCount++; + const model = req.body.model; + + if (callCount === 1) { + // First call: model decides to call the "add" tool + res.json({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_add_1', + name: 'add', + input: { a: 3, b: 5 }, + }, + ], + model: model, + stop_reason: 'tool_use', + usage: { input_tokens: 20, output_tokens: 10 }, + }); + } else if (callCount === 2) { + // Second call: model sees add result=8, calls "multiply" + res.json({ + id: 'msg_2', + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_mul_1', + name: 'multiply', + input: { a: 8, b: 4 }, + }, + ], + model: model, + stop_reason: 'tool_use', + usage: { input_tokens: 30, output_tokens: 10 }, + }); + } else { + // Third call: model returns final answer + res.json({ + id: 'msg_3', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'The result is 32.' }], + model: model, + stop_reason: 'end_turn', + usage: { input_tokens: 40, output_tokens: 10 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + const addTool = tool( + async ({ a, b }) => { + return String(a + b); + }, + { + name: 'add', + description: 'Add two numbers', + schema: z.object({ a: z.number(), b: z.number() }), + }, + ); + + const multiplyTool = tool( + async ({ a, b }) => { + return String(a * b); + }, + { + name: 'multiply', + description: 'Multiply two numbers', + schema: z.object({ a: z.number(), b: z.number() }), + }, + ); + + const agent = createReactAgent({ + llm, + tools: [addTool, multiplyTool], + name: 'math_assistant', + }); + + await agent.invoke({ + messages: [new HumanMessage('Calculate (3 + 5) * 4')], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs new file mode 100644 index 000000000000..dbd4e959020a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction && event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs new file mode 100644 index 000000000000..d06c1fdd7d4a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs @@ -0,0 +1,56 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_stategraph_chat_1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hello from mock.' }], + model: req.body.model, + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 3 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + const callLlm = async state => { + const response = await llm.invoke(state.messages); + return { messages: [response] }; + }; + + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', callLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'plain_assistant' }); + + await graph.invoke({ messages: [{ role: 'user', content: 'Hi.' }] }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 0837efb63c2f..d17e789d73f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -13,6 +13,7 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, @@ -445,4 +446,111 @@ describe('LangGraph integration', () => { }); }, ); + + // createReactAgent tests + const EXPECTED_TRANSACTION_REACT_AGENT = { + transaction: 'main', + spans: [ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langgraph', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'helpful_assistant', + [GEN_AI_PIPELINE_NAME_ATTRIBUTE]: 'helpful_assistant', + }), + description: 'invoke_agent helpful_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'helpful_assistant', + }), + op: 'gen_ai.chat', + }), + ], + }; + + createEsmAndCjsTests(__dirname, 'agent-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('should instrument createReactAgent with agent and chat spans', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_REACT_AGENT }) + .start() + .completed(); + }); + }); + + // createReactAgent with tools - verifies tool execution spans + const EXPECTED_TRANSACTION_REACT_AGENT_TOOLS = { + transaction: 'main', + spans: [ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'math_assistant', + }), + op: 'gen_ai.invoke_agent', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: 'add', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool add', + op: 'gen_ai.execute_tool', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: 'multiply', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool multiply', + op: 'gen_ai.execute_tool', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + ], + }; + + createEsmAndCjsTests(__dirname, 'agent-tools-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('should create tool execution spans for createReactAgent with tools', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_REACT_AGENT_TOOLS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-stategraph-chat.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('auto-injects langchain handler for plain StateGraph and emits chat spans', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: event => { + const spans = event.spans ?? []; + const chatSpans = spans.filter(s => s.op === 'gen_ai.chat'); + expect(chatSpans).toHaveLength(1); + expect(chatSpans[0]?.data).toMatchObject({ + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'plain_assistant', + }); + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js index a2b405d71f60..cd5303c3de8a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +15,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs index f6e69354ccbc..c54fe084a1f6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +18,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs index 0ee537052a4a..6ceab6f9ec3b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs @@ -10,6 +10,7 @@ Sentry.init({ // Import postgres AFTER Sentry.init() so instrumentation is set up const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -25,6 +26,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); // Test sql.unsafe() - this was not being instrumented before the fix await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs index 9d2e7de99e51..8f0d5070b829 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -15,6 +19,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); // Test sql.unsafe() - this was not being instrumented before the fix await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs index 1a5cc93e2261..fbda092cad28 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs @@ -10,6 +10,7 @@ Sentry.init({ // Import postgres AFTER Sentry.init() so instrumentation is set up const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -25,6 +26,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs index 2694bca96569..7edc0a6590a7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -15,6 +19,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js index d9049353f6eb..373da6082d2e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js @@ -1,5 +1,6 @@ const { loggingTransport } = require('@sentry-internal/node-integration-tests'); const Sentry = require('@sentry/node'); +const { waitForPostgres } = require('./wait-for-postgres.js'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', @@ -23,6 +24,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs index 7d62c8d52dde..78536a82e3f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +18,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js new file mode 100644 index 000000000000..c8c10c6eeb80 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * Retries until Postgres accepts connections. `docker compose up --wait` can report healthy + * before the port forward on the host is ready (flaky on busy CI). + */ +async function waitForPostgres(sql, maxWaitMs = 60_000) { + const deadline = Date.now() + maxWaitMs; + for (;;) { + try { + await sql`SELECT 1`; + return; + } catch { + if (Date.now() > deadline) { + throw new Error('Timed out waiting for Postgres to accept connections'); + } + await new Promise(r => setTimeout(r, 250)); + } + } +} + +module.exports = { waitForPostgres }; diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts index f9fb22606772..e48feac3c793 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts @@ -37,7 +37,6 @@ conditionalTest({ min: 20 })('Prisma ORM v7 Tests', () => { expect(spanDescriptions).toContain('prisma:client:operation'); expect(spanDescriptions).toContain('prisma:client:serialize'); expect(spanDescriptions).toContain('prisma:client:connect'); - expect(spanDescriptions).toContain('prisma:client:db_query'); // Verify the create operation has correct metadata const createSpan = prismaSpans.find( @@ -48,11 +47,17 @@ conditionalTest({ min: 20 })('Prisma ORM v7 Tests', () => { ); expect(createSpan).toBeDefined(); - // Verify db_query span has system info and correct op (v7 uses db.system.name) - const dbQuerySpan = prismaSpans.find(span => span.description === 'prisma:client:db_query'); + // Verify db_query span has system info and correct op (v7 uses db.system.name). + // The SDK should rewrite the span name to the actual SQL text (same as v5/v6 + // `prisma:engine:db_query`), so we find it via op/origin rather than description. + const dbQuerySpan = prismaSpans.find( + span => span.data?.['sentry.op'] === 'db' && span.data?.['db.query.text'], + ); + expect(dbQuerySpan).toBeDefined(); expect(dbQuerySpan?.data?.['db.system.name']).toBe('postgresql'); - expect(dbQuerySpan?.data?.['sentry.op']).toBe('db'); expect(dbQuerySpan?.op).toBe('db'); + expect(dbQuerySpan?.description).toBe(dbQuerySpan?.data?.['db.query.text']); + expect(dbQuerySpan?.description).not.toBe('prisma:client:db_query'); }, }) .start() diff --git a/nx.json b/nx.json index 7cd807e089fb..656f381d9b43 100644 --- a/nx.json +++ b/nx.json @@ -61,9 +61,10 @@ } }, "$schema": "./node_modules/nx/schemas/nx-schema.json", - "cacheDirectory": ".nxcache", + "cacheDirectory": ".nx/cache", "tui": { "autoExit": true }, - "parallel": 5 + "parallel": 5, + "analytics": false } diff --git a/package.json b/package.json index f455f42e3bbf..396d361ade40 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "private": true, "scripts": { - "build": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types build:bundle build:layer", + "build": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types build:bundle", + "build:ci": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types", "build:bundle": "nx run-many -t build:bundle", + "build:layer": "nx run-many -t build:layer", "build:dev": "nx run-many -t build:types build:transpile", "build:dev:filter": "nx run-many -t build:dev -p", "build:transpile": "nx run-many -t build:transpile", @@ -11,6 +13,7 @@ "build:dev:watch": "nx run-many -t build:dev:watch", "build:tarball": "run-s clean:tarballs build:tarballs", "build:tarballs": "nx run-many -t build:tarball", + "ci:print-build-artifact-paths": "node ./scripts/ci-print-build-artifact-paths.mjs", "changelog": "ts-node ./scripts/get-commit-list.ts", "generate-changelog": "ts-node ./scripts/generate-changelog.ts", "circularDepCheck": "nx run-many -t circularDepCheck", @@ -32,7 +35,7 @@ "dedupe-deps:fix": "yarn-deduplicate yarn.lock", "postpublish": "nx run-many -t postpublish --parallel=1", "test": "nx run-many -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", - "test:scripts": "vitest run scripts/bump-version.test.ts", + "test:scripts": "vitest run scripts/*.test.ts", "test:unit": "nx run-many -t test:unit --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", "test:update-snapshots": "nx run-many -t test:update-snapshots", "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", @@ -71,6 +74,7 @@ "packages/integration-shims", "packages/nestjs", "packages/nextjs", + "packages/nitro", "packages/node", "packages/node-core", "packages/node-native", @@ -118,8 +122,9 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@rollup/pluginutils": "^5.1.0", - "@size-limit/file": "~11.1.6", - "@size-limit/webpack": "~11.1.6", + "@size-limit/file": "~12.1.0", + "@size-limit/webpack": "~12.1.0", + "@size-limit/esbuild": "~12.1.0", "@types/jsdom": "^21.1.6", "@types/node": "^18.19.1", "@vitest/coverage-v8": "^3.2.4", @@ -138,7 +143,7 @@ "rollup": "^4.59.0", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.3.1", - "size-limit": "~11.1.6", + "size-limit": "~12.1.0", "sucrase": "^3.35.0", "ts-node": "10.9.2", "typescript": "~5.8.0", @@ -151,7 +156,7 @@ "we need to resolve them to the CommonJS versions." ], "resolutions": { - "**/nx/minimatch": "10.2.4", + "**/nx/minimatch": "10.2.5", "**/ng-packagr/postcss-url/minimatch": "3.1.5", "**/@angular-devkit/build-angular/minimatch": "5.1.9", "gauge/strip-ansi": "6.0.1", diff --git a/packages/angular/package.json b/packages/angular/package.json index 62ec94958ceb..9494c96264fc 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -71,10 +71,7 @@ "^build:types" ], "outputs": [ - "{projectRoot}/build/esm2015", - "{projectRoot}/build/fesm2015", - "{projectRoot}/build/fesm2020", - "{projectRoot}/build/*.d.ts" + "{projectRoot}/build" ] } } diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 25dbb9416fe6..727350f2b046 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,5 +1,5 @@ import { applySdkMetadata } from '@sentry/core'; -import type { Event, NodeClient, NodeOptions } from '@sentry/node'; +import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; /** @@ -13,28 +13,14 @@ export function init(options: NodeOptions): NodeClient | undefined { applySdkMetadata(opts, 'astro', ['astro', 'node']); - const client = initNodeSdk(opts); + opts.ignoreSpans = [ + ...(opts.ignoreSpans || []), + // For http.server spans that did not go though the astro middleware, + // we want to drop them + // this is the case with http.server spans of prerendered pages + // we do not care about those, as they are effectively static + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]; - client?.addEventProcessor( - Object.assign( - (event: Event) => { - // For http.server spans that did not go though the astro middleware, - // we want to drop them - // this is the case with http.server spans of prerendered pages - // we do not care about those, as they are effectively static - if ( - event.type === 'transaction' && - event.contexts?.trace?.op === 'http.server' && - event.contexts?.trace?.origin === 'auto.http.otel.http' - ) { - return null; - } - - return event; - }, - { id: 'AstroHttpEventProcessor' }, - ), - ); - - return client; + return initNodeSdk(opts); } diff --git a/packages/astro/test/server/sdk.test.ts b/packages/astro/test/server/sdk.test.ts index 1d915152fdcc..19c80f4f46f0 100644 --- a/packages/astro/test/server/sdk.test.ts +++ b/packages/astro/test/server/sdk.test.ts @@ -41,5 +41,27 @@ describe('Sentry server SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('configures ignoreSpans to drop prerendered http.server spans', () => { + init({}); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([ + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]), + }), + ); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: [/keep-me/] }); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([/keep-me/]), + }), + ); + }); }); }); diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 99d97d5ecb33..0bc39e22634d 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -80,11 +80,12 @@ "@vercel/nft": "^1.3.0" }, "scripts": { - "build": "run-p build:transpile build:types build:extension && run-s build:layer", + "build": "run-p build:transpile build:types", "build:extension": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaExtension.ts", "build:layer": "rimraf build/aws && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", - "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:transpile": "run-s build:transpile:npm build:extension", + "build:transpile:npm": "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/npm/types build/npm/types-ts3.8 --to ts3.8", @@ -117,20 +118,10 @@ ], "outputs": [ "{projectRoot}/build/npm/esm", - "{projectRoot}/build/npm/cjs" - ] - }, - "build:extension": { - "inputs": [ - "production", - "^production" - ], - "dependsOn": [ - "^build:transpile" - ], - "outputs": [ + "{projectRoot}/build/npm/cjs", "{projectRoot}/build/lambda-extension" - ] + ], + "cache": true }, "build:layer": { "inputs": [ @@ -139,7 +130,7 @@ ], "dependsOn": [ "build:transpile", - "build:extension" + "build:types" ], "outputs": [ "{projectRoot}/build/aws" diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index ff2228fffabe..586027233ad5 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -1,6 +1,13 @@ import * as http from 'node:http'; import { buffer } from 'node:stream/consumers'; -import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core'; +import { + consoleSandbox, + debug, + type DsnComponents, + dsnToString, + getEnvelopeEndpointWithUrlEncodedAuth, + makeDsn, +} from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; /** @@ -94,6 +101,19 @@ export class AwsLambdaExtension { * Starts the Sentry tunnel. */ public startSentryTunnel(): void { + const allowedDsnComponents = getSentryDSNFromEnv(); + + if (!allowedDsnComponents) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + 'Sentry Lambda extension: SENTRY_DSN is not set or is invalid. The /envelope tunnel will forward ' + + 'any DSN in the envelope header without allowlist validation. Set SENTRY_DSN to the same DSN as ' + + 'your SDK to restrict outbound requests.', + ); + }); + } + const server = http.createServer(async (req, res) => { if (req.method === 'POST' && req.url?.startsWith('/envelope')) { try { @@ -104,12 +124,30 @@ export class AwsLambdaExtension { const envelope = new TextDecoder().decode(envelopeBytes); const piece = envelope.split('\n')[0]; const header = JSON.parse(piece || '{}') as { dsn?: string }; - if (!header.dsn) { - throw new Error('DSN is not set'); + const envelopeDsn = header.dsn; + if (!envelopeDsn) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid envelope: missing DSN' })); + return; } - const dsn = dsnFromString(header.dsn); + + // When SENTRY_DSN is set, same allowlist check as handleTunnelRequest in @sentry/core (SSRF protection). + // If not set, we allow any DSN (but warn about this once, above) + if (allowedDsnComponents) { + if (dsnToString(allowedDsnComponents) !== envelopeDsn) { + DEBUG_BUILD && + debug.warn(`Sentry Lambda extension tunnel: rejected request with unauthorized DSN (${envelopeDsn})`); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'DSN not allowed' })); + return; + } + } + + const dsn = allowedDsnComponents || makeDsn(envelopeDsn); if (!dsn) { - throw new Error('Invalid DSN'); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid DSN' })); + return; } const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn); @@ -143,3 +181,13 @@ export class AwsLambdaExtension { }); } } + +/** + * DSN components allowed for the Lambda extension `/envelope` tunnel, derived from `SENTRY_DSN`. + * + * Exported only for testing purposes. + */ +export function getSentryDSNFromEnv(): DsnComponents | undefined { + const raw = process.env.SENTRY_DSN?.trim(); + return raw ? makeDsn(raw) : undefined; +} diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts new file mode 100644 index 000000000000..4c3143eea442 --- /dev/null +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension'; + +describe('getSentryDSNFromEnv', () => { + afterEach(() => { + delete process.env.SENTRY_DSN; + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test('returns undefined when SENTRY_DSN is unset', () => { + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); + + test('returns canonical dsn string when SENTRY_DSN is valid', () => { + process.env.SENTRY_DSN = 'https://public@o1.ingest.sentry.io/1'; + + expect(getSentryDSNFromEnv()).toEqual({ + protocol: 'https', + publicKey: 'public', + host: 'o1.ingest.sentry.io', + projectId: '1', + pass: '', + path: '', + port: '', + }); + }); + + test('returns undefined when SENTRY_DSN is invalid', () => { + process.env.SENTRY_DSN = 'not-a-dsn'; + + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 844f6a170090..4709e6167b3c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -72,6 +72,7 @@ export { instrumentOpenAiClient, instrumentGoogleGenAIClient, instrumentLangGraph, + instrumentCreateReactAgent, createLangChainCallbackHandler, instrumentLangChainEmbeddings, logger, diff --git a/packages/browser/src/integrations/culturecontext.ts b/packages/browser/src/integrations/culturecontext.ts index f2b705e3e9a9..cb4a1c975937 100644 --- a/packages/browser/src/integrations/culturecontext.ts +++ b/packages/browser/src/integrations/culturecontext.ts @@ -1,5 +1,5 @@ import type { CultureContext, IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; import { WINDOW } from '../helpers'; const INTEGRATION_NAME = 'CultureContext'; @@ -21,12 +21,11 @@ const _cultureContextIntegration = (() => { const culture = getCultureContext(); if (culture) { - span.attributes = { + safeSetSpanJSONAttributes(span, { 'culture.locale': culture.locale, 'culture.timezone': culture.timezone, 'culture.calendar': culture.calendar, - ...span.attributes, - }; + }); } }, }; diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 9517b2364e83..bc331ed1e0f8 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,4 +1,4 @@ -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; /** @@ -26,5 +26,21 @@ export const httpContextIntegration = defineIntegration(() => { headers, }; }, + processSegmentSpan(span) { + // if none of the information we want exists, don't bother + if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) { + return; + } + + const reqData = getHttpRequestData(); + + safeSetSpanJSONAttributes(span, { + // Coerce empty string to undefined so the helper's nullish check drops it, + // rather than writing an empty `url.full` attribute onto the span. + 'url.full': reqData.url || undefined, + 'http.request.header.user_agent': reqData.headers['User-Agent'], + 'http.request.header.referer': reqData.headers['Referer'], + }); + }, }; }); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index d21ca8d10bf1..95068c7c9697 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -3,26 +3,34 @@ import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument'; +import { ensureInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; -import { getPrototypeMethodFilter } from './utils/rpcOptions'; -import type { UncheckedMethod } from './wrapMethodWithSentry'; -import { wrapMethodWithSentry } from './wrapMethodWithSentry'; +import { getEffectiveRpcPropagation } from './utils/rpcOptions'; +import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; + +const BUILT_IN_DO_METHODS = new Set([ + 'constructor', + 'fetch', + 'alarm', + 'webSocketError', + 'webSocketClose', + 'webSocketMessage', +]); /** * Instruments a Durable Object class to capture errors and performance data. * - * Instruments the following methods: + * Instruments the following methods by default: * - fetch * - alarm * - webSocketMessage * - webSocketClose * - webSocketError * - * as well as any other public RPC methods on the Durable Object instance. + * To instrument RPC methods (prototype methods), enable the `enableRpcTracePropagation` option. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. @@ -116,140 +124,68 @@ export function instrumentDurableObjectWithSentry< ); } - for (const method of Object.getOwnPropertyNames(obj)) { - if ( - method === 'fetch' || - method === 'alarm' || - method === 'webSocketError' || - method === 'webSocketClose' || - method === 'webSocketMessage' - ) { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - const value = (obj as any)[method] as unknown; - if (typeof value === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - (obj as any)[method] = wrapMethodWithSentry( - { options, context, spanName: method, spanOp: 'rpc' }, - value as UncheckedMethod, - ); - } - } + // Get effective RPC propagation setting (handles deprecation of instrumentPrototypeMethods) + const rpcPropagation = getEffectiveRpcPropagation(options); - // Store context and options on the instance for prototype methods to access - Object.defineProperty(obj, '__SENTRY_CONTEXT__', { - value: context, - enumerable: false, - writable: false, - configurable: false, - }); - - Object.defineProperty(obj, '__SENTRY_OPTIONS__', { - value: options, - enumerable: false, - writable: false, - configurable: false, - }); - - const methodFilter = getPrototypeMethodFilter(options); - - if (methodFilter) { - instrumentPrototype(target, methodFilter); + // Skip RPC instrumentation if not enabled + if (!rpcPropagation) { + return obj; } - return obj; - }, - }); -} - -function instrumentPrototype(target: T, methodsToInstrument: boolean | string[]): void { - const proto = target.prototype; + // If `instrumentPrototypeMethods` was passed as an array (deprecated), + // only the listed method names should be instrumented. + const instrumentPrototypeMethods = Array.isArray(options.instrumentPrototypeMethods) + ? options.instrumentPrototypeMethods + : undefined; + const allowSet = instrumentPrototypeMethods ? new Set(instrumentPrototypeMethods) : null; + + // Return a Proxy that lazily wraps prototype methods on access. + // This avoids iterating the prototype chain at construction time — + // we only check if a property is an RPC method when it's accessed. + const rpcMethodCache = new Map(); + + return new Proxy(obj, { + get(proxyTarget, prop, receiver) { + const value = Reflect.get(proxyTarget, prop, receiver); + + if (typeof prop !== 'string' || BUILT_IN_DO_METHODS.has(prop)) { + return value; + } + + const cached = rpcMethodCache.get(prop); + + if (cached) { + return cached; + } + + if ( + typeof value !== 'function' || + Object.prototype.hasOwnProperty.call(proxyTarget, prop) || + (allowSet && !allowSet.has(prop)) || + // Exclude inherited Object.prototype methods (toString, valueOf, etc.) + // These are not RPC methods and should not create spans + prop in Object.prototype + ) { + return value; + } + + // Bind the method to the original object to ensure private fields work correctly. + // When called via the Proxy, `this` would be the Proxy, but private fields require + // the original object. Bound functions ignore the thisArg passed via Reflect.apply. + const boundValue = (value as UncheckedMethod).bind(proxyTarget); + + const wrapped = wrapMethodWithSentry( + { options, context, spanName: prop, spanOp: 'rpc' }, + boundValue, + undefined, + true, + ); - // Get all methods from the prototype chain - const methodNames = new Set(); - let current = proto; + rpcMethodCache.set(prop, wrapped); - while (current && current !== Object.prototype) { - Object.getOwnPropertyNames(current).forEach(name => { - if (name !== 'constructor' && typeof (current as Record)[name] === 'function') { - methodNames.add(name); - } - }); - current = Object.getPrototypeOf(current); - } - - // Create a set for efficient lookups when methodsToInstrument is an array - const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null; - - // Instrument each method on the prototype - methodNames.forEach(methodName => { - const originalMethod = (proto as Record)[methodName]; - - if (!originalMethod) { - return; - } - - const existingInstrumented = getInstrumented(originalMethod); - if (existingInstrumented) { - Object.defineProperty(proto, methodName, { - value: existingInstrumented, - enumerable: false, - writable: true, - configurable: true, - }); - return; - } - - // If methodsToInstrument is an array, only instrument methods in that set - if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) { - return; - } - - // Create a wrapper that gets context/options from the instance at runtime - const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { - const thisWithSentry = this as { - __SENTRY_CONTEXT__: DurableObjectState; - __SENTRY_OPTIONS__: CloudflareOptions; - }; - const instanceContext = thisWithSentry.__SENTRY_CONTEXT__; - const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__; - - if (!instanceOptions) { - // Fallback to original method if no Sentry data found - return (originalMethod as UncheckedMethod).apply(this, args); - } - - // Use the existing wrapper but with instance-specific context/options - const wrapper = wrapMethodWithSentry( - { - options: instanceOptions, - context: instanceContext, - spanName: methodName, - spanOp: 'rpc', + return wrapped; }, - originalMethod as UncheckedMethod, - undefined, - true, // noMark = true since we'll mark the prototype method - ); - - return wrapper.apply(this, args); - }; - - // Only mark wrappedMethod as instrumented (not originalMethod → wrappedMethod). - // originalMethod must stay unmapped because wrappedMethod calls - // wrapMethodWithSentry(options, originalMethod) on each invocation to create - // a per-instance proxy. If originalMethod mapped to wrappedMethod, that call - // would return wrappedMethod itself, causing infinite recursion. - markAsInstrumented(wrappedMethod); - - // Replace the prototype method - Object.defineProperty(proto, methodName, { - value: wrappedMethod, - enumerable: false, - writable: true, - configurable: true, - }); + }); + }, }); } diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 961542e01446..eaa9b3ddb032 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,6 +110,7 @@ export { withStreamedSpan, spanStreamingIntegration, instrumentLangGraph, + instrumentCreateReactAgent, } from '@sentry/core'; export { withSentry } from './withSentry'; diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index ebbabd9855ad..4c29f6e9595e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -1,6 +1,10 @@ import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types'; +import { appendRpcMeta } from '../utils/rpcMeta'; import { instrumentFetcher } from './worker/instrumentFetcher'; +// Built-in DurableObjectStub methods that are not RPC calls. +export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); + /** * Instruments a DurableObjectNamespace binding to create spans for DO interactions. * @@ -33,17 +37,22 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa } /** - * Instruments a DurableObjectStub to propagate trace context across fetch calls. + * Instruments a DurableObjectStub to create spans for outgoing fetch calls + * and propagate trace context across RPC calls. * * @param stub - The DurableObjectStub to instrument */ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { return new Proxy(stub, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); + get(target, prop) { + const value = Reflect.get(target, prop); if (prop === 'fetch' && typeof value === 'function') { - return instrumentFetcher((input, init) => Reflect.apply(value, target, [input, init])); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof prop === 'string' && !STUB_NON_RPC_METHODS.has(prop)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index fd6a3c72c097..a29bec79e2e5 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,7 +1,8 @@ import type { CloudflareOptions } from '../../client'; import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; -import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; +import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; function isProxyable(item: unknown): item is object { @@ -58,11 +59,15 @@ export function instrumentEnv>(env: Env, opt if (isJSRPC(item)) { const instrumented = new Proxy(item, { - get(target, p, rcv) { - const value = Reflect.get(target, p, rcv); + get(target, p) { + const value = Reflect.get(target, p); if (p === 'fetch' && typeof value === 'function') { - return instrumentFetcher(value.bind(target)); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts new file mode 100644 index 000000000000..9389a221230c --- /dev/null +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -0,0 +1,59 @@ +import { getTraceData, type SerializedTraceData } from '@sentry/core'; + +/** + * Key used to identify Sentry RPC metadata in a trailing argument. + * This enables transparent trace propagation across Cloudflare Workers RPC + * calls (Cap'n Proto), which have no native header/metadata support. + */ +const SENTRY_RPC_META_KEY = '__sentry_rpc_meta__'; + +interface SentryRpcMeta { + __sentry_rpc_meta__: SerializedTraceData; +} + +function isSentryRpcMeta(value: unknown): value is SentryRpcMeta { + if (typeof value !== 'object' || value === null || !(SENTRY_RPC_META_KEY in value)) { + return false; + } + const sentry = (value as SentryRpcMeta).__sentry_rpc_meta__; + return typeof sentry === 'object' && sentry !== null; +} + +/** + * Appends Sentry RPC metadata to an args array for trace propagation. + * If no active trace exists, returns the original args unchanged. + */ +export function appendRpcMeta(args: unknown[]): unknown[] { + const traceData = getTraceData(); + + if (!traceData['sentry-trace']) { + return args; + } + + return [...args, { [SENTRY_RPC_META_KEY]: traceData }]; +} + +/** + * Extracts Sentry RPC metadata from the trailing argument of an args array. + * Returns cleaned args (without meta) and the extracted trace data if found. + */ +export function extractRpcMeta( + args: T, +): { + args: T; + rpcMeta?: SerializedTraceData; +} { + if (args.length === 0) { + return { args }; + } + + const last = args[args.length - 1]; + if (isSentryRpcMeta(last)) { + return { + args: args.slice(0, -1) as T, + rpcMeta: last.__sentry_rpc_meta__, + }; + } + + return { args }; +} diff --git a/packages/cloudflare/src/utils/rpcOptions.ts b/packages/cloudflare/src/utils/rpcOptions.ts index 6e71bb84a46f..5e920675d85a 100644 --- a/packages/cloudflare/src/utils/rpcOptions.ts +++ b/packages/cloudflare/src/utils/rpcOptions.ts @@ -7,7 +7,7 @@ import { DEBUG_BUILD } from '../debug-build'; * * Priority: * 1. If `enableRpcTracePropagation` is set, use it (ignore `instrumentPrototypeMethods`) - * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning (converted to boolean) + * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning * 3. If neither is set, return `false` * * @returns The effective setting for RPC trace propagation @@ -43,37 +43,3 @@ export function getEffectiveRpcPropagation(options: CloudflareOptions): boolean return false; } - -/** - * Gets the method filter for prototype method instrumentation. - * - * Returns: - * - `null` if no instrumentation should occur - * - `true` if all methods should be instrumented - * - `string[]` if only specific methods should be instrumented (deprecated behavior) - * - * @returns The method filter or null if no instrumentation - */ -export function getPrototypeMethodFilter(options: CloudflareOptions): boolean | string[] { - const { enableRpcTracePropagation, instrumentPrototypeMethods } = options; - - // If the new option is explicitly set, use it (boolean only, no filtering) - if (enableRpcTracePropagation !== undefined) { - return !!enableRpcTracePropagation; - } - - // Fall back to deprecated option - preserve array filtering behavior - if (instrumentPrototypeMethods !== undefined) { - if (instrumentPrototypeMethods === true) { - return true; - } - - if (Array.isArray(instrumentPrototypeMethods) && instrumentPrototypeMethods.length > 0) { - return instrumentPrototypeMethods; - } - - return false; - } - - return false; -} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 3a7218057c4a..dffb0338c1da 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -1,6 +1,8 @@ import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import type { SerializedTraceData } from '@sentry/core'; import { captureException, + continueTrace, getClient, isThenable, type Scope, @@ -15,6 +17,7 @@ import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { extractRpcMeta } from './utils/rpcMeta'; import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks'; /** Extended DurableObjectState with originalStorage exposed by instrumentContext */ @@ -64,9 +67,21 @@ export function wrapMethodWithSentry( handler, original => new Proxy(original, { - apply(target, thisArg, args: Parameters) { + apply(target, thisArg, rawArgs: Parameters) { const { startNewTrace } = wrapperOptions; + // For RPC methods, extract Sentry trace context from the trailing argument. + // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; + // we strip it here so the user's method never sees it. + let args = rawArgs; + let rpcMeta: SerializedTraceData | undefined; + + if (wrapperOptions.spanOp === 'rpc') { + const extracted = extractRpcMeta(rawArgs); + args = extracted.args; + rpcMeta = extracted.rpcMeta; + } + // For startNewTrace, always use withIsolationScope to ensure a fresh scope // Otherwise, use existing client's scope or isolation scope const currentClient = getClient(); @@ -213,6 +228,13 @@ export function wrapMethodWithSentry( }); }; + if (rpcMeta) { + return continueTrace( + { sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, + executeSpan, + ); + } + if (startNewTrace) { return startNewTraceCore(() => executeSpan()); } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index efce592a6cdd..dc65cb44f6cc 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,12 +81,8 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('All available durable object methods are instrumented when instrumentPrototypeMethods is enabled', () => { + it('Built-in durable object methods are always instrumented', () => { const testClass = class { - propertyFunction = vi.fn(); - - rpcMethod() {} - fetch() {} alarm() {} @@ -97,24 +93,157 @@ describe('instrumentDurableObjectWithSentry', () => { webSocketError() {} }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); - for (const method_name of [ - 'propertyFunction', - 'fetch', - 'alarm', - 'webSocketMessage', - 'webSocketClose', - 'webSocketError', - 'rpcMethod', - ]) { + + // Built-in DO methods are always instrumented + for (const method_name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { expect(getInstrumented((obj as any)[method_name]), `Method ${method_name} is instrumented`).toBeTruthy(); } }); + it('Does not instrument RPC methods when instrumentPrototypeMethods is not set', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + + describe('instrumentPrototypeMethods option', () => { + it('instruments all RPC methods when option is true', () => { + const testClass = class { + rpcMethodOne() { + return 'one'; + } + rpcMethodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC methods (prototype methods) are wrapped via Proxy - verify they are callable and cached + expect(typeof obj.rpcMethodOne).toBe('function'); + expect(typeof obj.rpcMethodTwo).toBe('function'); + expect(obj.rpcMethodOne).toBe(obj.rpcMethodOne); // Cached wrapper + expect(obj.rpcMethodTwo).toBe(obj.rpcMethodTwo); // Cached wrapper + expect(obj.rpcMethodOne()).toBe('one'); + expect(obj.rpcMethodTwo()).toBe('two'); + }); + + it('instruments only specified methods when option is array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + methodThree() { + return 'three'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // methodOne and methodThree should be wrapped — i.e. they should NOT be + // identical to the underlying prototype method. + expect(obj.methodOne).not.toBe(testClass.prototype.methodOne); + expect(obj.methodThree).not.toBe(testClass.prototype.methodThree); + + // methodTwo is not in the allow-list and must remain the original + // prototype method (i.e. not wrapped). + expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + + // All methods should still be callable and behave correctly. + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + expect(obj.methodThree()).toBe('three'); + }); + + it('does not instrument any RPC methods when option is empty array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: [] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Empty array means no methods are allowed → none should be wrapped. + expect(obj.methodOne).toBe(testClass.prototype.methodOne); + expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + }); + + it('does not instrument RPC methods when option is false', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + + it('does not wrap Object.prototype methods as RPC methods', () => { + const testClass = class { + rpcMethod() { + return 'rpc-result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Object.prototype methods should NOT be wrapped - they should be the original methods + expect(obj.toString).toBe(Object.prototype.toString); + expect(obj.valueOf).toBe(Object.prototype.valueOf); + expect(obj.hasOwnProperty).toBe(Object.prototype.hasOwnProperty); + expect(obj.propertyIsEnumerable).toBe(Object.prototype.propertyIsEnumerable); + expect(obj.isPrototypeOf).toBe(Object.prototype.isPrototypeOf); + expect(obj.toLocaleString).toBe(Object.prototype.toLocaleString); + + // They should still work correctly + expect(obj.toString()).toBe('[object Object]'); + expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own + expect(obj.valueOf()).toBe(obj); + + // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) + expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod); + expect(obj.rpcMethod()).toBe('rpc-result'); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); @@ -164,93 +293,4 @@ describe('instrumentDurableObjectWithSentry', () => { // Verify that exactly one flush call was made during this test expect(delta).toBe(1); }); - - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('does not instrument prototype methods when option is false', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('instruments all prototype methods when option is true', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeTruthy(); - }); - - it('instruments only specified methods when option is array', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - methodThree() { - return 'three'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(getInstrumented(obj.methodThree)).toBeTruthy(); - }); - - it('still instruments instance methods regardless of prototype option', () => { - const testClass = class { - propertyFunction = vi.fn(); - - fetch() {} - alarm() {} - webSocketMessage() {} - webSocketClose() {} - webSocketError() {} - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - // Instance methods should still be instrumented - expect(getInstrumented(obj.propertyFunction)).toBeTruthy(); - expect(getInstrumented(obj.fetch)).toBeTruthy(); - expect(getInstrumented(obj.alarm)).toBeTruthy(); - expect(getInstrumented(obj.webSocketMessage)).toBeTruthy(); - expect(getInstrumented(obj.webSocketClose)).toBeTruthy(); - expect(getInstrumented(obj.webSocketError)).toBeTruthy(); - }); - }); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 1b29d5062ce2..67c6420147ac 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -177,6 +178,84 @@ describe('instrumentDurableObjectNamespace', () => { }); }); + describe('RPC method instrumentation', () => { + it('injects Sentry RPC meta into RPC method calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('rpc-result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('does not wrap built-in stub methods (connect, dup)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const connectFn = vi.fn(); + const dupFn = vi.fn(); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + connect: connectFn, + dup: dupFn, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + + // connect and dup should be the original functions, not wrapped + expect((stub as any).connect).toBe(connectFn); + expect((stub as any).dup).toBe(dupFn); + }); + }); + describe('non-function properties', () => { it('returns non-function properties unchanged', () => { const { namespace: originalNamespace } = createMockNamespace(); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index c127406b8c7e..ab115317b7b0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; @@ -6,6 +7,7 @@ vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ __instrumented: true, __original: namespace, })), + STUB_NON_RPC_METHODS: new Set(['fetch', 'connect', 'dup']), })); import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -173,4 +175,115 @@ describe('instrumentEnv', () => { expect(instrumented.NULL_VAL).toBeNull(); expect(instrumented.UNDEF_VAL).toBeUndefined(); }); + + describe('JSRPC RPC method instrumentation', () => { + it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + // Without enableRpcTracePropagation, no metadata should be injected + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('injects Sentry RPC meta when enableRpcTracePropagation is true', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta into JSRPC fetch calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-baggage=value', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const jsrpcProxy = new Proxy( + { fetch: mockFetch }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).fetch('https://example.com'); + + // fetch should use HTTP header injection, not trailing arg + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs).not.toContainEqual(expect.objectContaining({ __sentry: expect.anything() })); + }); + + it('does not inject meta into JSRPC RPC calls when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + }); }); diff --git a/packages/cloudflare/test/utils/rpcMeta.test.ts b/packages/cloudflare/test/utils/rpcMeta.test.ts new file mode 100644 index 000000000000..38af0de23115 --- /dev/null +++ b/packages/cloudflare/test/utils/rpcMeta.test.ts @@ -0,0 +1,157 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { appendRpcMeta, extractRpcMeta } from '../../src/utils/rpcMeta'; + +describe('rpcMeta', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('appendRpcMeta', () => { + it('appends meta with trace data when active trace exists', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const result = appendRpcMeta(['arg1', 42]); + + expect(result).toEqual([ + 'arg1', + 42, + { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]); + }); + + it('returns original args when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const args = ['arg1', 'arg2']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('returns original args when sentry-trace is empty', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '' }); + + const args = ['arg1']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('appends meta to empty args', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-sample_rate=1.0', + }); + + const result = appendRpcMeta([]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1', baggage: 'sentry-sample_rate=1.0' }, + }); + }); + + it('does not mutate original args array', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const args = ['arg1']; + appendRpcMeta(args); + + expect(args).toEqual(['arg1']); + }); + }); + + describe('extractRpcMeta', () => { + it('extracts meta from trailing argument', () => { + const args = [ + 'arg1', + 42, + { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', 42]); + expect(result.rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + + it('returns original args when no meta present', () => { + const args = ['arg1', { someKey: 'value' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', { someKey: 'value' }]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('returns empty args unchanged', () => { + const result = extractRpcMeta([]); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry_rpc_meta__ value is not an object', () => { + const args = ['arg1', { __sentry_rpc_meta__: 'not-an-object' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry_rpc_meta__ value is null', () => { + const args = ['arg1', { __sentry_rpc_meta__: null }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('handles meta with only trace (no baggage)', () => { + const args = [{ __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1' } }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toEqual({ 'sentry-trace': 'abc-def-1' }); + }); + + it('round-trips with appendRpcMeta', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const originalArgs = ['hello', { data: true }, 42]; + const withMeta = appendRpcMeta(originalArgs); + const { args, rpcMeta } = extractRpcMeta(withMeta); + + expect(args).toEqual(originalArgs); + expect(rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 00c12db06855..2cf7c1afb171 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1169,10 +1169,25 @@ export abstract class Client { return {}; } + /** + * Register a cleanup function to be called when the client is disposed. + * This is useful for integrations that need to clean up global state. + * + * NOTE: This is a no-op in the base `Client` class. Subclasses like `ServerRuntimeClient` + * override this method to actually register and execute cleanup callbacks. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public registerCleanup(callback: () => void): void { + // No-op in base class - subclasses override to implement cleanup registration + } + /** * Disposes of the client and releases all resources. * - * Subclasses should override this method to clean up their own resources. + * Subclasses should override this method to clean up their own resources, including invoking + * any callbacks registered via {@link Client.registerCleanup}. The base implementation is a + * no-op and does NOT execute registered cleanup callbacks. + * * After calling dispose(), the client should not be used anymore. */ public dispose(): void { @@ -1600,7 +1615,13 @@ function processBeforeSend( const rootSpanJson = convertTransactionEventToSpanJson(processedEvent); // 1.1 If the root span should be ignored, drop the whole transaction - if (ignoreSpans?.length && shouldIgnoreSpan(rootSpanJson, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan( + { description: rootSpanJson.description, op: rootSpanJson.op, attributes: rootSpanJson.data }, + ignoreSpans, + ) + ) { // dropping the whole transaction! return null; } @@ -1624,7 +1645,10 @@ function processBeforeSend( for (const span of initialSpans) { // 2.a If the child span should be ignored, reparent it to the root span - if (ignoreSpans?.length && shouldIgnoreSpan(span, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan({ description: span.description, op: span.op, attributes: span.data }, ignoreSpans) + ) { reparentChildSpans(initialSpans, span); continue; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index dd91d077f45c..6b7b251c542c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -142,7 +142,10 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; const filteredSpans = ignoreSpans?.length - ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) + ? spans.filter(span => { + const json = spanToJSON(span); + return !shouldIgnoreSpan({ description: json.description, op: json.op, attributes: json.data }, ignoreSpans); + }) : spans; const droppedSpans = spans.length - filteredSpans.length; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3f8c454e997..4d80ea02ed33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,7 @@ export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; @@ -178,7 +179,7 @@ export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/langgraph'; +export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; @@ -256,6 +257,7 @@ export { } from './utils/misc'; export { isNodeEnv, loadModule } from './utils/node'; export { normalize, normalizeToSize, normalizeUrlToBase } from './utils/normalize'; +export { setNormalizationDepthOverrideHint, setSkipNormalizationHint } from './utils/normalizationHints'; export { addNonEnumerableProperty, convertToPlainObject, @@ -452,6 +454,8 @@ export type { ReplayStopReason, } from './types-hoist/replay'; export type { + FeedbackErrorCode, + FeedbackErrorMessages, FeedbackEvent, FeedbackFormData, FeedbackInternalOptions, diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index cecf1e5cad8a..ef7e9c804943 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -8,14 +8,16 @@ import { addHandler, maybeInstrument, triggerHandlers } from './handlers'; /** * Add an instrumentation handler for when a console.xxx method is called. + * Returns a function to remove the handler. * * Use at your own risk, this might break without changelog notice, only used internally. * @hidden */ -export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): void { +export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): () => void { const type = 'console'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, instrumentConsole); + return removeHandler; } function instrumentConsole(): void { diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 590830ab4e20..a3165cfbc13a 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -15,6 +15,7 @@ type FetchResource = string | { toString(): string } | { url: string }; * Add an instrumentation handler for when a fetch request happens. * The handler function is called once when the request starts and once when it ends, * which can be identified by checking if it has an `endTimestamp`. + * Returns a function to remove the handler. * * Use at your own risk, this might break without changelog notice, only used internally. * @hidden @@ -22,24 +23,27 @@ type FetchResource = string | { toString(): string } | { url: string }; export function addFetchInstrumentationHandler( handler: (data: HandlerDataFetch) => void, skipNativeFetchCheck?: boolean, -): void { +): () => void { const type = 'fetch'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, () => instrumentFetch(undefined, skipNativeFetchCheck)); + return removeHandler; } /** * Add an instrumentation handler for long-lived fetch requests, like consuming server-sent events (SSE) via fetch. * The handler will resolve the request body and emit the actual `endTimestamp`, so that the * span can be updated accordingly. + * Returns a function to remove the handler. * * Only used internally * @hidden */ -export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): void { +export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): () => void { const type = 'fetch-body-resolved'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, () => instrumentFetch(streamHandler)); + return removeHandler; } function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void { diff --git a/packages/core/src/instrument/handlers.ts b/packages/core/src/instrument/handlers.ts index 74dbc9902348..bd15263021c8 100644 --- a/packages/core/src/instrument/handlers.ts +++ b/packages/core/src/instrument/handlers.ts @@ -18,10 +18,20 @@ export type InstrumentHandlerCallback = (data: any) => void; const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {}; const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; -/** Add a handler function. */ -export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { +/** Add a handler function. Returns a function to remove the handler. */ +export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): () => void { handlers[type] = handlers[type] || []; handlers[type].push(handler); + + return () => { + const typeHandlers = handlers[type]; + if (typeHandlers) { + const index = typeHandlers.indexOf(handler); + if (index !== -1) { + typeHandlers.splice(index, 1); + } + } + }; } /** diff --git a/packages/core/src/integrations/console.ts b/packages/core/src/integrations/console.ts index dda44543cc03..e39fd5ddcf0d 100644 --- a/packages/core/src/integrations/console.ts +++ b/packages/core/src/integrations/console.ts @@ -41,13 +41,15 @@ export const consoleIntegration = defineIntegration((options: Partial { + const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => { if (getClient() !== client || !levels.has(level)) { return; } addConsoleBreadcrumb(level, args); }); + + client.registerCleanup(unsubscribe); }, }; }); diff --git a/packages/core/src/integrations/extraerrordata.ts b/packages/core/src/integrations/extraerrordata.ts index 59bac56908c3..8afde618af36 100644 --- a/packages/core/src/integrations/extraerrordata.ts +++ b/packages/core/src/integrations/extraerrordata.ts @@ -7,7 +7,7 @@ import type { IntegrationFn } from '../types-hoist/integration'; import { debug } from '../utils/debug-logger'; import { isError, isPlainObject } from '../utils/is'; import { normalize } from '../utils/normalize'; -import { addNonEnumerableProperty } from '../utils/object'; +import { setSkipNormalizationHint } from '../utils/normalizationHints'; import { truncate } from '../utils/string'; const INTEGRATION_NAME = 'ExtraErrorData'; @@ -66,7 +66,7 @@ function _enhanceEventWithErrorData( if (isPlainObject(normalizedErrorData)) { // We mark the error data as "already normalized" here, because we don't want other normalization procedures to // potentially truncate the data we just already normalized, with a certain depth setting. - addNonEnumerableProperty(normalizedErrorData, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(normalizedErrorData); contexts[exceptionName] = normalizedErrorData; } diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index b01ac13b1708..da1a7cee17c6 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -342,6 +342,8 @@ export function _reconstructQuery(strings: string[] | undefined): string | undef return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); } +let integerLiteralRE: RegExp | undefined; + /** * Sanitize SQL query as per the OTEL semantic conventions * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext @@ -356,6 +358,13 @@ export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { return 'Unknown SQL Query'; } + // Lazy init: constructing this at module scope would evaluate the lookbehind + // on import and crash Safari <16.4 browser bundles that reach this file via + // the core barrel. Building it on first call keeps the cost off the import path. + if (!integerLiteralRE) { + integerLiteralRE = new RegExp('(? { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (headers as Record)[ipHeaderName]; - }); + const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ipHeaderNamesLower.has(key.toLowerCase())) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (headers as Record)[key]; + } + } } } diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index dac7530b46f0..14427c0ee3b7 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; +import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; @@ -148,6 +149,25 @@ function isInstrumented(fn: T): boolean | undefined { } } +/** + * Plain-object bodies are copied into `plainBody`; array inserts (and other non-plain shapes) stay only on `rawBody`. + * Returns a payload suitable for span attributes / breadcrumbs when the client has `sendDefaultPii` enabled. + */ +function getMutationBodyPayloadForTelemetry(rawBody: unknown, plainBody: Record): unknown | undefined { + if (Object.keys(plainBody).length > 0) { + return plainBody; + } + if (Array.isArray(rawBody) && rawBody.length > 0) { + return rawBody; + } + return undefined; +} + +/** True when the PostgREST builder carries a mutation body (for `insert(...)`, etc. in span descriptions). */ +function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record): boolean { + return getMutationBodyPayloadForTelemetry(rawBody, plainBody) !== undefined; +} + /** * Extracts the database operation type from the HTTP method and headers * @param method - The HTTP method of the request @@ -361,12 +381,19 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } } + const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii); + const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body); + // Adding operation to the beginning of the description if it's not a `select` operation // For example, it can be an `insert` or `update` operation but the query can be `select(...)` // For `select` operations, we don't need repeat it in the description - const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join( - ' ', - )} from(${table})`; + const mutationPart = + operation === 'select' + ? '' + : `${operation}${hasMutationBodyForDescription(typedThis.body, body) ? '(...) ' : ''}`; + const queryPart = sendDefaultPii ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : ''; + const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' '); + const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`; const attributes: Record = { 'db.table': table, @@ -379,12 +406,12 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', }; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { attributes['db.query'] = queryItems; } - if (Object.keys(body).length) { - attributes['db.body'] = body; + if (bodyPayload !== undefined && sendDefaultPii) { + attributes['db.body'] = bodyPayload; } return startSpan( @@ -413,11 +440,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } const supabaseContext: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { supabaseContext.query = queryItems; } - if (Object.keys(body).length) { - supabaseContext.body = body; + if (bodyPayload !== undefined && sendDefaultPii) { + supabaseContext.body = bodyPayload; } captureException(err, scope => { @@ -444,12 +471,12 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte const data: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { data.query = queryItems; } - if (Object.keys(body).length) { - data.body = body; + if (bodyPayload !== undefined && sendDefaultPii) { + data.body = bodyPayload; } if (Object.keys(data).length) { diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index ccf14e3ebf48..e16016a1154a 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -31,7 +31,7 @@ const _consoleLoggingIntegration = ((options: Partial = { return; } - addConsoleInstrumentationHandler(({ args, level }) => { + const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => { if (getClient() !== client || !levels.includes(level)) { return; } @@ -66,6 +66,8 @@ const _consoleLoggingIntegration = ((options: Partial = { attributes, }); }); + + client.registerCleanup(unsubscribe); }, }; }) satisfies IntegrationFn; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index a1958f0bcbbb..da697c682436 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -31,6 +31,8 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends Client { + private _disposeCallbacks: (() => void)[] = []; + /** * Creates a new Edge SDK instance. * @param options Configuration options for this SDK. @@ -154,6 +156,13 @@ export class ServerRuntimeClient< return id; } + /** + * @inheritDoc + */ + public override registerCleanup(callback: () => void): void { + this._disposeCallbacks.push(callback); + } + /** * Disposes of the client and releases all resources. * @@ -168,6 +177,16 @@ export class ServerRuntimeClient< public override dispose(): void { DEBUG_BUILD && debug.log('Disposing client...'); + // Run all registered cleanup callbacks + for (const callback of this._disposeCallbacks) { + try { + callback(); + } catch { + // Ignore errors in cleanup callbacks + } + } + this._disposeCallbacks.length = 0; + for (const hookName of Object.keys(this._hooks)) { this._hooks[hookName]?.clear(); } diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 53848a9c9191..884a8bb05497 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -179,7 +179,13 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // Ignored spans will get dropped later (in the client) but since we already adjust // the idle span end timestamp here, we can already take to-be-ignored spans out of // the calculation here. - if (ignoreSpans && shouldIgnoreSpan(currentSpanJson, ignoreSpans)) { + if ( + ignoreSpans && + shouldIgnoreSpan( + { description: currentSpanJson.description, op: currentSpanJson.op, attributes: currentSpanJson.data }, + ignoreSpans, + ) + ) { return acc; } return acc ? Math.max(acc, currentSpanJson.timestamp) : currentSpanJson.timestamp; diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 64e9058d8ce2..8ccce0f0a183 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -5,6 +6,7 @@ import { startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_TOOL_INPUT_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE, @@ -23,6 +25,8 @@ import { extractChatModelRequestAttributes, extractLLMRequestAttributes, extractLlmResponseAttributes, + extractToolDefinitions, + getAgentNameFromMetadata, getInvocationParams, } from './utils'; @@ -102,6 +106,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { + ...getAgentNameFromMetadata(metadata), ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, @@ -119,7 +124,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): messages: unknown, runId: string, _parentRunId?: string, - _extraParams?: Record, + extraParams?: Record, tags?: string[], metadata?: Record, _runName?: string, @@ -133,6 +138,12 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): invocationParams, metadata, ); + + const toolDefsJson = extractToolDefinitions(extraParams); + if (toolDefsJson) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = toolDefsJson; + } + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; @@ -141,6 +152,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { + ...getAgentNameFromMetadata(metadata), ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, @@ -193,17 +205,23 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): runId: string, _parentRunId?: string, _tags?: string[], - _metadata?: Record, + metadata?: Record, _runType?: string, runName?: string, ) { + // Skip chain spans when inside an agent context (createReactAgent). + // The agent already creates an invoke_agent span; internal chain steps + // (ChannelWrite, Branch, prompt, etc.) are noise. + if (metadata?.__sentry_langgraph__) { + return; + } + const chainName = runName || chain.name || 'unknown_chain'; const attributes: Record = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', 'langchain.chain.name': chainName, }; - // Add inputs if recordInputs is enabled if (recordInputs) { attributes['langchain.chain.inputs'] = JSON.stringify(inputs); } @@ -255,14 +273,30 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Tool Start Handler - handleToolStart(tool: { name?: string }, input: string, runId: string, _parentRunId?: string) { - const toolName = tool.name || 'unknown_tool'; + handleToolStart( + tool: { name?: string }, + input: string, + runId: string, + _parentRunId?: string, + _tags?: string[], + metadata?: Record, + runName?: string, + ) { + // Skip tool spans when inside an agent context (createReactAgent). + // Tool spans are created by wrapToolsWithSpans with richer attributes. + if (metadata?.__sentry_langgraph__) { + return; + } + + // runName is set to tool.name by LangChain's StructuredTool.call() + const toolName = runName || tool.name || 'unknown_tool'; const attributes: Record = { + ...getAgentNameFromMetadata(metadata), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, }; - // Add input if recordInputs is enabled if (recordInputs) { attributes[GEN_AI_TOOL_INPUT_ATTRIBUTE] = input; } @@ -287,10 +321,13 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleToolEnd(output: unknown, runId: string) { const span = spanMap.get(runId); if (span?.isRecording()) { - // Add output if recordOutputs is enabled if (recordOutputs) { + // LangChain tools may return ToolMessage objects — extract the content + const outputObj = output as Record | undefined; + const content = + outputObj && typeof outputObj === 'object' && 'content' in outputObj ? outputObj.content : output; span.setAttributes({ - [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: JSON.stringify(output), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: typeof content === 'string' ? content : JSON.stringify(content), }); } exitSpan(runId); diff --git a/packages/core/src/tracing/langchain/types.ts b/packages/core/src/tracing/langchain/types.ts index 1c066269aba5..542b80b7df74 100644 --- a/packages/core/src/tracing/langchain/types.ts +++ b/packages/core/src/tracing/langchain/types.ts @@ -36,6 +36,15 @@ export interface LangChainSerialized { kwargs?: Record; } +/** + * Subset of the 'llm' param passed to createReactAgent + */ +export interface BaseChatModel { + lc_namespace: string[]; + modelName?: string; + model?: string; +} + /** * LangChain message structure * Supports both regular messages and LangChain serialized format diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 1227889f210d..34be8ba753bb 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -1,6 +1,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { SpanAttributeValue } from '../../types-hoist/span'; import { + GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -350,22 +351,28 @@ export function extractChatModelRequestAttributes( } /** - * Scans generations for Anthropic-style `tool_use` items and records them. - * - * LangChain represents some provider messages (e.g., Anthropic) with a `message.content` - * array that may include objects `{ type: 'tool_use', ... }`. We collect and attach - * them as a JSON array on `gen_ai.response.tool_calls` for downstream consumers. + * Extracts tool calls from generations and records them on the span attributes. + * Prefers message.tool_calls (LangChain's normalized format). Falls back to + * scanning message.content for Anthropic-style tool_use items in older versions + * where tool_calls may not be populated. */ function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record): void { const toolCalls: unknown[] = []; const flatGenerations = generations.flat(); for (const gen of flatGenerations) { - const content = gen.message?.content; - if (Array.isArray(content)) { - for (const item of content) { - const t = item as { type: string }; - if (t.type === 'tool_use') toolCalls.push(t); + const msg = gen.message as Record | undefined; + const msgToolCalls = msg?.tool_calls as unknown[] | undefined; + if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) { + toolCalls.push(...msgToolCalls); + } else { + // Fallback for older LangChain versions: scan message.content for Anthropic-style tool_use + const content = gen.message?.content; + if (Array.isArray(content)) { + for (const item of content) { + const t = item as Record; + if (t.type === 'tool_use') toolCalls.push(t); + } } } } @@ -504,3 +511,29 @@ export function extractLlmResponseAttributes( return attrs; } + +export function getAgentNameFromMetadata(metadata?: Record): Record { + const attrs: Record = {}; + // lc_agent_name is injected by instrumentCompiledGraphInvoke (langgraph integration) + const agentName = metadata?.lc_agent_name; + if (typeof agentName === 'string') { + attrs[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName; + } + return attrs; +} + +export function extractToolDefinitions(extraParams?: Record): string | undefined { + const tools = + (extraParams?.invocation_params as Record)?.tools ?? + (extraParams?.options as Record)?.tools; + if (!Array.isArray(tools) || tools.length === 0) return undefined; + const toolDefs = tools.map((tool: Record) => { + const fn = tool.function as Record | undefined; + return { + type: 'function', + name: tool.name ?? fn?.name ?? '', + description: tool.description ?? fn?.description, + }; + }); + return JSON.stringify(toolDefs); +} diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d188fe90d97f..d43159a62ee1 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -10,6 +10,7 @@ import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { @@ -19,12 +20,24 @@ import { resolveAIRecordingOptions, shouldEnableTruncation, } from '../ai/utils'; -import type { LangChainMessage } from '../langchain/types'; +import { createLangChainCallbackHandler } from '../langchain'; +import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; import { startSpan } from '../trace'; import { LANGGRAPH_ORIGIN } from './constants'; import type { CompiledGraph, LangGraphOptions } from './types'; -import { extractToolsFromCompiledGraph, setResponseAttributes } from './utils'; +import { + extractAgentNameFromParams, + extractLLMFromParams, + extractToolsFromCompiledGraph, + mergeSentryCallback, + setResponseAttributes, + wrapToolsWithSpans, +} from './utils'; + +let _insideCreateReactAgent = false; + +const SENTRY_PATCHED = '__sentry_patched__'; /** * Instruments StateGraph's compile method to create spans for agent creation and invocation @@ -38,8 +51,19 @@ export function instrumentStateGraphCompile( originalCompile: (...args: unknown[]) => CompiledGraph, options: LangGraphOptions, ): (...args: unknown[]) => CompiledGraph { - return new Proxy(originalCompile, { + if (Object.prototype.hasOwnProperty.call(originalCompile, SENTRY_PATCHED)) { + return originalCompile; + } + + const sentryHandler = createLangChainCallbackHandler(options); + + const wrapped = new Proxy(originalCompile, { apply(target, thisArg, args: unknown[]): CompiledGraph { + // Skip when called from within createReactAgent to avoid duplicate instrumentation + if (_insideCreateReactAgent) { + return Reflect.apply(target, thisArg, args); + } + return startSpan( { op: 'gen_ai.create_agent', @@ -69,6 +93,8 @@ export function instrumentStateGraphCompile( compiledGraph, compileOptions, options, + undefined, + sentryHandler, ) as typeof originalInvoke; } @@ -87,6 +113,9 @@ export function instrumentStateGraphCompile( ); }, }) as (...args: unknown[]) => CompiledGraph; + + Object.defineProperty(wrapped, SENTRY_PATCHED, { value: true, enumerable: false }); + return wrapped; } /** @@ -99,9 +128,12 @@ function instrumentCompiledGraphInvoke( graphInstance: CompiledGraph, compileOptions: Record, options: LangGraphOptions, + llm?: BaseChatModel | null, + sentryCallbackHandler?: unknown, ): (...args: unknown[]) => Promise { return new Proxy(originalInvoke, { apply(target, thisArg, args: unknown[]): Promise { + const modelName = llm?.modelName ?? llm?.model; return startSpan( { op: 'gen_ai.invoke_agent', @@ -122,6 +154,10 @@ function instrumentCompiledGraphInvoke( span.updateName(`invoke_agent ${graphName}`); } + if (modelName) { + span.setAttribute(GEN_AI_REQUEST_MODEL_ATTRIBUTE, modelName); + } + // Extract thread_id from the config (second argument) // LangGraph uses config.configurable.thread_id for conversation/session linking const config = args.length > 1 ? (args[1] as Record | undefined) : undefined; @@ -131,6 +167,21 @@ function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, threadId); } + // Inject callback handler and agent name into invoke config + if (sentryCallbackHandler) { + const invokeConfig = (args[1] ?? {}) as Record; + args[1] = invokeConfig; + + const existingMetadata = (invokeConfig.metadata ?? {}) as Record; + invokeConfig.metadata = { + ...existingMetadata, + __sentry_langgraph__: true, + ...(typeof graphName === 'string' ? { lc_agent_name: graphName } : {}), + }; + + invokeConfig.callbacks = mergeSentryCallback(invokeConfig.callbacks, sentryCallbackHandler); + } + // Extract available tools from the graph instance const tools = extractToolsFromCompiledGraph(graphInstance); if (tools) { @@ -164,7 +215,6 @@ function instrumentCompiledGraphInvoke( // Call original invoke const result = await Reflect.apply(target, thisArg, args); - // Set response attributes if (recordOutputs) { setResponseAttributes(span, inputMessages ?? null, result); } @@ -186,6 +236,66 @@ function instrumentCompiledGraphInvoke( }) as (...args: unknown[]) => Promise; } +/** + * Instruments createReactAgent to create invoke_agent and execute_tool spans. + */ +export function instrumentCreateReactAgent( + originalCreateReactAgent: (...args: unknown[]) => CompiledGraph, + options?: LangGraphOptions, +): (...args: unknown[]) => CompiledGraph { + if (Object.prototype.hasOwnProperty.call(originalCreateReactAgent, SENTRY_PATCHED)) { + return originalCreateReactAgent; + } + + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); + + const wrapped = new Proxy(originalCreateReactAgent, { + apply(target, thisArg, args: unknown[]): CompiledGraph { + const llm = extractLLMFromParams(args); + const agentName = extractAgentNameFromParams(args); + + // Wrap tools with execute_tool spans (direct access gives us name, type, description) + const params = args[0] as Record | undefined; + if (params && Array.isArray(params.tools) && params.tools.length > 0) { + wrapToolsWithSpans(params.tools, resolvedOptions, agentName ?? undefined); + } + + // Suppress StateGraph.compile instrumentation inside createReactAgent + _insideCreateReactAgent = true; + let compiledGraph: CompiledGraph; + try { + compiledGraph = Reflect.apply(target, thisArg, args); + } finally { + _insideCreateReactAgent = false; + } + + // Wrap invoke() on the returned compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + const compileOptions: Record = {}; + if (agentName) { + compileOptions.name = agentName; + } + + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + resolvedOptions, + llm, + sentryHandler, + ) as typeof originalInvoke; + } + + return compiledGraph; + }, + }) as (...args: unknown[]) => CompiledGraph; + + Object.defineProperty(wrapped, SENTRY_PATCHED, { value: true, enumerable: false }); + return wrapped; +} + /** * Directly instruments a StateGraph instance to add tracing spans * diff --git a/packages/core/src/tracing/langgraph/utils.ts b/packages/core/src/tracing/langgraph/utils.ts index 4b1990058924..8770cbbd629b 100644 --- a/packages/core/src/tracing/langgraph/utils.ts +++ b/packages/core/src/tracing/langgraph/utils.ts @@ -1,16 +1,165 @@ -import type { Span } from '../../types-hoist/span'; +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span, SpanAttributes } from '../../types-hoist/span'; import { + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import type { LangChainMessage } from '../langchain/types'; +import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; -import type { CompiledGraph, LangGraphTool } from './types'; +import { startSpan } from '../trace'; +import { LANGGRAPH_ORIGIN } from './constants'; +import type { CompiledGraph, LangGraphOptions, LangGraphTool } from './types'; + +/** + * Extract LLM model object from createReactAgent params + */ +export function extractLLMFromParams(args: unknown[]): BaseChatModel | null { + const arg = args[0]; + if (typeof arg !== 'object' || !arg || !('llm' in arg) || !arg.llm || typeof arg.llm !== 'object') { + return null; + } + const llm = arg.llm as BaseChatModel; + if (typeof llm.modelName !== 'string' && typeof llm.model !== 'string') { + return null; + } + return llm; +} + +/** + * Extract agent name from createReactAgent params + */ +export function extractAgentNameFromParams(args: unknown[]): string | null { + const arg = args[0]; + if (typeof arg === 'object' && !!arg && 'name' in arg && typeof arg.name === 'string') { + return arg.name; + } + return null; +} + +/** + * Wraps an array of LangChain tools so each invocation creates a gen_ai.execute_tool span. + * + * Wraps each tool's invoke() method in place. A marker prevents double-wrapping. + */ +export function wrapToolsWithSpans(tools: unknown[], options: LangGraphOptions, agentName?: string): unknown[] { + const SENTRY_WRAPPED = '__sentry_tool_wrapped__'; + + for (const tool of tools) { + if (!tool || typeof tool !== 'object') { + continue; + } + + const t = tool as Record; + const originalInvoke = t.invoke; + if (typeof originalInvoke !== 'function' || Object.prototype.hasOwnProperty.call(t, SENTRY_WRAPPED)) { + continue; + } + + const toolName = typeof t.name === 'string' ? t.name : 'unknown_tool'; + const toolDescription = typeof t.description === 'string' ? t.description : undefined; + + const wrappedInvoke = new Proxy(originalInvoke as (...args: unknown[]) => unknown, { + apply(target, thisArg, args: unknown[]): unknown { + const spanAttributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: 'function', + }; + + // Read agent name from LangChain's propagated config metadata at call time, + // so shared tools get the correct agent name for each invocation + const callConfig = args[1] as Record | undefined; + const callAgentName = (callConfig?.metadata as Record)?.lc_agent_name ?? agentName; + if (typeof callAgentName === 'string') { + spanAttributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = callAgentName; + } + + if (toolDescription) { + spanAttributes[GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE] = toolDescription; + } + + // LangGraph ToolNode passes { name, args, id, type: "tool_call" } + const input = args[0] as Record | undefined; + if (typeof input === 'object' && !!input) { + if ('id' in input && typeof input.id === 'string') { + spanAttributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE] = input.id; + } + + if (options.recordInputs) { + const toolArgs = 'args' in input && typeof input.args === 'object' ? input.args : input; + try { + spanAttributes[GEN_AI_TOOL_INPUT_ATTRIBUTE] = JSON.stringify(toolArgs); + } catch { + // skip if not serializable + } + } + } + + return startSpan( + { + op: GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + name: `execute_tool ${toolName}`, + attributes: spanAttributes, + }, + async span => { + try { + const result = await Reflect.apply(target, thisArg, args); + + if (options.recordOutputs) { + try { + // ToolMessage objects wrap the result in .content + const resultObj = result as Record | undefined; + const content = + resultObj && typeof resultObj === 'object' && 'content' in resultObj ? resultObj.content : result; + span.setAttribute( + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + typeof content === 'string' ? content : JSON.stringify(content), + ); + } catch { + // skip if not serializable + } + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }, + ); + }, + }); + + t.invoke = wrappedInvoke; + Object.defineProperty(t, SENTRY_WRAPPED, { value: true, enumerable: false }); + } + + return tools; +} /** * Extract tool calls from messages @@ -185,3 +334,27 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, totalTokens); } } + +/** Merge `sentryHandler` into a langchain `callbacks` value (`BaseCallbackHandler[]` or `BaseCallbackManager`). */ +export function mergeSentryCallback(existing: unknown, sentryHandler: unknown): unknown { + if (!existing) { + return [sentryHandler]; + } + + if (Array.isArray(existing)) { + if (existing.includes(sentryHandler)) { + return existing; + } + return [...existing, sentryHandler]; + } + + const manager = existing as { addHandler?: (h: unknown) => void; handlers?: unknown[] }; + if (typeof manager.addHandler === 'function') { + const alreadyAdded = Array.isArray(manager.handlers) && manager.handlers.includes(sentryHandler); + if (!alreadyAdded) { + manager.addHandler(sentryHandler); + } + } + + return existing; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index fe8bc31fcae7..e41a9cfdf484 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -2,7 +2,9 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; import { + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -51,6 +53,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + // Backfill span data from OTel semantic conventions when not explicitly set. + // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path + // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. + // This must run before all hooks and beforeSendSpan so that user callbacks can see and override inferred values. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(spanJSON, spanKind); + if (spanJSON.is_segment) { applyScopeToSegmentSpan(spanJSON, finalScopeData); // Allow hook subscribers to mutate the segment span JSON @@ -150,3 +160,119 @@ export function safeSetSpanJSONAttributes( } }); } + +// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) +const SPAN_KIND_SERVER = 1; +const SPAN_KIND_CLIENT = 2; + +/** + * Infer and backfill span data from OTel semantic conventions. + * This mirrors what the `SentrySpanExporter` does for non-streamed spans via `getSpanData`/`inferSpanData`. + * Streamed spans skip the exporter, so we do the inference here during capture. + * + * Backfills: `sentry.op`, `sentry.source`, and `name` (description). + * Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten. + */ +/** Exported only for tests. */ +export function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void { + const attributes = spanJSON.attributes; + if (!attributes) { + return; + } + + const httpMethod = attributes['http.request.method'] || attributes['http.method']; + if (httpMethod) { + inferHttpSpanData(spanJSON, attributes, spanKind, httpMethod); + return; + } + + const dbSystem = attributes['db.system.name'] || attributes['db.system']; + const opIsCache = + typeof attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'string' && + `${attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]}`.startsWith('cache.'); + if (dbSystem && !opIsCache) { + inferDbSpanData(spanJSON, attributes); + return; + } + + if (attributes['rpc.service']) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc' }); + return; + } + + if (attributes['messaging.system']) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message' }); + return; + } + + const faasTrigger = attributes['faas.trigger']; + if (faasTrigger) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}` }); + } +} + +function inferHttpSpanData( + spanJSON: StreamedSpanJSON, + attributes: RawAttributes>, + spanKind: number | undefined, + httpMethod: unknown, +): void { + // Infer op: http.client, http.server, or just http + const opParts = ['http']; + if (spanKind === SPAN_KIND_CLIENT) { + opParts.push('client'); + } else if (spanKind === SPAN_KIND_SERVER) { + opParts.push('server'); + } + if (attributes['sentry.http.prefetch']) { + opParts.push('prefetch'); + } + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.') }); + + // If the user set a custom span name via updateSpanName(), apply it — OTel instrumentation + // may have overwritten span.name after the user set it, so we restore from the attribute. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { + return; + } + + // Only overwrite the span name when we have an explicit http.route — it's more specific than + // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), + // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). + const httpRoute = attributes['http.route']; + if (typeof httpRoute === 'string') { + spanJSON.name = `${httpMethod} ${httpRoute}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); + } else { + // Fallback: set source to 'url' for HTTP spans without a route. + // The spec requires sentry.span.source on segment spans, and the non-streamed exporter + // always sets this — so we need to ensure it's present for streamed spans too. + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); + } +} + +function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); + + // If the user set a custom span name via updateSpanName(), apply it. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { + return; + } + + const statement = attributes['db.statement']; + if (statement) { + spanJSON.name = `${statement}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task' }); + } +} diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts index 8429b22d7e1c..57714fdf8117 100644 --- a/packages/core/src/tracing/spans/envelope.ts +++ b/packages/core/src/tracing/spans/envelope.ts @@ -3,6 +3,7 @@ import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } import type { SerializedStreamedSpan } from '../../types-hoist/span'; import { dsnToString } from '../../utils/dsn'; import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; +import { isBrowser } from '../../utils/isBrowser'; /** * Creates a span v2 span streaming envelope @@ -12,9 +13,10 @@ export function createStreamedSpanEnvelope( dsc: Partial, client: Client, ): StreamedSpanEnvelope { + const options = client.getOptions(); const dsn = client.getDsn(); - const tunnel = client.getOptions().tunnel; - const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + const tunnel = options.tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(options._metadata); const headers: StreamedSpanEnvelope[0] = { sent_at: new Date().toISOString(), @@ -23,9 +25,17 @@ export function createStreamedSpanEnvelope( ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; + const inferSetting = options.sendDefaultPii ? 'auto' : 'never'; + const spanContainer: SpanContainerItem = [ { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, - { items: serializedSpans }, + { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), + items: serializedSpans, + }, ]; return createEnvelope(headers, [spanContainer]); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 08411722cedf..45379866d56e 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -610,6 +610,7 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se { description: spanArguments.name || '', op: spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] || spanArguments.op, + attributes: spanArguments.attributes, }, ignoreSpans, ); diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 3a661ca90a3d..610a8d14870f 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -3,7 +3,7 @@ import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; import { normalize } from './utils/normalize'; -import { addNonEnumerableProperty } from './utils/object'; +import { setNormalizationDepthOverrideHint } from './utils/normalizationHints'; interface SentryTrpcMiddlewareOptions { /** Whether to include procedure inputs in reported events. Defaults to `false`. */ @@ -53,9 +53,8 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { procedure_type: type, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( trpcContext, - '__sentry_override_normalization_depth__', 1 + // 1 for context.input + the normal normalization depth (clientOptions?.normalizeDepth ?? 5), // 5 is a sane depth ); diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index f6a90c7c5b73..5d7db7636996 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -191,6 +191,31 @@ export interface FeedbackTextConfiguration { * The label for the button that removed a highlight/hidden section of the screenshot. */ removeHighlightText: string; + + /** + * Error text shown when feedback submission is attempted with an empty message + */ + errorEmptyMessageText: string; + + /** + * Error text shown when the Sentry client is not set up + */ + errorNoClientText: string; + + /** + * Error text shown when the feedback submission times out (after 30s) + */ + errorTimeoutText: string; + + /** + * Error text shown when the feedback submission is blocked because the domain is not allowed (HTTP 403) + */ + errorForbiddenText: string; + + /** + * Error text shown when the feedback submission fails for any other reason (e.g. network error, ad-blocker) + */ + errorGenericText: string; } /** diff --git a/packages/core/src/types-hoist/feedback/index.ts b/packages/core/src/types-hoist/feedback/index.ts index 239a44d82543..fd44615e878c 100644 --- a/packages/core/src/types-hoist/feedback/index.ts +++ b/packages/core/src/types-hoist/feedback/index.ts @@ -6,10 +6,17 @@ import type { FeedbackTextConfiguration, FeedbackThemeConfiguration, } from './config'; -import type { FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback } from './sendFeedback'; +import type { + FeedbackErrorCode, + FeedbackErrorMessages, + FeedbackEvent, + SendFeedback, + SendFeedbackParams, + UserFeedback, +} from './sendFeedback'; export type { FeedbackFormData } from './form'; -export type { FeedbackEvent, UserFeedback, SendFeedback, SendFeedbackParams }; +export type { FeedbackErrorCode, FeedbackErrorMessages, FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback }; /** * The integration's internal `options` member where every value should be set diff --git a/packages/core/src/types-hoist/feedback/sendFeedback.ts b/packages/core/src/types-hoist/feedback/sendFeedback.ts index 63d63b402b50..f1d0fd5d0f3e 100644 --- a/packages/core/src/types-hoist/feedback/sendFeedback.ts +++ b/packages/core/src/types-hoist/feedback/sendFeedback.ts @@ -47,7 +47,16 @@ export interface SendFeedbackParams { tags?: { [key: string]: Primitive }; } +export type FeedbackErrorCode = + | 'ERROR_EMPTY_MESSAGE' + | 'ERROR_NO_CLIENT' + | 'ERROR_TIMEOUT' + | 'ERROR_FORBIDDEN' + | 'ERROR_GENERIC'; + +export type FeedbackErrorMessages = Partial>; + export type SendFeedback = ( params: SendFeedbackParams, - hint?: EventHint & { includeReplay?: boolean }, + hint?: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages }, ) => Promise; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 4f3df1f6365a..a1fc1e074a75 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -100,9 +100,17 @@ export interface ServerRuntimeOptions { onFatalError?(this: void, error: Error): void; } +/** + * Allowed attribute value matchers in `ignoreSpans` filters. + * String span attributes use pattern matching (substring or RegExp). + * Non-string attribute values match by strict equality (arrays element-wise). + */ +export type IgnoreSpanAttributeValue = string | boolean | number | string[] | boolean[] | number[] | RegExp; + /** * A filter object for ignoring spans. - * At least one of the properties (`op` or `name`) must be set. + * At least one of the properties (`name`, `op`, or `attributes`) must be set. + * If multiple are set, all must match for the span to be ignored. */ type IgnoreSpanFilter = | { @@ -114,6 +122,12 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; } | { /** @@ -124,6 +138,28 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; + } + | { + /** + * Spans with a name matching this pattern will be ignored. + */ + name?: string | RegExp; + /** + * Spans with an op matching this pattern will be ignored. + */ + op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes: Record; }; export interface ClientOptions { @@ -326,7 +362,8 @@ export interface ClientOptions; }; diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts index 8cda9404164a..edcb6fe30591 100644 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -32,7 +32,15 @@ function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { return false; } - return dsn ? urlParts.host.includes(dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search) : false; + if (!dsn) { + return false; + } + + return hostnameMatchesDsnHost(urlParts.hostname, dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search); +} + +function hostnameMatchesDsnHost(hostname: string, dsnHost: string): boolean { + return hostname === dsnHost || (dsnHost.length > 0 && hostname.endsWith(`.${dsnHost}`)); } function removeTrailingSlash(str: string): string { diff --git a/packages/core/src/utils/normalizationHints.ts b/packages/core/src/utils/normalizationHints.ts new file mode 100644 index 000000000000..5787f8decbbc --- /dev/null +++ b/packages/core/src/utils/normalizationHints.ts @@ -0,0 +1,30 @@ +import { addNonEnumerableProperty } from './object'; + +/** + * Internal symbols for normalization behavior. JSON and other structured user payloads cannot + * carry these keys, so they cannot spoof SDK-only normalization hints. + * We use Symbol.for to ensure that the symbols are the same across different modules/files. + */ +const SENTRY_SKIP_NORMALIZATION = Symbol.for('sentry.skipNormalization'); +const SENTRY_OVERRIDE_NORMALIZATION_DEPTH = Symbol.for('sentry.overrideNormalizationDepth'); + +/** Marks an object so `normalize` returns it unchanged (already-normalized SDK data). */ +export function setSkipNormalizationHint(obj: object): void { + addNonEnumerableProperty(obj, SENTRY_SKIP_NORMALIZATION, true); +} + +/** Overrides remaining normalization depth from this object downward (e.g. Redux / Pinia state). */ +export function setNormalizationDepthOverrideHint(obj: object, depth: number): void { + addNonEnumerableProperty(obj, SENTRY_OVERRIDE_NORMALIZATION_DEPTH, depth); +} + +/** @internal */ +export function hasSkipNormalizationHint(value: object) { + return Boolean((value as Record)[SENTRY_SKIP_NORMALIZATION]); +} + +/** @internal */ +export function getNormalizationDepthOverrideHint(value: object): number | undefined { + const v = (value as Record)[SENTRY_OVERRIDE_NORMALIZATION_DEPTH]; + return typeof v === 'number' ? v : undefined; +} diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 1c25d937cfe4..117d32b3ae4d 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,5 +1,6 @@ import type { Primitive } from '../types-hoist/misc'; import { isSyntheticEvent, isVueViewModel } from './is'; +import { getNormalizationDepthOverrideHint, hasSkipNormalizationHint } from './normalizationHints'; import { convertToPlainObject } from './object'; import { getFunctionName, getVueInternalName } from './stacktrace'; @@ -101,20 +102,15 @@ function visit( // From here on, we can assert that `value` is either an object or an array. - // Do not normalize objects that we know have already been normalized. As a general rule, the - // "__sentry_skip_normalization__" property should only be used sparingly and only should only be set on objects that - // have already been normalized. - if ((value as ObjOrArray)['__sentry_skip_normalization__']) { + // Do not normalize objects that we know have already been normalized. Hints use internal symbols + // (see normalizationHints.ts) so user-controlled JSON cannot spoof them. + if (hasSkipNormalizationHint(value)) { return value as ObjOrArray; } - // We can set `__sentry_override_normalization_depth__` on an object to ensure that from there - // We keep a certain amount of depth. - // This should be used sparingly, e.g. we use it for the redux integration to ensure we get a certain amount of state. - const remainingDepth = - typeof (value as ObjOrArray)['__sentry_override_normalization_depth__'] === 'number' - ? ((value as ObjOrArray)['__sentry_override_normalization_depth__'] as number) - : depth; + // Override remaining depth from this node (e.g. Redux / Pinia state). Set via setNormalizationDepthOverrideHint. + const overrideDepth = getNormalizationDepthOverrideHint(value); + const remainingDepth = overrideDepth !== undefined ? overrideDepth : depth; // We're also done if we've reached the max depth if (remainingDepth === 0) { diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index 787a66ac8525..34d8a267aacf 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -53,7 +53,7 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa * @param name The name of the property to be set * @param value The value to which to set the property */ -export function addNonEnumerableProperty(obj: object, name: string, value: unknown): void { +export function addNonEnumerableProperty(obj: object, name: string | symbol, value: unknown): void { try { Object.defineProperty(obj, name, { // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it @@ -62,7 +62,7 @@ export function addNonEnumerableProperty(obj: object, name: string, value: unkno configurable: true, }); } catch { - DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${name}" to object`, obj); + DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${String(name)}" to object`, obj); } } diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 6aaceb8fc201..3f7477e6459b 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -149,6 +149,44 @@ const SENSITIVE_HEADER_SNIPPETS = [ 'cookie', ]; +/** + * Extra substrings matched only against individual Cookie / Set-Cookie **names** (not header names), + * so we can cover common session secrets that do not match {@link SENSITIVE_HEADER_SNIPPETS} + * (e.g. `connect.sid` does not contain `session`) without false positives on arbitrary HTTP headers. + * + * Cookie names are checked with the same `includes()` list as headers plus these entries; omit redundant + * cookie-only snippets that are already implied by a header match (e.g. `oauth` → `auth`, `id_token` → `token`, + * `next-auth` → `auth`). + */ +const SENSITIVE_COOKIE_NAME_SNIPPETS = [ + // Express / Connect default session cookie + '.sid', + // Opaque session ids (PHPSESSID, ASPSESSIONID*, BIGipServer*, *sessid*, …) + 'sessid', + // Laravel etc. "remember me" tokens + 'remember', + // OIDC / OAuth auxiliary (`oauth*` covered by header snippet `auth`) + 'oidc', + 'pkce', + 'nonce', + // RFC 6265bis high-security cookie name prefixes + '__secure-', + '__host-', + // Load balancer / CDN sticky-session cookies (opaque routing tokens) + 'awsalb', + 'awselb', + 'akamai', + // BaaS / IdP session cookies (names often omit "session") + '__stripe', + 'cognito', + 'firebase', + 'supabase', + 'sb-', + // Step-up / MFA cookies + 'mfa', + '2fa', +]; + const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user']; /** @@ -196,17 +234,23 @@ export function httpHeadersToSpanAttributes( const lowerCasedCookieKey = cookieKey.toLowerCase(); - addSpanAttribute( + addSpanAttribute({ spanAttributes, - lowerCasedHeaderKey, - lowerCasedCookieKey, - cookieValue, + headerKey: lowerCasedHeaderKey, + cookieKey: lowerCasedCookieKey, + value: cookieValue, sendDefaultPii, lifecycle, - ); + }); } } else { - addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii, lifecycle); + addSpanAttribute({ + spanAttributes, + headerKey: lowerCasedHeaderKey, + value, + sendDefaultPii, + lifecycle, + }); } }); } catch { @@ -220,15 +264,31 @@ function normalizeAttributeKey(key: string): string { return key.replace(/-/g, '_'); } -function addSpanAttribute( - spanAttributes: Record, - headerKey: string, - cookieKey: string, - value: string | string[] | undefined, - sendPii: boolean, - lifecycle: 'request' | 'response', -): void { - const headerValue = handleHttpHeader(cookieKey || headerKey, value, sendPii); +type AddSpanAttributeOptions = { + spanAttributes: Record; + /** Lowercased HTTP header name (e.g. `cookie`, `set-cookie`, `accept`). */ + headerKey: string; + /** + * Lowercased cookie name when this attribute comes from a parsed `Cookie` / `Set-Cookie` value. + * Omit for non-cookie headers; when present and non-empty, cookie-specific sensitivity rules apply. + */ + cookieKey?: string; + value: string | string[] | undefined; + sendDefaultPii: boolean; + lifecycle: 'request' | 'response'; +}; + +function addSpanAttribute({ + spanAttributes, + headerKey, + cookieKey, + value, + sendDefaultPii, + lifecycle, +}: AddSpanAttributeOptions): void { + const isCookieSubKey = Boolean(cookieKey); + const nameForSensitivity = cookieKey || headerKey; + const headerValue = handleHttpHeader(nameForSensitivity, value, sendDefaultPii, isCookieSubKey); if (headerValue == null) { return; } @@ -241,10 +301,15 @@ function handleHttpHeader( lowerCasedKey: string, value: string | string[] | undefined, sendPii: boolean, + isCookieSubKey: boolean = false, ): string | undefined { + const snippetsForSensitivity = isCookieSubKey + ? [...SENSITIVE_HEADER_SNIPPETS, ...SENSITIVE_COOKIE_NAME_SNIPPETS] + : SENSITIVE_HEADER_SNIPPETS; + const isSensitive = sendPii - ? SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet)) - : [...PII_HEADER_SNIPPETS, ...SENSITIVE_HEADER_SNIPPETS].some(snippet => lowerCasedKey.includes(snippet)); + ? snippetsForSensitivity.some(snippet => lowerCasedKey.includes(snippet)) + : [...PII_HEADER_SNIPPETS, ...snippetsForSensitivity].some(snippet => lowerCasedKey.includes(snippet)); if (isSensitive) { return '[Filtered]'; diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts index a8d3ac0211c7..3113446a0b5d 100644 --- a/packages/core/src/utils/should-ignore-span.ts +++ b/packages/core/src/utils/should-ignore-span.ts @@ -1,5 +1,5 @@ import { DEBUG_BUILD } from '../debug-build'; -import type { ClientOptions } from '../types-hoist/options'; +import type { ClientOptions, IgnoreSpanAttributeValue } from '../types-hoist/options'; import type { SpanJSON } from '../types-hoist/span'; import { debug } from './debug-logger'; import { isMatchingPattern } from './string'; @@ -12,34 +12,40 @@ function logIgnoredSpan(droppedSpan: Pick): void * Check if a span should be ignored based on the ignoreSpans configuration. */ export function shouldIgnoreSpan( - span: Pick, + span: Pick & { attributes?: Record }, ignoreSpans: Required['ignoreSpans'], ): boolean { - if (!ignoreSpans?.length || !span.description) { + if (!ignoreSpans?.length) { return false; } for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { - if (isMatchingPattern(span.description, pattern)) { + if (span.description && isMatchingPattern(span.description, pattern)) { DEBUG_BUILD && logIgnoredSpan(span); return true; } continue; } - if (!pattern.name && !pattern.op) { + const hasAttributes = !!pattern.attributes && Object.keys(pattern.attributes).length > 0; + if (!pattern.name && !pattern.op && !hasAttributes) { continue; } - const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; + const nameMatches = pattern.name ? span.description && isMatchingPattern(span.description, pattern.name) : true; const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + const attrsMatch = pattern.attributes + ? Object.entries(pattern.attributes).every(([key, valuePattern]) => + _matchesAttributeValue(span.attributes?.[key], valuePattern), + ) + : true; // This check here is only correct because we can guarantee that we ran `isMatchingPattern` - // for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks, - // not both op and name actually have to match. This is the most efficient way to check - // for all combinations of name and op patterns. - if (nameMatches && opMatches) { + // for at least one of `nameMatches`, `opMatches`, or `attrsMatch`. So in contrary to how this looks, + // not all of op, name, and attributes actually have to match. This is the most efficient way to check + // for all combinations of name, op, and attribute patterns. + if (nameMatches && opMatches && attrsMatch) { DEBUG_BUILD && logIgnoredSpan(span); return true; } @@ -48,6 +54,19 @@ export function shouldIgnoreSpan( return false; } +function _matchesAttributeValue(actual: unknown, pat: IgnoreSpanAttributeValue): boolean { + // String values support pattern matching + if (typeof actual === 'string' && (typeof pat === 'string' || pat instanceof RegExp)) { + return isMatchingPattern(actual, pat); + } + // Arrays: element-wise strict equality + if (Array.isArray(actual) && Array.isArray(pat)) { + return actual.length === pat.length && actual.every((v, i) => v === pat[i]); + } + // Primitives: strict equality + return actual === pat; +} + /** * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. * This mutates the spans array in place! diff --git a/packages/core/test/lib/instrument/handlers.test.ts b/packages/core/test/lib/instrument/handlers.test.ts index 87e227a99323..cb894514b24a 100644 --- a/packages/core/test/lib/instrument/handlers.test.ts +++ b/packages/core/test/lib/instrument/handlers.test.ts @@ -1,5 +1,14 @@ -import { describe, test } from 'vitest'; -import { maybeInstrument } from '../../../src/instrument/handlers'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + addHandler, + maybeInstrument, + resetInstrumentationHandlers, + triggerHandlers, +} from '../../../src/instrument/handlers'; + +afterEach(() => { + resetInstrumentationHandlers(); +}); describe('maybeInstrument', () => { test('does not throw when instrumenting fails', () => { @@ -12,3 +21,89 @@ describe('maybeInstrument', () => { maybeInstrument('xhr', undefined as any); }); }); + +describe('addHandler', () => { + test('returns an unsubscribe function', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + expect(typeof unsubscribe).toBe('function'); + }); + + test('handler is called when triggerHandlers is invoked', () => { + const handler = vi.fn(); + addHandler('fetch', handler); + + triggerHandlers('fetch', { url: 'https://example.com' }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ url: 'https://example.com' }); + }); + + test('unsubscribe removes the handler', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + triggerHandlers('fetch', { test: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + unsubscribe(); + + triggerHandlers('fetch', { test: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('unsubscribe only removes the specific handler', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + const unsubscribe1 = addHandler('fetch', handler1); + addHandler('fetch', handler2); + + triggerHandlers('fetch', { test: 1 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + triggerHandlers('fetch', { test: 2 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(2); + }); + + test('calling unsubscribe multiple times is safe', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + }); + + test('unsubscribe works with different handler types', () => { + const consoleHandler = vi.fn(); + const fetchHandler = vi.fn(); + + const unsubscribeConsole = addHandler('console', consoleHandler); + const unsubscribeFetch = addHandler('fetch', fetchHandler); + + triggerHandlers('console', { level: 'log' }); + triggerHandlers('fetch', { url: 'test' }); + + expect(consoleHandler).toHaveBeenCalledTimes(1); + expect(fetchHandler).toHaveBeenCalledTimes(1); + + unsubscribeConsole(); + + triggerHandlers('console', { level: 'warn' }); + triggerHandlers('fetch', { url: 'test2' }); + + expect(consoleHandler).toHaveBeenCalledTimes(1); + expect(fetchHandler).toHaveBeenCalledTimes(2); + + unsubscribeFetch(); + + triggerHandlers('fetch', { url: 'test3' }); + expect(fetchHandler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts new file mode 100644 index 000000000000..df8e8d4d8766 --- /dev/null +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, it } from 'vitest'; +import type { Client } from '../../../src/client'; +import { requestDataIntegration } from '../../../src/integrations/requestdata'; +import type { Event } from '../../../src/types-hoist/event'; +import { ipHeaderNames } from '../../../src/vendor/getIpAddress'; + +function mockClient(sendDefaultPii: boolean | undefined): Client { + return { + getOptions: () => ({ sendDefaultPii: sendDefaultPii as boolean | undefined }), + } as unknown as Client; +} + +function baseEvent(overrides: Partial = {}): Event { + return { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + 'CF-Connecting-IP': '10.0.0.2', + }, + }, + }, + ...overrides, + }; +} + +/** Rich normalized request (Cookie header only — tests `parseCookie` path). */ +function richNormalizedRequest() { + return { + method: 'POST', + url: 'https://example.com/items?q=1', + query_string: 'q=1', + data: { body: 'payload' }, + headers: { + Host: 'example.com', + cookie: 'session=from-header', + 'X-Forwarded-For': '192.168.1.1', + 'X-Custom': 'keep', + }, + }; +} + +describe('requestDataIntegration', () => { + describe('IP-related headers on event.request', () => { + it('removes known IP headers from event.request.headers when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + }); + }); + + it('removes every ipHeaderNames entry when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const headers: Record = { Host: 'example.com', 'X-Other': 'keep-me' }; + for (const name of ipHeaderNames) { + headers[name] = '203.0.113.1'; + } + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Other': 'keep-me', + }); + }); + + it('keeps IP headers on event.request.headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + 'CF-Connecting-IP': '10.0.0.2', + }); + }); + + it('keeps IP headers when include.ip is true even if sendDefaultPii is false', () => { + const integration = requestDataIntegration({ include: { ip: true } }); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers?.['X-Forwarded-For']).toBe('192.168.1.1'); + }); + + it('strips IP headers when include.ip is false even if sendDefaultPii is true', () => { + const integration = requestDataIntegration({ include: { ip: false } }); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ Host: 'example.com' }); + }); + + it('removes every ipHeaderNames entry when keys use lowercase spelling and sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const headers: Record = { host: 'example.com', 'x-other': 'keep-me' }; + for (const name of ipHeaderNames) { + headers[name.toLowerCase()] = '203.0.113.1'; + } + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + host: 'example.com', + 'x-other': 'keep-me', + }); + }); + + it('keeps lowercase IP headers on event.request.headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + host: 'example.com', + 'x-forwarded-for': '192.168.1.1', + 'cf-connecting-ip': '10.0.0.2', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + host: 'example.com', + 'x-forwarded-for': '192.168.1.1', + 'cf-connecting-ip': '10.0.0.2', + }); + }); + }); + + describe('user.ip_address', () => { + it('does not set user.ip_address when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.user?.ip_address).toBeUndefined(); + }); + + it('sets user.ip_address from request headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('192.168.1.1'); + }); + + it('sets user.ip_address from lowercase IP headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + host: 'example.com', + 'x-forwarded-for': '192.168.1.9', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('192.168.1.9'); + }); + + it('sets user.ip_address from sdkProcessingMetadata.ipAddress when headers yield no IP', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + ipAddress: '198.51.100.7', + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('198.51.100.7'); + }); + + it('does not set user.ip_address from sdkProcessingMetadata when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + ipAddress: '198.51.100.7', + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.user?.ip_address).toBeUndefined(); + }); + }); + + describe('include.headers', () => { + it('omits event.request.headers when include.headers is false', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.request?.method).toBe('POST'); + expect(event.request?.url).toBe('https://example.com/items?q=1'); + }); + + it('with include.headers false and include.cookies true, parses cookies from the cookie header without exposing headers', () => { + const integration = requestDataIntegration({ + include: { headers: false, cookies: true }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'id=42' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.request?.cookies).toEqual({ id: '42' }); + }); + + it('with include.headers false, still sets user.ip_address from original headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { 'X-Forwarded-For': '192.0.2.1' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.user?.ip_address).toBe('192.0.2.1'); + }); + }); + + describe('include.cookies', () => { + it('removes the cookie header from event.request.headers when include.cookies is false', () => { + const integration = requestDataIntegration({ + include: { cookies: false }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { + Host: 'example.com', + cookie: 'secret=value', + 'X-Custom': 'ok', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Custom': 'ok', + }); + }); + + it('omits event.request.cookies when include.cookies is false', () => { + const integration = requestDataIntegration({ + include: { cookies: false }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'a=b' }, + cookies: { sid: '1' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toBeUndefined(); + }); + + it('uses normalizedRequest.cookies when set', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + cookies: { session_id: 'abc' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ session_id: 'abc' }); + }); + + it('prefers normalizedRequest.cookies over the Cookie header when both are present', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'from=header' }, + cookies: { from: 'object' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ from: 'object' }); + }); + + it('parses the Cookie header when normalizedRequest.cookies is absent', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'a=1; b=two' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ a: '1', b: 'two' }); + }); + + it('sets event.request.cookies to an empty object when include.cookies is true but no cookies are present', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({}); + }); + }); + + describe('include.url', () => { + it('omits event.request.url when include.url is false', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.url).toBeUndefined(); + expect(event.request?.method).toBe('POST'); + }); + }); + + describe('include.query_string', () => { + it('omits event.request.query_string when include.query_string is false', () => { + const integration = requestDataIntegration({ include: { query_string: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.query_string).toBeUndefined(); + expect(event.request?.url).toBe('https://example.com/items?q=1'); + }); + }); + + describe('include.data', () => { + it('omits event.request.data when include.data is false', () => { + const integration = requestDataIntegration({ include: { data: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.data).toBeUndefined(); + }); + }); + + describe('defaults and combined include options', () => { + it('with default include and sendDefaultPii true, copies method, url, query_string, data, headers, cookies, and user IP', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toEqual({ + method: 'POST', + url: 'https://example.com/items?q=1', + query_string: 'q=1', + data: { body: 'payload' }, + headers: { + Host: 'example.com', + cookie: 'session=from-header', + 'X-Forwarded-For': '192.168.1.1', + 'X-Custom': 'keep', + }, + cookies: { session: 'from-header' }, + }); + expect(event.user?.ip_address).toBe('192.168.1.1'); + }); + + it('with default include and sendDefaultPii false, keeps non-IP fields and strips IP from headers and user', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + cookie: 'session=from-header', + 'X-Custom': 'keep', + }); + expect(event.request?.cookies).toEqual({ session: 'from-header' }); + expect(event.user?.ip_address).toBeUndefined(); + }); + + it('can disable multiple include flags at once', () => { + const integration = requestDataIntegration({ + include: { + url: false, + query_string: false, + data: false, + cookies: false, + }, + }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.method).toBe('POST'); + expect(event.request?.headers?.Host).toBe('example.com'); + expect(event.request?.url).toBeUndefined(); + expect(event.request?.query_string).toBeUndefined(); + expect(event.request?.data).toBeUndefined(); + expect(event.request?.cookies).toBeUndefined(); + expect(event.request?.headers?.cookie).toBeUndefined(); + }); + }); + + describe('normalizedRequest absent', () => { + it('does not add event.request when it was undefined and there is no normalizedRequest', () => { + const integration = requestDataIntegration(); + const event: Event = { sdkProcessingMetadata: {} }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toBeUndefined(); + }); + + it('preserves existing event.request when there is no normalizedRequest', () => { + const integration = requestDataIntegration(); + const event: Event = { + request: { url: 'https://unchanged/' }, + sdkProcessingMetadata: {}, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toEqual({ url: 'https://unchanged/' }); + }); + }); + + describe('merging with existing event.request', () => { + it('merges new request fields into an existing event.request', () => { + const integration = requestDataIntegration(); + const event: Event = { + request: { env: { INTEGRATION: 'test' } }, + sdkProcessingMetadata: { + normalizedRequest: { + method: 'PUT', + url: 'https://example.com/r', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.env).toEqual({ INTEGRATION: 'test' }); + expect(event.request?.method).toBe('PUT'); + expect(event.request?.url).toBe('https://example.com/r'); + }); + + it('does not clear an existing event.request.url when include.url is false (object spread merge)', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const event: Event = { + request: { url: 'https://preserved/' }, + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/new', + headers: {}, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.url).toBe('https://preserved/'); + expect(event.request?.method).toBe('GET'); + }); + }); + + it('does not mutate normalizedRequest.headers on the event (copy is used)', () => { + const integration = requestDataIntegration(); + const normalizedHeaders = { + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + }; + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: normalizedHeaders, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(normalizedHeaders['X-Forwarded-For']).toBe('192.168.1.1'); + expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); + }); +}); diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index 519dda4f06a0..6906d48ec98a 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -8,21 +8,113 @@ import { } from '../../../src/integrations/supabase'; import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; -// Mock tracing to avoid needing full SDK setup -vi.mock('../../../src/tracing', () => ({ - startSpan: (_opts: any, cb: (span: any) => any) => { +const tracingMocks = vi.hoisted(() => ({ + startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => { const mockSpan = { setStatus: vi.fn(), end: vi.fn(), }; return cb(mockSpan); - }, + }), +})); + +const currentScopesMocks = vi.hoisted(() => ({ + getClient: vi.fn(), +})); + +// Mock tracing to avoid needing full SDK setup +vi.mock('../../../src/tracing', () => ({ + startSpan: tracingMocks.startSpan, setHttpStatus: vi.fn(), SPAN_STATUS_OK: 1, SPAN_STATUS_ERROR: 2, })); +vi.mock('../../../src/currentScopes', () => ({ + getClient: currentScopesMocks.getClient, +})); + +type CreateMockSupabaseClientOptions = { + method?: string; + url?: URL | string; + body?: unknown; + /** When set, configures the mocked Sentry client `sendDefaultPii`. Omit to leave `getClient` to the test file `beforeEach`. */ + sendDefaultPii?: boolean; +}; + +const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos'; + +/** Shared PATCH + query string + body shape for `sendDefaultPii` tests. */ +const MOCK_SUPABASE_PII_SCENARIO: Pick = { + method: 'PATCH', + url: 'https://example.supabase.co/rest/v1/users?email=eq.secret%40example.com&select=id', + body: { full_name: 'Jane Doe', phone: '555-0100' }, +}; + +function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown { + if (options?.sendDefaultPii !== undefined) { + currentScopesMocks.getClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }), + } as any); + } + + const method = options?.method ?? 'GET'; + const requestUrl = + options?.url !== undefined + ? options.url instanceof URL + ? options.url + : new URL(options.url) + : new URL(DEFAULT_MOCK_SUPABASE_REST_URL); + const body = options?.body; + + class MockPostgRESTFilterBuilder { + method = method; + headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + url = requestUrl; + schema = 'public'; + body = body; + + then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { + return Promise.resolve(resolveWith).then(onfulfilled, onrejected); + } + } + + class MockPostgRESTQueryBuilder { + select() { + return new MockPostgRESTFilterBuilder(); + } + insert() { + return new MockPostgRESTFilterBuilder(); + } + upsert() { + return new MockPostgRESTFilterBuilder(); + } + update() { + return new MockPostgRESTFilterBuilder(); + } + delete() { + return new MockPostgRESTFilterBuilder(); + } + } + + class MockSupabaseClient { + auth = { + admin: {} as any, + } as SupabaseClientInstance['auth']; + + from(_table: string): PostgRESTQueryBuilder { + return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; + } + } + + return new MockSupabaseClient(); +} + describe('Supabase Integration', () => { + beforeEach(() => { + currentScopesMocks.getClient.mockReturnValue(undefined); + }); + describe('extractOperation', () => { it('returns select for GET', () => { expect(extractOperation('GET')).toBe('select'); @@ -72,52 +164,6 @@ describe('Supabase Integration', () => { vi.restoreAllMocks(); }); - function createMockSupabaseClient(resolveWith: unknown): unknown { - // Create a PostgRESTFilterBuilder-like class - class MockPostgRESTFilterBuilder { - method = 'GET'; - headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; - url = new URL('https://example.supabase.co/rest/v1/todos'); - schema = 'public'; - body = undefined; - - then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { - return Promise.resolve(resolveWith).then(onfulfilled, onrejected); - } - } - - class MockPostgRESTQueryBuilder { - select() { - return new MockPostgRESTFilterBuilder(); - } - insert() { - return new MockPostgRESTFilterBuilder(); - } - upsert() { - return new MockPostgRESTFilterBuilder(); - } - update() { - return new MockPostgRESTFilterBuilder(); - } - delete() { - return new MockPostgRESTFilterBuilder(); - } - } - - // Create a mock SupabaseClient constructor - class MockSupabaseClient { - auth = { - admin: {} as any, - } as SupabaseClientInstance['auth']; - - from(_table: string): PostgRESTQueryBuilder { - return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; - } - } - - return new MockSupabaseClient(); - } - it('handles undefined response without throwing', async () => { const client = createMockSupabaseClient(undefined); instrumentSupabaseClient(client); @@ -176,4 +222,126 @@ describe('Supabase Integration', () => { expect(captureExceptionSpy).toHaveBeenCalled(); }); }); + + describe('sendDefaultPii', () => { + let captureExceptionSpy: ReturnType; + let addBreadcrumbSpy: ReturnType; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 200 }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('[redacted]'); + expect(spanOptions.name).not.toContain('secret'); + expect(spanOptions.attributes['db.query']).toBeUndefined(); + expect(spanOptions.attributes['db.body']).toBeUndefined(); + + const breadcrumb = addBreadcrumbSpy.mock.calls[0]![0] as { data?: unknown }; + expect(breadcrumb).not.toHaveProperty('data'); + }); + + it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => { + const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true }); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('eq(email, secret@example.com)'); + expect(spanOptions.attributes['db.query']).toEqual( + expect.arrayContaining([expect.stringContaining('secret@example.com')]), + ); + expect(spanOptions.attributes['db.body']).toEqual( + expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }), + ); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + query: expect.any(Array), + body: expect.objectContaining({ full_name: 'Jane Doe' }), + }), + }), + ); + }); + + it('omits supabase error context query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 400, error: { message: 'Bad request', code: '400' } }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + expect(captureExceptionSpy).toHaveBeenCalled(); + const scopeCallback = captureExceptionSpy.mock.calls[0]![1] as (scope: { + addEventProcessor: (fn: (e: unknown) => unknown) => void; + setContext: (key: string, ctx: Record) => void; + }) => unknown; + const contexts: Record> = {}; + scopeCallback({ + addEventProcessor: () => {}, + setContext(key: string, ctx: Record) { + contexts[key] = ctx; + }, + } as any); + expect(contexts.supabase).toEqual({}); + }); + }); + + describe('array insert body', () => { + beforeEach(() => { + vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('includes insert(...) in span description and db.body when payload is a non-empty array', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { + method: 'POST', + url: 'https://example.supabase.co/rest/v1/todos?columns=', + body: [{ title: 'Test Todo' }], + sendDefaultPii: true, + }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').insert({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toMatch(/^insert\(\.\.\.\)/); + expect(spanOptions.name).toContain('from(todos)'); + expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]); + }); + }); }); diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index bbe9ee84a716..8d7f7fbc48c5 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -320,5 +320,90 @@ describe('ServerRuntimeClient', () => { // Verify it's a fresh buffer with no pending items expect(bufferAfterDispose.$).toEqual([]); }); + + it('calls registered cleanup callbacks on dispose', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup1 = vi.fn(); + const cleanup2 = vi.fn(); + const cleanup3 = vi.fn(); + + client.registerCleanup(cleanup1); + client.registerCleanup(cleanup2); + client.registerCleanup(cleanup3); + + expect(cleanup1).not.toHaveBeenCalled(); + expect(cleanup2).not.toHaveBeenCalled(); + expect(cleanup3).not.toHaveBeenCalled(); + + client.dispose(); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(cleanup2).toHaveBeenCalledTimes(1); + expect(cleanup3).toHaveBeenCalledTimes(1); + }); + + it('clears cleanup callbacks after dispose', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup = vi.fn(); + client.registerCleanup(cleanup); + + client.dispose(); + expect(cleanup).toHaveBeenCalledTimes(1); + + // Calling dispose again should not call cleanup again + client.dispose(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('continues to call other cleanup callbacks if one throws', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup1 = vi.fn(); + const throwingCleanup = vi.fn(() => { + throw new Error('cleanup error'); + }); + const cleanup2 = vi.fn(); + + client.registerCleanup(cleanup1); + client.registerCleanup(throwingCleanup); + client.registerCleanup(cleanup2); + + expect(() => client.dispose()).not.toThrow(); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(throwingCleanup).toHaveBeenCalledTimes(1); + expect(cleanup2).toHaveBeenCalledTimes(1); + }); + }); + + describe('registerCleanup', () => { + it('accepts cleanup functions', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup = vi.fn(); + + expect(() => client.registerCleanup(cleanup)).not.toThrow(); + }); + + it('can register multiple cleanup functions', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanups = Array.from({ length: 10 }, () => vi.fn()); + + cleanups.forEach(cleanup => client.registerCleanup(cleanup)); + + client.dispose(); + + cleanups.forEach(cleanup => { + expect(cleanup).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/packages/core/test/lib/tracing/langgraph.test.ts b/packages/core/test/lib/tracing/langgraph.test.ts new file mode 100644 index 000000000000..6cbd6ff2fdcb --- /dev/null +++ b/packages/core/test/lib/tracing/langgraph.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { instrumentCreateReactAgent, instrumentStateGraphCompile } from '../../../src/tracing/langgraph'; + +describe('langgraph double-patch guard', () => { + it('instrumentStateGraphCompile returns the same wrapper when applied twice', () => { + const original = (() => ({})) as unknown as Parameters[0]; + const first = instrumentStateGraphCompile(original, {}); + const second = instrumentStateGraphCompile(first, {}); + expect(second).toBe(first); + }); + + it('instrumentCreateReactAgent returns the same wrapper when applied twice', () => { + const original = (() => ({})) as unknown as Parameters[0]; + const first = instrumentCreateReactAgent(original); + const second = instrumentCreateReactAgent(first); + expect(second).toBe(first); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index d429d50714a2..56b039d56b67 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -21,7 +21,7 @@ import { withScope, withStreamedSpan, } from '../../../../src'; -import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { @@ -483,3 +483,158 @@ describe('safeSetSpanJSONAttributes', () => { expect(spanJSON.attributes).toEqual({}); }); }); + +describe('inferSpanDataFromOtelAttributes', () => { + function makeSpanJSON(name: string, attributes: Record): StreamedSpanJSON { + return { + name, + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes, + }; + } + + describe('http spans', () => { + it('infers http.client op for CLIENT kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); // SPAN_KIND_CLIENT + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + + it('infers http.server op for SERVER kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); // SPAN_KIND_SERVER + expect(spanJSON.attributes?.['sentry.op']).toBe('http.server'); + }); + + it('infers http op when kind is unknown', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + + it('appends prefetch to op', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.http.prefetch': true }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.prefetch'); + }); + + it('sets name and source from http.route', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'http.route': '/users/:id' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('GET /users/:id'); + expect(spanJSON.attributes?.['sentry.source']).toBe('route'); + }); + + it('does not overwrite name when no http.route but sets source to url', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.name).toBe('GET'); + expect(spanJSON.attributes?.['sentry.source']).toBe('url'); + }); + + it('does not overwrite sentry.op if already set', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.op': 'http.client.custom' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.custom'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten-by-otel', { + 'http.request.method': 'GET', + 'sentry.custom_span_name': 'my-custom-name', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-custom-name'); + }); + + it('does not overwrite name when sentry.source is custom', () => { + const spanJSON = makeSpanJSON('my-name', { + 'http.request.method': 'GET', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-name'); + }); + + it('supports legacy http.method attribute', () => { + const spanJSON = makeSpanJSON('GET', { 'http.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + }); + + describe('db spans', () => { + it('infers db op', () => { + const spanJSON = makeSpanJSON('redis', { 'db.system': 'redis' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('db'); + }); + + it('sets name from db.statement', () => { + const spanJSON = makeSpanJSON('mysql', { 'db.system': 'mysql', 'db.statement': 'SELECT * FROM users' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('SELECT * FROM users'); + expect(spanJSON.attributes?.['sentry.source']).toBe('task'); + }); + + it('skips db inference for cache spans', () => { + const spanJSON = makeSpanJSON('cache-get', { 'db.system': 'redis', 'sentry.op': 'cache.get_item' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('cache.get_item'); + expect(spanJSON.name).toBe('cache-get'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten', { + 'db.system': 'mysql', + 'db.statement': 'SELECT 1', + 'sentry.custom_span_name': 'my-db-span', + 'sentry.source': 'custom', + }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('my-db-span'); + }); + }); + + describe('other span types', () => { + it('infers rpc op', () => { + const spanJSON = makeSpanJSON('grpc', { 'rpc.service': 'UserService' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('rpc'); + }); + + it('infers message op', () => { + const spanJSON = makeSpanJSON('kafka', { 'messaging.system': 'kafka' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('message'); + }); + + it('infers faas op from trigger', () => { + const spanJSON = makeSpanJSON('lambda', { 'faas.trigger': 'http' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + }); + + it('does nothing when attributes are missing', () => { + const spanJSON = makeSpanJSON('test', undefined as unknown as Record); + spanJSON.attributes = undefined; + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes).toBeUndefined(); + }); + + it('does nothing for spans without recognizable attributes', () => { + const spanJSON = makeSpanJSON('test', { 'custom.attr': 'value' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBeUndefined(); + expect(spanJSON.name).toBe('test'); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts index 197b7ed40365..983a7e198b73 100644 --- a/packages/core/test/lib/tracing/spans/envelope.test.ts +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { isBrowser } from '../../../../src/utils/isBrowser'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; +vi.mock('../../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); + function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { return { trace_id: 'abc123', @@ -181,6 +190,7 @@ describe('createStreamedSpanEnvelope', () => { type: 'span', }, { + version: 2, items: [mockSpan], }, ], @@ -199,7 +209,7 @@ describe('createStreamedSpanEnvelope', () => { expect(envelopeItems).toEqual([ [ { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, - { items: [mockSpan1, mockSpan2, mockSpan3] }, + { version: 2, items: [mockSpan1, mockSpan2, mockSpan3] }, ], ]); }); @@ -222,11 +232,72 @@ describe('createStreamedSpanEnvelope', () => { type: 'span', }, { + version: 2, items: [], }, ], ], ]); }); + + it("includes ingest_settings with 'auto' values when in browser and sendDefaultPii is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: true })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockSpan], + }, + ], + ]); + }); + + it("includes ingest_settings with 'never' values when in browser and sendDefaultPii is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: false })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockSpan], + }, + ], + ]); + }); + + it('omits ingest_settings when not in browser', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: true })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + items: [mockSpan], + }, + ], + ]); + }); }); }); diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts index 806165fb52be..e8aeef78b631 100644 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -46,4 +46,24 @@ describe('isSentryRequestUrl', () => { it('handles undefined client', () => { expect(isSentryRequestUrl('http://sentry-dsn.com/my-url?sentry_key=123', undefined)).toBe(false); }); + + it('does not treat attacker-controlled hostnames that merely contain the DSN host as Sentry URLs', () => { + const dsnHost = 'o123456.ingest.sentry.io'; + const client = { + getOptions: () => ({ tunnel: '' }), + getDsn: () => ({ host: dsnHost }), + } as unknown as Client; + + expect(isSentryRequestUrl(`https://${dsnHost}.attacker.com/exfil?sentry_key=fake&data=stolen`, client)).toBe(false); + }); + + it('still matches legitimate subdomains of the DSN host', () => { + const dsnHost = 'ingest.sentry.io'; + const client = { + getOptions: () => ({ tunnel: '' }), + getDsn: () => ({ host: dsnHost }), + } as unknown as Client; + + expect(isSentryRequestUrl('https://o123456.ingest.sentry.io/api/1/store/?sentry_key=abc', client)).toBe(true); + }); }); diff --git a/packages/core/test/lib/utils/langgraph-utils.test.ts b/packages/core/test/lib/utils/langgraph-utils.test.ts new file mode 100644 index 000000000000..829317518622 --- /dev/null +++ b/packages/core/test/lib/utils/langgraph-utils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + extractAgentNameFromParams, + extractLLMFromParams, + mergeSentryCallback, +} from '../../../src/tracing/langgraph/utils'; + +describe('extractLLMFromParams', () => { + it('returns null for empty or invalid args', () => { + expect(extractLLMFromParams([])).toBe(null); + expect(extractLLMFromParams([null])).toBe(null); + expect(extractLLMFromParams([{}])).toBe(null); + expect(extractLLMFromParams([{ llm: false }])).toBe(null); + expect(extractLLMFromParams([{ llm: 123 }])).toBe(null); + expect(extractLLMFromParams([{ llm: {} }])).toBe(null); + }); + + it('extracts llm object with modelName', () => { + expect(extractLLMFromParams([{ llm: { modelName: 'gpt-4o-mini', lc_namespace: ['langchain'] } }])).toStrictEqual({ + modelName: 'gpt-4o-mini', + lc_namespace: ['langchain'], + }); + }); + + it('extracts llm object with model when modelName is absent', () => { + expect( + extractLLMFromParams([{ llm: { model: 'claude-3-5-sonnet-20241022', lc_namespace: ['langchain'] } }]), + ).toStrictEqual({ + model: 'claude-3-5-sonnet-20241022', + lc_namespace: ['langchain'], + }); + }); +}); + +describe('extractAgentNameFromParams', () => { + it('returns null for empty or invalid args', () => { + expect(extractAgentNameFromParams([])).toBe(null); + expect(extractAgentNameFromParams([null])).toBe(null); + expect(extractAgentNameFromParams([{}])).toBe(null); + expect(extractAgentNameFromParams([{ name: 123 }])).toBe(null); + }); + + it('extracts agent name from params', () => { + expect(extractAgentNameFromParams([{ name: 'my_agent' }])).toBe('my_agent'); + }); +}); + +describe('mergeSentryCallback', () => { + const sentryHandler = { _sentry: true }; + + it('returns a fresh array when no existing callbacks are present', () => { + expect(mergeSentryCallback(undefined, sentryHandler)).toStrictEqual([sentryHandler]); + expect(mergeSentryCallback(null, sentryHandler)).toStrictEqual([sentryHandler]); + }); + + it('appends to an existing callbacks array', () => { + const userA = { _user: 'A' }; + const userB = { _user: 'B' }; + expect(mergeSentryCallback([userA, userB], sentryHandler)).toStrictEqual([userA, userB, sentryHandler]); + }); + + it('does not duplicate when the sentry handler is already in the array', () => { + const userA = { _user: 'A' }; + const existing = [userA, sentryHandler]; + expect(mergeSentryCallback(existing, sentryHandler)).toBe(existing); + }); + + it('calls addHandler on a CallbackManager-like object', () => { + const addHandler = vi.fn(); + const manager = { addHandler, handlers: [] as unknown[] }; + const result = mergeSentryCallback(manager, sentryHandler); + expect(result).toBe(manager); + expect(addHandler).toHaveBeenCalledWith(sentryHandler); + expect(addHandler).toHaveBeenCalledTimes(1); + }); + + it('does not re-add when the manager already has the sentry handler', () => { + const addHandler = vi.fn(); + const manager = { addHandler, handlers: [sentryHandler] }; + mergeSentryCallback(manager, sentryHandler); + expect(addHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index 17c0628e53df..b296a8766f4f 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, test, vi } from 'vitest'; -import { addNonEnumerableProperty, normalize } from '../../../src'; +import { normalize, setNormalizationDepthOverrideHint, setSkipNormalizationHint } from '../../../src'; import * as isModule from '../../../src/utils/is'; import * as stacktraceModule from '../../../src/utils/stacktrace'; @@ -655,7 +655,28 @@ describe('normalize()', () => { }); }); - describe('skips normalizing objects marked with a non-enumerable property __sentry_skip_normalization__', () => { + describe('regression: JSON cannot spoof skip-normalization via string keys', () => { + test('__sentry_skip_normalization__ as an own string property is still normalized', () => { + function someFun(): void { + /* no-empty */ + } + const jsonLikePayload = { + __sentry_skip_normalization__: true, + nan: NaN, + fun: someFun, + }; + + const result = normalize(jsonLikePayload); + + expect(result).toEqual({ + __sentry_skip_normalization__: true, + nan: '[NaN]', + fun: '[Function: someFun]', + }); + }); + }); + + describe('skips normalizing objects marked with setSkipNormalizationHint (internal symbol)', () => { test('by leaving non-serializable values intact', () => { const someFun = () => undefined; const alreadyNormalizedObj = { @@ -663,7 +684,7 @@ describe('normalize()', () => { fun: someFun, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const result = normalize(alreadyNormalizedObj); expect(result).toEqual({ @@ -681,7 +702,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const obj = { foo: { @@ -703,7 +724,7 @@ describe('normalize()', () => { }); }); - describe('overrides normalization depth with a non-enumerable property __sentry_override_normalization_depth__', () => { + describe('overrides normalization depth with setNormalizationDepthOverrideHint', () => { test('by increasing depth if it is higher', () => { const normalizationTarget = { foo: 'bar', @@ -717,7 +738,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 3); + setNormalizationDepthOverrideHint(normalizationTarget, 3); const result = normalize(normalizationTarget, 1); @@ -745,7 +766,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 1); + setNormalizationDepthOverrideHint(normalizationTarget, 1); const result = normalize(normalizationTarget, 3); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 73a19c2bfa45..250fcf8443c8 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -650,6 +650,35 @@ describe('request utils', () => { }); }); + it('filters common framework and provider session-style cookie names', () => { + const headers = { + Cookie: + 'connect.sid=s3cr3t; express.sid=opaque; PHPSESSID=abcd; theme=light; sb-access-token=x; __stripe_mid=y', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.express.sid': '[Filtered]', + 'http.request.header.cookie.phpsessid': '[Filtered]', + 'http.request.header.cookie.theme': 'light', + 'http.request.header.cookie.sb_access_token': '[Filtered]', + 'http.request.header.cookie.__stripe_mid': '[Filtered]', + }); + }); + + it('still filters session-style cookie names when sendDefaultPii is true', () => { + const headers = { Cookie: 'connect.sid=s3cr3t; analytics=1' }; + + const result = httpHeadersToSpanAttributes(headers, true); + + expect(result).toEqual({ + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.analytics': '1', + }); + }); + it('adds a filtered cookie header when cookie header is present, but has no valid key=value pairs', () => { const headers1 = { Cookie: ['key', 'val'] }; const result1 = httpHeadersToSpanAttributes(headers1); diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts index a9aa3953b458..e329f3d7f00b 100644 --- a/packages/core/test/lib/utils/should-ignore-span.test.ts +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -103,6 +103,61 @@ describe('shouldIgnoreSpan', () => { expect(shouldIgnoreSpan({ description: 'GET /health', op: 'http.server' }, [{ op: 'http.server' }])).toBe(true); }); + describe('attribute matching', () => { + it.each([ + // strings: pattern matching (substring + regex) + ['GET', 'GE', true], + ['GET', 'POST', false], + ['GET', /^GET$/, true], + ['GET', /^POST$/, false], + // numbers: strict equality + [200, 200, true], + [404, 200, false], + // booleans: strict equality + [true, true, true], + [true, false, false], + // no type coercion across primitive types + [true, 'true', false], + // arrays: element-wise strict equality (one positive per element type, plus mismatch shapes) + [['a', 'b'], ['a', 'b'], true], + [['a', 'b'], ['a', 'c'], false], + [['a', 'b'], ['a'], false], + [[1, 2], [1, 2], true], + [[true, false], [true, false], true], + ])('matches attribute value %j against pattern %j → %s', (actual, pattern, expected) => { + const span = { description: 'span', op: 'op', attributes: { x: actual } }; + expect(shouldIgnoreSpan(span, [{ attributes: { x: pattern } }])).toBe(expected); + }); + + it('does not match when the attribute key is absent on the span', () => { + const span = { description: 'span', op: 'op', attributes: {} }; + expect(shouldIgnoreSpan(span, [{ attributes: { 'missing.key': 'x' } }])).toBe(false); + }); + + it('does not match a filter with an empty attributes object', () => { + const span = { description: 'foo', op: 'bar', attributes: { x: 1 } }; + expect(shouldIgnoreSpan(span, [{ attributes: {} }])).toBe(false); + }); + + it('requires every attribute entry to match', () => { + const span = { description: 'span', op: 'op', attributes: { a: 1, b: 2 } }; + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 2 } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 3 } }])).toBe(false); + }); + + it('requires both name and attributes to match', () => { + const span = { description: 'GET /healthz', op: 'http.server', attributes: { 'http.method': 'GET' } }; + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'GET' } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'POST' } }])).toBe(false); + expect(shouldIgnoreSpan(span, [{ name: /other/, attributes: { 'http.method': 'GET' } }])).toBe(false); + }); + + it('still matches an attribute-only filter on a span without a description', () => { + const span = { description: undefined as unknown as string, op: undefined, attributes: { foo: 'bar' } }; + expect(shouldIgnoreSpan(span, [{ attributes: { foo: 'bar' } }])).toBe(true); + }); + }); + it('emits a debug log when a span is ignored', () => { const debugLogSpy = vi.spyOn(debug, 'log'); const span = { description: 'testDescription', op: 'testOp' }; diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index d18392258417..309bba7490ab 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -26,6 +26,14 @@ export const HIGHLIGHT_TOOL_TEXT = 'Highlight'; export const HIDE_TOOL_TEXT = 'Hide'; export const REMOVE_HIGHLIGHT_TEXT = 'Remove'; +export const ERROR_EMPTY_MESSAGE_TEXT = 'Unable to submit feedback with empty message'; +export const ERROR_NO_CLIENT_TEXT = 'No client setup, cannot send feedback.'; +export const ERROR_TIMEOUT_TEXT = 'Unable to determine if Feedback was correctly sent.'; +export const ERROR_FORBIDDEN_TEXT = + 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.'; +export const ERROR_GENERIC_TEXT = + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.'; + export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 1dc418ed131f..a2f94a078976 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -1,11 +1,14 @@ /* eslint-disable max-lines */ +/* eslint-disable complexity */ import type { + FeedbackErrorMessages, FeedbackInternalOptions, FeedbackModalIntegration, FeedbackScreenshotIntegration, Integration, IntegrationFn, + SendFeedback, } from '@sentry/core'; import { addIntegration, debug, isBrowser } from '@sentry/core'; import { @@ -15,6 +18,11 @@ import { DOCUMENT, EMAIL_LABEL, EMAIL_PLACEHOLDER, + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, FORM_TITLE, HIDE_TOOL_TEXT, HIGHLIGHT_TOOL_TEXT, @@ -119,6 +127,11 @@ export const buildFeedbackIntegration = ({ highlightToolText = HIGHLIGHT_TOOL_TEXT, hideToolText = HIDE_TOOL_TEXT, removeHighlightText = REMOVE_HIGHLIGHT_TEXT, + errorEmptyMessageText = ERROR_EMPTY_MESSAGE_TEXT, + errorNoClientText = ERROR_NO_CLIENT_TEXT, + errorTimeoutText = ERROR_TIMEOUT_TEXT, + errorForbiddenText = ERROR_FORBIDDEN_TEXT, + errorGenericText = ERROR_GENERIC_TEXT, // FeedbackCallbacks onFormOpen, @@ -164,6 +177,11 @@ export const buildFeedbackIntegration = ({ highlightToolText, hideToolText, removeHighlightText, + errorEmptyMessageText, + errorNoClientText, + errorTimeoutText, + errorForbiddenText, + errorGenericText, onFormClose, onFormOpen, @@ -230,6 +248,16 @@ export const buildFeedbackIntegration = ({ debug.error('[Feedback] Missing feedback screenshot integration. Proceeding without screenshots.'); } + const errorMessages: FeedbackErrorMessages = { + ERROR_EMPTY_MESSAGE: options.errorEmptyMessageText, + ERROR_NO_CLIENT: options.errorNoClientText, + ERROR_TIMEOUT: options.errorTimeoutText, + ERROR_FORBIDDEN: options.errorForbiddenText, + ERROR_GENERIC: options.errorGenericText, + }; + const wrappedSendFeedback: SendFeedback = (params, hint) => + sendFeedback(params, { includeReplay: true, ...hint, errorMessages }); + const dialog = modalIntegration.createDialog({ options: { ...options, @@ -243,7 +271,7 @@ export const buildFeedbackIntegration = ({ }, }, screenshotIntegration, - sendFeedback, + sendFeedback: wrappedSendFeedback, shadow: _createShadow(options), }); diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 712da5c269bf..204cf1cfa6c7 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -1,23 +1,33 @@ -import type { Event, EventHint, SendFeedback, SendFeedbackParams, TransportMakeRequestResponse } from '@sentry/core'; +import type { + Event, + EventHint, + FeedbackErrorMessages, + SendFeedback, + SendFeedbackParams, + TransportMakeRequestResponse, +} from '@sentry/core'; import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; +import { createFeedbackError, resolveFeedbackErrorMessage } from '../util/createFeedbackError'; /** * Public API to send a Feedback item to Sentry */ export const sendFeedback: SendFeedback = ( params: SendFeedbackParams, - hint: EventHint & { includeReplay?: boolean } = { includeReplay: true }, + hint: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages } = { includeReplay: true }, ): Promise => { + const errorMessages = hint.errorMessages; + if (!params.message) { - throw new Error('Unable to submit feedback with empty message'); + throw createFeedbackError('ERROR_EMPTY_MESSAGE', errorMessages); } // We want to wait for the feedback to be sent (or not) const client = getClient(); if (!client) { - throw new Error('No client setup, cannot send feedback.'); + throw createFeedbackError('ERROR_NO_CLIENT', errorMessages); } if (params.tags && Object.keys(params.tags).length) { @@ -35,7 +45,10 @@ export const sendFeedback: SendFeedback = ( // We want to wait for the feedback to be sent (or not) return new Promise((resolve, reject) => { // After 30s, we want to clear anyhow - const timeout = setTimeout(() => reject('Unable to determine if Feedback was correctly sent.'), 30_000); + const timeout = setTimeout(() => { + cleanup(); + reject(resolveFeedbackErrorMessage('ERROR_TIMEOUT', errorMessages)); + }, 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { if (event.event_id !== eventId) { @@ -51,14 +64,10 @@ export const sendFeedback: SendFeedback = ( } if (response?.statusCode === 403) { - return reject( - 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', - ); + return reject(resolveFeedbackErrorMessage('ERROR_FORBIDDEN', errorMessages)); } - return reject( - 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', - ); + return reject(resolveFeedbackErrorMessage('ERROR_GENERIC', errorMessages)); }); }); }; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index 0f09e969fee5..ee4b06b25d88 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -131,8 +131,9 @@ export function Form({ onSubmitSuccess(data, eventId); } catch (error) { DEBUG_BUILD && debug.error(error); - setError(error as string); - onSubmitError(error as Error); + const err = error instanceof Error ? error : new Error(String(error)); + setError(err.message); + onSubmitError(err); } } finally { setIsSubmitting(false); diff --git a/packages/feedback/src/util/createFeedbackError.ts b/packages/feedback/src/util/createFeedbackError.ts new file mode 100644 index 000000000000..d7c2c4100f3e --- /dev/null +++ b/packages/feedback/src/util/createFeedbackError.ts @@ -0,0 +1,24 @@ +import type { FeedbackErrorCode, FeedbackErrorMessages } from '@sentry/core'; +import { + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, +} from '../constants'; + +const DEFAULT_MESSAGES: Record = { + ERROR_EMPTY_MESSAGE: ERROR_EMPTY_MESSAGE_TEXT, + ERROR_NO_CLIENT: ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT: ERROR_TIMEOUT_TEXT, + ERROR_FORBIDDEN: ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC: ERROR_GENERIC_TEXT, +}; + +export function resolveFeedbackErrorMessage(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): string { + return messages?.[code] ?? DEFAULT_MESSAGES[code]; +} + +export function createFeedbackError(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): Error { + return new Error(resolveFeedbackErrorMessage(code, messages)); +} diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index a0cbb084da59..56938d1ddd6a 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -267,6 +267,46 @@ describe('sendFeedback', () => { ]); }); + it('throws when message is empty', () => { + mockSdk(); + expect(() => sendFeedback({ message: '' })).toThrow('Unable to submit feedback with empty message'); + }); + + it('throws when no client is set up', async () => { + // Isolate in its own scope so the client set up by other tests doesn't bleed in. + // `getClient` reads from the current scope; resetting it here leaves no client. + const { getGlobalScope } = await import('@sentry/core'); + getGlobalScope().setClient(undefined); + getCurrentScope().setClient(undefined); + getIsolationScope().setClient(undefined); + expect(() => sendFeedback({ message: 'mi' })).toThrow('No client setup, cannot send feedback.'); + }); + + it('uses provided errorMessages overrides', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 403 }); + }); + + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch('custom forbidden text'); + }); + + it('falls back to default messages for codes not in errorMessages', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 400 }); + }); + + // Only override ERROR_FORBIDDEN — a 400 should still use the default generic message. + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch( + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', + ); + }); + it('handles 400 transport error', async () => { mockSdk(); vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index d57aabb667df..f21151738f7d 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -84,5 +84,24 @@ "volta": { "extends": "../../package.json" }, + "nx": { + "targets": { + "build:transpile": { + "inputs": [ + "production", + "^production" + ], + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/*.d.ts" + ], + "dependsOn": [ + "^build:transpile" + ], + "cache": true + } + } + }, "sideEffects": false } diff --git a/packages/hono/README.md b/packages/hono/README.md index 236a4133bf67..c0d791030134 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -33,7 +33,7 @@ npm install @sentry/hono Additionally to `@sentry/hono`, install the `@sentry/cloudflare` package: -```bashbash +```bash npm install --save @sentry/cloudflare ``` @@ -100,54 +100,60 @@ export default app; Additionally to `@sentry/hono`, install the `@sentry/node` package: -```bashbash +```bash npm install --save @sentry/node ``` Make sure the installed version always stays in sync. The `@sentry/node` package is a required peer dependency when using `@sentry/hono/node`. You won't import `@sentry/node` directly in your code, but it needs to be installed in your project. -### 2. Initialize Sentry in your Hono app +### 2. Initialize Sentry in a separate file -Initialize the Sentry Hono middleware as early as possible in your app: +Create an `instrument.mjs` (or `instrument.ts`) file that initializes Sentry before the rest of your application runs. +This ensures Sentry can wrap third-party libraries (e.g. database clients) as early as possible: ```ts -import { Hono } from 'hono'; -import { serve } from '@hono/node-server'; -import { sentry } from '@sentry/hono/node'; - -const app = new Hono(); - -// Initialize Sentry middleware right after creating the app -app.use( - sentry(app, { - dsn: '__DSN__', // or process.env.SENTRY_DSN - tracesSampleRate: 1.0, - }), -); - -// ... your routes and other middleware +// instrument.mjs (or instrument.ts) +import * as Sentry from '@sentry/hono/node'; -serve(app); +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +}); ``` -### 3. Add `preload` script to start command - -To ensure that Sentry can capture spans from third-party libraries (e.g. database clients) used in your Hono app, Sentry needs to wrap these libraries as early as possible. +### 3. Load the instrument file with `--import` -When starting the Hono Node application, use the `@sentry/node/preload` hook with the `--import` CLI option to ensure modules are wrapped before the application code runs: +When starting your Hono Node application, use the `--import` CLI flag to load `instrument.mjs` before your app code: ```bash -node --import @sentry/node/preload index.js +node --import ./instrument.mjs app.js ``` This option can also be added to the `NODE_OPTIONS` environment variable: ```bash -NODE_OPTIONS="--import @sentry/node/preload" +NODE_OPTIONS="--import ./instrument.mjs" ``` -Read more about this preload script in the docs: https://docs.sentry.io/platforms/javascript/guides/hono/install/late-initialization/#late-initialization-with-esm +### 4. Add the Sentry middleware to your Hono app + +Add the `sentry` middleware to your Hono app. Since Sentry was already initialized in the instrument file, no options are passed here: + +```ts +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { sentry } from '@sentry/hono/node'; + +const app = new Hono(); + +// Add Sentry middleware right after creating the app +app.use(sentry(app)); + +// ... your routes and other middleware + +serve(app); +``` ## Setup (Bun) @@ -155,7 +161,7 @@ Read more about this preload script in the docs: https://docs.sentry.io/platform Additionally to `@sentry/hono`, install the `@sentry/bun` package: -```bashbash +```bash npm install --save @sentry/bun ``` diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index fbcbffb15019..651cb4649378 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,8 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import type { Hono, MiddlewareHandler } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoBunOptions extends Options {} @@ -16,7 +16,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): MiddlewareHandler => init(options); - patchAppUse(app); + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 66151af2f87f..7a4f8a4d4139 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -3,7 +3,7 @@ import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { patchAppUse } from '../shared/patchAppUse'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoCloudflareOptions extends Options {} @@ -33,7 +33,7 @@ export function sentry( app as unknown as ExportedHandler, ); - patchAppUse(app); + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 2a85575db0d8..bcfd65d573c1 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,22 +1,30 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; -import { init } from './sdk'; +import { type BaseTransportOptions, debug, type Options, getClient } from '@sentry/core'; import type { Hono, MiddlewareHandler } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoNodeOptions extends Options {} /** - * Sentry middleware for Hono running in a Node runtime environment. + * Sentry middleware for Hono applications running in a Node.js environment. + * + * This middleware enhances your Hono application by automatically instrumenting incoming requests and outgoing responses. + * It also applies the necessary patches to ensure Sentry captures execution context correctly in Node.js. + * + * **Note:** You must initialize Sentry separately before using this middleware. Typically, this is done by calling `Sentry.init()` in an `instrument.ts` file and loading it via the Node `--import` flag. */ -export const sentry = (app: Hono, options: HonoNodeOptions): MiddlewareHandler => { - const isDebug = options.debug; - - isDebug && debug.log('Initialized Sentry Hono middleware (Node)'); - - init(options); - - patchAppUse(app); +export const sentry = (app: Hono): MiddlewareHandler => { + const sentryClient = getClient(); + if (sentryClient === undefined) { + debug.warn( + 'Sentry is not initialized. Call `init()` from @sentry/hono/node in an `instrument.ts` file loaded via `--import` to set up Sentry for your application.', + ); + } else { + sentryClient.getOptions().debug && + debug.log('Sentry is initialized, proceeding to set up Hono `sentry` middleware.'); + } + + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/node/sdk.ts b/packages/hono/src/node/sdk.ts index 936cf612bb44..419d71d765eb 100644 --- a/packages/hono/src/node/sdk.ts +++ b/packages/hono/src/node/sdk.ts @@ -1,5 +1,5 @@ import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, debug, getClient } from '@sentry/core'; import { init as initNode } from '@sentry/node'; import type { HonoNodeOptions } from './middleware'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; @@ -7,14 +7,17 @@ import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; /** * Initializes Sentry for Hono running in a Node runtime environment. * - * In general, it is recommended to initialize Sentry via the `sentry()` middleware, as it sets up everything by default and calls `init` internally. - * - * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. + * This function should be called in an `instrument.ts` file loaded via `--import` to set up Sentry globally for the application. */ export function init(options: HonoNodeOptions): Client | undefined { + const existingClient = getClient(); + if (existingClient) { + existingClient.getOptions().debug && debug.log('Sentry is already initialized, skipping re-initialization.'); + return existingClient; + } + applySdkMetadata(options, 'hono', ['hono', 'node']); - // Remove Hono from the SDK defaults to prevent double instrumentation: @sentry/node const filteredOptions: HonoNodeOptions = { ...options, integrations: buildFilteredIntegrations(options.integrations, false), diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/hono/src/shared/applyPatches.ts new file mode 100644 index 000000000000..1b694ca7cfa5 --- /dev/null +++ b/packages/hono/src/shared/applyPatches.ts @@ -0,0 +1,14 @@ +import type { Env, Hono } from 'hono'; +import { patchAppUse } from '../shared/patchAppUse'; +import { patchRoute } from '../shared/patchRoute'; + +/** + * Applies necessary patches to the Hono app to ensure that Sentry can properly trace middleware and route handlers. + */ +export function applyPatches(app: Hono): void { + // `app.use` (instance own property) — wraps middleware at registration time on this instance. + patchAppUse(app); + + //`HonoBase.prototype.route` — wraps sub-app middleware at mount time so that route groups (`app.route('/prefix', subApp)`) are also instrumented. + patchRoute(app); +} diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts index f4bb9205c0f6..c0d620692278 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/hono/src/shared/patchAppUse.ts @@ -1,20 +1,8 @@ -import { - captureException, - getActiveSpan, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - startInactiveSpan, -} from '@sentry/core'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; - /** - * Patches `app.use` so that every middleware registered through it is automatically - * wrapped in a Sentry span. Supports both forms: `app.use(...handlers)` and `app.use(path, ...handlers)`. + * Patches the Hono app so that middleware is automatically traced as Sentry spans. */ export function patchAppUse(app: Hono): void { app.use = new Proxy(app.use, { @@ -31,41 +19,3 @@ export function patchAppUse(app: Hono): void { }, }); } - -/** - * Wraps a Hono middleware handler so that its execution is traced as a Sentry span. - * Explicitly parents each span under the root (transaction) span so that all middleware - * spans are siblings — even when OTel instrumentation introduces nested active contexts - * (onion order: A → B → handler → B → A would otherwise nest B under A). - */ -function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { - return async function sentryTracedMiddleware(context, next) { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - - const span = startInactiveSpan({ - name: handler.name || '', - op: 'middleware.hono', - onlyIfParent: true, - parentSpan: rootSpan, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN, - }, - }); - - try { - const result = await handler(context, next); - span.setStatus({ code: SPAN_STATUS_OK }); - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, - }); - throw error; - } finally { - span.end(); - } - }; -} diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts new file mode 100644 index 000000000000..d3f732e30793 --- /dev/null +++ b/packages/hono/src/shared/patchRoute.ts @@ -0,0 +1,81 @@ +import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; +import type { WrappedFunction } from '@sentry/core'; +import type { Env, Hono, MiddlewareHandler } from 'hono'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; + +interface HonoRoute { + method: string; + path: string; + handler: MiddlewareHandler; +} + +interface HonoBaseProto { + // oxlint-disable-next-line typescript/no-explicit-any + route: (path: string, app: Hono) => Hono; +} + +/** + * Patches `HonoBase.prototype.route` so that when a sub-app is mounted via `app.route('/prefix', subApp)`, its middleware handlers + * are retroactively wrapped in Sentry spans before the parent copies them. + * + * `route` lives on the prototype (unlike `use` which is a class field) + */ +export function patchRoute(app: Hono): void { + const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(app)) as HonoBaseProto; + if (!honoBaseProto || typeof honoBaseProto?.route !== 'function') { + return; + } + + if (getOriginalFunction(honoBaseProto.route as WrappedFunction)) { + return; + } + + const originalRoute = honoBaseProto.route; + + // oxlint-disable-next-line typescript/no-explicit-any + const patchedRoute = function (this: Hono, path: string, subApp: Hono): Hono { + if (subApp && Array.isArray(subApp.routes)) { + wrapSubAppMiddleware(subApp.routes as HonoRoute[]); + } + return originalRoute.call(this, path, subApp); + }; + + markFunctionWrapped(patchedRoute as unknown as WrappedFunction, originalRoute as unknown as WrappedFunction); + honoBaseProto.route = patchedRoute; +} + +/** + * Figures out which handlers in a sub-app's flat routes array are middleware (and should get a span), then wraps them. + * + * The challenge: Hono stores every handler as a plain { method, path, handler } entry. There is no "isMiddleware" flag. + * Two heuristics identify middleware: + * + * 1. Position within a group. `app.get('/path', mw, handler)` produces two entries with the same method+path. + * All but the last one must be middleware, because only middleware calls `next()` to pass control to the next handler. + * + * 2. Function arity (# of params) for method 'ALL'. Both `.use()` and `.all()` store their handlers under method 'ALL', + * so we can't use position alone to tell them apart when one is the last (or only) entry in its group. + * The deciding factor: Hono's `.use()` only accepts `(context, next)` (handlers with 2+ params). While `.all()` route + * handlers typically only accept `(context)`. + * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + */ +export function wrapSubAppMiddleware(routes: HonoRoute[]): void { + const lastIndexByKey = new Map(); + for (const [i, route] of routes.entries()) { + // \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path + lastIndexByKey.set(`${route.method}\0${route.path}`, i); + } + + for (const [i, route] of routes.entries()) { + if (typeof route.handler !== 'function') { + continue; + } + + const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i; + + const isMiddleware = !isLastForGroup || (route.method === 'ALL' && route.handler.length >= 2); + if (isMiddleware) { + route.handler = wrapMiddlewareWithSpan(route.handler); + } + } +} diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/hono/src/shared/wrapMiddlewareSpan.ts new file mode 100644 index 000000000000..b93e5de0bded --- /dev/null +++ b/packages/hono/src/shared/wrapMiddlewareSpan.ts @@ -0,0 +1,60 @@ +import { + captureException, + getActiveSpan, + getRootSpan, + getOriginalFunction, + markFunctionWrapped, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startInactiveSpan, + type WrappedFunction, +} from '@sentry/core'; +import { type MiddlewareHandler } from 'hono'; + +const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; + +/** + * Wraps a Hono middleware handler so that its execution is traced as a Sentry span. + * Explicitly parents each span under the root (transaction) span so that all middleware + * spans are siblings — even when OTel instrumentation introduces nested active contexts + * (onion order: A → B → handler → B → A would otherwise nest B under A). + */ +export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + if (getOriginalFunction(handler as unknown as WrappedFunction)) { + return handler; + } + + const wrapped: MiddlewareHandler = async function sentryTracedMiddleware(context, next) { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const span = startInactiveSpan({ + name: handler.name || '', + op: 'middleware.hono', + onlyIfParent: true, + parentSpan: rootSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN, + }, + }); + + try { + const result = await handler(context, next); + span.setStatus({ code: SPAN_STATUS_OK }); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, + }); + throw error; + } finally { + span.end(); + } + }; + + markFunctionWrapped(wrapped as unknown as WrappedFunction, handler as unknown as WrappedFunction); + return wrapped; +} diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index b6561098ed8a..745e924e7804 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -1,8 +1,8 @@ import * as SentryCore from '@sentry/core'; -import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry } from '../../src/node/middleware'; +import { init } from '../../src/node/sdk'; vi.mock('@sentry/node', () => ({ init: vi.fn(), @@ -28,122 +28,150 @@ describe('Hono Node Middleware', () => { vi.clearAllMocks(); }); - describe('sentry middleware', () => { - it('calls applySdkMetadata with "hono"', () => { + describe('sentry middleware (external init)', () => { + it('does not call init', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; + sentry(app); - sentry(app, options); + expect(initNodeMock).not.toHaveBeenCalled(); + }); + + it('returns a middleware handler function', () => { + const app = new Hono(); + const middleware = sentry(app); - expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); - expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono', ['hono', 'node']); + expect(middleware).toBeDefined(); + expect(typeof middleware).toBe('function'); + expect(middleware).toHaveLength(2); }); - it('calls init from @sentry/node', () => { + it('returns an async middleware handler', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; + const middleware = sentry(app); - sentry(app, options); + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); - expect(initNodeMock).toHaveBeenCalledTimes(1); - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }), - ); + it('emits a warning when Sentry is not initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + const app = new Hono(); + sentry(app); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sentry is not initialized')); }); - it('sets SDK metadata before calling Node init', () => { + it('does not emit a warning when Sentry is already initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + const fakeClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(fakeClient as unknown as SentryCore.Client); + const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; + sentry(app); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); - sentry(app, options); + describe('double-init guard', () => { + it('skips re-initialization when a client already exists', () => { + const fakeClient = { getOptions: () => ({}) }; + const getClientSpy = vi + .spyOn(SentryCore, 'getClient') + .mockReturnValue(fakeClient as unknown as SentryCore.Client); - const applySdkMetadataCallOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; - const initNodeCallOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); - expect(applySdkMetadataCallOrder).toBeLessThan(initNodeCallOrder as number); + expect(result).toBe(fakeClient); + expect(initNodeMock).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); }); - it('preserves all user options', () => { + it('initializes normally when no client exists yet', () => { + const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + + getClientSpy.mockRestore(); + }); + }); + + describe('sentry middleware without options (external init)', () => { + it('does not call init when no options are provided', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'production', - sampleRate: 0.5, - tracesSampleRate: 1.0, - debug: true, - }; - - sentry(app, options); - - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'production', - sampleRate: 0.5, - tracesSampleRate: 1.0, - debug: true, - }), - ); + sentry(app); + + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); }); it('returns a middleware handler function', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - const middleware = sentry(app, options); + const middleware = sentry(app); expect(middleware).toBeDefined(); expect(typeof middleware).toBe('function'); - expect(middleware).toHaveLength(2); // Hono middleware takes (context, next) + expect(middleware).toHaveLength(2); }); it('returns an async middleware handler', () => { const app = new Hono(); - const middleware = sentry(app, {}); + const middleware = sentry(app); expect(middleware.constructor.name).toBe('AsyncFunction'); }); - it('passes an integrations function to initNode (never a raw array)', () => { + it('emits a warning when Sentry is not initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const app = new Hono(); - sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + sentry(app); - const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; - expect(typeof callArgs.integrations).toBe('function'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sentry is not initialized')); }); - it('includes hono SDK metadata', () => { + it('does not emit a warning when Sentry is already initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + const fakeClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(fakeClient as unknown as SentryCore.Client); + const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - sentry(app, options); - - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - _metadata: expect.objectContaining({ - sdk: expect.objectContaining({ - name: 'sentry.javascript.hono', - version: SDK_VERSION, - packages: [ - { name: 'npm:@sentry/hono', version: SDK_VERSION }, - { name: 'npm:@sentry/node', version: SDK_VERSION }, - ], - }), - }), - }), - ); + const middleware = sentry(app); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); + }); + + describe('double-init guard', () => { + it('skips re-initialization when a client already exists', () => { + const fakeClient = { getOptions: () => ({}) }; + const getClientSpy = vi + .spyOn(SentryCore, 'getClient') + .mockReturnValue(fakeClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(fakeClient); + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + }); + + it('initializes normally when no client exists yet', () => { + const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + + getClientSpy.mockRestore(); }); }); }); diff --git a/packages/hono/test/node/sdk.test.ts b/packages/hono/test/node/sdk.test.ts new file mode 100644 index 000000000000..99f2ef5568e9 --- /dev/null +++ b/packages/hono/test/node/sdk.test.ts @@ -0,0 +1,175 @@ +import * as SentryCore from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { init } from '../../src/node/sdk'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn().mockReturnValue({ + /* fake client returned by node init */ + }), +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { init: initNodeMock } = await vi.importMock('@sentry/node'); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + applySdkMetadata: vi.fn(actual.applySdkMetadata), + }; +}); + +const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; + +describe('Hono Node SDK – init()', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + }); + + // ─── Happy path ─────────────────────────────────────────────────────────── + + it('calls applySdkMetadata with the correct SDK identifiers', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledWith(expect.any(Object), 'hono', ['hono', 'node']); + }); + + it('calls @sentry/node init with the provided DSN', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }), + ); + }); + + it('applies SDK metadata before calling @sentry/node init', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const metaOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; + const initOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + + expect(metaOrder).toBeLessThan(initOrder as number); + }); + + it('attaches correct SDK metadata (name, version, packages)', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.hono', + version: SDK_VERSION, + packages: [ + { name: 'npm:@sentry/hono', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], + }), + }), + }), + ); + }); + + it('preserves all user-supplied options', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }), + ); + }); + + it('always passes integrations as a function, never a raw array', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('wraps a user-supplied integrations array into a function', () => { + const userIntegration = { name: 'MyIntegration', setupOnce: vi.fn() }; + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [userIntegration], + }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('wraps a user-supplied integrations factory into a function', () => { + const factory = vi.fn((defaults: SentryCore.Integration[]) => defaults); + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: factory, + }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('returns the value produced by @sentry/node init', () => { + const fakeClient = { getOptions: () => ({}) }; + (initNodeMock as Mock).mockReturnValueOnce(fakeClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(fakeClient); + }); + + // ─── Double-init guard ───────────────────────────────────────────────────── + + it('returns the existing client without re-initializing when already set up', () => { + const existingClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(existingClient); + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); + }); + + it('logs a debug message when skipping re-initialization', () => { + const logSpy = vi.spyOn(SentryCore.debug, 'log'); + const existingClient = { getOptions: () => ({ debug: true }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('already initialized')); + }); + + it('does not log when debug is false and skipping re-initialization', () => { + const logSpy = vi.spyOn(SentryCore.debug, 'log'); + const existingClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('proceeds with initialization when no client exists', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts new file mode 100644 index 000000000000..83099370320c --- /dev/null +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -0,0 +1,119 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { responseHandler } from '../../src/shared/middlewareHandlers'; + +vi.mock('hono/route', () => ({ + routePath: () => '/test', +})); + +vi.mock('../../src/utils/hono-context', () => ({ + hasFetchEvent: () => false, +})); + +const mockSetTransactionName = vi.fn(); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getActiveSpan: vi.fn(() => null), + getIsolationScope: vi.fn(() => ({ + setTransactionName: mockSetTransactionName, + })), + getClient: vi.fn(() => undefined), + }; +}); + +const getClientMock = SentryCore.getClient as ReturnType; + +function createMockContext(status: number, error?: Error): unknown { + return { + req: { method: 'GET', raw: new Request('http://localhost/test') }, + res: { status }, + error, + }; +} + +describe('responseHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('error capture', () => { + it('captures error when context.error is set', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('server error'); + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(500, error) as any); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + + it('captures error regardless of status code', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('not found'); + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(404, error) as any); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + + it('does not call captureException when there is no error', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(200) as any); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not throw when client is undefined', () => { + getClientMock.mockReturnValue(undefined); + + // oxlint-disable-next-line typescript/no-explicit-any + expect(() => responseHandler(createMockContext(500, new Error('boom')) as any)).not.toThrow(); + }); + + it('delegates deduplication to captureException — calls it even for errors with __sentry_captured__', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('already captured'); + Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); + + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(500, error) as any); + + // captureException is called — it handles deduplication internally via checkOrSetAlreadyCaught + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + }); + + describe('transaction name', () => { + it('sets transaction name on isolation scope', () => { + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(200) as any); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); + }); + }); +}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index 0482d3569c84..ee376127baaa 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,7 +1,8 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { patchAppUse } from '../../src/shared/patchAppUse'; +import { patchRoute } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -18,11 +19,18 @@ vi.mock('@sentry/core', async () => { const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + describe('patchAppUse (middleware spans)', () => { beforeEach(() => { vi.clearAllMocks(); }); + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); patchAppUse(app); @@ -156,22 +164,288 @@ describe('patchAppUse (middleware spans)', () => { expect(fakeApp._capturedThis).toBe(fakeApp); }); - // todo: support sub-app (Hono route groups) patching in the future - it('does not wrap middleware on sub-apps (instance-level patching limitation)', async () => { - const app = new Hono(); - patchAppUse(app); + describe('route() patching (sub-app / route group support)', () => { + beforeEach(() => { + honoBaseProto.route = originalRoute; + }); - // Route Grouping: https://hono.dev/docs/api/routing#grouping - const subApp = new Hono(); - subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { - await next(); + it('wraps middleware on sub-apps mounted via route()', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); + }); + + it('does not wrap sole route handlers on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + app.get('/', () => new Response('ok')); + + // Mount the main app as a sub-app of another app (contrived but tests the guard) + const parent = new Hono(); + parent.route('/', app); + + await parent.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); + }); + + it('does not patch route() twice when patchRoute is called multiple times', () => { + const app1 = new Hono(); + patchRoute(app1); + + const patchedRoute = honoBaseProto.route; + + const app2 = new Hono(); + patchRoute(app2); + + expect(honoBaseProto.route).toBe(patchedRoute); + }); + + it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { + const app = new Hono(); + patchRoute(app); + + // oxlint-disable-next-line typescript/no-explicit-any + const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; + expect(sentryOriginal).toBe(originalRoute); + }); + + it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/admin/dashboard', () => new Response('dashboard')); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/admin/dashboard')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); + }); + + it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.all('/catch-all', async function allHandler() { + return new Response('catch-all'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/catch-all')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use(async function mw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.all('/wildcard', async function allRoute() { + return new Response('wildcard'); + }); + subApp.get('/specific', () => new Response('specific')); + + app.route('/mixed', subApp); + await app.fetch(new Request('http://localhost/mixed/wildcard')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('mw'); + expect(spanNames).not.toContain('allRoute'); + }); + + it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps (they are final handlers, not middleware)', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + subApp.put('/resource', async function postHandler() { + return new Response('put'); + }); + subApp.delete('/resource', async function postHandler() { + return new Response('delete'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); }); - subApp.get('/', () => new Response('sub')); - app.route('/sub', subApp); + it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get( + '/resource', + async function inlineMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function getHandler() { + return new Response('get'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('inlineMw'); + expect(spanNames).not.toContain('getHandler'); + }); - await app.fetch(new Request('http://localhost/sub')); + it('wraps separately registered middleware for .get() on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); - expect(startInactiveSpanMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); + const subApp = new Hono(); + subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('separateMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps inline middleware registered via .on() on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.on( + 'GET', + '/resource', + async function onMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function onHandler() { + return new Response('on'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('onMw'); + expect(spanNames).not.toContain('onHandler'); + }); + + it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const innerSub = new Hono(); + innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + innerSub.get('/', () => new Response('inner')); + + const outerSub = new Hono(); + outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + outerSub.route('/inner', innerSub); + + app.route('/outer', outerSub); + await app.fetch(new Request('http://localhost/outer/inner')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('outerMiddleware'); + expect(spanNames).toContain('innerMiddleware'); + }); + + it('handles sub-app with multiple path-targeted middleware for different paths', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/a/test', () => new Response('a')); + subApp.get('/b/test', () => new Response('b')); + + app.route('/sub', subApp); + + // Hit path /a — only mwForA should fire + await app.fetch(new Request('http://localhost/sub/a/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); + + startInactiveSpanMock.mockClear(); + + // Hit path /b — only mwForB should fire + await app.fetch(new Request('http://localhost/sub/b/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); + }); }); }); diff --git a/packages/hono/test/shared/patchRoute.test.ts b/packages/hono/test/shared/patchRoute.test.ts new file mode 100644 index 000000000000..d9dd4d6795ad --- /dev/null +++ b/packages/hono/test/shared/patchRoute.test.ts @@ -0,0 +1,141 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { patchRoute } from '../../src/shared/patchRoute'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: vi.fn((_opts: unknown) => ({ + setStatus: vi.fn(), + end: vi.fn(), + })), + }; +}); + +const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; + +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + +describe('patchRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + honoBaseProto.route = originalRoute; + }); + + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + + it('is a no-op when honoBaseProto.route is not a function', () => { + const fakeApp = Object.create({ notRoute: () => {} }) as Hono; + // Should not throw even when the expected method shape is missing + expect(() => patchRoute(fakeApp)).not.toThrow(); + expect(honoBaseProto.route).toBe(originalRoute); + }); + + describe('wrapSubAppMiddleware', () => { + it('does nothing when a sub-app has an empty routes array', async () => { + const app = new Hono(); + patchRoute(app); + + const emptySubApp = new Hono(); + // routes is an empty array — nothing to wrap, nothing should throw + app.route('/empty', emptySubApp); + + const res = await app.fetch(new Request('http://localhost/empty')); + expect(res.status).toBe(404); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('skips route entries whose handler is not a function', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/resource', () => new Response('ok')); + + // Corrupt one handler to a non-function to simulate unexpected route shapes + (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; + + // Should not throw when iterating over the corrupted routes + expect(() => app.route('/api', subApp)).not.toThrow(); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same path with different HTTP methods as separate groups', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + // Each of these is the sole (last) handler for its method+path group, + // so none should be wrapped as middleware. + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); + await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same HTTP method with different paths as separate groups', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + // Each is the sole handler for its own method+path group — neither is middleware. + subApp.get('/alpha', async function alphaHandler() { + return new Response('alpha'); + }); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + subApp.get( + '/alpha', + async function alphaMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function alphaHandler() { + return new Response('alpha'); + }, + ); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toHaveLength(1); + expect(spanNames).toContain('alphaMw'); + expect(spanNames).not.toContain('alphaHandler'); + expect(spanNames).not.toContain('betaHandler'); + }); + }); +}); diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 95a47170c52b..803ffac74c92 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -85,5 +85,24 @@ "volta": { "extends": "../../package.json" }, + "nx": { + "targets": { + "build:types": { + "inputs": [ + "production", + "^production" + ], + "dependsOn": [ + "^build:types" + ], + "outputs": [ + "{projectRoot}/build/types", + "{projectRoot}/build/types-ts3.8", + "{projectRoot}/*.d.ts" + ], + "cache": true + } + } + }, "sideEffects": false } diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index fb8b7acea878..b7f9c482b816 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -74,19 +74,17 @@ export function init(options: BrowserOptions): Client | undefined { applyTunnelRouteOption(opts); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); - const client = reactInit(opts); - - const filterTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === '/404' ? null : event; - filterTransactions.id = 'NextClient404Filter'; - addEventProcessor(filterTransactions); + opts.ignoreSpans = [ + ...(opts.ignoreSpans || []), + // we filter out segment spans for /404 pages + /^\/404$/, + // segment spans where we didn't get a reasonable transaction name + // in this case, constructing a dynamic RegExp is fine because the variable is a constant + // we need to ensure to exact-match, so a string match isn't safe (same for /404 above) + new RegExp(`^${INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME}$`), + ]; - const filterIncompleteNavigationTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME - ? null - : event; - filterIncompleteNavigationTransactions.id = 'IncompleteTransactionFilter'; - addEventProcessor(filterIncompleteNavigationTransactions); + const client = reactInit(opts); const filterNextRedirectError: EventProcessor = (event, hint) => isRedirectNavigationError(hint?.originalException) || event.exception?.values?.[0]?.value === 'NEXT_REDIRECT' diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index ce54e8e25f85..fbbe8f704fbe 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -2,6 +2,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import { getClient, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core'; import { isSentryRequestSpan } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -59,7 +60,7 @@ function isTunnelRouteSpan(spanAttributes: Record): boolean { // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = httpTarget.split('?')[0] || ''; - return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); + return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); } return false; diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts new file mode 100644 index 000000000000..9f107d33636c --- /dev/null +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -0,0 +1,8 @@ +/** + * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path + * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel + * `/api/t` must not match `/api/things`. + */ +export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { + return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); +} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 985354543a0d..d383837cbf17 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,6 +13,7 @@ import { withIsolationScope, } from '@sentry/core'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; +import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathnameMatch'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -36,7 +37,7 @@ export function wrapMiddlewareWithSentry( // Check if the current request matches the tunnel route if (req instanceof Request) { const url = new URL(req.url); - const isTunnelRequest = url.pathname.startsWith(tunnelRoute); + const isTunnelRequest = isPathnameUnderSentryTunnelRoute(url.pathname, tunnelRoute); if (isTunnelRequest) { // Create a simple response that mimics NextResponse.next() so we don't need to import internals here diff --git a/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts new file mode 100644 index 000000000000..cd71928e9109 --- /dev/null +++ b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts @@ -0,0 +1,41 @@ +import { stripUrlQueryAndFragment } from '@sentry/core'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; + +export interface MutableMiddlewareRootSpan { + attributes: Record; + getName(): string | undefined; + setName(name: string): void; +} + +/** + * Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime. + * + * Older Next.js versions append the full URL to the middleware span name (e.g. `middleware GET /foo?bar=1`), + * producing high-cardinality transaction names. We collapse the name to `middleware {METHOD}` when possible, + * and strip query/fragment otherwise. + * + * Called from two places that operate on different shapes of the same underlying root span: + * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` + * holds the root span's attributes and whose `event.transaction` is the root span's name. + * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed + * counterpart of the legacy transaction root) directly. + */ +export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void { + const { attributes } = span; + + if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Middleware.execute') { + return; + } + + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; + if (typeof spanName !== 'string' || !spanName || !span.getName()) { + return; + } + + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + span.setName(`middleware ${match[1]}`); + } else { + span.setName(stripUrlQueryAndFragment(spanName)); + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 96a03541ab22..e92f919a8b57 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -4,7 +4,6 @@ import { context } from '@opentelemetry/api'; import { applySdkMetadata, - type EventProcessor, getCapturedScopesOnSpan, getCurrentScope, getGlobalScope, @@ -17,7 +16,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, spanToJSON, - stripUrlQueryAndFragment, } from '@sentry/core'; import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; @@ -31,6 +29,7 @@ import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceMiddlewareRootSpan } from './enhanceMiddlewareRootSpan'; export * from '@sentry/vercel-edge'; export * from '../common'; @@ -85,6 +84,12 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + const nextjsIgnoreSpans: NonNullable = [ + // (set in `dropMiddlewareTunnelRequests` during `spanStart`) + { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + // Use appropriate SDK metadata based on the runtime environment if (isRunningOnCloudflare) { applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']); @@ -137,61 +142,47 @@ export function init(options: VercelEdgeOptions = {}): void { // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to // "custom", doesn't trigger. + // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; + // `enhanceMiddlewareRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. + // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { - // The otel auto inference will clobber the transaction name because the span has an http.target - if ( - event.type === 'transaction' && - event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' && - event.contexts?.trace?.data?.['next.span_name'] - ) { - if (event.transaction) { - // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. - // We want to remove the url from the name here. - const spanName = event.contexts.trace.data['next.span_name']; - - if (typeof spanName === 'string') { - const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); - if (match) { - const normalizedName = `middleware ${match[1]}`; - event.transaction = normalizedName; - } else { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); - } - } - } + if (event.type === 'transaction' && event.contexts?.trace?.data) { + enhanceMiddlewareRootSpan({ + attributes: event.contexts.trace.data, + getName: () => event.transaction, + setName: name => { + event.transaction = name; + }, + }); } setUrlProcessingMetadata(event); }); + // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become + // transaction events, so the same enhancement has to be applied here directly on the span JSON. + client?.on('processSegmentSpan', span => { + const attributes = (span.attributes ??= {}); + enhanceMiddlewareRootSpan({ + attributes, + getName: () => span.name, + setName: name => { + span.name = name; + }, + }); + }); + client?.on('spanEnd', span => { if (span === getRootSpan(span)) { waitUntil(flushSafelyWithTimeout()); } }); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - // Filter transactions that we explicitly want to drop. - if (event.type === 'transaction') { - if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { - return null; - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { getGlobalScope().setTag('turbopack', true); + getGlobalScope().setAttribute('turbopack', true); } } catch { // Noop diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts new file mode 100644 index 000000000000..a934380492dc --- /dev/null +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -0,0 +1,78 @@ +import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_ROUTE, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_TARGET, +} from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, stripUrlQueryAndFragment } from '@sentry/core'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../common/span-attributes-with-logic-attached'; + +export interface MutableRootSpan { + attributes: Record; + getName(): string | undefined; + setName(name: string): void; + setOp(op: string): void; +} + +/** + * Normalizes name, op and source for the root span of a Next.js `BaseServer.handleRequest` request. + * + * Called from two places that operate on different shapes of the same underlying root span: + * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` + * holds the root span's attributes and whose `event.transaction` is the root span's name. + * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed + * counterpart of the legacy transaction root) directly. + * + * The `MutableRootSpan` adapter hides those differences so the enhancement logic can be shared. + */ +export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { + const { attributes } = span; + + if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') { + return; + } + + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; + span.setOp('http.server'); + + const currentName = span.getName(); + if (currentName) { + span.setName(stripUrlQueryAndFragment(currentName)); + } + + // eslint-disable-next-line deprecation/deprecation + const method = attributes[SEMATTRS_HTTP_METHOD] ?? attributes[ATTR_HTTP_REQUEST_METHOD]; + // eslint-disable-next-line deprecation/deprecation + const target = attributes[SEMATTRS_HTTP_TARGET]; + const route = attributes[ATTR_HTTP_ROUTE] || attributes[ATTR_NEXT_ROUTE]; + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; + + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { + const cleanRoute = route.replace(/\/route$/, ''); + span.setName(`${method} ${cleanRoute}`); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + // Preserve next.route in case it did not get hoisted + attributes[ATTR_NEXT_ROUTE] = cleanRoute; + } + + // backfill transaction name for pages that would otherwise contain unparameterized routes + const routeBackfill = attributes[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]; + if (typeof routeBackfill === 'string' && span.getName() !== 'GET /_app') { + span.setName(`${typeof method === 'string' ? method : 'GET'} ${routeBackfill}`); + } + + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + span.setName(`middleware ${middlewareMatch[1]}`); + span.setOp('http.server.middleware'); + } + + // Next.js overrides transaction names for page loads that throw an error + // but we want to keep the original target name + if (span.getName() === 'GET /_error' && typeof target === 'string') { + span.setName(`${typeof method === 'string' ? `${method} ` : ''}${target}`); + } +} diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 0483ab6448ff..d283bef38263 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,39 +1,27 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { - ATTR_HTTP_ROUTE, - ATTR_URL_QUERY, - SEMATTRS_HTTP_METHOD, - SEMATTRS_HTTP_TARGET, -} from '@opentelemetry/semantic-conventions'; +import { ATTR_URL_QUERY, SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { EventProcessor } from '@sentry/core'; import { applySdkMetadata, debug, - extractTraceparentData, getClient, getGlobalScope, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; -import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; -import { - TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, - TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, - TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, -} from '../common/span-attributes-with-logic-attached'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring'; @@ -155,6 +143,23 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + const nextjsIgnoreSpans: NonNullable = [ + // Static assets (matches `_next/static` anywhere in the name to handle custom basePath) + /^GET (\/.*)?\/_next\/static\//, + // Dev source-map fetch endpoints + /\/__nextjs_original-stack-frame/, + // Pages router /404 + /^\/404$/, + // App router /404 and /_not-found segments (any HTTP method) + /^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/, + // Next.js 13 root transactions named "NextServer.getRequestHandler" containing useless tracing + /^NextServer\.getRequestHandler$/, + // Spans flagged via TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION + // (set in `dropMiddlewareTunnelRequests` during `spanStart`) + { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + if (DEBUG_BUILD && opts.debug) { debug.enable(); } @@ -195,62 +200,6 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanEnd', maybeCompleteCronCheckIn); client?.on('spanEnd', maybeCleanupQueueSpan); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - if (event.type === 'transaction') { - // Filter out transactions for static assets - // This regex matches the default path to the static assets (`_next/static`) and could potentially filter out too many transactions. - // We match `/_next/static/` anywhere in the transaction name because its location may change with the basePath setting. - if (event.transaction?.match(/^GET (\/.*)?\/_next\/static\//)) { - return null; - } - - // Filter out requests to resolve source maps for stack frames in dev mode - if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { - return null; - } - - // Filter out /404 transactions which seem to be created excessively - if ( - // Pages router - event.transaction === '/404' || - // App router (could be "GET /404", "POST /404", ...) - event.transaction?.match(/^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/) - ) { - return null; - } - - // Filter transactions that we explicitly want to drop. - if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { - return null; - } - - // Next.js 13 sometimes names the root transactions like this containing useless tracing. - if (event.transaction === 'NextServer.getRequestHandler') { - return null; - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if (typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string') { - const traceparentData = extractTraceparentData( - event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL], - ); - - if (traceparentData?.parentSampled === false) { - return null; - } - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - getGlobalScope().addEventProcessor( Object.assign( ((event, hint) => { @@ -289,74 +238,44 @@ export function init(options: NodeOptions): NodeClient | undefined { // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to // "custom", doesn't trigger. + // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; + // `enhanceHandleRequestRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. + // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { - // Enhance route handler transactions - if ( - event.type === 'transaction' && - event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' - ) { - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; - event.contexts.trace.op = 'http.server'; - - if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.transaction); - } - - // eslint-disable-next-line deprecation/deprecation - const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; - // eslint-disable-next-line deprecation/deprecation - const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; - const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - - if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { - const cleanRoute = route.replace(/\/route$/, ''); - event.transaction = `${method} ${cleanRoute}`; - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; - // Preserve next.route in case it did not get hoisted - event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; - } - - // backfill transaction name for pages that would otherwise contain unparameterized routes - if (event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL] && event.transaction !== 'GET /_app') { - event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; - } - - const middlewareMatch = - typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); - - if (middlewareMatch) { - const normalizedName = `middleware ${middlewareMatch[1]}`; - event.transaction = normalizedName; - event.contexts.trace.op = 'http.server.middleware'; - } - - // Next.js overrides transaction names for page loads that throw an error - // but we want to keep the original target name - if (event.transaction === 'GET /_error' && target) { - event.transaction = `${method ? `${method} ` : ''}${target}`; - } - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if ( - event.type === 'transaction' && - typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string' - ) { - const traceparentData = extractTraceparentData(event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL]); - - if (traceparentData?.traceId) { - event.contexts.trace.trace_id = traceparentData.traceId; - } - - if (traceparentData?.parentSpanId) { - event.contexts.trace.parent_span_id = traceparentData.parentSpanId; - } + if (event.type === 'transaction' && event.contexts?.trace?.data) { + enhanceHandleRequestRootSpan({ + attributes: event.contexts.trace.data, + getName: () => event.transaction, + setName: name => { + event.transaction = name; + }, + setOp: op => { + event.contexts!.trace!.op = op; + }, + }); } setUrlProcessingMetadata(event); }); + // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become + // transaction events, so the same enhancement has to be applied here directly on the span JSON. + client?.on('processSegmentSpan', span => { + const attributes = (span.attributes ??= {}); + enhanceHandleRequestRootSpan({ + attributes, + getName: () => span.name, + setName: name => { + span.name = name; + }, + // For streamed spans, op lives in `attributes['sentry.op']` - mirror it there so middleware + // overrides land somewhere readable (the legacy path uses a separate `event.contexts.trace.op`). + setOp: op => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }); + }); + if (process.env.NODE_ENV === 'development') { getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); } @@ -365,6 +284,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { getGlobalScope().setTag('turbopack', true); + getGlobalScope().setAttribute('turbopack', true); } } catch { // Noop diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 873aa5a2511e..090ef61fe5cd 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,10 +1,11 @@ import type { Integration } from '@sentry/core'; -import { debug, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { debug, getGlobalScope, getIsolationScope, SentryNonRecordingSpan } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, getCurrentScope, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import { breadcrumbsIntegration, browserTracingIntegration, init } from '../src/client'; +import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from '../src/client/routing/appRouterRoutingInstrumentation'; const reactInit = vi.spyOn(SentryReact, 'init'); const debugLogSpy = vi.spyOn(debug, 'log'); @@ -83,20 +84,68 @@ describe('Client init()', () => { ); }); - it('adds 404 transaction filter', () => { - init({ - dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', - tracesSampleRate: 1.0, + describe('transaction filtering', () => { + const TEST_DSN_404 = 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012'; + + it('drops /404 transactions', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0 }); + const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); + + // Ensure we have no current span, so our next span is a transaction + SentryReact.withActiveSpan(null, () => { + SentryReact.startInactiveSpan({ name: '/404' })?.end(); + }); + + expect(transportSend).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); }); - const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); - // Ensure we have no current span, so our next span is a transaction - SentryReact.withActiveSpan(null, () => { - SentryReact.startInactiveSpan({ name: '/404' })?.end(); + it('drops incomplete navigation transactions', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0 }); + const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); + + // Ensure we have no current span, so our next span is a transaction + SentryReact.withActiveSpan(null, () => { + SentryReact.startInactiveSpan({ name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME })?.end(); + }); + + expect(transportSend).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); }); - expect(transportSend).not.toHaveBeenCalled(); - expect(debugLogSpy).toHaveBeenCalledWith('An event processor returned `null`, will not send event.'); + describe('span streaming', () => { + it('drops /404 segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + // Ensure we have no current span, so our next span is a segment span + const span = SentryReact.withActiveSpan(null, () => SentryReact.startInactiveSpan({ name: '/404' })); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); + }); + + it('drops incomplete navigation segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + // Ensure we have no current span, so our next span is a segment span + const span = SentryReact.withActiveSpan(null, () => + SentryReact.startInactiveSpan({ name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME }), + ); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); + }); + + it('drops /404 non-segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + SentryReact.startSpan({ name: 'parent' }, parent => { + expect(parent).not.toBeInstanceOf(SentryNonRecordingSpan); + const child = SentryReact.startInactiveSpan({ name: '/404' }); + expect(child).toBeInstanceOf(SentryNonRecordingSpan); + }); + }); + }); }); describe('integrations', () => { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index c61c92026f60..7d5f4029bd94 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -193,4 +193,33 @@ describe('wrapMiddlewareWithSentry', () => { expect(origFunction).toHaveBeenCalledWith(mockRequest); expect(result).toBe(mockReturnValue); }); + + test('should not treat paths as tunnel when they only share a prefix with tunnelRoute', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + + const mockReturnValue = { status: 200 }; + const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request('https://example.com/api/things', { method: 'GET' }); + + const result = await wrappedOriginal(mockRequest); + + expect(origFunction).toHaveBeenCalledWith(mockRequest); + expect(result).toBe(mockReturnValue); + }); + + test('should skip processing for tunnel sub-paths under tunnelRoute', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + + const origFunction: EdgeRouteHandler = vi.fn(async () => ({ status: 200 })); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request('https://example.com/api/t/envelope?o=1'); + + const result = await wrappedOriginal(mockRequest); + + expect(origFunction).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + }); }); diff --git a/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts new file mode 100644 index 000000000000..6308c6a75bab --- /dev/null +++ b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { enhanceMiddlewareRootSpan } from '../../src/edge/enhanceMiddlewareRootSpan'; + +function makeSpan(attributes: Record, name?: string) { + let currentName = name; + return { + span: { + attributes, + getName: () => currentName, + setName: (n: string) => { + currentName = n; + }, + }, + getName: () => currentName, + }; +} + +describe('enhanceMiddlewareRootSpan', () => { + it('does nothing for spans that are not Middleware.execute', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, + 'GET /foo', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('GET /foo'); + }); + + it('does nothing when next.span_name is missing', () => { + const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware'); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when next.span_name is an empty string', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' }, + 'middleware', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when next.span_name is not a string', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 }, + 'middleware', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when the current name is empty', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, + undefined, + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBeUndefined(); + }); + + it.each([ + ['middleware GET /foo', 'middleware GET'], + ['middleware POST /api/protected?token=abc', 'middleware POST'], + ['middleware DELETE /resources/[id]', 'middleware DELETE'], + ['middleware HEAD /', 'middleware HEAD'], + ])('collapses "%s" to "%s"', (spanName, expected) => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName }, + spanName, + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe(expected); + }); + + it('strips query and fragment from non-method-prefixed middleware names', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '/api/foo?token=abc#section' }, + '/api/foo?token=abc#section', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('/api/foo'); + }); + + it('does not collapse names that do not match the middleware-method prefix', () => { + // CONNECT and TRACE are not in the regex - they fall through to query/fragment stripping + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware CONNECT /foo?bar=1' }, + 'middleware CONNECT /foo?bar=1', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware CONNECT /foo'); + }); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index de0dd041e972..8d4fee1f926e 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; // normally this is set as part of the build process, so mock it here @@ -74,6 +75,30 @@ describe('Edge init()', () => { }); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = vercelEdgeInit.mock.calls[0]?.[0] as SentryVercelEdge.VercelEdgeOptions; + return callArgs.ignoreSpans ?? []; + } + + it('appends the TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(patterns.some(p => p instanceof RegExp && p.source === 'custom-regex')).toBe(true); + }); + }); + describe('environment option', () => { const originalEnv = process.env.SENTRY_ENVIRONMENT; diff --git a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts new file mode 100644 index 000000000000..8373c3a6e744 --- /dev/null +++ b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts @@ -0,0 +1,179 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../../src/common/span-attributes-with-logic-attached'; +import { enhanceHandleRequestRootSpan } from '../../src/server/enhanceHandleRequestRootSpan'; + +function makeSpan(attributes: Record, name?: string) { + let currentName = name; + let op: string | undefined; + return { + span: { + attributes, + getName: () => currentName, + setName: (n: string) => { + currentName = n; + }, + setOp: (o: string) => { + op = o; + }, + }, + getName: () => currentName, + getOp: () => op, + }; +} + +describe('enhanceHandleRequestRootSpan', () => { + it('does nothing for non-BaseServer.handleRequest spans', () => { + const { span, getName, getOp } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Render.getServerSideProps' }, 'GET /api/foo'); + enhanceHandleRequestRootSpan(span); + expect(getName()).toBe('GET /api/foo'); + expect(getOp()).toBeUndefined(); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); + }); + + it('sets http.server op and source=route for parameterized routes', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': '/api/users/[id]', + }, + 'GET /api/users/123', + ); + enhanceHandleRequestRootSpan(span); + + expect(getOp()).toBe('http.server'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server'); + expect(getName()).toBe('GET /api/users/[id]'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toBe('route'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/users/[id]'); + }); + + it('strips trailing /route from app router route handler routes', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'POST', + 'next.route': '/api/widgets/route', + }, + 'POST /api/widgets/route', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('POST /api/widgets'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/widgets'); + }); + + it('strips URL query and fragment from the segment name', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, + 'GET /search?q=foo#section', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /search'); + }); + + it('does not rename middleware-prefixed routes via the route attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': 'middleware GET', + }, + 'GET /foo', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /foo'); + }); + + it('uses the route backfill attribute when present', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /posts/hello-world', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /posts/[slug]'); + }); + + it('does not apply the backfill for the special GET /_app transaction', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /_app', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /_app'); + }); + + it('normalizes middleware span names and sets http.server.middleware op', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware POST /api/protected', + }, + 'middleware POST /api/protected?token=abc', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('middleware POST'); + expect(getOp()).toBe('http.server.middleware'); + }); + + it('writes the middleware op into attributes when the adapter mirrors op writes (streamed shape)', () => { + // Mirrors the `processSegmentSpan` adapter in src/server/index.ts where `setOp` writes back + // into `attributes['sentry.op']` because that is the only op storage for streamed segment spans. + const attributes: Record = { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware GET /api', + }; + let name: string | undefined = 'middleware GET /api'; + const span = { + attributes, + getName: () => name, + setName: (n: string) => { + name = n; + }, + setOp: (op: string) => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }; + + enhanceHandleRequestRootSpan(span); + + expect(name).toBe('middleware GET'); + expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server.middleware'); + }); + + it('rewrites GET /_error using the http.target attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'http.target': '/api/broken', + }, + 'GET /_error', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /api/broken'); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 26a73aa676d3..5ef92ae2d890 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -3,6 +3,7 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { getCurrentScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; // normally this is set as part of the build process, so mock it here @@ -116,6 +117,40 @@ describe('Server init()', () => { expect(init({})).not.toBeUndefined(); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = nodeInit.mock.calls[0]?.[0] as SentryNode.NodeOptions; + return callArgs.ignoreSpans ?? []; + } + + function regexSources(patterns: NonNullable): string[] { + return patterns.filter((p): p is RegExp => p instanceof RegExp).map(p => p.source); + } + + it('appends the Next.js name patterns and attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + const sources = regexSources(patterns); + + expect(sources).toContain('^GET (\\/.*)?\\/_next\\/static\\/'); + expect(sources).toContain('\\/__nextjs_original-stack-frame'); + expect(sources).toContain('^\\/404$'); + expect(sources).toContain('^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \\/(404|_not-found)$'); + expect(sources).toContain('^NextServer\\.getRequestHandler$'); + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(regexSources(patterns)).toContain('custom-regex'); + }); + }); + describe('OpenNext/Cloudflare runtime detection', () => { const cloudflareContextSymbol = Symbol.for('__cloudflare-context__'); diff --git a/packages/nitro/.oxlintrc.json b/packages/nitro/.oxlintrc.json new file mode 100644 index 000000000000..f079a7bc588c --- /dev/null +++ b/packages/nitro/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], + "env": { + "node": true + } +} diff --git a/packages/nitro/LICENSE b/packages/nitro/LICENSE new file mode 100644 index 000000000000..0ecae617386e --- /dev/null +++ b/packages/nitro/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/nitro/README.md b/packages/nitro/README.md new file mode 100644 index 000000000000..459d8087ce0a --- /dev/null +++ b/packages/nitro/README.md @@ -0,0 +1,129 @@ +

+ + Sentry + +

+ +> NOTICE: This package is in beta state and may be subject to breaking changes. + +# Official Sentry SDK for Nitro + +[![npm version](https://img.shields.io/npm/v/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) +[![npm dm](https://img.shields.io/npm/dm/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) +[![npm dt](https://img.shields.io/npm/dt/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) + +## Links + +- [Official Nitro SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) +- [Example Nitro app](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3) + +## Compatibility + +The minimum supported version of Nitro is `3.0.260415-beta`. + +## General + +This package is a wrapper around `@sentry/node` with added instrumentation for Nitro's features like: + +- HTTP handlers and error capturing. +- [Middleware instrumentation](https://nitro.build/guide/routing#middleware). + + + +## Manual Setup + +### 1. Prerequisites & Installation + +1. Install the Sentry Nitro SDK: + + ```bash + # Using npm + npm install @sentry/nitro + + # Using yarn + yarn add @sentry/nitro + + # Using pnpm + pnpm add @sentry/nitro + ``` + +### 2. Build-Time Nitro Config Setup + +1. Import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +#### In `nitro.config.ts` + +If you are using a dedicated `nitro.config.ts` file, you can import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +```javascript +import { defineNitroConfig } from 'nitro/config'; +import { withSentryConfig } from '@sentry/nitro'; + +const config = defineNitroConfig({ + // ... +}); + +export default withSentryConfig(config, { + // Sentry Build Options +}); +``` + +#### In `vite.config.ts` + +If you are using Nitro as a Vite plugin, you can import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +```ts +import { defineConfig } from 'vite'; +import { nitro } from 'nitro/vite'; +import { withSentryConfig } from '@sentry/nitro'; + +export default defineConfig({ + plugins: [nitro()], + nitro: withSentryConfig( + { + // Nitro options + }, + { + // Sentry Build Options + }, + ), +}); +``` + +### 3. Sentry Config Setup + +Create an `instrument.mjs` file in your project root to initialize the Sentry SDK: + +```javascript +import * as Sentry from '@sentry/nitro'; + +Sentry.init({ + dsn: '__YOUR_DSN__', + tracesSampleRate: 1.0, +}); +``` + +Then use `--import` in `NODE_OPTIONS` to load the instrumentation before your app code: + +```bash +NODE_OPTIONS='--import ./instrument.mjs' npx nitro dev +``` + +This works with any Nitro command (`nitro dev`, `nitro preview`, or a production start script). + +## Uploading Source Maps + +The `withSentryConfig` function automatically configures source map uploading when the `authToken`, `org`, and `project` +options are provided: + +```javascript +export default withSentryConfig(config, { + org: 'your-sentry-org', + project: 'your-sentry-project', + authToken: process.env.SENTRY_AUTH_TOKEN, +}); +``` + +## Troubleshoot + +If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nitro SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nitro/). If the documentation does not provide the necessary information, consider opening an issue on GitHub. diff --git a/packages/nitro/package.json b/packages/nitro/package.json new file mode 100644 index 000000000000..e85f71c7fe2b --- /dev/null +++ b/packages/nitro/package.json @@ -0,0 +1,84 @@ +{ + "name": "@sentry/nitro", + "version": "10.50.0", + "description": "Official Sentry SDK for Nitro", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "nitro", + "sentry", + "apm", + "tracing", + "error-tracking" + ], + "engines": { + "node": ">=18.19.1" + }, + "files": [ + "/build" + ], + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "nitro": ">=3.0.0-0 <4.0.0 || 3.0.260311-beta || 3.0.260415-beta" + }, + "dependencies": { + "@sentry/bundler-plugin-core": "^5.2.0", + "@sentry/core": "10.50.0", + "@sentry/node": "10.50.0", + "@sentry/opentelemetry": "10.50.0" + }, + "devDependencies": { + "nitro": "^3.0.260415-beta", + "h3": "^2.0.1-rc.13" + }, + "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:core": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-nitro-*.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/esm/*.js --module", + "test": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "dependsOn": [ + "^build:transpile", + "^build:types", + "build:types" + ], + "outputs": [ + "{projectRoot}/build/esm" + ] + } + } + } +} diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs new file mode 100644 index 000000000000..140655a7eca8 --- /dev/null +++ b/packages/nitro/rollup.npm.config.mjs @@ -0,0 +1,13 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default [ + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], + packageSpecificConfig: { + external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'], + }, + }), + { emitCjs: false }, + ), +]; diff --git a/packages/nitro/src/common/debug-build.ts b/packages/nitro/src/common/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nitro/src/common/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts new file mode 100644 index 000000000000..cdc0f2b00dfb --- /dev/null +++ b/packages/nitro/src/config.ts @@ -0,0 +1,33 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { NitroConfig } from 'nitro/types'; +import { createNitroModule } from './module'; +import { configureSourcemapSettings } from './sourceMaps'; + +export type SentryNitroOptions = BuildTimeOptionsBase; + +/** + * Modifies the passed in Nitro configuration with automatic build-time instrumentation. + */ +export function withSentryConfig(config: NitroConfig, sentryOptions?: SentryNitroOptions): NitroConfig { + return setupSentryNitroModule(config, sentryOptions); +} + +/** + * Sets up the Sentry Nitro module, useful for meta framework integrations. + */ +export function setupSentryNitroModule( + config: NitroConfig, + moduleOptions?: SentryNitroOptions, + _serverConfigFile?: string, +): NitroConfig { + if (!config.tracingChannel) { + config.tracingChannel = true; + } + + const { sentryEnabledSourcemaps } = configureSourcemapSettings(config, moduleOptions); + + config.modules = config.modules || []; + config.modules.push(createNitroModule(moduleOptions, sentryEnabledSourcemaps)); + + return config; +} diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts new file mode 100644 index 000000000000..51f10f7ba5b5 --- /dev/null +++ b/packages/nitro/src/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/export */ +export * from './config'; +export * from '@sentry/node'; +export { init } from './sdk'; diff --git a/packages/nitro/src/instruments/instrumentServer.ts b/packages/nitro/src/instruments/instrumentServer.ts new file mode 100644 index 000000000000..ec891055558b --- /dev/null +++ b/packages/nitro/src/instruments/instrumentServer.ts @@ -0,0 +1,12 @@ +import type { Nitro } from 'nitro/types'; +import { addPlugin } from '../utils/plugin'; +import { createResolver } from '../utils/resolver'; + +/** + * Sets up the Nitro server instrumentation plugin + * @param nitro - The Nitro instance. + */ +export function instrumentServer(nitro: Nitro): void { + const moduleResolver = createResolver(import.meta.url); + addPlugin(nitro, moduleResolver.resolve('../runtime/plugins/server')); +} diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts new file mode 100644 index 000000000000..1a4e5b0478d1 --- /dev/null +++ b/packages/nitro/src/module.ts @@ -0,0 +1,17 @@ +import type { NitroModule } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; +import { instrumentServer } from './instruments/instrumentServer'; +import { setupSourceMaps } from './sourceMaps'; + +/** + * Creates a Nitro module to setup the Sentry SDK. + */ +export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): NitroModule { + return { + name: 'sentry', + setup: nitro => { + instrumentServer(nitro); + setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + }, + }; +} diff --git a/packages/nitro/src/runtime/README.md b/packages/nitro/src/runtime/README.md new file mode 100644 index 000000000000..43c190e6d015 --- /dev/null +++ b/packages/nitro/src/runtime/README.md @@ -0,0 +1,5 @@ +# Nitro Runtime + +This directory contains the runtime code for Nitro, this includes plugins or any runtime code they may use. + +Do not mix runtime code with other code, this directory will be packaged with the SDK and shipped as-is. diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts new file mode 100644 index 000000000000..fab0c5eff05a --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts @@ -0,0 +1,76 @@ +import { + captureException, + flushIfServerless, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + parseUrl, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { HTTPError } from 'h3'; +import type { CapturedErrorContext } from 'nitro/types'; + +/** + * Extracts the relevant context information from the error context (HTTPEvent in Nitro Error) + * and creates a structured context object. + */ +function extractErrorContext(errorContext: CapturedErrorContext | undefined): Record { + const ctx: Record = {}; + + if (!errorContext) { + return ctx; + } + + if (errorContext.event) { + ctx.method = errorContext.event.req.method; + ctx.path = parseUrl(errorContext.event.req.url).path; + } + + if (Array.isArray(errorContext.tags)) { + ctx.tags = errorContext.tags; + } + + return ctx; +} + +/** + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + */ +export async function captureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise { + const sentryClient = getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + + // Do not report HTTPErrors with 3xx or 4xx status codes + if (HTTPError.isError(error) && error.status >= 300 && error.status < 500) { + return; + } + + const method = errorContext.event?.req.method ?? ''; + const path = errorContext.event?.req.url ? parseUrl(errorContext.event.req.url).path : null; + + if (path) { + getCurrentScope().setTransactionName(`${method} ${path}`); + const activeSpan = getActiveSpan(); + const activeRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + activeRootSpan?.updateName(`${method} ${path}`); + activeRootSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + + const structuredContext = extractErrorContext(errorContext); + + captureException(error, { + captureContext: { contexts: { nitro: structuredContext } }, + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }); + + await flushIfServerless(); +} diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts new file mode 100644 index 000000000000..bf70536b7800 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -0,0 +1,280 @@ +import { + captureException, + getActiveSpan, + getClient, + getHttpSpanDetailsFromUrlObject, + getRootSpan, + GLOBAL_OBJ, + httpHeadersToSpanAttributes, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setHttpStatus, + type Span, + SPAN_STATUS_ERROR, + startSpanManual, + updateSpanName, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; +import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; +import { setServerTimingHeaders } from './setServerTimingHeaders'; + +/** + * Global object with the trace channels + */ +const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean; +}; + +/** + * Captures tracing events emitted by Nitro tracing channels. + */ +export function captureTracingEvents(): void { + if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) { + return; + } + + setupH3TracingChannels(); + setupSrvxTracingChannels(); + globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; +} + +/** + * No-op function to satisfy the tracing channel subscribe callbacks + */ +const NOOP = (): void => {}; + +/** + * Extracts the HTTP status code from a tracing channel result. + * The result is the return value of the traced handler, which is a Response for srvx + * and may or may not be a Response for h3. + */ +function getResponseStatusCode(result: unknown): number | undefined { + if (result && typeof result === 'object' && 'status' in result && typeof result.status === 'number') { + return result.status; + } + return undefined; +} + +function onTraceEnd(data: TracingChannelContextWithSpan<{ result?: unknown }>): void { + const statusCode = getResponseStatusCode(data.result); + if (data._sentrySpan && statusCode !== undefined) { + setHttpStatus(data._sentrySpan, statusCode); + } + + data._sentrySpan?.end(); +} + +function onTraceError(data: TracingChannelContextWithSpan<{ error: unknown }>): void { + captureException(data.error, { mechanism: { type: 'auto.http.nitro.onTraceError', handled: false } }); + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data._sentrySpan?.end(); +} + +/** + * Extracts the parameterized route pattern from the h3 event context. + */ +function getParameterizedRoute(event: H3TracingRequestEvent['event']): string | undefined { + const matchedRoute = event.context?.matchedRoute; + if (!matchedRoute) { + return undefined; + } + + const routePath = matchedRoute.route; + + // Skip catch-all routes as they're not useful for transaction grouping + if (!routePath || routePath === '/**') { + return undefined; + } + + return routePath; +} + +function setupH3TracingChannels(): void { + const h3Channel = tracingChannel('h3.request', data => { + const parsedUrl = parseStringToURLObject(data.event.url.href); + const routePattern = getParameterizedRoute(data.event); + + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject( + parsedUrl, + 'server', + 'auto.http.nitro.h3', + { method: data.event.req.method }, + routePattern, + ); + + return startSpanManual( + { + name: spanName, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.h3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', + }, + }, + span => { + setParameterizedRouteAttributes(span, data.event); + + return span; + }, + ); + }); + + h3Channel.subscribe({ + start: (data: H3TracingRequestEvent) => { + setServerTimingHeaders(data.event); + }, + asyncStart: NOOP, + end: NOOP, + asyncEnd: (data: TracingChannelContextWithSpan) => { + onTraceEnd(data); + + if (!data._sentrySpan) { + return; + } + + // Update the root span (srvx transaction) with the parameterized route name. + // The srvx span is created before h3 resolves the route, so it initially has the raw URL. + // Note: data.type is always 'middleware' in asyncEnd regardless of handler type, + // so we rely on getParameterizedRoute() to filter out catch-all routes instead. + const rootSpan = getRootSpan(data._sentrySpan); + if (rootSpan && rootSpan !== data._sentrySpan) { + const routePattern = getParameterizedRoute(data.event); + if (routePattern) { + const method = data.event.req.method || 'GET'; + updateSpanName(rootSpan, `${method} ${routePattern}`); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': routePattern, + }); + } + } + }, + error: onTraceError, + }); +} + +function setupSrvxTracingChannels(): void { + // Store the parent span per-request so middleware and fetch share the same parent. + // WeakMap ensures per-request isolation in concurrent environments and automatic cleanup. + const requestParentSpans = new WeakMap(); + + const fetchChannel = tracingChannel('srvx.request', data => { + const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', { + method: data.request.method, + }); + + const sendDefaultPii = getClient()?.getOptions().sendDefaultPii ?? false; + const headerAttributes = httpHeadersToSpanAttributes( + Object.fromEntries(data.request.headers.entries()), + sendDefaultPii, + ); + + return startSpanManual( + { + name: spanName, + attributes: { + ...urlAttributes, + ...headerAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data.middleware ? 'middleware.nitro' : 'http.server', + 'server.port': data.server.options.port, + }, + // Use the same parent span as middleware to make them siblings + parentSpan: requestParentSpans.get(data.request) || undefined, + }, + span => span, + ); + }); + + // Subscribe to events (span already created in bindStore) + fetchChannel.subscribe({ + start: () => {}, + asyncStart: () => {}, + end: () => {}, + asyncEnd: data => { + onTraceEnd(data); + + // Clean up parent span reference after the fetch handler completes. + requestParentSpans.delete(data.request); + }, + error: data => { + onTraceError(data); + // Clean up parent span reference on error too + requestParentSpans.delete(data.request); + }, + }); + + const middlewareChannel = tracingChannel('srvx.middleware', data => { + // For the first middleware, capture the current parent span per-request + if (data.middleware?.index === 0) { + const activeSpan = getActiveSpan(); + if (activeSpan) { + requestParentSpans.set(data.request, activeSpan); + } + } + + const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; + const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', { + method: data.request.method, + }); + + // Create span as a child of the original parent, not the previous middleware + return startSpanManual( + { + name: `${data.middleware?.handler.name ?? 'unknown'} - ${data.request.method} ${data.request._url?.pathname}`, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nitro', + }, + parentSpan: requestParentSpans.get(data.request) || undefined, + }, + span => span, + ); + }); + + // Subscribe to events (span already created in bindStore) + middlewareChannel.subscribe({ + start: () => {}, + asyncStart: () => {}, + end: () => {}, + asyncEnd: onTraceEnd, + error: onTraceError, + }); +} + +/** + * Sets the parameterized route attributes on the span. + */ +function setParameterizedRouteAttributes(span: Span, event: H3TracingRequestEvent['event']): void { + const rootSpan = getRootSpan(span); + if (!rootSpan) { + return; + } + + const matchedRoutePath = getParameterizedRoute(event); + if (!matchedRoutePath) { + return; + } + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': matchedRoutePath, + }); + + const params = event.context?.params; + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + // Based on this convention: https://getsentry.github.io/sentry-conventions/generated/attributes/url.html#urlpathparameterkey + rootSpan.setAttributes({ + [`url.path.parameter.${key}`]: String(value), + [`params.${key}`]: String(value), + }); + }); + } +} diff --git a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts new file mode 100644 index 000000000000..4573f8171c19 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts @@ -0,0 +1,27 @@ +import { getTraceData } from '@sentry/core'; +import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; + +/** + * Sets Server-Timing response headers for trace propagation to the client. + * The browser SDK reads these via the Performance API to connect pageload traces. + */ +export function setServerTimingHeaders(event: H3TracingRequestEvent['event']): void { + if (event.context._sentryServerTimingSet) { + return; + } + + const headers = event.res?.headers; + if (!headers) { + return; + } + + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); + } + + event.context._sentryServerTimingSet = true; +} diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts new file mode 100644 index 000000000000..2feee84bcc55 --- /dev/null +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -0,0 +1,9 @@ +import { definePlugin } from 'nitro'; +import { captureErrorHook } from '../hooks/captureErrorHook'; +import { captureTracingEvents } from '../hooks/captureTracingEvents'; + +export default definePlugin(nitroApp => { + nitroApp.hooks.hook('error', captureErrorHook); + + captureTracingEvents(); +}); diff --git a/packages/nitro/src/sdk.ts b/packages/nitro/src/sdk.ts new file mode 100644 index 000000000000..d67c0594aa2b --- /dev/null +++ b/packages/nitro/src/sdk.ts @@ -0,0 +1,32 @@ +import type { Integration } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node'; + +/** + * Initializes the Nitro SDK + */ +export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + const opts: NodeOptions = { + ...options, + }; + + if (opts.defaultIntegrations === undefined) { + opts.defaultIntegrations = getDefaultIntegrations(opts); + } + + applySdkMetadata(opts, 'nitro', ['nitro', 'node']); + + const client = nodeInit(opts); + + return client; +} + +/** + * Get the default integrations for the Nitro SDK. + * + * @returns The default integrations for the Nitro SDK. + */ +export function getDefaultIntegrations(options: NodeOptions): Integration[] | undefined { + return [...getDefaultNodeIntegrations(options)]; +} diff --git a/packages/nitro/src/sourceMaps.ts b/packages/nitro/src/sourceMaps.ts new file mode 100644 index 000000000000..9aa470a88d90 --- /dev/null +++ b/packages/nitro/src/sourceMaps.ts @@ -0,0 +1,192 @@ +import type { Options as BundlerPluginOptions } from '@sentry/bundler-plugin-core'; +import { createSentryBuildPluginManager } from '@sentry/bundler-plugin-core'; +import type { Nitro, NitroConfig } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; + +/** + * Registers a `compiled` hook to upload source maps after the build completes. + */ +export function setupSourceMaps(nitro: Nitro, options?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): void { + // The `compiled` hook fires on EVERY rebuild during `nitro dev` watch mode. + // nitro.options.dev is reliably set by the time module setup runs. + if (shouldSkipSourcemapUpload(nitro, options)) { + return; + } + + nitro.hooks.hook('compiled', async (_nitro: Nitro) => { + await handleSourceMapUpload(_nitro, options, sentryEnabledSourcemaps); + }); +} + +/** + * Determines if sourcemap uploads should be skipped. + */ +function shouldSkipSourcemapUpload(nitro: Nitro, options?: SentryNitroOptions): boolean { + return !!( + nitro.options.dev || + nitro.options.preset === 'nitro-prerender' || + nitro.options.sourcemap === false || + (nitro.options.sourcemap as unknown) === 'inline' || + options?.sourcemaps?.disable === true + ); +} + +/** + * Handles the actual source map upload after the build completes. + */ +async function handleSourceMapUpload( + nitro: Nitro, + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, +): Promise { + const outputDir = nitro.options.output.serverDir; + const pluginOptions = getPluginOptions(options, sentryEnabledSourcemaps, outputDir); + + const sentryBuildPluginManager = createSentryBuildPluginManager(pluginOptions, { + buildTool: 'nitro', + loggerPrefix: '[@sentry/nitro]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + + await sentryBuildPluginManager.injectDebugIds([outputDir]); + + if (options?.sourcemaps?.disable !== 'disable-upload') { + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't prepare the artifacts because we injected debug IDs manually before + prepareArtifacts: false, + }); + await sentryBuildPluginManager.deleteArtifacts(); + } +} + +/** + * Normalizes the beginning of a path from e.g. ../../../ to ./ + */ +function normalizePath(path: string): string { + return path.replace(/^(\.\.\/)+/, './'); +} + +/** + * Removes a trailing slash from a path so glob patterns can be appended cleanly. + */ +function removeTrailingSlash(path: string): string { + return path.replace(/\/$/, ''); +} + +/** + * Builds the plugin options for `createSentryBuildPluginManager` from the Sentry Nitro options. + * + * Only exported for testing purposes. + */ +// oxlint-disable-next-line complexity +export function getPluginOptions( + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, + outputDir?: string, +): BundlerPluginOptions { + const defaultFilesToDelete = + sentryEnabledSourcemaps && outputDir ? [`${removeTrailingSlash(outputDir)}/**/*.map`] : undefined; + + if (options?.debug && defaultFilesToDelete && options?.sourcemaps?.filesToDeleteAfterUpload === undefined) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/nitro] Setting \`sourcemaps.filesToDeleteAfterUpload: ["${defaultFilesToDelete[0]}"]\` to delete generated source maps after they were uploaded to Sentry.`, + ); + } + + return { + org: options?.org ?? process.env.SENTRY_ORG, + project: options?.project ?? process.env.SENTRY_PROJECT, + authToken: options?.authToken ?? process.env.SENTRY_AUTH_TOKEN, + url: options?.sentryUrl ?? process.env.SENTRY_URL, + headers: options?.headers, + telemetry: options?.telemetry ?? true, + debug: options?.debug ?? false, + silent: options?.silent ?? false, + errorHandler: options?.errorHandler, + sourcemaps: { + disable: options?.sourcemaps?.disable, + assets: options?.sourcemaps?.assets, + ignore: options?.sourcemaps?.ignore, + filesToDeleteAfterUpload: options?.sourcemaps?.filesToDeleteAfterUpload ?? defaultFilesToDelete, + rewriteSources: options?.sourcemaps?.rewriteSources ?? ((source: string) => normalizePath(source)), + }, + release: options?.release, + bundleSizeOptimizations: options?.bundleSizeOptimizations, + _metaOptions: { + telemetry: { + metaFramework: 'nitro', + }, + }, + }; +} + +/* Source map configuration rules: + 1. User explicitly disabled source maps (sourcemap: false) + - Keep their setting, emit a warning that errors won't be unminified in Sentry + - We will not upload anything + 2. User enabled source map generation (true) + - Keep their setting (don't modify besides uploading) + 3. User did not set source maps (undefined) + - We enable source maps for Sentry + - Configure `filesToDeleteAfterUpload` to clean up .map files after upload +*/ +export function configureSourcemapSettings( + config: NitroConfig, + moduleOptions?: SentryNitroOptions, +): { sentryEnabledSourcemaps: boolean } { + const sourcemapUploadDisabled = moduleOptions?.sourcemaps?.disable === true; + if (sourcemapUploadDisabled) { + return { sentryEnabledSourcemaps: false }; + } + + // Nitro types `sourcemap` as `boolean`, but it forwards the value to Vite which also accepts `'hidden'` and `'inline'`. + const userSourcemap = (config as { sourcemap?: boolean | 'hidden' | 'inline' }).sourcemap; + + if (userSourcemap === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nitro] You have explicitly disabled source maps (`sourcemap: false`). Sentry will not upload source maps, and errors will not be unminified. To let Sentry handle source maps, remove the `sourcemap` option from your Nitro config, or use `sourcemaps: { disable: true }` in your Sentry options to silence this warning.', + ); + return { sentryEnabledSourcemaps: false }; + } + + if (userSourcemap === 'inline') { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nitro] You have set `sourcemap: "inline"`. Inline source maps are embedded in the output bundle, so there are no `.map` files to upload. Sentry will not upload source maps. Set `sourcemap: "hidden"` (or leave it unset) to let Sentry upload source maps and un-minify errors.', + ); + return { sentryEnabledSourcemaps: false }; + } + + let sentryEnabledSourcemaps = false; + if (userSourcemap === true || userSourcemap === 'hidden') { + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/nitro] Source maps are already enabled (\`sourcemap: ${JSON.stringify(userSourcemap)}\`). Sentry will upload them for error unminification.`, + ); + } + } else { + // User did not explicitly set sourcemap, enable hidden source maps for Sentry. + // `'hidden'` emits .map files without adding a `//# sourceMappingURL=` comment to the output, avoiding public exposure. + (config as { sourcemap?: unknown }).sourcemap = 'hidden'; + sentryEnabledSourcemaps = true; + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log( + '[@sentry/nitro] Enabled hidden source map generation for Sentry. Source map files will be deleted after upload.', + ); + } + } + + // Nitro v3 has a `sourcemapMinify` plugin that destructively deletes `sourcesContent`, + // `x_google_ignoreList`, and clears `mappings` for any chunk containing `node_modules`. + // This makes sourcemaps unusable for Sentry. + config.experimental = config.experimental || {}; + config.experimental.sourcemapMinify = false; + + return { sentryEnabledSourcemaps }; +} diff --git a/packages/nitro/src/utils/plugin.ts b/packages/nitro/src/utils/plugin.ts new file mode 100644 index 000000000000..443e3f430ba1 --- /dev/null +++ b/packages/nitro/src/utils/plugin.ts @@ -0,0 +1,9 @@ +import type { Nitro } from 'nitro/types'; + +/** + * Adds a Nitro plugin + */ +export function addPlugin(nitro: Nitro, plugin: string): void { + nitro.options.plugins = nitro.options.plugins || []; + nitro.options.plugins.push(plugin); +} diff --git a/packages/nitro/src/utils/resolver.ts b/packages/nitro/src/utils/resolver.ts new file mode 100644 index 000000000000..f0bde304d929 --- /dev/null +++ b/packages/nitro/src/utils/resolver.ts @@ -0,0 +1,25 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface Resolver { + resolve(...path: string[]): string; +} + +/** + * Creates a resolver for the given base path. + * @example + * ```ts + * const resolver = createResolver(import.meta.url); + * resolver.resolve('foo/bar.js'); + * ``` + */ +export function createResolver(base: string): Resolver { + let resolvedBase = base; + if (base.startsWith('file://')) { + resolvedBase = dirname(fileURLToPath(base)); + } + + return { + resolve: (...path) => resolve(resolvedBase, ...path), + }; +} diff --git a/packages/nitro/test/index.test.ts b/packages/nitro/test/index.test.ts new file mode 100644 index 000000000000..bc9db1cddfbb --- /dev/null +++ b/packages/nitro/test/index.test.ts @@ -0,0 +1,11 @@ +// Dummy test to satisfy the test runner +import { describe, expect, test } from 'vitest'; +import * as NitroServer from '../src'; + +describe('Nitro SDK', () => { + // This is a place holder test at best to satisfy the test runner + test('exports client and server SDKs', () => { + expect(NitroServer).toBeDefined(); + expect(NitroServer.init).toBeDefined(); + }); +}); diff --git a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts new file mode 100644 index 000000000000..804ef569a619 --- /dev/null +++ b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts @@ -0,0 +1,168 @@ +import * as SentryCore from '@sentry/core'; +import { HTTPError } from 'h3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { captureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; + +vi.mock('@sentry/core', async importOriginal => { + const mod = await importOriginal(); + return { + ...(mod as any), + captureException: vi.fn(), + flushIfServerless: vi.fn(), + getClient: vi.fn(), + getCurrentScope: vi.fn(() => ({ + setTransactionName: vi.fn(), + })), + }; +}); + +describe('captureErrorHook', () => { + const mockErrorContext = { + event: { + req: { method: 'GET', url: 'http://localhost/test-path' }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({}), + }); + (SentryCore.flushIfServerless as any).mockResolvedValue(undefined); + }); + + it('should capture regular errors', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }), + ); + }); + + it('should include structured context with method and path', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: { method: 'GET', path: '/test-path' }, + }, + }, + }), + ); + }); + + it('should set transaction name from method and path', async () => { + const mockSetTransactionName = vi.fn(); + (SentryCore.getCurrentScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); + + it('should skip HTTPError with 4xx status codes', async () => { + const error = new HTTPError({ status: 404, message: 'Not found' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should skip HTTPError with 3xx status codes', async () => { + const error = new HTTPError({ status: 302, message: 'Redirect' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture HTTPError with 5xx status codes', async () => { + const error = new HTTPError({ status: 500, message: 'Server error' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }), + ); + }); + + it('should skip when enableNitroErrorHandler is false', async () => { + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({ enableNitroErrorHandler: false }), + }); + + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should call flushIfServerless after capturing', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.flushIfServerless).toHaveBeenCalled(); + }); + + it('should handle missing event in error context', async () => { + const error = new Error('Test error'); + const contextWithoutEvent = { + event: undefined, + }; + + await captureErrorHook(error, contextWithoutEvent); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: {}, + }, + }, + }), + ); + }); + + it('should include tags in structured context when available', async () => { + const error = new Error('Test error'); + const contextWithTags = { + event: { + req: { method: 'POST', url: 'http://localhost/api/test' }, + } as any, + tags: ['tag1', 'tag2'], + }; + + await captureErrorHook(error, contextWithTags); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: { method: 'POST', path: '/api/test', tags: ['tag1', 'tag2'] }, + }, + }, + }), + ); + }); +}); diff --git a/packages/nitro/test/sourceMaps.test.ts b/packages/nitro/test/sourceMaps.test.ts new file mode 100644 index 000000000000..421994c4e2f1 --- /dev/null +++ b/packages/nitro/test/sourceMaps.test.ts @@ -0,0 +1,367 @@ +import type { NitroConfig } from 'nitro/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNitroOptions } from '../src/config'; +import { setupSentryNitroModule } from '../src/config'; +import { configureSourcemapSettings, getPluginOptions, setupSourceMaps } from '../src/sourceMaps'; + +vi.mock('../src/instruments/instrumentServer', () => ({ + instrumentServer: vi.fn(), +})); + +describe('getPluginOptions', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns default options when no options are provided', () => { + const options = getPluginOptions(undefined, true, '/project/.output/server'); + + expect(options).toEqual( + expect.objectContaining({ + telemetry: true, + debug: false, + silent: false, + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: ['/project/.output/server/**/*.map'], + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nitro', + }), + }), + }), + ); + expect(options.org).toBeUndefined(); + expect(options.project).toBeUndefined(); + expect(options.authToken).toBeUndefined(); + expect(options.url).toBeUndefined(); + }); + + it('does not default filesToDeleteAfterUpload when user enabled sourcemaps themselves', () => { + const options = getPluginOptions(undefined, false); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + + it('respects user-provided filesToDeleteAfterUpload even when Sentry enabled sourcemaps', () => { + const options = getPluginOptions( + { sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + true, + '/project/.output/server', + ); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('logs the default filesToDeleteAfterUpload glob in debug mode', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + getPluginOptions({ debug: true }, true, '/project/.output/server'); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/project/.output/server/**/*.map')); + logSpy.mockRestore(); + }); + + it('does not log the default glob when user provides filesToDeleteAfterUpload', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + getPluginOptions( + { debug: true, sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + true, + '/project/.output/server', + ); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('filesToDeleteAfterUpload')); + logSpy.mockRestore(); + }); + + it('uses environment variables as fallback', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_PROJECT = 'env-project'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://custom.sentry.io'; + + const options = getPluginOptions(); + + expect(options.org).toBe('env-org'); + expect(options.project).toBe('env-project'); + expect(options.authToken).toBe('env-token'); + expect(options.url).toBe('https://custom.sentry.io'); // sentryUrl maps to url + }); + + it('prefers direct options over environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://env.sentry.io'; + + const options = getPluginOptions({ + org: 'direct-org', + authToken: 'direct-token', + sentryUrl: 'https://direct.sentry.io', + }); + + expect(options.org).toBe('direct-org'); + expect(options.authToken).toBe('direct-token'); + expect(options.url).toBe('https://direct.sentry.io'); + }); + + it('passes through all user options', () => { + const sentryOptions: SentryNitroOptions = { + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://my-sentry.io', + headers: { 'X-Custom': 'header' }, + debug: true, + silent: true, + telemetry: false, + errorHandler: () => {}, + release: { name: 'v1.0.0' }, + sourcemaps: { + assets: ['dist/**'], + ignore: ['dist/test/**'], + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + }; + + const options = getPluginOptions(sentryOptions); + + expect(options.org).toBe('my-org'); + expect(options.project).toBe('my-project'); + expect(options.authToken).toBe('my-token'); + expect(options.url).toBe('https://my-sentry.io'); + expect(options.headers).toEqual({ 'X-Custom': 'header' }); + expect(options.debug).toBe(true); + expect(options.silent).toBe(true); + expect(options.telemetry).toBe(false); + expect(options.errorHandler).toBeDefined(); + expect(options.release).toEqual({ name: 'v1.0.0' }); + expect(options.sourcemaps?.assets).toEqual(['dist/**']); + expect(options.sourcemaps?.ignore).toEqual(['dist/test/**']); + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('normalizes source paths via rewriteSources', () => { + const options = getPluginOptions(); + const rewriteSources = options.sourcemaps?.rewriteSources; + + expect(rewriteSources?.('../../../src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('../../lib/utils.ts', undefined)).toBe('./lib/utils.ts'); + expect(rewriteSources?.('./src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('src/index.ts', undefined)).toBe('src/index.ts'); + }); + + it('uses user-provided rewriteSources when given', () => { + const customRewrite = (source: string) => `/custom/${source}`; + const options = getPluginOptions({ sourcemaps: { rewriteSources: customRewrite } }); + + expect(options.sourcemaps?.rewriteSources?.('../../../src/index.ts', undefined)).toBe( + '/custom/../../../src/index.ts', + ); + }); + + it('always sets metaFramework to nitro', () => { + const options = getPluginOptions(); + + expect(options._metaOptions?.telemetry?.metaFramework).toBe('nitro'); + }); + + it('passes through sourcemaps.disable', () => { + const options = getPluginOptions({ sourcemaps: { disable: 'disable-upload' } }); + + expect(options.sourcemaps?.disable).toBe('disable-upload'); + }); +}); + +describe('configureSourcemapSettings', () => { + it('enables hidden sourcemap generation on the config', () => { + const config: NitroConfig = {}; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe('hidden'); + expect(result.sentryEnabledSourcemaps).toBe(true); + }); + + it('respects user explicitly disabling sourcemaps and warns', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(false); + expect(result.sentryEnabledSourcemaps).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('explicitly disabled source maps')); + warnSpy.mockRestore(); + }); + + it('does not modify experimental config when user disabled sourcemaps', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config); + + expect(config.experimental).toBeUndefined(); + vi.restoreAllMocks(); + }); + + it('keeps sourcemap true when user already set it', () => { + const config: NitroConfig = { sourcemap: true }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + expect(result.sentryEnabledSourcemaps).toBe(false); + }); + + it('keeps sourcemap "hidden" when user already set it and does not enable deletion', () => { + const config = { sourcemap: 'hidden' } as unknown as NitroConfig; + const result = configureSourcemapSettings(config); + + expect((config as { sourcemap?: unknown }).sourcemap).toBe('hidden'); + expect(result.sentryEnabledSourcemaps).toBe(false); + }); + + it('keeps sourcemap "inline", warns, and does not enable uploads', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { sourcemap: 'inline' } as unknown as NitroConfig; + const result = configureSourcemapSettings(config); + + expect((config as { sourcemap?: unknown }).sourcemap).toBe('inline'); + expect(result.sentryEnabledSourcemaps).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`sourcemap: "inline"`')); + warnSpy.mockRestore(); + }); + + it('disables experimental sourcemapMinify', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('preserves existing experimental config', () => { + const config: NitroConfig = { + experimental: { + sourcemapMinify: undefined, + }, + }; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('skips sourcemap config when sourcemaps.disable is true', () => { + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config, { sourcemaps: { disable: true } }); + + expect(config.sourcemap).toBe(false); + }); + + it('still configures sourcemaps when sourcemaps.disable is disable-upload', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config, { sourcemaps: { disable: 'disable-upload' } }); + + expect(config.sourcemap).toBe('hidden'); + }); +}); + +describe('setupSentryNitroModule', () => { + it('enables tracing', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.tracingChannel).toBe(true); + }); + + it('adds the sentry module', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); + + it('still adds module when sourcemaps are disabled', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config, { sourcemaps: { disable: true } }); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); +}); + +describe('setupSourceMaps', () => { + it('does not register hook in dev mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: true, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook when sourcemaps.disable is true', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { sourcemaps: { disable: true } }); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook when nitro sourcemap is disabled', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, sourcemap: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook in nitro-prerender preset', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, preset: 'nitro-prerender', output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('registers compiled hook in production mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); + + it('registers compiled hook with custom options', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { org: 'my-org', project: 'my-project' }); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); +}); diff --git a/packages/nitro/test/tsconfig.json b/packages/nitro/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/packages/nitro/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/packages/nitro/tsconfig.json b/packages/nitro/tsconfig.json new file mode 100644 index 000000000000..202590772b10 --- /dev/null +++ b/packages/nitro/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + // package-specific options + "module": "esnext", + "moduleResolution": "bundler" + } +} diff --git a/packages/nitro/tsconfig.test.json b/packages/nitro/tsconfig.test.json new file mode 100644 index 000000000000..c41efeacd92f --- /dev/null +++ b/packages/nitro/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + } +} diff --git a/packages/nitro/tsconfig.types.json b/packages/nitro/tsconfig.types.json new file mode 100644 index 000000000000..b1a51db073c2 --- /dev/null +++ b/packages/nitro/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/nitro/vite.config.ts b/packages/nitro/vite.config.ts new file mode 100644 index 000000000000..4c0db8cdc068 --- /dev/null +++ b/packages/nitro/vite.config.ts @@ -0,0 +1,11 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + }, + }, +}; diff --git a/packages/node-core/test/integrations/console.test.ts b/packages/node-core/test/integrations/console.test.ts index 0355fe2d076b..39086e9768e3 100644 --- a/packages/node-core/test/integrations/console.test.ts +++ b/packages/node-core/test/integrations/console.test.ts @@ -25,7 +25,7 @@ describe('consoleIntegration in Lambda (patchWithDefineProperty)', () => { it('calls registered handler when console.log is called', () => { const handler = vi.fn(); // Setup the integration so it calls maybeInstrument with the Lambda strategy - consoleIntegration().setup?.({ on: vi.fn() } as any); + consoleIntegration().setup?.({ on: vi.fn(), registerCleanup: vi.fn() } as any); addConsoleInstrumentationHandler(handler); diff --git a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts index d43765206b61..f1e87e1c8c4c 100644 --- a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts @@ -5,8 +5,8 @@ import { InstrumentationNodeModuleDefinition, InstrumentationNodeModuleFile, } from '@opentelemetry/instrumentation'; -import type { LangGraphOptions } from '@sentry/core'; -import { instrumentLangGraph, SDK_VERSION } from '@sentry/core'; +import type { CompiledGraph, LangGraphOptions } from '@sentry/core'; +import { getClient, instrumentCreateReactAgent, instrumentLangGraph, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.0.0 <2.0.0']; @@ -18,6 +18,7 @@ type LangGraphInstrumentationOptions = InstrumentationConfig & LangGraphOptions; interface PatchedModuleExports { [key: string]: unknown; StateGraph?: abstract new (...args: unknown[]) => unknown; + createReactAgent?: (...args: unknown[]) => CompiledGraph; } /** @@ -31,40 +32,85 @@ export class SentryLangGraphInstrumentation extends InstrumentationBase exports, - [ - new InstrumentationNodeModuleFile( - /** - * In CJS, LangGraph packages re-export from dist/index.cjs files. - * Patching only the root module sometimes misses the real implementation or - * gets overwritten when that file is loaded. We add a file-level patch so that - * _patch runs again on the concrete implementation - */ - '@langchain/langgraph/dist/index.cjs', - supportedVersions, - this._patch.bind(this), - exports => exports, - ), - ], - ); - return module; + public init(): InstrumentationModuleDefinition[] { + return [ + new InstrumentationNodeModuleDefinition( + '@langchain/langgraph', + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + /** + * In CJS, LangGraph packages re-export from dist/index.cjs files. + * Patching only the root module sometimes misses the real implementation or + * gets overwritten when that file is loaded. We add a file-level patch so that + * _patch runs again on the concrete implementation + */ + '@langchain/langgraph/dist/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + new InstrumentationNodeModuleFile( + /** + * In CJS, the prebuilt submodule re-exports from dist/prebuilt/index.cjs. + * We add a file-level patch under the main module so that CJS require() + * of @langchain/langgraph/prebuilt gets patched. + */ + '@langchain/langgraph/dist/prebuilt/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + new InstrumentationNodeModuleDefinition( + '@langchain/langgraph/prebuilt', + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + /** + * In CJS, the prebuilt submodule re-exports from dist/prebuilt/index.cjs. + * We add file-level patches so _patch runs on the concrete implementation. + */ + '@langchain/langgraph/dist/prebuilt/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + ]; } /** * Core patch logic applying instrumentation to the LangGraph module. */ private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const client = getClient(); + const options = { + ...this.getConfig(), + recordInputs: this.getConfig().recordInputs ?? client?.getOptions().sendDefaultPii, + recordOutputs: this.getConfig().recordOutputs ?? client?.getOptions().sendDefaultPii, + }; + // Patch StateGraph.compile to instrument both compile() and invoke() if (exports.StateGraph && typeof exports.StateGraph === 'function') { - instrumentLangGraph( - exports.StateGraph.prototype as { compile: (...args: unknown[]) => unknown }, - this.getConfig(), - ); + instrumentLangGraph(exports.StateGraph.prototype as { compile: (...args: unknown[]) => unknown }, options); + } + + // Patch createReactAgent to instrument agent creation and invocation + if (exports.createReactAgent && typeof exports.createReactAgent === 'function') { + const originalCreateReactAgent = exports.createReactAgent; + Object.defineProperty(exports, 'createReactAgent', { + value: instrumentCreateReactAgent(originalCreateReactAgent as (...args: unknown[]) => CompiledGraph, options), + writable: true, + enumerable: true, + configurable: true, + }); } return exports; diff --git a/packages/node/src/integrations/tracing/prisma.ts b/packages/node/src/integrations/tracing/prisma.ts index b81adc9552a8..71ec0fd3b49e 100644 --- a/packages/node/src/integrations/tracing/prisma.ts +++ b/packages/node/src/integrations/tracing/prisma.ts @@ -224,8 +224,12 @@ export const prismaIntegration = defineIntegration((options?: PrismaOptions) => span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.prisma'); } - // Make sure we use the query text as the span name, for ex. SELECT * FROM "User" WHERE "id" = $1 - if (spanJSON.description === 'prisma:engine:db_query' && spanJSON.data['db.query.text']) { + // Make sure we use the query text as the span name, for ex. SELECT * FROM "User" WHERE "id" = $1. + // v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits `prisma:client:db_query`. + if ( + (spanJSON.description === 'prisma:engine:db_query' || spanJSON.description === 'prisma:client:db_query') && + spanJSON.data['db.query.text'] + ) { span.updateName(spanJSON.data['db.query.text'] as string); } diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 5195c8b0638a..efa8a0cb6aff 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -19,10 +19,26 @@ "./package.json": "./package.json", ".": { "import": { + "node": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "browser": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.browser.js" + }, "types": "./build/types/index.d.ts", "default": "./build/esm/index.js" }, "require": { + "node": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + }, + "browser": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.browser.js" + }, "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } diff --git a/packages/opentelemetry/rollup.npm.config.mjs b/packages/opentelemetry/rollup.npm.config.mjs index e6f5ecdd4871..d0d33de1790f 100644 --- a/packages/opentelemetry/rollup.npm.config.mjs +++ b/packages/opentelemetry/rollup.npm.config.mjs @@ -4,7 +4,7 @@ export default makeNPMConfigVariants( makeBaseNPMConfig({ // `tracingChannel` is a Node.js-only subpath so `node:diagnostics_channel` // isn't pulled into the main bundle (breaks edge/browser builds). - entrypoints: ['src/index.ts', 'src/tracingChannel.ts'], + entrypoints: ['src/index.ts', 'src/tracingChannel.ts', 'src/index.browser.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts new file mode 100644 index 000000000000..bdda20fd94ce --- /dev/null +++ b/packages/opentelemetry/src/exports.ts @@ -0,0 +1,56 @@ +export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes'; + +export { getRequestSpanData } from './utils/getRequestSpanData'; + +export type { OpenTelemetryClient } from './types'; +export { wrapClientClass } from './custom/client'; + +export { getSpanKind } from './utils/getSpanKind'; + +export { getScopesFromContext } from './utils/contextData'; + +export { + spanHasAttributes, + spanHasEvents, + spanHasKind, + spanHasName, + spanHasParentId, + spanHasStatus, +} from './utils/spanTypes'; + +// Re-export this for backwards compatibility (this used to be a different implementation) +export { getDynamicSamplingContextFromSpan } from '@sentry/core'; + +export { isSentryRequestSpan } from './utils/isSentryRequest'; + +export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; + +export { getActiveSpan } from './utils/getActiveSpan'; +export { + startSpan, + startSpanManual, + startInactiveSpan, + withActiveSpan, + continueTrace, + getTraceContextForScope, +} from './trace'; + +export { suppressTracing } from './utils/suppressTracing'; + +export { setupEventContextTrace } from './setupEventContextTrace'; + +export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; +export { wrapContextManagerClass } from './contextManager'; + +export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; +export { SentrySpanProcessor } from './spanProcessor'; +export { SentrySampler, wrapSamplingDecision } from './sampler'; + +export { openTelemetrySetupCheck } from './utils/setupCheck'; + +export { getSentryResource } from './resource'; + +export { withStreamedSpan } from '@sentry/core'; + +// Legacy +export { getClient } from '@sentry/core'; diff --git a/packages/opentelemetry/src/index.browser.ts b/packages/opentelemetry/src/index.browser.ts new file mode 100644 index 000000000000..4667379fc749 --- /dev/null +++ b/packages/opentelemetry/src/index.browser.ts @@ -0,0 +1,18 @@ +import { consoleSandbox } from '@sentry/core'; + +export * from './exports'; + +// Stubs for node-specific exports +export class SentryAsyncLocalStorageContextManager { + public constructor() { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.error('SentryAsyncLocalStorageContextManager is not supported in the browser'); + }); + } +} + +export type AsyncLocalStorageLookup = { + asyncLocalStorage: unknown; + contextSymbol: symbol; +}; diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index a49597f67fdf..66766f554327 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,57 +1,5 @@ -export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes'; +export * from './exports'; -export { getRequestSpanData } from './utils/getRequestSpanData'; - -export type { OpenTelemetryClient } from './types'; -export { wrapClientClass } from './custom/client'; - -export { getSpanKind } from './utils/getSpanKind'; - -export { getScopesFromContext } from './utils/contextData'; - -export { - spanHasAttributes, - spanHasEvents, - spanHasKind, - spanHasName, - spanHasParentId, - spanHasStatus, -} from './utils/spanTypes'; - -// Re-export this for backwards compatibility (this used to be a different implementation) -export { getDynamicSamplingContextFromSpan } from '@sentry/core'; - -export { isSentryRequestSpan } from './utils/isSentryRequest'; - -export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; - -export { getActiveSpan } from './utils/getActiveSpan'; -export { - startSpan, - startSpanManual, - startInactiveSpan, - withActiveSpan, - continueTrace, - getTraceContextForScope, -} from './trace'; - -export { suppressTracing } from './utils/suppressTracing'; - -export { setupEventContextTrace } from './setupEventContextTrace'; - -export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; -export { wrapContextManagerClass } from './contextManager'; +// Node-specific exports export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; export type { AsyncLocalStorageLookup } from './contextManager'; -export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; -export { SentrySpanProcessor } from './spanProcessor'; -export { SentrySampler, wrapSamplingDecision } from './sampler'; - -export { openTelemetrySetupCheck } from './utils/setupCheck'; - -export { getSentryResource } from './resource'; - -export { withStreamedSpan } from '@sentry/core'; - -// Legacy -export { getClient } from '@sentry/core'; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 1e65e9d15d14..235ff3247f5d 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -96,7 +96,11 @@ export class SentrySampler implements Sampler { const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); if ( shouldIgnoreSpan( - { description: inferredChildName, op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp }, + { + description: inferredChildName, + op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp, + attributes: spanAttributes, + }, ignoreSpans, ) ) { @@ -144,7 +148,11 @@ export class SentrySampler implements Sampler { this._isSpanStreaming && ignoreSpans?.length && shouldIgnoreSpan( - { description: inferredSpanName, op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op }, + { + description: inferredSpanName, + op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op, + attributes: mergedAttributes, + }, ignoreSpans, ) ) { diff --git a/packages/opentelemetry/src/tracingChannel.ts b/packages/opentelemetry/src/tracingChannel.ts index 984986b7cdcb..5548201c5f4c 100644 --- a/packages/opentelemetry/src/tracingChannel.ts +++ b/packages/opentelemetry/src/tracingChannel.ts @@ -18,7 +18,7 @@ import { DEBUG_BUILD } from './debug-build'; */ export type OtelTracingChannelTransform = (data: TData) => Span; -type WithSpan = TData & { _sentrySpan?: Span }; +export type TracingChannelContextWithSpan = TContext & { _sentrySpan?: Span }; /** * A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber @@ -26,7 +26,7 @@ type WithSpan = TData & { _sentrySpan?: Span }; */ export interface OtelTracingChannel< TData extends object = object, - TDataWithSpan extends object = WithSpan, + TDataWithSpan extends object = TracingChannelContextWithSpan, > extends Omit, 'subscribe' | 'unsubscribe'> { subscribe(subscribers: Partial>): void; unsubscribe(subscribers: Partial>): void; @@ -52,10 +52,10 @@ interface ContextApi { export function tracingChannel( channelNameOrInstance: string, transformStart: OtelTracingChannelTransform, -): OtelTracingChannel> { - const channel = nativeTracingChannel, WithSpan>( +): OtelTracingChannel> { + const channel = nativeTracingChannel, TracingChannelContextWithSpan>( channelNameOrInstance, - ) as unknown as OtelTracingChannel>; + ) as unknown as OtelTracingChannel>; let lookup: AsyncLocalStorageLookup | undefined; try { @@ -78,7 +78,7 @@ export function tracingChannel( // Bind the start channel so that each trace invocation runs the transform // and stores the resulting context (with span) in AsyncLocalStorage. // @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type - channel.start.bindStore(otelStorage, (data: WithSpan) => { + channel.start.bindStore(otelStorage, (data: TracingChannelContextWithSpan) => { const span = transformStart(data); // Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it. diff --git a/packages/profiling-node/README.md b/packages/profiling-node/README.md index 51e447640c14..b5957f023b10 100644 --- a/packages/profiling-node/README.md +++ b/packages/profiling-node/README.md @@ -238,8 +238,10 @@ Once you run `node esbuild.serverless.js` esbuild wil bundle and output the file the binaries will be copied. This is wasteful as you will likely only need one of these libraries to be available during runtime. -To prune the other libraries, profiling-node ships with a small utility script that helps you prune unused binaries. The -script can be invoked via `sentry-prune-profiler-binaries`: +> **Deprecation notice:** This script will be removed in the next major version. If you depend on it, please comment on +> [this issue](https://github.com/getsentry/sentry-javascript/issues/20567). + +To prune the other libraries, profiling-node ships with a small utility script that helps you prune unused binaries: ```bash npx --package=@sentry/profiling-node sentry-prune-profiler-binaries diff --git a/packages/profiling-node/scripts/prune-profiler-binaries.js b/packages/profiling-node/scripts/prune-profiler-binaries.js index 4314c2cb7fb2..11e8dc7f05f4 100755 --- a/packages/profiling-node/scripts/prune-profiler-binaries.js +++ b/packages/profiling-node/scripts/prune-profiler-binaries.js @@ -56,6 +56,10 @@ Arguments:\n process.exit(0); } +console.warn( + '[Sentry] Warning: This script will be removed in the next major version. See: https://github.com/getsentry/sentry-javascript/issues/20567', +); + const ARGV_ERRORS = []; const NODE_TO_ABI = { diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts index e4471167f7ce..b17627f4bb85 100644 --- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts @@ -1,37 +1,27 @@ -import { type Client, debug, defineIntegration, type Event, type EventHint } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; +const LOW_QUALITY_TRANSACTIONS_FILTERS = [ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + // The span description for the `__manifest` endpoint is `GET *` (`http.route` resolves to `*`). + // Filter by `http.target` instead, which carries the raw request path. + { attributes: { 'http.target': /\/__manifest/ } }, +]; + +// TODO(v11): Remove the `_options` parameter (unused and only kept for back-compat with the previous signature) +const _lowQualityTransactionsFilterIntegration = ((_options?: NodeOptions) => ({ + name: 'LowQualityTransactionsFilter', + beforeSetup(client) { + const opts = client.getOptions(); + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...LOW_QUALITY_TRANSACTIONS_FILTERS]; + }, +})) satisfies IntegrationFn; + /** - * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/ - * + * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/, __manifest. + * Adds entries to `ignoreSpans` so the filter applies in both static and streaming trace lifecycles. */ - -function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { - name: string; - processEvent: (event: Event, hint: EventHint, client: Client) => Event | null; -} { - const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/]; - - return { - name: 'LowQualityTransactionsFilter', - - processEvent(event: Event, _hint: EventHint, _client: Client): Event | null { - if (event.type !== 'transaction' || !event.transaction) { - return event; - } - - const transaction = event.transaction; - - if (matchedRegexes.some(regex => transaction.match(regex))) { - options.debug && debug.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); - return null; - } - - return event; - }, - }; -} - -export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) => - _lowQualityTransactionsFilterIntegration(options), -); +export const lowQualityTransactionsFilterIntegration = defineIntegration(_lowQualityTransactionsFilterIntegration); diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts index e067ba06c830..6682c5b3516d 100644 --- a/packages/react-router/src/server/integration/reactRouterServer.ts +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -1,5 +1,5 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { generateInstrumentOnce, NODE_VERSION } from '@sentry/node'; import { ReactRouterInstrumentation } from '../instrumentation/reactRouter'; import { registerServerBuildGlobal } from '../serverBuild'; @@ -60,5 +60,23 @@ export const reactRouterServerIntegration = defineIntegration(() => { return event; }, + processSegmentSpan(span) { + // Express generates bogus `*` routes for data loaders, which we want to remove here + // we cannot do this earlier because some OTEL instrumentation adds this at some unexpected point + const attributes = span.attributes; + if (attributes?.[ATTR_HTTP_ROUTE] !== '*') { + return; + } + + const origin = attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; + const isInstrumentationApiOrigin = typeof origin === 'string' && origin.includes('instrumentation_api'); + + // For instrumentation_api, always clean up bogus `*` route since we set better names + // For legacy, only clean up if the name has been adjusted (not METHOD *) + if (isInstrumentationApiOrigin || !span.name?.endsWith(' *')) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[ATTR_HTTP_ROUTE]; + } + }, }; }); diff --git a/packages/react-router/test/server/integration/reactRouterServer.test.ts b/packages/react-router/test/server/integration/reactRouterServer.test.ts index 096095984eec..b97d6403bd18 100644 --- a/packages/react-router/test/server/integration/reactRouterServer.test.ts +++ b/packages/react-router/test/server/integration/reactRouterServer.test.ts @@ -1,3 +1,5 @@ +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type { Client, Event, EventType, StreamedSpanJSON } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ReactRouterInstrumentation } from '../../../src/server/instrumentation/reactRouter'; import { reactRouterServerIntegration } from '../../../src/server/integration/reactRouterServer'; @@ -98,4 +100,133 @@ describe('reactRouterServerIntegration', () => { expect(ReactRouterInstrumentation).toHaveBeenCalledTimes(1); expect(registerServerBuildGlobalSpy).toHaveBeenCalledTimes(1); }); + + describe('processEvent', () => { + const client = {} as Client; + const hint = {}; + + it('preserves http.route when it is not "*"', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET /users/:id', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '/users/:id' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBe('/users/:id'); + }); + + it('deletes bogus "*" route when origin is instrumentation_api', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET *', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.instrumentation_api', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('deletes bogus "*" route when legacy origin and transaction name was renamed', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET /api/users', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('keeps "*" when legacy origin and transaction name still ends with " *"', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET *', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBe('*'); + }); + }); + + describe('processSegmentSpan', () => { + const client = {} as Client; + + it('preserves http.route when it is not "*"', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET /users/:id', + attributes: { [ATTR_HTTP_ROUTE]: '/users/:id', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBe('/users/:id'); + }); + + it('deletes bogus "*" route when origin is instrumentation_api', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET *', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.instrumentation_api' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('deletes bogus "*" route when legacy origin and span name was renamed', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET /api/users', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('keeps "*" when legacy origin and span name still ends with " *"', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET *', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBe('*'); + }); + }); }); diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts index 7edd75c9e996..b64b850c9b94 100644 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -1,67 +1,60 @@ -import type { Event, EventType } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import * as SentryNode from '@sentry/node'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Client, ClientOptions } from '@sentry/core'; +import { shouldIgnoreSpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration'; -const debugLoggerLogSpy = vi.spyOn(SentryCore.debug, 'log').mockImplementation(() => {}); - -describe('Low Quality Transactions Filter Integration', () => { - afterEach(() => { - vi.clearAllMocks(); - SentryNode.getGlobalScope().clear(); +function makeMockClient(initial: Partial = {}): Client { + const options = { ...initial } as ClientOptions; + return { getOptions: () => options } as Client; +} + +function setupIntegrationAndGetIgnoreSpans(initial: Partial = {}) { + const integration = lowQualityTransactionsFilterIntegration({}); + const client = makeMockClient(initial); + integration.beforeSetup!(client); + return client.getOptions().ignoreSpans!; +} + +describe('lowQualityTransactionsFilterIntegration', () => { + it('appends the low-quality filters to ignoreSpans', () => { + expect(setupIntegrationAndGetIgnoreSpans()).toEqual([ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); }); - describe('integration functionality', () => { - describe('filters out low quality transactions', () => { - it.each([ - ['node_modules requests', 'GET /node_modules/some-package/index.js'], - ['favicon.ico requests', 'GET /favicon.ico'], - ['@id/ requests', 'GET /@id/some-id'], - ['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({ debug: true }); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toBeNull(); - - expect(debugLoggerLogSpy).toHaveBeenCalledWith('[ReactRouter] Filtered node_modules transaction:', transaction); - }); - }); - - describe('allows high quality transactions', () => { - it.each([ - ['normal page requests', 'GET /api/users'], - ['API endpoints', 'POST /data'], - ['app routes', 'GET /projects/123'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + it('preserves user-provided ignoreSpans entries', () => { + expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([ + /keep-me/, + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); + }); - expect(result).toEqual(event); - }); + describe('drops low-quality transactions', () => { + it.each([ + ['node_modules requests', { description: 'GET /node_modules/some-package/index.js' }], + ['favicon.ico requests', { description: 'GET /favicon.ico' }], + ['@id/ requests', { description: 'GET /@id/some-id' }], + ['manifest requests', { description: 'GET *', attributes: { 'http.target': '/__manifest?paths=foo' } }], + ])('%s', (_label, span) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ op: 'http.server', ...span }, ignoreSpans)).toBe(true); }); + }); - it('does not affect non-transaction events', () => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'error' as EventType, - transaction: 'GET /node_modules/some-package/index.js', - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toEqual(event); + describe('keeps high-quality transactions', () => { + it.each([ + ['normal page requests', 'GET /api/users'], + ['API endpoints', 'POST /data'], + ['app routes', 'GET /projects/123'], + ])('%s', (_label, name) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op: 'http.server' }, ignoreSpans)).toBe(false); }); }); }); diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index e9c5eac8424e..b04510a68cc9 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Scope } from '@sentry/core'; -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; interface Action { type: T; @@ -138,9 +144,8 @@ function createReduxEnhancer(enhancerOptions?: Partial): // Set the normalization depth of the redux state to the configured `normalizeDepth` option or a sane number as a fallback const newStateContext = { state: { type: 'redux', value: transformedState } }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newStateContext, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState` normalizationDepth, // rest for the actual state ); diff --git a/packages/remix/README.md b/packages/remix/README.md index 2589ed9f7e6b..ddb0c643a869 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -122,13 +122,13 @@ Sentry.captureEvent({ The Remix SDK provides a script that automatically creates a release and uploads sourcemaps. To generate sourcemaps with Remix, you need to call `remix build` with the `--sourcemap` option. -On release, call `sentry-upload-sourcemaps` to upload source maps and create a release: +On release, call the upload sourcemaps command to upload source maps and create a release: ```bash -npx --package=@sentry/remix sentry-upload-sourcemaps +npx @sentry/remix --upload-sourcemaps ``` -To see more details on how to use the command, call `npx --package=@sentry/remix sentry-upload-sourcemaps --help`. +To see more details on how to use the command, run `npx @sentry/remix --upload-sourcemaps --help`. For more advanced configuration, [directly use `sentry-cli` to upload source maps.](https://github.com/getsentry/sentry-cli). diff --git a/packages/remix/scripts/sentry-upload-sourcemaps.js b/packages/remix/scripts/sentry-upload-sourcemaps.js index 625526af5a8a..ceb41d3ac14c 100755 --- a/packages/remix/scripts/sentry-upload-sourcemaps.js +++ b/packages/remix/scripts/sentry-upload-sourcemaps.js @@ -8,6 +8,10 @@ const DEFAULT_URL_PREFIX = '~/build/'; const DEFAULT_BUILD_PATH = 'public/build'; const argv = yargs(process.argv.slice(2)) + .option('upload-sourcemaps', { + type: 'boolean', + describe: 'Specifies the upload sourcemaps command. Recommended for forward compatibility.', + }) .option('release', { type: 'string', describe: @@ -48,7 +52,7 @@ const argv = yargs(process.argv.slice(2)) default: true, }) .usage( - 'Usage: $0\n' + + 'Usage: npx @sentry/remix --upload-sourcemaps\n' + ' [--release RELEASE]\n' + ' [--org ORG]\n' + ' [--project PROJECT]\n' + @@ -64,6 +68,12 @@ const argv = yargs(process.argv.slice(2)) ) .wrap(120).argv; +if (!argv.uploadSourcemaps) { + process.stderr.write( + '[Sentry] Warning: Calling this script without --upload-sourcemaps is deprecated. Use: `npx @sentry/remix --upload-sourcemaps`\n', + ); +} + const buildPath = argv.buildPath || DEFAULT_BUILD_PATH; const urlPrefix = argv.urlPrefix || DEFAULT_URL_PREFIX; diff --git a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts index 790185712b1c..dba3c858b711 100644 --- a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts +++ b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts @@ -2,6 +2,12 @@ import { DEBUG_BUILD } from '../debug-build'; import type { WorkerRequest, WorkerResponse } from '../types'; import { debug } from '../util/logger'; +interface PendingRequest { + method: WorkerRequest['method']; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +} + /** * Event buffer that uses a web worker to compress events. * Exported only for testing. @@ -10,10 +16,16 @@ export class WorkerHandler { private _worker: Worker; private _id: number; private _ensureReadyPromise?: Promise; + private _pending: Map; public constructor(worker: Worker) { this._worker = worker; this._id = 0; + this._pending = new Map(); + // A single long-lived listener routes responses by id. Per-request + // listeners would make worker dispatch O(n) per response, so a burst of N + // in-flight requests becomes O(n^2) main-thread work. + this._worker.addEventListener('message', this._onMessage); } /** @@ -62,6 +74,9 @@ export class WorkerHandler { */ public destroy(): void { DEBUG_BUILD && debug.log('Destroying compression worker'); + this._worker.removeEventListener('message', this._onMessage); + this._pending.forEach(pending => pending.reject(new Error('Worker destroyed'))); + this._pending.clear(); this._worker.terminate(); } @@ -71,39 +86,46 @@ export class WorkerHandler { public postMessage(method: WorkerRequest['method'], arg?: WorkerRequest['arg']): Promise { const id = this._getAndIncrementId(); - return new Promise((resolve, reject) => { - const listener = ({ data }: MessageEvent): void => { - const response = data as WorkerResponse; - if (response.method !== method) { - return; - } - - // There can be multiple listeners for a single method, the id ensures - // that the response matches the caller. - if (response.id !== id) { - return; - } - - // At this point, we'll always want to remove listener regardless of result status - this._worker.removeEventListener('message', listener); + return new Promise((resolve, reject) => { + this._pending.set(id, { + method, + resolve: resolve as (value: unknown) => void, + reject, + }); + try { + this._worker.postMessage({ id, method, arg }); + } catch (error) { + // If postMessage throws synchronously (e.g. DataCloneError, worker + // already terminated), drop the pending entry so it doesn't leak. + this._pending.delete(id); + reject(error); + } + }); + } - if (!response.success) { - // TODO: Do some error handling, not sure what - DEBUG_BUILD && debug.error('Error in compression worker: ', response.response); + private _onMessage = ({ data }: MessageEvent): void => { + const response = data as WorkerResponse; + // The worker emits an init message with `id: undefined` on load, which is + // handled by `ensureReady()` via its own listener. Ignore anything that + // doesn't carry a numeric id we issued. + if (typeof response.id !== 'number') { + return; + } + const pending = this._pending.get(response.id); + if (!pending || pending.method !== response.method) { + return; + } - reject(new Error('Error in compression worker')); - return; - } + this._pending.delete(response.id); - resolve(response.response as T); - }; + if (!response.success) { + DEBUG_BUILD && debug.error('Error in compression worker: ', response.response); + pending.reject(new Error('Error in compression worker')); + return; + } - // Note: we can't use `once` option because it's possible it needs to - // listen to multiple messages - this._worker.addEventListener('message', listener); - this._worker.postMessage({ id, method, arg }); - }); - } + pending.resolve(response.response); + }; /** Get the current ID and increment it for the next call. */ private _getAndIncrementId(): number { diff --git a/packages/replay-internal/src/util/maskAttribute.ts b/packages/replay-internal/src/util/maskAttribute.ts index 12daaeb97dff..feb3898fc6ae 100644 --- a/packages/replay-internal/src/util/maskAttribute.ts +++ b/packages/replay-internal/src/util/maskAttribute.ts @@ -11,6 +11,8 @@ interface MaskAttributeParams { /** * Masks an attribute if necessary, otherwise return attribute value as-is. + * Keys listed in `maskAttributes` are masked even when `maskAllText` is false; + * masking `value` on submit/button inputs without listing `value` still requires `maskAllText`. */ export function maskAttribute({ el, @@ -20,22 +22,20 @@ export function maskAttribute({ privacyOptions, value, }: MaskAttributeParams): string { - // We only mask attributes if `maskAllText` is true - if (!maskAllText) { - return value; - } - // unmaskTextSelector takes precedence if (privacyOptions.unmaskTextSelector && el.matches(privacyOptions.unmaskTextSelector)) { return value; } - if ( - maskAttributes.includes(key) || - // Need to mask `value` attribute for `` if it's a button-like - // type - (key === 'value' && el.tagName === 'INPUT' && ['submit', 'button'].includes(el.getAttribute('type') || '')) - ) { + const masksNamedAttribute = maskAttributes.includes(key); + // When `maskAllText` is enabled, also mask `value` on button-like inputs even if `value` is not listed. + const masksSubmitButtonValue = + maskAllText && + key === 'value' && + el.tagName === 'INPUT' && + ['submit', 'button'].includes(el.getAttribute('type') || ''); + + if (masksNamedAttribute || masksSubmitButtonValue) { return value.replace(/[\S]/g, '*'); } diff --git a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts new file mode 100644 index 000000000000..0b28cec37348 --- /dev/null +++ b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler'; +import type { WorkerResponse } from '../../../src/types'; + +/** + * Minimal Worker stub that lets tests control when responses dispatch and + * track how many 'message' listeners are attached at any time. Real workers + * are async; we model that with a queue we drain manually so the test can + * assert on the listener count while requests are in flight. + */ +class MockWorker implements Pick { + public listenerCount = 0; + public terminated = false; + + private _listeners = new Map>(); + private _pendingRequests: Array<{ id: number; method: string }> = []; + + public addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (!this._listeners.has(type)) this._listeners.set(type, new Set()); + this._listeners.get(type)!.add(listener); + if (type === 'message') this.listenerCount++; + } + + public removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + const set = this._listeners.get(type); + if (set?.delete(listener) && type === 'message') this.listenerCount--; + } + + public postMessage(data: unknown): void { + const { id, method } = data as { id: number; method: string }; + this._pendingRequests.push({ id, method }); + } + + public terminate(): void { + this.terminated = true; + } + + /** Dispatch the queued response for a given id (FIFO order otherwise). */ + public flushOne(overrides?: Partial): void { + const next = this._pendingRequests.shift(); + if (!next) return; + const response: WorkerResponse = { + id: next.id, + method: next.method, + success: true, + response: `result-${next.id}`, + ...overrides, + }; + this._dispatch('message', { data: response } as MessageEvent); + } + + public flushAll(): void { + while (this._pendingRequests.length > 0) this.flushOne(); + } + + /** Dispatch a message that doesn't correspond to a queued request. */ + public dispatchRaw(response: Partial): void { + this._dispatch('message', { data: response } as MessageEvent); + } + + public get pendingCount(): number { + return this._pendingRequests.length; + } + + private _dispatch(type: string, event: MessageEvent): void { + const set = this._listeners.get(type); + if (!set) return; + for (const listener of set) { + if (typeof listener === 'function') listener(event); + else listener.handleEvent(event); + } + } +} + +const makeHandler = () => { + const worker = new MockWorker(); + const handler = new WorkerHandler(worker as unknown as Worker); + return { worker, handler }; +}; + +describe('Unit | eventBuffer | WorkerHandler', () => { + it('does not attach a new message listener per postMessage call (regression: #20547)', async () => { + const { worker, handler } = makeHandler(); + + // One listener is attached at construction time. + expect(worker.listenerCount).toBe(1); + + // Fire a burst of in-flight requests. The pre-fix implementation attached + // one listener per call, growing linearly; this would dispatch every + // response to all attached listeners (O(n^2) main-thread work). + const promises = Array.from({ length: 100 }, (_, i) => handler.postMessage('addEvent', `arg-${i}`)); + + expect(worker.listenerCount).toBe(1); + expect(worker.pendingCount).toBe(100); + + worker.flushAll(); + await Promise.all(promises); + + // Listener count is still 1 after the burst drains. + expect(worker.listenerCount).toBe(1); + }); + + it('resolves concurrent postMessage calls with the correct response per id', async () => { + const { worker, handler } = makeHandler(); + + const p0 = handler.postMessage('addEvent', 'a'); + const p1 = handler.postMessage('addEvent', 'b'); + const p2 = handler.postMessage('addEvent', 'c'); + + worker.flushAll(); + + await expect(p0).resolves.toBe('result-0'); + await expect(p1).resolves.toBe('result-1'); + await expect(p2).resolves.toBe('result-2'); + }); + + it('rejects when the worker reports success: false', async () => { + const { worker, handler } = makeHandler(); + + const promise = handler.postMessage('addEvent', 'a'); + worker.flushOne({ success: false, response: 'boom' }); + + await expect(promise).rejects.toThrow('Error in compression worker'); + }); + + it('rejects and cleans up the pending entry when worker.postMessage throws synchronously', async () => { + const { worker, handler } = makeHandler(); + const error = new Error('DataCloneError'); + worker.postMessage = () => { + throw error; + }; + + await expect(handler.postMessage('addEvent', 'a')).rejects.toBe(error); + + // A subsequent successful call should still work — the previous failure + // didn't leave a stale entry behind. + worker.postMessage = MockWorker.prototype.postMessage.bind(worker); + const promise = handler.postMessage('addEvent', 'b'); + worker.flushOne(); + await expect(promise).resolves.toBe('result-1'); + }); + + it('ignores messages without a numeric id (e.g. the worker init message)', async () => { + const { worker, handler } = makeHandler(); + + const promise = handler.postMessage('addEvent', 'a'); + + // Simulate the init message the worker emits on load. Should be ignored + // and not crash. + worker.dispatchRaw({ id: undefined, method: 'init', success: true }); + + // The legitimate response still resolves. + worker.flushOne(); + await expect(promise).resolves.toBe('result-0'); + }); + + it('destroy() rejects pending requests and detaches the listener', async () => { + const { worker, handler } = makeHandler(); + + const p1 = handler.postMessage('addEvent', 'a'); + const p2 = handler.postMessage('addEvent', 'b'); + + handler.destroy(); + + await expect(p1).rejects.toThrow('Worker destroyed'); + await expect(p2).rejects.toThrow('Worker destroyed'); + expect(worker.terminated).toBe(true); + expect(worker.listenerCount).toBe(0); + }); +}); diff --git a/packages/replay-internal/test/unit/util/maskAttribute.test.ts b/packages/replay-internal/test/unit/util/maskAttribute.test.ts index 4819e5411e11..0446f67b04a3 100644 --- a/packages/replay-internal/test/unit/util/maskAttribute.test.ts +++ b/packages/replay-internal/test/unit/util/maskAttribute.test.ts @@ -33,11 +33,15 @@ describe('maskAttribute', () => { test.each([ ['masks if `maskAllText` is true', defaultArgs, '***'], [ - 'does not mask if `maskAllText` is false, despite `maskTextSelector` ', - { ...defaultArgs, maskAllText: false, maskTextSelector: 'classy' }, + 'masks when key is in `maskAttributes` even if `maskAllText` is false', + { ...defaultArgs, maskAllText: false }, + '***', + ], + [ + 'does not mask when key is not in `maskAttributes` and `maskAllText` is false', + { ...defaultArgs, maskAllText: false, key: 'id', maskAttributes: ['title'] }, 'foo', ], - ['does not mask if `maskAllText` is false', { ...defaultArgs, maskAllText: false }, 'foo'], [ 'does not mask if `unmaskTextSelector` matches', { ...defaultArgs, privacyOptions: { ...privacyOptions, unmaskTextSelector: '.classy' } }, @@ -53,6 +57,30 @@ describe('maskAttribute', () => { { ...defaultArgs, el: inputButton, value: 'input value' }, '***** *****', ], + [ + 'does not mask submit `value` when `maskAllText` is false unless `value` is in `maskAttributes`', + { + ...defaultArgs, + el: inputSubmit, + key: 'value', + maskAttributes: ['title'], + maskAllText: false, + value: 'input value', + }, + 'input value', + ], + [ + 'masks submit `value` when `maskAllText` is false if `value` is in `maskAttributes`', + { + ...defaultArgs, + el: inputSubmit, + key: 'value', + maskAttributes: ['value'], + maskAllText: false, + value: 'input value', + }, + '***** *****', + ], ])('%s', (_: string, input, output) => { expect(maskAttribute(input)).toEqual(output); }); diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index c38108c75542..fe5d1ed31e23 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -1,5 +1,9 @@ -import type { Integration, SpanJSON, SpanOrigin } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import type { Integration, SpanJSON, SpanOrigin, StreamedSpanJSON } from '@sentry/core'; +import { + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; /** * A small integration that preprocesses spans so that SvelteKit-generated spans @@ -20,6 +24,9 @@ export function svelteKitSpansIntegration(): Integration { event.spans?.forEach(_enhanceKitSpan); } }, + processSpan(span) { + _enhanceKitSpanStreamed(span); + }, }; } @@ -28,51 +35,63 @@ export function svelteKitSpansIntegration(): Integration { * @exported for testing */ export function _enhanceKitSpan(span: SpanJSON): void { - let op: string | undefined = undefined; - let origin: SpanOrigin | undefined = undefined; - - const spanName = span.description; + const { op, origin } = _getKitSpanEnhancement(span.description); const previousOp = span.op || span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]; const previousOrigin = span.origin || span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; + if (!previousOp && op) { + span.op = op; + span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + } + + if ((!previousOrigin || previousOrigin === 'manual') && origin) { + span.origin = origin; + span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + } +} + +/** + * Streaming-mode counterpart of {@link _enhanceKitSpan} operating on {@link StreamedSpanJSON}. + * @exported for testing + */ +export function _enhanceKitSpanStreamed(span: StreamedSpanJSON): void { + const { op, origin } = _getKitSpanEnhancement(span.name); + const previousOrigin = span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined; + + if (op) { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op }); + } + + if (previousOrigin === 'manual' && origin) { + // `safeSetSpanJSONAttributes` skips existing keys, so overwrite the 'manual' sentinel directly. + span.attributes![SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + } else { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }); + } +} + +function _getKitSpanEnhancement(spanName: string | undefined): { + op?: string; + origin?: SpanOrigin; +} { switch (spanName) { case 'sveltekit.resolve': - op = 'function.sveltekit.resolve'; - origin = 'auto.http.sveltekit'; - break; + return { op: 'function.sveltekit.resolve', origin: 'auto.http.sveltekit' }; case 'sveltekit.load': - op = 'function.sveltekit.load'; - origin = 'auto.function.sveltekit.load'; - break; + return { op: 'function.sveltekit.load', origin: 'auto.function.sveltekit.load' }; case 'sveltekit.form_action': - op = 'function.sveltekit.form_action'; - origin = 'auto.function.sveltekit.action'; - break; + return { op: 'function.sveltekit.form_action', origin: 'auto.function.sveltekit.action' }; case 'sveltekit.remote.call': - op = 'function.sveltekit.remote'; - origin = 'auto.rpc.sveltekit.remote'; - break; + return { op: 'function.sveltekit.remote', origin: 'auto.rpc.sveltekit.remote' }; case 'sveltekit.handle.root': // We don't want to overwrite the root handle span at this point since // we already enhance the root span in our `sentryHandle` hook. - break; - default: { + return {}; + default: if (spanName?.startsWith('sveltekit.handle.sequenced.')) { - op = 'function.sveltekit.handle'; - origin = 'auto.function.sveltekit.handle'; + return { op: 'function.sveltekit.handle', origin: 'auto.function.sveltekit.handle' }; } - break; - } - } - - if (!previousOp && op) { - span.op = op; - span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; - } - - if ((!previousOrigin || previousOrigin === 'manual') && origin) { - span.origin = origin; - span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + return {}; } } diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts index 0d95cb3d6fb6..b051d613aad1 100644 --- a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts +++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts @@ -1,14 +1,19 @@ -import type { SpanJSON, TransactionEvent } from '@sentry/core'; +import type { SpanJSON, StreamedSpanJSON, TransactionEvent } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { describe, expect, it } from 'vitest'; -import { _enhanceKitSpan, svelteKitSpansIntegration } from '../../../src/server-common/integrations/svelteKitSpans'; +import { + _enhanceKitSpan, + _enhanceKitSpanStreamed, + svelteKitSpansIntegration, +} from '../../../src/server-common/integrations/svelteKitSpans'; describe('svelteKitSpansIntegration', () => { - it('has a name and a preprocessEventHook', () => { + it('has a name and a preprocessEvent and processSpan hook', () => { const integration = svelteKitSpansIntegration(); expect(integration.name).toBe('SvelteKitSpansEnhancement'); expect(typeof integration.preprocessEvent).toBe('function'); + expect(typeof integration.processSpan).toBe('function'); }); it('enhances spans from SvelteKit', () => { @@ -169,4 +174,106 @@ describe('svelteKitSpansIntegration', () => { expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); }); }); + + describe('_enhanceKitSpanStreamed', () => { + function makeStreamedSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'unspecified', + span_id: '123', + trace_id: 'abc', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes: {}, + ...overrides, + }; + } + + it.each([ + ['sveltekit.resolve', 'function.sveltekit.resolve', 'auto.http.sveltekit'], + ['sveltekit.load', 'function.sveltekit.load', 'auto.function.sveltekit.load'], + ['sveltekit.form_action', 'function.sveltekit.form_action', 'auto.function.sveltekit.action'], + ['sveltekit.remote.call', 'function.sveltekit.remote', 'auto.rpc.sveltekit.remote'], + ['sveltekit.handle.sequenced.0', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'], + ['sveltekit.handle.sequenced.myHandler', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'], + ])('enhances %s span with the correct op and origin', (spanName, op, origin) => { + const span = makeStreamedSpan({ name: spanName, attributes: { someAttribute: 'someValue' } }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe(op); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(origin); + }); + + it("doesn't change spans from other origins", () => { + const span = makeStreamedSpan({ name: 'someOtherSpan' }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBeUndefined(); + }); + + it("doesn't overwrite the sveltekit.handle.root span", () => { + const rootHandleSpan = makeStreamedSpan({ + name: 'sveltekit.handle.root', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', + }, + }); + + _enhanceKitSpanStreamed(rootHandleSpan); + + expect(rootHandleSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server'); + expect(rootHandleSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit'); + }); + + it("doesn't enhance unrelated spans", () => { + const span = makeStreamedSpan({ + name: 'someOtherSpan', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.pg', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('db'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.db.pg'); + }); + + it("doesn't overwrite already set ops or origins on sveltekit spans", () => { + // for example, if users manually set this (for whatever reason) + const span = makeStreamedSpan({ + name: 'sveltekit.resolve', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.custom.origin', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.custom.origin'); + }); + + it('overwrites previously set "manual" origins on sveltekit spans', () => { + const span = makeStreamedSpan({ + name: 'sveltekit.resolve', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit'); + }); + }); }); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 3e762580830c..7607c32faaa3 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -2,6 +2,7 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { TanStackMiddlewareBase } from '../common/types'; +import type { CreateSentryTunnelRouteOptions } from '../server/tunnelRoute'; export * from '@sentry/react'; @@ -26,3 +27,19 @@ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { '~types': * The actual implementation is server-only, but this stub is needed to prevent rendering errors. */ export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} }; + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent rendering errors. + */ +export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions): { + handlers: { + POST: () => Promise; + }; +} { + return { + handlers: { + POST: async () => new Response(null, { status: 500 }), + }, + }; +} diff --git a/packages/tanstackstart-react/src/client/sdk.ts b/packages/tanstackstart-react/src/client/sdk.ts index b0ee3b53053f..0998c027f112 100644 --- a/packages/tanstackstart-react/src/client/sdk.ts +++ b/packages/tanstackstart-react/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as initReactSDK } from '@sentry/react'; +import { applyTunnelRouteOption } from './tunnelRoute'; /** * Initializes the TanStack Start React SDK @@ -14,6 +15,7 @@ export function init(options: ReactBrowserOptions): Client | undefined { ...options, }; + applyTunnelRouteOption(sentryOptions); applySdkMetadata(sentryOptions, 'tanstackstart-react', ['tanstackstart-react', 'react']); return initReactSDK(sentryOptions); diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts new file mode 100644 index 000000000000..b22612c123d0 --- /dev/null +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -0,0 +1,35 @@ +import { consoleSandbox } from '@sentry/core'; +import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; + +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; + +let hasWarnedAboutManagedTunnelRouteOverride = false; + +/** + * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already + * configured an explicit runtime `tunnel` option in `Sentry.init()`. + */ +export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + const managedTunnelRoute = + typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; + + if (!managedTunnelRoute) { + return; + } + + if (options.tunnel) { + if (!hasWarnedAboutManagedTunnelRouteOverride) { + hasWarnedAboutManagedTunnelRouteOverride = true; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + } + + return; + } + + options.tunnel = managedTunnelRoute; +} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 76e4ced73c92..79edcef0bbfa 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -42,3 +42,4 @@ export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewares export declare const tanstackRouterBrowserTracingIntegration: typeof clientSdk.tanstackRouterBrowserTracingIntegration; export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware; export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; +export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 4fe781b6d778..0ae0968e574b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,6 +9,7 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; +export { createSentryTunnelRoute } from './tunnelRoute'; /** * A no-op stub of the browser tracing integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..815a8d635c9f --- /dev/null +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -0,0 +1,60 @@ +import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + allowedDsns?: string[]; +} + +type SentryTunnelRouteHandlerContext = { + request: Request; +}; + +type SentryTunnelRoute = { + handlers: { + POST: (context: SentryTunnelRouteHandlerContext) => Promise; + }; +}; + +/** + * Creates a TanStack Start server route configuration for tunneling Sentry envelopes. + * + * @example + * ```ts + * import { createFileRoute } from '@tanstack/react-router'; + * import * as Sentry from '@sentry/tanstackstart-react'; + * + * export const Route = createFileRoute('/monitoring')({ + * server: Sentry.createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }), + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions): SentryTunnelRoute { + return { + handlers: { + POST: async ({ request }) => { + const allowedDsnsFromOptions = options.allowedDsns?.length ? options.allowedDsns : undefined; + + const allowedDsns = + allowedDsnsFromOptions ?? + (() => { + const client = getClient(); + const dsn = client?.getDsn(); + return dsn ? [dsnToString(dsn)] : undefined; + })(); + + if (!allowedDsns) { + return new Response( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + + return handleTunnelRequest({ + request, + allowedDsns, + }); + }, + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts index 85143344028d..b7f65e26f5d2 100644 --- a/packages/tanstackstart-react/src/vite/index.ts +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -1,2 +1,3 @@ export { sentryTanstackStart } from './sentryTanstackStart'; export type { SentryTanstackStartOptions } from './sentryTanstackStart'; +export type { TunnelRouteOptions } from './tunnelRoute'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index fd5d5b2f0d05..75e9963c0387 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,8 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import type { TunnelRouteOptions } from './tunnelRoute'; +import { makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -19,6 +21,23 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * Configures a framework-managed same-origin tunnel route for Sentry envelopes. + * + * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path + * as the default `tunnel` option on the client. + * + * You can pass: + * - `true` to generate an opaque route path per dev session or production build. + * - `'/custom-path'` to use a fixed static route path. + * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN + * from the active server Sentry client at runtime. + * + * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because + * the managed tunnel route is being bypassed. + */ + tunnelRoute?: TunnelRouteOptions; } /** @@ -46,13 +65,19 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - // only add plugins in production builds + const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined; + + // only add build-time plugins in production builds if (process.env.NODE_ENV === 'development') { - return []; + return tunnelRoutePlugin ? [tunnelRoutePlugin] : []; } const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + if (tunnelRoutePlugin) { + plugins.push(tunnelRoutePlugin); + } + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 288c725dbc93..296e8582cde8 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -65,7 +65,9 @@ export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[] assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + // BuildTimeOptionsBase types can lag behind bundler plugin options in some local setups. + // Keep runtime support while staying resilient to type version skew. + rewriteSources: (sourcemaps as unknown as { rewriteSources?: unknown } | undefined)?.rewriteSources as never, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, telemetry: telemetry ?? true, diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts new file mode 100644 index 000000000000..6a9400b32c95 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -0,0 +1,202 @@ +import type { Plugin } from 'vite'; + +export type TunnelRouteOptions = + | true + | string + | { + /** + * A list of DSNs that are allowed to use the managed tunnel route. + * + * If omitted or empty, the tunnel route will derive the allowed DSN from the active server Sentry SDK at runtime. + */ + allowedDsns?: string[]; + + /** + * Controls the public route path used by the managed tunnel route. + * + * If omitted, an opaque path is generated once per dev session or production build. + */ + path?: string; + }; + +const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; +const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; + +const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; +const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; + +function generateRandomTunnelRoute(): string { + const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); + + return `/${randomPath}`; +} + +export function resolveTunnelRoute(tunnel: true | string): string { + if (typeof tunnel === 'string') { + return tunnel; + } + + if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { + return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + } + + const resolvedTunnelRoute = generateRandomTunnelRoute(); + process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; + return resolvedTunnelRoute; +} + +type NormalizedTunnelRouteOptions = { + resolvedPath: string; + allowedDsns: string[] | undefined; +}; + +function validateStaticPath(path: string): void { + if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { + throw new Error( + '[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.', + ); + } +} + +function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { + if (options === true) { + return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + } + + if (typeof options === 'string') { + validateStaticPath(options); + return { + resolvedPath: resolveTunnelRoute(options), + allowedDsns: undefined, + }; + } + + const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; + const path = options.path; + + if (path) { + validateStaticPath(path); + } + + return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; +} + +// `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), +// so we check both forms for each route-identifying key. +const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; + +function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { + const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; + return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); +} + +function injectAfterLastImport(source: string, statement: string): string { + const importMatches = [...source.matchAll(/^import .+$/gm)]; + const lastImport = importMatches.at(-1); + + if (lastImport?.index === undefined) { + throw new Error( + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', + ); + } + + const insertIndex = lastImport.index + lastImport[0].length; + return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; +} + +export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { + if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { + return source; + } + + if (hasRouteConflict(source, resolvedTunnelRoute)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, + ); + } + + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + + let transformedSource = injectAfterLastImport( + source, + `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, + ); + + const rootRouteChildrenMatch = transformedSource.match( + /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, + ); + + if (rootRouteChildrenMatch?.index === undefined) { + throw new Error( + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', + ); + } + + const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ + id: ${serializedTunnelRoute}, + path: ${serializedTunnelRoute}, + getParentRoute: () => rootRouteImport, +} as any) + +${rootRouteChildrenMatch[0]} + ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, +`; + + transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; + + return transformedSource; +} + +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { + const normalized = normalizeTunnelRouteOptions(options); + const resolvedTunnelRoute = normalized.resolvedPath; + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; + + if (debug) { + // eslint-disable-next-line no-console + console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); + } + + return { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + config() { + return { + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, + }, + }; + }, + resolveId(source) { + return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; + }, + load(id) { + if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { + return null; + } + + return `import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute(${serializedTunnelRoute})({ + server: { + handlers: { + async POST({ request }) { + const Sentry = await import('@sentry/tanstackstart-react'); + return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); + }, + }, + }, +}); +`; + }, + transform(source, id) { + if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { + return null; + } + + return injectManagedTunnelRoute(source, resolvedTunnelRoute); + }, + }; +} diff --git a/packages/tanstackstart-react/test/client/sdk.test.ts b/packages/tanstackstart-react/test/client/sdk.test.ts index 4cba4a199ef5..400bbe877dc1 100644 --- a/packages/tanstackstart-react/test/client/sdk.test.ts +++ b/packages/tanstackstart-react/test/client/sdk.test.ts @@ -9,6 +9,7 @@ describe('TanStack Start React Client SDK', () => { describe('init', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('Adds TanStack Start React client metadata to the SDK options', () => { @@ -41,5 +42,15 @@ describe('TanStack Start React Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('applies the managed tunnel route when no runtime tunnel is provided', () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: '/managed-tunnel' })); + }); }); }); diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts new file mode 100644 index 000000000000..90b91481305b --- /dev/null +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -0,0 +1,59 @@ +import type { BrowserOptions } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('applyTunnelRouteOption()', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('applies the managed tunnel route when no runtime tunnel is set', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/managed-tunnel'); + }); + + it('does not override an explicit runtime tunnel and warns instead', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: '/runtime-tunnel', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/runtime-tunnel'); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + + it('does nothing when no managed tunnel route was injected', async () => { + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..59e9ae130e0e --- /dev/null +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const handleTunnelRequestSpy = vi.fn(); +const getClientSpy = vi.fn(); + +vi.mock('@sentry/core', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args), + getClient: (...args: unknown[]) => getClientSpy(...args), + }; +}); + +const { createSentryTunnelRoute } = await import('../../src/server/tunnelRoute'); + +describe('createSentryTunnelRoute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns a server route config with only a POST handler', () => { + const route = createSentryTunnelRoute({ + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + }); + + expect(Object.keys(route.handlers)).toEqual(['POST']); + expect(route.handlers.POST).toBeTypeOf('function'); + }); + + it('forwards the request and allowed DSNs to handleTunnelRequest', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const allowedDsns = ['https://public@o0.ingest.sentry.io/0']; + const response = new Response('ok', { status: 200 }); + + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({ allowedDsns }); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns, + }); + expect(options.allowedDsns).toBe(allowedDsns); + expect(result).toBe(response); + }); + + it('derives the allowed DSN from the active server Sentry client when allowedDsns is omitted', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const response = new Response('ok', { status: 200 }); + + getClientSpy.mockReturnValueOnce({ + getDsn: () => ({ + protocol: 'http', + publicKey: 'public', + pass: '', + host: 'localhost', + port: '3031', + path: '', + projectId: '1337', + }), + }); + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns: ['http://public@localhost:3031/1337'], + }); + expect(result).toBe(response); + }); + + it('returns 500 when allowedDsns is omitted and no active server Sentry client DSN exists', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + + getClientSpy.mockReturnValueOnce(undefined); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).not.toHaveBeenCalled(); + expect(result.status).toBe(500); + await expect(result.text()).resolves.toContain('Tunnel route requires Sentry server SDK initialized with a DSN'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..516edadd0bb0 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -1,7 +1,8 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; -import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; +import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -28,6 +29,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockTunnelRoutePlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + transform: vi.fn(), +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +44,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/tunnelRoute', () => ({ + makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,7 +65,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); }); - it('returns no plugins in development mode', () => { + it('returns no plugins in development mode when tunnelRoute is not configured', () => { process.env.NODE_ENV = 'development'; const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); @@ -62,6 +73,17 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([]); }); + it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { + process.env.NODE_ENV = 'development'; + + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, + }); + + expect(plugins).toEqual([mockTunnelRoutePlugin]); + }); + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false, @@ -127,4 +149,37 @@ describe('sentryTanstackStart()', () => { expect(makeAutoInstrumentMiddlewarePlugin).toHaveBeenCalledWith({ enabled: true, debug: undefined }); }); }); + + describe('managed tunnel route', () => { + it('includes the managed tunnel route plugin in production when configured', () => { + const plugins = sentryTanstackStart({ + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + path: '/monitor', + }, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockTunnelRoutePlugin, + mockMiddlewarePlugin, + ]); + }); + + it('passes tunnelRoute options through to the tunnel route plugin', () => { + const options: SentryTanstackStartOptions = { + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + path: '/monitor' as const, + }, + sourcemaps: { disable: true }, + }; + + sentryTanstackStart(options); + + expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + }); + }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts new file mode 100644 index 000000000000..822a01aeeeff --- /dev/null +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; +import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; + +const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) +`; + +const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( + 'const rootRouteChildren: RootRouteChildren = {', + 'const rootRouteChildren = {', +); + +describe('tunnelRoute vite plugin', () => { + beforeEach(() => { + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; + }); + + afterEach(() => { + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; + }); + + it('reuses the same generated tunnel route within one process', () => { + const firstTunnelRoute = resolveTunnelRoute(true); + const secondTunnelRoute = resolveTunnelRoute(true); + + expect(firstTunnelRoute).toBe(secondTunnelRoute); + expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); + }); + + it('always generates an 8-character tunnel route', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); + }); + + it('returns the provided static tunnel route without reusing a generated one', () => { + resolveTunnelRoute(true); + + expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + }); + + it('rejects invalid static tunnel routes', () => { + expect(() => makeTunnelRoutePlugin('monitor')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin('/monitor?x=1')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin({ path: 'monitor' })).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + }); + + it('injects the managed tunnel route into the generated TanStack route tree', () => { + const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain( + "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", + ); + expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); + expect(transformedRouteTree).toContain('id: "/monitor"'); + expect(transformedRouteTree).toContain('path: "/monitor"'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + }); + + it('injects the managed tunnel route when rootRouteChildren is untyped', () => { + const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain('const rootRouteChildren = {'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + }); + + it('fails when the managed tunnel route conflicts with an existing route', () => { + expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( + 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', + ); + }); + + it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { + const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( + "id: '/'", + 'id: "/monitor"', + ); + + expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + }); + + it('loads a virtual managed tunnel route module for a static tunnel path', async () => { + const plugin = makeTunnelRoutePlugin({ + allowedDsns: ['http://public@localhost:3031/1337'], + path: '/monitor', + }); + + expect(plugin.config && plugin.config()).toEqual({ + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', + }, + }); + + expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( + '\0virtual:sentry-tanstackstart-react/tunnel-route', + ); + + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + }); + + it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { + const plugin = makeTunnelRoutePlugin('/monitor'); + + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + }); + + it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { + const plugin = makeTunnelRoutePlugin({ path: '' }); + + const defined = plugin.config && plugin.config(); + const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; + expect(typeof serialized).toBe('string'); + expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + }); +}); diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index 596efb6ef182..df0a3c4d8938 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -1,4 +1,10 @@ -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; import type { Ref } from 'vue'; // Inline Pinia types @@ -112,9 +118,8 @@ export const createSentryPiniaPlugin: ( state: piniaStateContext, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newState, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState normalizationDepth, // rest for the actual state ); diff --git a/scripts/__fixtures__/size-limit-sample.js b/scripts/__fixtures__/size-limit-sample.js new file mode 100644 index 000000000000..07bbccfd22e1 --- /dev/null +++ b/scripts/__fixtures__/size-limit-sample.js @@ -0,0 +1,27 @@ +module.exports = [ + { + name: '@sentry/browser', + path: 'packages/browser/build/npm/esm/prod/index.js', + gzip: true, + limit: '27 KB', + }, + { + name: '@sentry/browser - with treeshaking flags', + path: 'packages/browser/build/npm/esm/prod/index.js', + gzip: true, + limit: '25 KB', + }, + { + name: 'CDN Bundle (incl. Tracing)', + path: 'packages/browser/build/bundles/bundle.tracing.min.js', + gzip: true, + limit: '46.5 KB', + }, + { + name: '@sentry/cloudflare (withSentry)', + path: 'packages/cloudflare/build/esm/index.js', + gzip: false, + brotli: false, + limit: '420 KiB', + }, +]; diff --git a/scripts/bump-size-limits.mjs b/scripts/bump-size-limits.mjs new file mode 100644 index 000000000000..bf2ab92909fd --- /dev/null +++ b/scripts/bump-size-limits.mjs @@ -0,0 +1,252 @@ +/** + * Auto-bumper for .size-limit.js. + * + * - Reads `yarn size-limit --json` output + * - For each entry, computes a new limit of roundUpToKB(currentSize + 5000) + * and applies it whenever the displayed value would change + * - Rewrites .size-limit.js as plain text (NEVER require()d — the file contains + * user-defined webpack/esbuild config functions that we don't want executing) + * + * Exit codes: 0 = wrote changes, 2 = no-op, 1 = error. + */ + +import { execFile } from 'node:child_process'; +import { readFile, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const SIZE_LIMIT_FILE = path.join(REPO_ROOT, '.size-limit.js'); + +export const HEADROOM_BYTES = 5000; +export const BYTES_PER_KB = 1000; +export const BYTES_PER_KIB = 1024; + +/** + * Compute the new size-limit in bytes for an entry: currentSize + 5KB, + * rounded up to the next full KB. Always returns a number — the no-op + * check is done downstream by comparing the displayed (KB/KiB-rounded) + * value against the existing one. + * + * @param {number} currentBytes - measured size in bytes + * @returns {number} new limit in bytes, rounded up to the next KB + */ +export function computeNewLimit(currentBytes) { + const target = currentBytes + HEADROOM_BYTES; + return Math.ceil(target / BYTES_PER_KB) * BYTES_PER_KB; +} + +/** + * Parse and strict-validate the JSON output from `yarn size-limit --json`. + * + * @param {string} raw - JSON string + * @returns {Array<{ name: string, size: number, sizeLimit: number }>} + * @throws {TypeError | SyntaxError} on malformed input + */ +export function parseSizeLimitOutput(raw) { + const data = JSON.parse(raw); + if (!Array.isArray(data)) { + throw new TypeError(`size-limit output: expected array, got ${typeof data}`); + } + return data.map((entry, i) => { + if (!entry || typeof entry !== 'object') { + throw new TypeError(`size-limit entry [${i}]: expected object`); + } + if (typeof entry.name !== 'string' || entry.name.length === 0) { + throw new TypeError(`size-limit entry [${i}]: 'name' must be a non-empty string`); + } + if (typeof entry.size !== 'number' || !Number.isFinite(entry.size)) { + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'size' must be a finite number`); + } + if (typeof entry.sizeLimit !== 'number' || !Number.isFinite(entry.sizeLimit)) { + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'sizeLimit' must be a finite number`); + } + return { name: entry.name, size: entry.size, sizeLimit: entry.sizeLimit }; + }); +} + +/** + * Escape a string for safe inclusion in a markdown table cell. + * Replaces newlines with spaces, escapes pipes and backticks. + * + * @param {unknown} value + * @returns {string} + */ +export function sanitizeMarkdownCell(value) { + return String(value) + .replace(/\r\n|\r|\n/g, ' ') + .replace(/[|`]/g, m => `\\${m}`); +} + +/** + * Escape a string for literal use inside a RegExp. + */ +function reEscape(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Inspect the source for the current limit string of a given entry. + * Returns null if no entry with that name is found. + * + * @param {string} src + * @param {string} name + * @returns {{ value: number, unit: 'KB' | 'KiB', raw: string } | null} + */ +export function extractCurrentLimit(src, name) { + const namePattern = `name:\\s*'${reEscape(name)}'`; + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; + const re = new RegExp(`${namePattern}[^]*?${limitPattern}`); + const m = re.exec(src); + if (!m) return null; + return { value: Number(m[1]), unit: /** @type {'KB' | 'KiB'} */ (m[2]), raw: `${m[1]} ${m[2]}` }; +} + +/** + * Convert a numeric byte value into a whole-unit display value matching the + * entry's existing unit. KB uses 1000, KiB uses 1024. + * + * @param {number} newBytes + * @param {'KB' | 'KiB'} unit + * @returns {number} + */ +function bytesToDisplay(newBytes, unit) { + const divisor = unit === 'KiB' ? BYTES_PER_KIB : BYTES_PER_KB; + return Math.ceil(newBytes / divisor); +} + +/** + * Rewrite `.size-limit.js` source to apply a list of limit updates. + * Operates on plain text — never executes the source. For each change, + * locates the entry by exact `name:` match and rewrites the next `limit:` + * line in that window. + * + * @param {string} src - contents of .size-limit.js + * @param {Array<{ name: string, newLimitKb: number, unit: 'KB' | 'KiB' }>} changes + * @returns {string} updated source + * @throws {Error} if any change's name doesn't match exactly one entry + */ +export function rewriteSizeLimitFile(src, changes) { + let out = src; + for (const { name, newLimitKb, unit } of changes) { + const namePattern = `name:\\s*'${reEscape(name)}'`; + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; + const re = new RegExp(`(${namePattern}[^]*?)${limitPattern}`); + + let matchCount = 0; + const replaced = out.replace(re, (_full, prefix) => { + matchCount++; + return `${prefix}limit: '${newLimitKb} ${unit}'`; + }); + + if (matchCount === 0) { + throw new Error(`rewriteSizeLimitFile: no entry matched for name='${name}'`); + } + out = replaced; + } + return out; +} + +/** + * Render a markdown summary of size-limit changes for the PR body. + * + * @param {Array<{ name: string, oldLimit: string, newLimit: string, delta: number, unit: 'KB' | 'KiB' }>} changes + * @returns {string} + */ +export function renderSummary(changes) { + const header = '## Size limit auto-bump\n'; + if (changes.length === 0) { + return `${header}\nAll size limits already provide ≥5 KB headroom. No changes needed.\n`; + } + const lines = [header, '| Entry | Old limit | New limit | Δ |', '| --- | --- | --- | --- |']; + for (const c of changes) { + const sign = c.delta >= 0 ? '+' : ''; + const delta = `${sign}${c.delta} ${c.unit}`; + lines.push(`| ${sanitizeMarkdownCell(c.name)} | ${c.oldLimit} | ${c.newLimit} | ${delta} |`); + } + return `${lines.join('\n')}\n`; +} + +// CLI entrypoint +async function main() { + // 1. Run size-limit. Capture JSON. execFile (no shell). + let raw; + try { + // `--silent` suppresses yarn's `yarn run v…` header and `Done in …` footer, + // which would otherwise break JSON.parse on the captured stdout. + const { stdout } = await execFileAsync('yarn', ['--silent', 'size-limit', '--json'], { + cwd: REPO_ROOT, + maxBuffer: 16 * 1024 * 1024, + }); + raw = stdout; + } catch (err) { + // size-limit exits non-zero when entries fail their existing limit. We still want the JSON. + if (err && typeof err === 'object' && 'stdout' in err && err.stdout) { + raw = /** @type {string} */ (err.stdout); + } else { + throw err; + } + } + + const measurements = parseSizeLimitOutput(raw); + + // 2. Read .size-limit.js as text. NEVER require() it. + const src = await readFile(SIZE_LIMIT_FILE, 'utf8'); + + // 3. Compute changes. + const changes = []; + const summaryRows = []; + for (const m of measurements) { + const newBytes = computeNewLimit(m.size); + + const cur = extractCurrentLimit(src, m.name); + if (!cur) { + throw new Error(`size-limit reported entry '${m.name}' but it was not found in .size-limit.js`); + } + + const displayValue = bytesToDisplay(newBytes, cur.unit); + const newLimitStr = `${displayValue} ${cur.unit}`; + + if (newLimitStr === cur.raw) { + // After unit conversion the displayed value didn't move. Skip — avoids + // no-op edits caused by KiB rounding. + continue; + } + + changes.push({ name: m.name, newLimitKb: displayValue, unit: cur.unit }); + summaryRows.push({ + name: m.name, + oldLimit: cur.raw, + newLimit: newLimitStr, + delta: displayValue - cur.value, + unit: cur.unit, + }); + } + + // 4. Print summary regardless (workflow captures stdout). + process.stdout.write(renderSummary(summaryRows)); + + if (changes.length === 0) { + process.exit(2); + } + + // 5. Atomic write: temp file + rename. + const updated = rewriteSizeLimitFile(src, changes); + const tmpPath = `${SIZE_LIMIT_FILE}.tmp`; + await writeFile(tmpPath, updated, 'utf8'); + await rename(tmpPath, SIZE_LIMIT_FILE); + + process.exit(0); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + main().catch(err => { + // oxlint-disable-next-line no-console + console.error(err.stack || err.message || err); + process.exit(1); + }); +} diff --git a/scripts/bump-size-limits.test.ts b/scripts/bump-size-limits.test.ts new file mode 100644 index 000000000000..ee046ea9f619 --- /dev/null +++ b/scripts/bump-size-limits.test.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +// @ts-expect-error -- .mjs source has no declarations under `moduleResolution: "node"` +import * as bumpSizeLimits from './bump-size-limits.mjs'; + +const { + BYTES_PER_KB, + BYTES_PER_KIB, + computeNewLimit, + extractCurrentLimit, + HEADROOM_BYTES, + parseSizeLimitOutput, + renderSummary, + rewriteSizeLimitFile, + sanitizeMarkdownCell, +} = bumpSizeLimits; + +const FIXTURE_PATH = path.join(__dirname, '__fixtures__', 'size-limit-sample.js'); +function readFixture(): string { + return fs.readFileSync(FIXTURE_PATH, 'utf8'); +} + +describe('constants', () => { + it('exports the documented thresholds', () => { + expect(HEADROOM_BYTES).toBe(5000); + expect(BYTES_PER_KB).toBe(1000); + expect(BYTES_PER_KIB).toBe(1024); + }); +}); + +describe('computeNewLimit', () => { + it('always returns currentSize + 5 KB, rounded up to the next full KB', () => { + // current 27_500 → +5000 = 32_500 → ceil to 33_000 + expect(computeNewLimit(27_500)).toBe(33_000); + // current 21_000 → +5000 = 26_000 → already round → 26_000 + expect(computeNewLimit(21_000)).toBe(26_000); + }); + + it('rounds up to next full KB', () => { + // current 27_001 → +5000 = 32_001 → ceil to 33_000 + expect(computeNewLimit(27_001)).toBe(33_000); + // current 27_999 → +5000 = 32_999 → ceil to 33_000 + expect(computeNewLimit(27_999)).toBe(33_000); + // current 28_000 → +5000 = 33_000 → already round → 33_000 + expect(computeNewLimit(28_000)).toBe(33_000); + }); + + it('handles zero-size measurements safely', () => { + expect(computeNewLimit(0)).toBe(5_000); + }); +}); + +describe('parseSizeLimitOutput', () => { + it('accepts well-formed input and returns name/size/sizeLimit triples', () => { + const raw = JSON.stringify([ + { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000, passed: false }, + { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000, passed: true }, + ]); + expect(parseSizeLimitOutput(raw)).toEqual([ + { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000 }, + { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000 }, + ]); + }); + + it('rejects non-array root', () => { + expect(() => parseSizeLimitOutput('{}')).toThrow(/expected array/i); + expect(() => parseSizeLimitOutput('null')).toThrow(/expected array/i); + }); + + it('rejects malformed JSON', () => { + expect(() => parseSizeLimitOutput('not json')).toThrow(SyntaxError); + }); + + it('rejects entries missing required fields', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 1 }]))).toThrow(/sizeLimit/); + expect(() => parseSizeLimitOutput(JSON.stringify([{ size: 1, sizeLimit: 2 }]))).toThrow(/name/); + }); + + it('rejects entries with non-string name', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 42, size: 1, sizeLimit: 2 }]))).toThrow(/name/); + }); + + it('rejects entries with non-finite numbers', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 'one', sizeLimit: 2 }]))).toThrow(/size/); + expect(() => parseSizeLimitOutput('[{"name":"x","size":1e500,"sizeLimit":2}]')).toThrow(/size/); + }); + + it('ignores extra fields without complaint', () => { + const raw = JSON.stringify([{ name: 'x', size: 1, sizeLimit: 2, passed: true, extra: 'ok' }]); + expect(parseSizeLimitOutput(raw)).toEqual([{ name: 'x', size: 1, sizeLimit: 2 }]); + }); +}); + +describe('sanitizeMarkdownCell', () => { + it('passes plain text through unchanged', () => { + expect(sanitizeMarkdownCell('@sentry/browser')).toBe('@sentry/browser'); + }); + + it('escapes pipes', () => { + expect(sanitizeMarkdownCell('a|b')).toBe('a\\|b'); + }); + + it('escapes backticks', () => { + expect(sanitizeMarkdownCell('a`b')).toBe('a\\`b'); + }); + + it('replaces newlines with spaces', () => { + expect(sanitizeMarkdownCell('a\nb')).toBe('a b'); + expect(sanitizeMarkdownCell('a\r\nb')).toBe('a b'); + }); + + it('preserves parentheses, commas, periods', () => { + expect(sanitizeMarkdownCell('CDN Bundle (incl. Tracing, Replay)')).toBe('CDN Bundle (incl. Tracing, Replay)'); + }); +}); + +describe('renderSummary', () => { + it('renders an empty header when there are no changes', () => { + const out = renderSummary([]); + expect(out).toContain('## Size limit auto-bump'); + expect(out).toContain('All size limits already provide ≥5 KB headroom. No changes needed.'); + }); + + it('renders a markdown table for one change', () => { + const out = renderSummary([ + { name: '@sentry/browser', oldLimit: '27 KB', newLimit: '28 KB', delta: 1, unit: 'KB' }, + ]); + expect(out).toContain('| Entry | Old limit | New limit | Δ |'); + expect(out).toContain('| @sentry/browser | 27 KB | 28 KB | +1 KB |'); + }); + + it('formats negative deltas with a minus', () => { + const out = renderSummary([ + { name: '@sentry/node', oldLimit: '177 KB', newLimit: '175 KB', delta: -2, unit: 'KB' }, + ]); + expect(out).toContain('| @sentry/node | 177 KB | 175 KB | -2 KB |'); + }); + + it('uses the entry unit for the delta column (KiB)', () => { + const out = renderSummary([ + { + name: '@sentry/cloudflare (withSentry)', + oldLimit: '420 KiB', + newLimit: '425 KiB', + delta: 5, + unit: 'KiB', + }, + ]); + expect(out).toContain('| @sentry/cloudflare (withSentry) | 420 KiB | 425 KiB | +5 KiB |'); + }); + + it('escapes pipes in entry names', () => { + const out = renderSummary([{ name: 'evil|name', oldLimit: '1 KB', newLimit: '2 KB', delta: 1, unit: 'KB' }]); + expect(out).toContain('evil\\|name'); + }); +}); + +describe('rewriteSizeLimitFile', () => { + it('updates a single entry, preserving KB unit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); + expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '28 KB',/); + expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '25 KB',/); + }); + + it('updates entries with name-prefix collision correctly', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [ + { name: '@sentry/browser - with treeshaking flags', newLimitKb: 30, unit: 'KB' }, + ]); + expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '27 KB',/); + expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '30 KB',/); + }); + + it('preserves KiB unit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: '@sentry/cloudflare (withSentry)', newLimitKb: 425, unit: 'KiB' }]); + expect(out).toMatch(/name: '@sentry\/cloudflare \(withSentry\)',[\s\S]*?limit: '425 KiB',/); + }); + + it('handles names with parentheses and decimals in original limit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }]); + expect(out).toMatch(/name: 'CDN Bundle \(incl\. Tracing\)',[\s\S]*?limit: '50 KB',/); + expect(out).not.toContain("limit: '46.5 KB'"); + }); + + it('applies multiple changes', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [ + { name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }, + { name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }, + ]); + expect(out).toContain("limit: '28 KB'"); + expect(out).toContain("limit: '50 KB'"); + }); + + it('throws if a name does not match any entry', () => { + const src = readFixture(); + expect(() => rewriteSizeLimitFile(src, [{ name: '@sentry/nonexistent', newLimitKb: 1, unit: 'KB' }])).toThrow( + /@sentry\/nonexistent/, + ); + }); + + it('returns unchanged source when changes is empty', () => { + const src = readFixture(); + expect(rewriteSizeLimitFile(src, [])).toBe(src); + }); + + it('does not modify the input string in-place', () => { + const src = readFixture(); + const before = src; + rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); + expect(src).toBe(before); + }); +}); + +describe('extractCurrentLimit', () => { + const FIXTURE_SRC = `module.exports = [ + { name: '@sentry/browser', limit: '27 KB' }, + { name: '@sentry/cloudflare (withSentry)', limit: '420 KiB' }, +];`; + + it('extracts the limit value and unit by name', () => { + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/browser')).toEqual({ + value: 27, + unit: 'KB', + raw: '27 KB', + }); + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/cloudflare (withSentry)')).toEqual({ + value: 420, + unit: 'KiB', + raw: '420 KiB', + }); + }); + + it('returns null when the name is not present', () => { + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/missing')).toBeNull(); + }); +}); diff --git a/scripts/ci-print-build-artifact-paths.mjs b/scripts/ci-print-build-artifact-paths.mjs new file mode 100644 index 000000000000..690b995d78b2 --- /dev/null +++ b/scripts/ci-print-build-artifact-paths.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Prints multiline paths for actions/upload-artifact `path` (often wired via a prior step's + * `GITHUB_OUTPUT` `paths< yarn ci:print-build-artifact-paths + * (defaults to cwd when GITHUB_WORKSPACE is unset) + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = path.resolve(__dirname, '..'); +const graphPath = path.join(workspaceRoot, '.nx', 'ci-print-build-artifact-paths-graph.json'); + +const TARGETS = ['build:transpile', 'build:types']; + +fs.mkdirSync(path.dirname(graphPath), { recursive: true }); +execSync(`yarn nx graph --file="${graphPath}"`, { + cwd: workspaceRoot, + stdio: ['ignore', 'pipe', 'inherit'], +}); + +const { graph } = JSON.parse(fs.readFileSync(graphPath, 'utf8')); +try { + fs.unlinkSync(graphPath); +} catch { + // ignore +} + +/** @type {Map>} key = `${kind}\0${suffix}` */ +const groups = new Map(); + +for (const node of Object.values(graph.nodes)) { + const root = node.data?.root; + if (!root || (!root.startsWith('packages/') && !root.startsWith('dev-packages/'))) { + continue; + } + + const [kind, pkg] = root.split('/'); + if (!kind || !pkg) { + continue; + } + + const targets = node.data?.targets || {}; + for (const targetName of TARGETS) { + const outputs = targets[targetName]?.outputs; + if (!Array.isArray(outputs)) { + continue; + } + + for (const output of outputs) { + const rel = output.replace(/\{projectRoot\}/g, root).replace(/\\/g, '/'); + const prefix = `${kind}/${pkg}/`; + if (!rel.startsWith(prefix)) { + throw new Error(`Unexpected Nx output (missing project prefix): ${rel}`); + } + const suffix = rel.slice(prefix.length); + const key = `${kind}\0${suffix}`; + if (!groups.has(key)) { + groups.set(key, new Set()); + } + groups.get(key).add(pkg); + } + } +} + +const ws = (process.env.GITHUB_WORKSPACE || workspaceRoot).replace(/\\/g, '/'); +const lines = new Set(); + +// A glob like packages + star + slash + "build" matches every package's build tree, so we +// never emit that when several projects each declare a top-level {projectRoot}/build output. +function isUnsafeSharedTopLevelBuildSuffix(suffix, pkgCount) { + return pkgCount > 1 && !suffix.includes('/') && !/[?*]/.test(suffix) && suffix === 'build'; +} + +for (const [key, pkgSet] of groups) { + const [kind, suffix] = key.split('\0'); + const pkgs = [...pkgSet].sort((a, b) => a.localeCompare(b)); + const n = pkgs.length; + + if (n === 1) { + lines.add(`${ws}/${kind}/${pkgs[0]}/${suffix}`); + continue; + } + + if (isUnsafeSharedTopLevelBuildSuffix(suffix, n)) { + for (const pkg of pkgs) { + lines.add(`${ws}/${kind}/${pkg}/build`); + } + continue; + } + + lines.add(`${ws}/${kind}/*/${suffix}`); +} + +process.stdout.write([...lines].sort((a, b) => a.localeCompare(b)).join('\n')); +if (lines.size) { + process.stdout.write('\n'); +} diff --git a/scripts/report-ci-failures.mjs b/scripts/report-ci-failures.mjs index 5a05e1144bec..b407eac157c0 100644 --- a/scripts/report-ci-failures.mjs +++ b/scripts/report-ci-failures.mjs @@ -68,9 +68,10 @@ export default async function run({ github, context, core }) { core.info(`Could not fetch annotations for ${jobName}: ${e.message}`); } - // If no test names found, fall back to one issue per job + // If no test names found, abort - this could mean something else, e.g. cache restoration or similar fails + // and also the issue is not super helpful in this case if (testNames.length === 0) { - testNames = ['Unknown test']; + continue; } // Create one issue per failing test for proper deduplication diff --git a/yarn.lock b/yarn.lock index d95a0b67c008..40d531ea0275 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3158,12 +3158,12 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz#2cbcf822bf3764c9658c4d2e568bd0c0cb748016" integrity sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw== -"@dabh/diagnostics@^2.0.2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" - integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== +"@dabh/diagnostics@^2.0.2", "@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== dependencies: - colorspace "1.1.x" + "@so-ric/colorspace" "^1.1.6" enabled "2.0.x" kuler "^2.0.0" @@ -3305,25 +3305,25 @@ lodash "^4.17.21" resolve "^1.20.0" -"@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== +"@emnapi/core@1.9.2", "@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== dependencies: - "@emnapi/wasi-threads" "1.2.0" + "@emnapi/wasi-threads" "1.2.1" tslib "^2.4.0" -"@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== +"@emnapi/runtime@1.9.2", "@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== dependencies: tslib "^2.4.0" -"@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== +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== dependencies: tslib "^2.4.0" @@ -3373,6 +3373,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -3413,6 +3418,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + "@esbuild/android-arm@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" @@ -3458,6 +3468,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + "@esbuild/android-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" @@ -3498,6 +3513,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + "@esbuild/darwin-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" @@ -3538,6 +3558,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256" integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + "@esbuild/darwin-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" @@ -3578,6 +3603,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + "@esbuild/freebsd-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" @@ -3618,6 +3648,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + "@esbuild/freebsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" @@ -3658,6 +3693,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + "@esbuild/linux-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" @@ -3698,6 +3738,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + "@esbuild/linux-arm@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" @@ -3738,6 +3783,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + "@esbuild/linux-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" @@ -3778,6 +3828,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + "@esbuild/linux-loong64@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239" @@ -3828,6 +3883,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + "@esbuild/linux-mips64el@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" @@ -3868,6 +3928,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + "@esbuild/linux-ppc64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" @@ -3908,6 +3973,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + "@esbuild/linux-riscv64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" @@ -3948,6 +4018,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + "@esbuild/linux-s390x@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" @@ -3988,6 +4063,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + "@esbuild/linux-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" @@ -4028,6 +4108,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + "@esbuild/netbsd-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" @@ -4043,6 +4128,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + "@esbuild/netbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" @@ -4083,6 +4173,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + "@esbuild/openbsd-arm64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" @@ -4103,6 +4198,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + "@esbuild/openbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" @@ -4143,6 +4243,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + "@esbuild/openharmony-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" @@ -4158,6 +4263,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + "@esbuild/sunos-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" @@ -4198,6 +4308,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + "@esbuild/win32-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" @@ -4238,6 +4353,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + "@esbuild/win32-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" @@ -4278,6 +4398,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + "@esbuild/win32-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" @@ -4318,6 +4443,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -5338,13 +5468,11 @@ "@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== +"@napi-rs/wasm-runtime@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz#1eeb8699770481306e5fcd84471f20fcb6177336" + integrity sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ== dependencies: - "@emnapi/core" "^1.7.1" - "@emnapi/runtime" "^1.7.1" "@tybys/wasm-util" "^0.10.1" "@nestjs/common@^10.0.0": @@ -6468,10 +6596,10 @@ 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.124.0": + version "0.124.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.124.0.tgz#1dfd7b3fbb98febc2f91b505f48c940db73c8701" + integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg== "@oxc-project/types@^0.76.0": version "0.76.0" @@ -6823,9 +6951,9 @@ integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== "@poppinss/colors@^4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@poppinss/colors/-/colors-4.1.5.tgz#09273b845a4816f5fd9c53c78a3bc656650fe18f" - integrity sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw== + version "4.1.6" + resolved "https://registry.yarnpkg.com/@poppinss/colors/-/colors-4.1.6.tgz#bf8546e30cfc5ee8dfe68988ce58eb0ad9d7c21b" + integrity sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg== dependencies: kleur "^4.1.5" @@ -7183,87 +7311,94 @@ dependencies: web-streams-polyfill "^3.1.1" -"@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== +"@rolldown/binding-android-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz#ca20574c469ade7b941f90c9af5e83e7c67f06b7" + integrity sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz#ce2c5c7fc4958dfc94783dc09b3d09f3c2e1d072" + integrity sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg== + +"@rolldown/binding-darwin-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz#251ecdf1fdb751031cb6486907c105daaf9dab21" + integrity sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz#dbcfe95f409bf671a77bd83bff0fdc877d217728" + integrity sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz#ea002b45445be6f9ed1883a834b335bc2ccd510f" + integrity sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz#12b96e7e7821a9dc2cd5c670ad56882987ed5c62" + integrity sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz#738b0f62f0b65bf676dfe48595017f1883859d1f" + integrity sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz#3088b9fbc2783033985b558316f87f39281bc533" + integrity sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz#ac0aa6f1b72e3151d56c43145a71c745cf862a9a" + integrity sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz#b8cf27aa5be6da641c22dad5665d0240551d2dec" + integrity sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz#4531f9eca77963935026634ba9b61c2535340534" + integrity sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz#66ff691a65f9325171bced98e353b4cc4b0095c3" + integrity sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz#7db6c90aa510eef65d7d0f14e8ca23775e8e5eee" + integrity sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.3" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz#81f9097abbd4493cc13373b26f5a3da8461dbb47" + integrity sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz#cef11bc89149f3a77771727be75490fbb13ae193" + integrity sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g== + +"@rolldown/pluginutils@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz#e75d7731593e195d23710f9ff49bf5c745c96682" + integrity sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g== + +"@rolldown/pluginutils@^1.0.0-beta.9": + version "1.0.0-rc.16" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz#bc27c8f906309b57c6c10eddb21043fd8e86b87e" + integrity sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA== "@rollup/plugin-alias@^5.0.0": version "5.1.1" @@ -7864,18 +7999,26 @@ "@sinonjs/commons" "^3.0.1" type-detect "^4.1.0" -"@size-limit/file@~11.1.6": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-11.1.6.tgz#de1244aef06081a93bd594ddc28ef14080ca5b01" - integrity sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ== +"@size-limit/esbuild@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/esbuild/-/esbuild-12.1.0.tgz#d0527ee8eed98794966b089c7fe61760cf4e6c7b" + integrity sha512-Um6MVrX+05kIxI4+zk0ZByG9dA/Th1f+sfGc571D95BnCPc90/pl2+2OdsQuOyoWEbeAMqfcTKo0v07i+E65Vw== + dependencies: + esbuild "^0.28.0" + nanoid "^5.1.7" -"@size-limit/webpack@~11.1.6": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-11.1.6.tgz#a73f5b82a88d0896e45697863370e7a56e6cf2b9" - integrity sha512-PTZCgwJsgdzdEj2wPFuLm0cCge8N2WbswMcKWNwMJibxQxPAmiF+sZ2F6GYBS7G7K3Fb4ovCliuN+wnnRACPNg== +"@size-limit/file@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-12.1.0.tgz#3e0740e98cbb5c46c7e53939a37df5de68269fe2" + integrity sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ== + +"@size-limit/webpack@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-12.1.0.tgz#34dae69cc571c504486e055face540f180cf1fb8" + integrity sha512-3v/evOHskR0eVD6hpRO9jFpRPkpJb+GhqKJGxrqf1j1ZeoYu9A8v8iT3+U4TJOXTZbCbJ+Q6lWRBPFEcLZbNuw== dependencies: - nanoid "^5.0.7" - webpack "^5.95.0" + nanoid "^5.1.7" + webpack "^5.106.1" "@smithy/abort-controller@^4.2.8": version "4.2.8" @@ -8377,6 +8520,14 @@ dependencies: tslib "^2.6.2" +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + "@socket.io/component-emitter@~3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" @@ -10907,10 +11058,10 @@ acorn@8.11.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: - version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -12268,7 +12419,7 @@ brace-expansion@^2.0.1, brace-expansion@^2.0.2: dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.2, brace-expansion@^5.0.5: +brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== @@ -13183,7 +13334,7 @@ check-error@^2.1.1: optionalDependencies: fsevents "~2.3.2" -chokidar@^4.0.0, chokidar@^4.0.1, chokidar@^4.0.3: +chokidar@^4.0.0, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== @@ -13423,7 +13574,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0, color-convert@^1.9.3: +color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -13437,6 +13588,13 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" +color-convert@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.3.tgz#db6627b97181cb8facdfce755ae26f97ab0711f1" + integrity sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg== + dependencies: + color-name "^2.0.0" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -13447,7 +13605,12 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.6.0, color-string@^1.9.0: +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -13455,19 +13618,18 @@ color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== + dependencies: + color-name "^2.0.0" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -color@^3.1.3: - version "3.2.1" - resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" - integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== - dependencies: - color-convert "^1.9.3" - color-string "^1.6.0" - color@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" @@ -13476,6 +13638,14 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" +color@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.3.tgz#f79390b1b778e222ffbb54304d3dbeaef633f97f" + integrity sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA== + dependencies: + color-convert "^3.1.3" + color-string "^2.1.3" + colord@^2.9.3: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" @@ -13501,14 +13671,6 @@ colors@^1.4.0: resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== -colorspace@1.1.x: - version "1.1.4" - resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" - integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== - dependencies: - color "^3.1.3" - text-hex "1.0.x" - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -13983,10 +14145,10 @@ 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== +crossws@^0.4.4, crossws@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.4.5.tgz#e300fec909cd93fe377a1cee84f6813c9c786edf" + integrity sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA== crypto-random-string@^2.0.0: version "2.0.0" @@ -15601,10 +15763,10 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.4, enhanced-resolve@^5.18.0: - version "5.19.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" - integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== +enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.4, enhanced-resolve@^5.18.0, enhanced-resolve@^5.20.0: + version "5.20.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz#eeeb3966bea62c348c40a0cc9e7912e2557d0be0" + integrity sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA== dependencies: graceful-fs "^4.2.4" tapable "^2.3.0" @@ -15651,14 +15813,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== +env-runner@^0.1.6, env-runner@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/env-runner/-/env-runner-0.1.7.tgz#ab26aa711cf195c9d8e158b6e864291fbd8d202e" + integrity sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA== dependencies: crossws "^0.4.4" - httpxy "^0.3.1" - srvx "^0.11.9" + exsolve "^1.0.8" + httpxy "^0.5.0" + srvx "^0.11.13" err-code@^2.0.2: version "2.0.3" @@ -16358,6 +16521,38 @@ esbuild@^0.27.2: "@esbuild/win32-ia32" "0.27.2" "@esbuild/win32-x64" "0.27.2" +esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -18363,13 +18558,13 @@ 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== +h3@^2.0.1-rc.13, h3@^2.0.1-rc.16, h3@^2.0.1-rc.20: + version "2.0.1-rc.20" + resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.20.tgz#51050db30afb0b6e69718d88cccc23666fbe8039" + integrity sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg== dependencies: rou3 "^0.8.1" - srvx "^0.11.12" + srvx "^0.11.13" handle-thing@^2.0.0: version "2.0.1" @@ -18728,10 +18923,10 @@ hookable@^5.5.3: resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== -hookable@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/hookable/-/hookable-6.0.1.tgz#be950f1b8ef38af24d4354657e9e3590d2a5b5e6" - integrity sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw== +hookable@^6.0.1, hookable@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-6.1.1.tgz#825f966b4b426db2e622d94d7a31a70f196f9d2f" + integrity sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ== hosted-git-info@^5.0.0: version "5.1.0" @@ -18956,10 +19151,10 @@ 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== +httpxy@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.5.0.tgz#a9c53543760dee498611827a464e56e14639c0d0" + integrity sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg== human-signals@^1.1.1: version "1.1.1" @@ -20042,7 +20237,7 @@ jiti@^1.19.3, jiti@^1.21.0, jiti@^1.21.6: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== -jiti@^2.0.0, jiti@^2.1.2, jiti@^2.4.2, jiti@^2.6.1: +jiti@^2.1.2, jiti@^2.4.2, jiti@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== @@ -20548,7 +20743,7 @@ license-webpack-plugin@4.0.2: dependencies: webpack-sources "^3.0.0" -lilconfig@^3.1.2, lilconfig@^3.1.3: +lilconfig@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== @@ -21967,12 +22162,12 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@10.2.4: - version "10.2.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" - integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== +minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== dependencies: - brace-expansion "^5.0.2" + brace-expansion "^5.0.5" "minimatch@2 || 3", minimatch@3.1.5, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@~3.0.4: version "3.1.5" @@ -21988,13 +22183,6 @@ minimatch@5.1.0, minimatch@5.1.9, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.2.2, minimatch@^10.2.4: - version "10.2.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - dependencies: - brace-expansion "^5.0.5" - minimatch@^7.4.1, minimatch@~7.4.9: version "7.4.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.9.tgz#ef35412b1b36261b78ef1b2f0db29b759bbcaf5d" @@ -22479,10 +22667,10 @@ nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.8: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== -nanoid@^5.0.7, nanoid@^5.1.0: - version "5.1.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.6.tgz#30363f664797e7d40429f6c16946d6bd7a3f26c9" - integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg== +nanoid@^5.1.0, nanoid@^5.1.7: + version "5.1.9" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.9.tgz#aac959acf7d685269fb1be7f70a90d9db0848948" + integrity sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw== nanomatch@^1.2.9: version "1.2.13" @@ -22501,12 +22689,12 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -nanospinner@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97" - integrity sha512-yFvNYMig4AthKYfHFl1sLj7B2nkHL4lzdig4osvl9/LdGbXwrdFRoqBS98gsEsOakr0yH+r5NZ/1Y9gdVB8trA== +nanospinner@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.2.2.tgz#5a38f4410b5bf7a41585964bee74d32eab3e040b" + integrity sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA== dependencies: - picocolors "^1.0.0" + picocolors "^1.1.1" nanotar@^0.2.0: version "0.2.0" @@ -22606,10 +22794,10 @@ 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== +nf3@^0.3.11, nf3@^0.3.16: + version "0.3.16" + resolved "https://registry.yarnpkg.com/nf3/-/nf3-0.3.16.tgz#36e3d1bb36d98ee78b47627b7967864c2ea01720" + integrity sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw== ng-packagr@^14.2.2: version "14.3.0" @@ -22676,6 +22864,26 @@ nitro@^3.0.260311-beta: unenv "^2.0.0-rc.24" unstorage "^2.0.0-alpha.6" +nitro@^3.0.260415-beta: + version "3.0.260415-beta" + resolved "https://registry.yarnpkg.com/nitro/-/nitro-3.0.260415-beta.tgz#2a40c38c9a2d6ae14b259ebe78e5ce1142d0c5e5" + integrity sha512-J0ntJERWtIdvweZdmkCiF8eOFvP9fIAJR2gpeIDrHbAlYavK41WQfADo/YoZ/LF7RMTZBiPaH/pt2s/nPru9Iw== + dependencies: + consola "^3.4.2" + crossws "^0.4.5" + db0 "^0.3.4" + env-runner "^0.1.7" + h3 "^2.0.1-rc.20" + hookable "^6.1.1" + nf3 "^0.3.16" + ocache "^0.1.4" + ofetch "^2.0.0-alpha.3" + ohash "^2.0.11" + rolldown "^1.0.0-rc.15" + srvx "^0.11.15" + unenv "^2.0.0-rc.24" + unstorage "^2.0.0-alpha.7" + 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" @@ -23416,7 +23624,7 @@ 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: +ocache@^0.1.2, ocache@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/ocache/-/ocache-0.1.4.tgz#d4a71be84ceaeb5685cc0128c197d44713dda9a7" integrity sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ== @@ -24324,10 +24532,10 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== pidtree@^0.6.0: version "0.6.0" @@ -26541,29 +26749,29 @@ 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== +rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8: + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.15.tgz#ea3526443b2dbe834e9f8f6c1fde6232ec687170" + integrity sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g== dependencies: - "@oxc-project/types" "=0.120.0" - "@rolldown/pluginutils" "1.0.0-rc.10" + "@oxc-project/types" "=0.124.0" + "@rolldown/pluginutils" "1.0.0-rc.15" 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" + "@rolldown/binding-android-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-x64" "1.0.0-rc.15" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.15" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.15" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.15" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.15" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.15" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.15" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.15" rollup-plugin-cleanup@^3.2.1: version "3.2.1" @@ -27035,7 +27243,7 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: +serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== @@ -27439,18 +27647,16 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -size-limit@~11.1.6: - version "11.1.6" - resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-11.1.6.tgz#75cd54f9326d1b065ebcb6ca9ec27294e7ccdfb1" - integrity sha512-S5ux2IB8rU26xwVgMskmknGMFkieaIAqDLuwgKiypk6oa4lFsie8yFPrzRFV+yrLDY2GddjXuCaVk5PveVOHiQ== +size-limit@~12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-12.1.0.tgz#4e62d95773f3ea86d4dac7727fa6de8478050782" + integrity sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw== dependencies: bytes-iec "^3.1.1" - chokidar "^4.0.1" - jiti "^2.0.0" - lilconfig "^3.1.2" - nanospinner "^1.1.0" - picocolors "^1.1.0" - tinyglobby "^0.2.7" + lilconfig "^3.1.3" + nanospinner "^1.2.2" + picocolors "^1.1.1" + tinyglobby "^0.2.16" skip-regex@^1.0.2: version "1.0.2" @@ -27887,10 +28093,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.12, srvx@^0.11.2, srvx@^0.11.9: - version "0.11.13" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.13.tgz#cc77a98cb9a459c34f75ee4345bd0eef9f613a54" - integrity sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw== +srvx@^0.11.13, srvx@^0.11.15, srvx@^0.11.2, srvx@^0.11.9: + version "0.11.15" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.15.tgz#51c08f993bb116f5821ec929a466a29e8d5c7b61" + integrity sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg== ssri@^9.0.0: version "9.0.1" @@ -28639,15 +28845,14 @@ terracotta@^1.0.4: dependencies: solid-use "^0.8.0" -terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.3.16: - version "5.3.16" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" - integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== +terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.3.16, terser-webpack-plugin@^5.3.17: + version "5.4.0" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz#95fc4cf4437e587be11ecf37d08636089174d76b" + integrity sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g== dependencies: "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" schema-utils "^4.3.0" - serialize-javascript "^6.0.2" terser "^5.31.1" terser@5.14.2: @@ -28843,13 +29048,13 @@ tinyglobby@0.2.14: fdir "^6.4.4" picomatch "^4.0.2" -tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.2, tinyglobby@^0.2.7: - version "0.2.15" - resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" - integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== +tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.16, tinyglobby@^0.2.2: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== dependencies: fdir "^6.5.0" - picomatch "^4.0.3" + picomatch "^4.0.4" tinypool@2.1.0: version "2.1.0" @@ -29817,7 +30022,7 @@ unstorage@^1.16.0, unstorage@^1.17.4: ofetch "^1.5.1" ufo "^1.6.3" -unstorage@^2.0.0-alpha.6: +unstorage@^2.0.0-alpha.6, unstorage@^2.0.0-alpha.7: 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== @@ -30438,7 +30643,7 @@ watch-detector@^1.0.0, watch-detector@^1.0.2: silent-error "^1.1.1" tmp "^0.1.0" -watchpack@^2.4.0, watchpack@^2.4.4: +watchpack@^2.4.0, watchpack@^2.4.4, watchpack@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== @@ -30572,10 +30777,10 @@ webpack-merge@5.8.0: clone-deep "^4.0.1" wildcard "^2.0.0" -webpack-sources@^3.0.0, webpack-sources@^3.2.3, webpack-sources@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" - integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== +webpack-sources@^3.0.0, webpack-sources@^3.2.3, webpack-sources@^3.3.3, webpack-sources@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" + integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== webpack-subresource-integrity@5.1.0: version "5.1.0" @@ -30619,7 +30824,37 @@ webpack@5.76.1: watchpack "^2.4.0" webpack-sources "^3.2.3" -webpack@^5.0.0, webpack@^5.95.0, webpack@~5.104.1: +webpack@^5.0.0, webpack@^5.106.1, webpack@^5.95.0: + version "5.106.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5" + integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.16.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.20.0" + es-module-lexer "^2.0.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + loader-runner "^4.3.1" + mime-db "^1.54.0" + neo-async "^2.6.2" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.17" + watchpack "^2.5.1" + webpack-sources "^3.3.4" + +webpack@~5.104.1: version "5.104.1" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.104.1.tgz#94bd41eb5dbf06e93be165ba8be41b8260d4fb1a" integrity sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA== @@ -30876,12 +31111,12 @@ winston@3.13.0: winston-transport "^4.7.0" winston@^3.17.0: - version "3.17.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" - integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== + version "3.19.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.19.0.tgz#cc1d1262f5f45946904085cfffe73efb4b7a581d" + integrity sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA== dependencies: "@colors/colors" "^1.6.0" - "@dabh/diagnostics" "^2.0.2" + "@dabh/diagnostics" "^2.0.8" async "^3.2.3" is-stream "^2.0.0" logform "^2.7.0"