diff --git a/.agents/skills/track-framework-updates/SKILL.md b/.agents/skills/track-framework-updates/SKILL.md index 7ebd148d6c85..a3a14417619c 100644 --- a/.agents/skills/track-framework-updates/SKILL.md +++ b/.agents/skills/track-framework-updates/SKILL.md @@ -107,12 +107,20 @@ For each release or RFC that plausibly needs SDK work, draft one concrete, actio ### Step 6: Write output artifacts -Produce **three files** in the skill's `output/` directory: +The `collect_updates.py` script in Step 1 prints its output path, e.g.: -1. **`output/framework-updates-raw.json`** — already written by Step 1. -2. **`output/framework-updates-digest.json`** — structured, machine-readable digest. Follow the schema in `assets/digest-schema.json`. -3. **`output/framework-updates-digest.md`** — human-readable digest. Follow the structure in `assets/digest-template.md`: - - Group by Client-Side / Server-Side / Meta-Framework. +``` +Wrote /abs/path/to/.agents/skills/track-framework-updates/output/framework-updates-raw.json: ... +``` + +Use that printed path to derive the output directory. All three files must be written to the **same directory** as `framework-updates-raw.json` — never relative paths like `output/` from the workspace root. + +Produce **three files**: + +1. The raw JSON was already written by Step 1 — no action needed. +2. **`/framework-updates-digest.json`** — structured, machine-readable digest. Follow the schema in `assets/digest-schema.json`. +3. **`/framework-updates-digest.md`** — human-readable digest. Follow the structure in `assets/digest-template.md`: + - Group by Client-Side / Server-Side / Meta-Framework / Platform / Libraries. - Omit frameworks with no activity. - Include a "Run notes" section only if a fetcher reported errors. diff --git a/.agents/skills/track-framework-updates/assets/digest-schema.json b/.agents/skills/track-framework-updates/assets/digest-schema.json index 55b503ed7c10..843ba26acc72 100644 --- a/.agents/skills/track-framework-updates/assets/digest-schema.json +++ b/.agents/skills/track-framework-updates/assets/digest-schema.json @@ -14,7 +14,7 @@ { "name": "React", "sentryPackages": ["@sentry/react"], - "category": "client", + "category": "client|server|meta-framework|platform|library", "releases": [ { "tag": "v19.0.0", diff --git a/.agents/skills/track-framework-updates/assets/digest-template.md b/.agents/skills/track-framework-updates/assets/digest-template.md index 6d8f9a31f9fc..cbb17fa62e30 100644 --- a/.agents/skills/track-framework-updates/assets/digest-template.md +++ b/.agents/skills/track-framework-updates/assets/digest-template.md @@ -39,6 +39,10 @@ _Window: last days · generated _ +## Libraries + + + ## Source coverage ⚠ The following SDK packages are **not tracked** in `sources.json`. Add an upstream framework entry or exclude them in `scripts/check_sources.py`: diff --git a/.agents/skills/track-framework-updates/scripts/write_job_summary.py b/.agents/skills/track-framework-updates/scripts/write_job_summary.py index 6ba3c406c524..595c9ec7b2ac 100644 --- a/.agents/skills/track-framework-updates/scripts/write_job_summary.py +++ b/.agents/skills/track-framework-updates/scripts/write_job_summary.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import os import sys @@ -85,8 +86,20 @@ def main() -> int: digest = f.read().strip() if digest: lines.append(digest) - except OSError: - lines.append("_Digest file not found._") + except OSError as e: + lines.append(f"_Digest file not found: `{digest_path}` ({e})_") + lines.append("") + # List what is actually in the output dir to help diagnose path mismatches + output_dir = os.path.dirname(os.path.abspath(digest_path)) + try: + found = os.listdir(output_dir) + if found: + lines.append(f"Files present in `{output_dir}`:") + lines.extend(f"- `{name}`" for name in sorted(found)) + else: + lines.append(f"`{output_dir}` exists but is empty.") + except OSError: + lines.append(f"Output directory not found: `{output_dir}`") # Run metrics at the bottom cost_str = ( diff --git a/.agents/skills/track-framework-updates/sources.json b/.agents/skills/track-framework-updates/sources.json index 1bab8000f8fe..76f8f18964a6 100644 --- a/.agents/skills/track-framework-updates/sources.json +++ b/.agents/skills/track-framework-updates/sources.json @@ -100,6 +100,17 @@ }, "rss": [] }, + { + "name": "Express", + "sentryPackages": ["@sentry/node"], + "category": "server", + "github": { + "repo": "expressjs/express", + "discussions": false, + "rfcsRepo": null + }, + "rss": ["https://expressjs.com/feed.xml"] + }, { "name": "Elysia", "sentryPackages": ["@sentry/elysia"], @@ -275,6 +286,61 @@ "rfcsRepo": null }, "rss": [] + }, + { + "name": "Pinia", + "sentryPackages": ["@sentry/vue", "@sentry/nuxt"], + "category": "library", + "github": { + "repo": "vuejs/pinia", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Pino", + "sentryPackages": ["@sentry/node"], + "category": "library", + "github": { + "repo": "pinojs/pino", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Winston", + "sentryPackages": ["@sentry/node"], + "category": "library", + "github": { + "repo": "winstonjs/winston", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Consola", + "sentryPackages": ["@sentry/core"], + "category": "library", + "github": { + "repo": "unjs/consola", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Zod", + "sentryPackages": ["@sentry/core"], + "category": "library", + "github": { + "repo": "colinhacks/zod", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] } ] } diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index a03007fce8a3..87fede1ea9be 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -78,8 +78,10 @@ jobs: - name: Try to fix the issue with Claude id: triage uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' show_full_output: ${{ github.event.inputs.show_full_output || 'false' }} diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index 7b888c51433f..1589dbaf0a3f 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -159,8 +159,10 @@ jobs: - name: Open batched runtime fix PR via skill uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} settings: | { @@ -234,8 +236,10 @@ jobs: - name: Open batched dev fix PR via skill uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} settings: | { diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 6bdb4bdaee15..1fa3fce76a32 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -44,8 +44,10 @@ jobs: - name: Run Claude digest id: digest uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' prompt: | diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index e4431205647b..3546a77e1c0e 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -55,8 +55,10 @@ jobs: - name: Run Claude triage id: triage uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' settings: | diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index dd6ed529bc95..57e39bcc20dd 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -147,6 +147,20 @@ "no-param-reassign": "off" } }, + { + "files": ["**/integrations/tracing-channel/aws-sdk/**/*.ts"], + "rules": { + "typescript/no-explicit-any": "off", + "typescript/no-unsafe-member-access": "off" + } + }, + { + "files": ["**/integrations/tracing-channel/aws-sdk/services/**/*.ts"], + "rules": { + "max-lines": "off", + "complexity": "off" + } + }, { "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], "rules": { diff --git a/.size-limit.js b/.size-limit.js index 53bd15c11bf6..476a39a18815 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '142 KB', + limit: '150 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -460,7 +460,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '183 KiB', + limit: '193 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '445 KiB', + limit: '475 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/AGENTS.md b/AGENTS.md index 9c00f0403fb9..c39555fc1d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ Uses **Git Flow** (see `docs/gitflow.md`). ## Coding Standards - Follow existing conventions — check neighboring files +- Reach for existing utils before writing a new one. Most shared helpers live in `@sentry/core` (`packages/core/src/utils/`), with browser helpers in `packages/browser-utils/`. Search first (LSP `workspaceSymbol` or grep) for common needs (type guards in `is.ts`, object/array helpers, `normalize`, `dsn`, `merge`, string/url helpers). Reuse or extend the existing util rather than adding a near-duplicate; only introduce a new util when nothing fits. - Only use libraries already in the codebase - Never expose secrets or keys - When modifying files, cover all occurrences (including `src/` and `test/`) diff --git a/CHANGELOG.md b/CHANGELOG.md index d116d36ed648..f8190e95a4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,77 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.67.0 + +### Important Changes + +- **feat(sveltekit): Add support for SvelteKit 3 ([#22264](https://github.com/getsentry/sentry-javascript/pull/22264))** + + The SvelteKit SDK now supports the pre-release of SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. + +### Other Changes + +- feat(aws-serverless): Use orchestrion aws-sdk integration under diagnostics-channel opt-in ([#22143](https://github.com/getsentry/sentry-javascript/pull/22143)) +- feat(cloudflare): Auto-instrument Workers AI binding via env instrumentation ([#22126](https://github.com/getsentry/sentry-javascript/pull/22126)) +- feat(cloudflare): Instrument Cloudflare rate limiter bindings ([#22035](https://github.com/getsentry/sentry-javascript/pull/22035)) +- feat(core): Instrument workers-ai-provider ([#22119](https://github.com/getsentry/sentry-javascript/pull/22119)) +- feat(core): Rename `queryParams` to `urlQueryParams` ([#22217](https://github.com/getsentry/sentry-javascript/pull/22217)) +- feat(mongodb): implement orchestrion mongodb integration ([#22308](https://github.com/getsentry/sentry-javascript/pull/22308)) +- feat(mongoose): implement orchestrion mongoose integration ([#22202](https://github.com/getsentry/sentry-javascript/pull/22202)) +- feat(node): Rewrite tedious instrumentation to orchestrion tracing channels ([#22238](https://github.com/getsentry/sentry-javascript/pull/22238)) +- feat(nuxt): Add `_experimental.useDiagnosticsChannelInjection` ([#22323](https://github.com/getsentry/sentry-javascript/pull/22323)) +- feat(remix): Add orchestrion-based remix instrumentation ([#22244](https://github.com/getsentry/sentry-javascript/pull/22244)) +- feat(replay): Allow skipping the final flush on stop() via { flush: false } ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) +- feat(server-utils): Add @opentelemetry/instrumentation-koa orchestrion integration ([#22146](https://github.com/getsentry/sentry-javascript/pull/22146)) +- feat(server-utils): Add orchestrion aws-sdk channel integration core ([#22142](https://github.com/getsentry/sentry-javascript/pull/22142)) +- feat(server-utils): Allow passing custom transforms to orchestrion ([#22319](https://github.com/getsentry/sentry-javascript/pull/22319)) +- feat(server-utils): Port Bedrock aws-sdk extension and add integration tests ([#22166](https://github.com/getsentry/sentry-javascript/pull/22166)) +- feat(server-utils): Port S3, Kinesis, DynamoDB, SecretsManager and StepFunctions aws-sdk extensions ([#22164](https://github.com/getsentry/sentry-javascript/pull/22164)) +- feat(server-utils): Port SQS, SNS and Lambda aws-sdk extensions with trace propagation ([#22165](https://github.com/getsentry/sentry-javascript/pull/22165)) +- feat(server-utils): Rewrite `SentryLangChainInstrumentation` to orchestrion ([#22266](https://github.com/getsentry/sentry-javascript/pull/22266)) +- feat(server-utils): Rewrite `SentryLangGraphInstrumentation` to orchestrion ([#22268](https://github.com/getsentry/sentry-javascript/pull/22268)) +- feat(vue): Set `navigation.route.id` from the route name ([#22372](https://github.com/getsentry/sentry-javascript/pull/22372)) +- fix(bundler): do not import type from optional peer dep ([#22304](https://github.com/getsentry/sentry-javascript/pull/22304)) +- fix(cloudflare): Bind Durable Object built-in handlers ([#22340](https://github.com/getsentry/sentry-javascript/pull/22340)) +- fix(cloudflare): Instrument custom WorkerEntrypoint RPC methods ([#22310](https://github.com/getsentry/sentry-javascript/pull/22310)) +- fix(cloudflare): Route DO teardown through original `waitUntil` ([#22339](https://github.com/getsentry/sentry-javascript/pull/22339)) +- fix(cloudflare): Skip spans for Cloudflare-internal Durable Object SQL queries ([#22376](https://github.com/getsentry/sentry-javascript/pull/22376)) +- fix(core): Prevent functionToStringIntegration from throwing on cross-origin realms ([#22273](https://github.com/getsentry/sentry-javascript/pull/22273)) +- fix(core): Prevent infinite recursion when console is instrumented twice ([#21959](https://github.com/getsentry/sentry-javascript/pull/21959)) +- fix(nextjs): Mark internal chunk frames as not `in_app` ([#22354](https://github.com/getsentry/sentry-javascript/pull/22354)) +- fix(node): Register tracer provider when OTel API global pre-exists with a different version ([#22374](https://github.com/getsentry/sentry-javascript/pull/22374)) +- fix(node-native): Don't drop breadcrumbs from event loop block events ([#22322](https://github.com/getsentry/sentry-javascript/pull/22322)) +- fix(serve-rutils): Orchestrion diagnostics for async and require ([#22327](https://github.com/getsentry/sentry-javascript/pull/22327)) +- fix(server-utils): Only skip loader thread ([#22336](https://github.com/getsentry/sentry-javascript/pull/22336)) +- fix(sveltekit): Detect SvelteKit server build via Vite Environment API ([#21629](https://github.com/getsentry/sentry-javascript/pull/21629)) +- fix(vercelai): Avoid double-capturing v4 tool errors in orchestrion mode ([#22293](https://github.com/getsentry/sentry-javascript/pull/22293)) +- fix(vue): Support pinia 4 in peer dependency range ([#22324](https://github.com/getsentry/sentry-javascript/pull/22324)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#22357](https://github.com/getsentry/sentry-javascript/pull/22357)) +- chore: Add external contributor to CHANGELOG.md ([#22359](https://github.com/getsentry/sentry-javascript/pull/22359)) +- chore: Remove `@apm-js-collab/code-transformer` as a dependency ([#22317](https://github.com/getsentry/sentry-javascript/pull/22317)) +- chore(changelog): Add callout for SvelteKit 3 support ([#22371](https://github.com/getsentry/sentry-javascript/pull/22371)) +- chore(ci): Use openrouter keys for claude in ci ([#22381](https://github.com/getsentry/sentry-javascript/pull/22381)) +- chore(skills): Add libraries to framework update watcher ([#22326](https://github.com/getsentry/sentry-javascript/pull/22326)) +- docs(agents): Prefer reusing existing utils before adding new ones ([#22370](https://github.com/getsentry/sentry-javascript/pull/22370)) +- feat(deps): Bump @opentelemetry/semantic-conventions from 1.42.0 to 1.43.0 in the opentelemetry group ([#22333](https://github.com/getsentry/sentry-javascript/pull/22333)) +- fix(skills): Make sure digest file is written/read correctly ([#22330](https://github.com/getsentry/sentry-javascript/pull/22330)) +- ref: Omit redundant `captureError: false` from tracing-channel callsites ([#22368](https://github.com/getsentry/sentry-javascript/pull/22368)) +- ref: Use base keys from conventions for dynamic suffix attribute keys ([#22356](https://github.com/getsentry/sentry-javascript/pull/22356)) +- ref(core): Replace `sentry.span.source` attribute with `sentry.segment.name.source` ([#22358](https://github.com/getsentry/sentry-javascript/pull/22358)) +- ref(server-utils): Collapse express route/use into one orchestrion channel ([#22331](https://github.com/getsentry/sentry-javascript/pull/22331)) +- ref(server-utils): Share one orchestrion channel across OpenAI chat/responses/conversations ([#22332](https://github.com/getsentry/sentry-javascript/pull/22332)) +- refactor(nextjs): Change ESM interop. to remove lazy loading ([#22193](https://github.com/getsentry/sentry-javascript/pull/22193)) +- test(cloudflare): Add tests for `ignoreSpans` with continued traces ([#22316](https://github.com/getsentry/sentry-javascript/pull/22316)) +- test(cloudflare): Proof that AI providers work OOTB on Cloudflare ([#22313](https://github.com/getsentry/sentry-javascript/pull/22313)) + +
+ +Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! + ## 10.66.0 - chore(node-core): Deprecate `@sentry/node-core` package ([#22285](https://github.com/getsentry/sentry-javascript/pull/22285)) 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 6498acb5109b..8aacd9528d57 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 @@ -259,7 +259,7 @@ sentryTest( type: 'string', value: 'custom', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'custom', }, 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 6426afe12a9c..aad52b5b78c8 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 @@ -105,7 +105,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, 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 221e5409dd70..19cb5c08d97d 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 @@ -158,7 +158,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, 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 feb12b9b5a51..50722ad418a8 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 @@ -165,7 +165,7 @@ sentryTest( type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 1a224e6d0071..7f3362d1f612 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -15,12 +15,18 @@ }, "dependencies": { "ai": "^6.0.0", + "@anthropic-ai/sdk": "0.63.0", + "@google/genai": "^1.20.0", + "@langchain/core": "^0.3.80", "@langchain/langgraph": "^1.0.1", + "@langchain/openai": "^0.5.0", "@prisma/adapter-d1": "6.15.0", "@prisma/client": "6.15.0", "@sentry/cloudflare": "10.66.0", + "@sentry/core": "10.66.0", "@sentry/hono": "10.66.0", - "hono": "^4.12.25" + "hono": "^4.12.25", + "openai": "5.18.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260426.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts index 84d66bf88f0b..cd900f6b2a43 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -201,7 +201,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - 'sentry.span.source': { type: 'string', value: 'route' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, 'server.address': { type: 'string', value: 'localhost', diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts new file mode 100644 index 000000000000..89f4810b26dd --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts @@ -0,0 +1,37 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_RATE_LIMITER: RateLimit; +} + +function json(data: unknown): Response { + return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/ratelimit/allowed') { + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'allowed-key' }); + return json(outcome); + } + + if (url.pathname === '/ratelimit/blocked') { + // The binding's limit is 1, so the second call within the period is rate limited. + await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + return json(outcome); + } + + return new Response('not found', { status: 404 }); + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts new file mode 100644 index 000000000000..30653c6e943d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts @@ -0,0 +1,63 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findRateLimitSpans(envelope: Envelope): Array> { + if (envelopeItemType(envelope) !== 'transaction') return []; + const spans = (envelopeItem(envelope).spans as Array>) || []; + return spans.filter( + s => (s.data as Record | undefined)?.['sentry.origin'] === 'auto.faas.cloudflare.rate_limit', + ); +} + +it('instruments an allowed rate limiter call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + const event = envelopeItem(envelope); + + expect(event.spans).toEqual([ + { + data: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + description: 'rate_limit MY_RATE_LIMITER', + origin: 'auto.faas.cloudflare.rate_limit', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ]); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/allowed'); + expect(response).toEqual({ success: true }); + await runner.completed(); +}); + +it('instruments a rate-limited call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + // Both `limit()` calls on the blocked endpoint are instrumented. + expect(findRateLimitSpans(envelope)).toHaveLength(2); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/blocked'); + expect(response).toEqual({ success: false }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc new file mode 100644 index 000000000000..97ed092cdc9b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], + "ratelimits": [ + { + "name": "MY_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 1, "period": 60 }, + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts index 9ff6a9406258..384fba1965dd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts @@ -1,16 +1,27 @@ +import Anthropic from '@anthropic-ai/sdk'; import * as Sentry from '@sentry/cloudflare'; -import type { AnthropicAiClient } from '@sentry/core'; -import { MockAnthropic } from './mocks'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockAnthropic({ - apiKey: 'mock-api-key', -}); +// Return a canned response so the `@anthropic-ai/sdk` runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'msg_mock123', + type: 'message', + role: 'assistant', + model: 'claude-3-haiku-20240307', + content: [{ type: 'text', text: 'Hello from Anthropic!' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 15 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); -const client: AnthropicAiClient = Sentry.instrumentAnthropicAiClient(mockClient); +const client = Sentry.instrumentAnthropicAiClient(new Anthropic({ apiKey: 'mock-api-key', fetch: mockFetch })); export default Sentry.withSentry( (env: Env) => ({ @@ -20,7 +31,7 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - const response = await client.messages?.create({ + const response = await client.messages.create({ model: 'claude-3-haiku-20240307', messages: [{ role: 'user', content: 'What is the capital of France?' }], temperature: 0.7, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts deleted file mode 100644 index cff87ca84cbc..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { AnthropicAiClient, AnthropicAiResponse } from '@sentry/core'; - -export class MockAnthropic implements AnthropicAiClient { - public messages: { - create: (...args: unknown[]) => Promise; - countTokens: (...args: unknown[]) => Promise; - }; - public models: { - list: (...args: unknown[]) => Promise; - get: (...args: unknown[]) => Promise; - }; - public completions: { - create: (...args: unknown[]) => Promise; - }; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - // Main focus: messages.create functionality - this.messages = { - create: async (...args: unknown[]) => { - const params = args[0] as { model: string; stream?: boolean }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - id: 'msg_mock123', - type: 'message', - role: 'assistant', - model: params.model, - content: [ - { - type: 'text', - text: 'Hello from Anthropic mock!', - }, - ], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 15, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }; - }, - countTokens: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock', input_tokens: 0 }), - }; - - // Minimal implementations for required interface compliance - this.models = { - list: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock' }), - get: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock' }), - }; - - this.completions = { - create: async (..._args: unknown[]) => ({ id: 'mock', type: 'completion', model: 'mock' }), - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index 4f60868cddfb..6817843cde27 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, @@ -8,48 +9,49 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@anthropic-ai/sdk` on the Workers runtime (with a +// canned fetch) to verify the instrumentation works end-to-end on Cloudflare, +// not just against a hand-written mock client. -it('traces a basic message creation request', async ({ signal }) => { +it('traces a basic message creation request with the anthropic SDK', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - // [0] chat claude-3-haiku-20240307 - expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(firstSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(firstSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.anthropic' }); - expect(firstSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-haiku-20240307', - }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-haiku-20240307', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat claude-3-haiku-20240307', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.anthropic', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'anthropic', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'claude-3-haiku-20240307', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'msg_mock123', type: 'string' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'claude-3-haiku-20240307', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + }, }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]).toEqual({ type: 'string', value: 'msg_mock123' }); - expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); }) .start(signal); await runner.makeRequest('get', '/'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc index 48aaa79409a3..d853a1899850 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "anthropic-ai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index ca6138ce17ef..635de9ebe95f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -1,16 +1,45 @@ +import { GoogleGenAI } from '@google/genai'; import * as Sentry from '@sentry/cloudflare'; -import type { GoogleGenAIClient } from '@sentry/core'; -import { MockGoogleGenAI } from './mocks'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockGoogleGenAI({ - apiKey: 'mock-api-key', -}); +// `@google/genai` has no per-client fetch option — it calls the global `fetch` +// directly. Override it (gated to the Gemini host) so the SDK runs on +// workerd without hitting the network, while Sentry envelope delivery still +// uses the original fetch. +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; -const client: GoogleGenAIClient = Sentry.instrumentGoogleGenAIClient(mockClient); + if (!url.includes('generativelanguage.googleapis.com')) { + return originalFetch(input, init); + } + + if (url.includes(':embedContent') || url.includes(':batchEmbedContents')) { + return new Response(JSON.stringify({ embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: 'Hello from Google GenAI!' }], role: 'model' }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); +}) as typeof fetch; + +const client = Sentry.instrumentGoogleGenAIClient(new GoogleGenAI({ apiKey: 'mock-api-key' })); export default Sentry.withSentry( (env: Env) => ({ @@ -36,12 +65,10 @@ export default Sentry.withSentry( ], }); - const chatResponse = await chat.sendMessage({ - message: 'Tell me a joke', - }); + await chat.sendMessage({ message: 'Tell me a joke' }); // Test 2: models.generateContent - const modelResponse = await client.models.generateContent({ + await client.models.generateContent({ model: 'gemini-1.5-flash', config: { temperature: 0.7, @@ -57,12 +84,12 @@ export default Sentry.withSentry( }); // Test 3: models.embedContent - const embedResponse = await client.models.embedContent({ + await client.models.embedContent({ model: 'text-embedding-004', contents: 'Hello world', }); - return new Response(JSON.stringify({ chatResponse, modelResponse, embedResponse })); + return new Response(JSON.stringify({ success: true })); }, }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts deleted file mode 100644 index c3eb23d7d5f5..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { GoogleGenAIChat, GoogleGenAIClient, GoogleGenAIResponse } from '@sentry/core'; - -export class MockGoogleGenAI implements GoogleGenAIClient { - public models: { - generateContent: (...args: unknown[]) => Promise; - generateContentStream: (...args: unknown[]) => Promise>; - embedContent: (...args: unknown[]) => Promise<{ embeddings: { values: number[] }[] }>; - }; - public chats: { - create: (...args: unknown[]) => GoogleGenAIChat; - }; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - // models.generateContent functionality - this.models = { - generateContent: async (...args: unknown[]) => { - const params = args[0] as { model: string; contents?: unknown }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - candidates: [ - { - content: { - parts: [ - { - text: 'Hello from Google GenAI mock!', - }, - ], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { - promptTokenCount: 8, - candidatesTokenCount: 12, - totalTokenCount: 20, - }, - }; - }, - embedContent: async (...args: unknown[]) => { - const params = args[0] as { model: string; contents?: unknown }; - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - throw error; - } - - return { - embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }], - }; - }, - generateContentStream: async () => { - // Return a promise that resolves to an async generator - return (async function* (): AsyncGenerator { - yield { - candidates: [ - { - content: { - parts: [{ text: 'Streaming response' }], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - }; - })(); - }, - }; - - // chats.create implementation - this.chats = { - create: (...args: unknown[]) => { - const params = args[0] as { model: string; config?: Record }; - const model = params.model; - - return { - modelVersion: model, - sendMessage: async (..._messageArgs: unknown[]) => { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - return { - candidates: [ - { - content: { - parts: [ - { - text: 'This is a joke from the chat!', - }, - ], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { - promptTokenCount: 8, - candidatesTokenCount: 12, - totalTokenCount: 20, - }, - modelVersion: model, // Include model version in response - }; - }, - sendMessageStream: async () => { - // Return a promise that resolves to an async generator - return (async function* (): AsyncGenerator { - yield { - candidates: [ - { - content: { - parts: [{ text: 'Streaming chat response' }], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - }; - })(); - }, - }; - }, - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index 9dfa2b3d221d..30941df9b7ea 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -13,106 +13,85 @@ import { } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@google/genai` SDK on the Workers runtime (with a +// canned global fetch) to verify the instrumentation works end-to-end on +// Cloudflare, not just against a hand-written mock client. -it('traces Google GenAI chat creation and message sending', async ({ signal }) => { +it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); expect(container.items).toHaveLength(3); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'chat gemini-1.5-pro', - 'embeddings text-embedding-004', - 'generate_content gemini-1.5-flash', - ]); - const chatSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'chat gemini-1.5-pro'); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.status).toBe('ok'); - expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(chatSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(chatSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.google_genai' }); - expect(chatSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'google_genai' }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gemini-1.5-pro', - }); - expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 8 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 12 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 20 }); + const byName = (name: string): SerializedStreamedSpan => + container.items.find((span: SerializedStreamedSpan) => span.name === name); - const generateContentSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'generate_content gemini-1.5-flash', - ); - expect(generateContentSpan).toBeDefined(); - expect(generateContentSpan!.status).toBe('ok'); - expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'generate_content', - }); - expect(generateContentSpan!.attributes['sentry.op']).toEqual({ - type: 'string', - value: 'gen_ai.generate_content', - }); - expect(generateContentSpan!.attributes['sentry.origin']).toEqual({ - type: 'string', - value: 'auto.ai.google_genai', - }); - expect(generateContentSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'google_genai', - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gemini-1.5-flash', - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ - type: 'double', - value: 0.7, - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE]).toEqual({ type: 'double', value: 0.9 }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 100, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 8, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 12, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 20, + expect(byName('chat gemini-1.5-pro')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gemini-1.5-pro', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gemini-1.5-pro', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 8, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 12, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 20, type: 'integer' }, + }, }); - const embeddingsSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'embeddings text-embedding-004', - ); - expect(embeddingsSpan).toBeDefined(); - expect(embeddingsSpan!.status).toBe('ok'); - expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'embeddings', + expect(byName('generate_content gemini-1.5-flash')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'generate_content gemini-1.5-flash', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.generate_content', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'generate_content', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gemini-1.5-flash', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_TOP_P_ATTRIBUTE]: { value: 0.9, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 8, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 12, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 20, type: 'integer' }, + }, }); - expect(embeddingsSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.embeddings' }); - expect(embeddingsSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.google_genai' }); - expect(embeddingsSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'google_genai' }); - expect(embeddingsSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'text-embedding-004', + + expect(byName('embeddings text-embedding-004')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'embeddings text-embedding-004', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.embeddings', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'embeddings', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'text-embedding-004', type: 'string' }, + }, }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc index 48aaa79409a3..089f1332bd7a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "google-genai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts new file mode 100644 index 000000000000..89b990b60865 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-child'], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await Sentry.startSpan({ name: 'ignored-child' }, async () => { + await fetch(`${env.SERVER_URL}/outgoing`); + }); + + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts new file mode 100644 index 000000000000..d9f587a14ccb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts @@ -0,0 +1,51 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('preserves a positive sampling decision across an ignored child span', async ({ signal }) => { + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect(headers['sentry-trace']).toMatch(/^12345678901234567890123456789012-[\da-f]{16}-1$/); + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(serverUrl) + .expect(envelope => { + const container = getSpanContainer(envelope); + const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + const fetchSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.client'); + + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); + expect(fetchSpan?.parent_span_id).toBe(serverSpan?.span_id); + expect(container.items.some(item => item.name === 'ignored-child')).toBe(false); + }) + .start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } +}); + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc new file mode 100644 index 000000000000..e0705205fde7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-child", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts new file mode 100644 index 000000000000..eee940bc8f3a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'sentry.op': 'http.client' } }], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await fetch(`${env.SERVER_URL}/outgoing`); + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts new file mode 100644 index 000000000000..d7dc66316b88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts @@ -0,0 +1,52 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('preserves a positive sampling decision when the outgoing fetch span is ignored', async ({ signal }) => { + let outgoingSentryTrace: string | string[] | undefined; + + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + outgoingSentryTrace = headers['sentry-trace']; + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(serverUrl) + .expect(envelope => { + const container = getSpanContainer(envelope); + const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); + expect(outgoingSentryTrace).toBe(`12345678901234567890123456789012-${serverSpan?.span_id}-1`); + expect(container.items.some(item => item.attributes[SENTRY_OP]?.value === 'http.client')).toBe(false); + }) + .start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } +}); + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc new file mode 100644 index 000000000000..bb4a0574d8e4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-http-client", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts new file mode 100644 index 000000000000..457c8dce5ed6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: [{ op: 'http.server' }], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await fetch(`${env.SERVER_URL}/outgoing`); + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts new file mode 100644 index 000000000000..b912c1cef95a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts @@ -0,0 +1,32 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('propagates a negative sampling decision when the continued server segment is ignored', async ({ signal }) => { + expect.assertions(3); + + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect(headers['sentry-trace']).toMatch(/^12345678901234567890123456789012-[\da-f]{16}-0$/); + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=false,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname).withServerUrl(serverUrl).start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + } finally { + closeTestServer(); + } +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc new file mode 100644 index 000000000000..762349725a01 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-segment", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts index 7fac26ed8774..bae6245a01ad 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -1,10 +1,31 @@ +import { ChatOpenAI } from '@langchain/openai'; import * as Sentry from '@sentry/cloudflare'; -import { type CallbackHandler, MockChain, MockChatModel, MockTool } from './mocks'; interface Env { SENTRY_DSN: string; } +// Return a canned response so the `@langchain/openai` model (backed by the +// `openai` SDK) runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from LangChain!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -13,38 +34,21 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - // Create LangChain callback handler - // The mock models accept their own local `CallbackHandler` shape, which differs from the SDK's handler type. const callbackHandler = Sentry.createLangChainCallbackHandler({ recordInputs: false, recordOutputs: false, - }) as unknown as CallbackHandler; + }); - // Test 1: Chat model invocation - const chatModel = new MockChatModel({ - model: 'claude-3-5-sonnet-20241022', + const model = new ChatOpenAI({ + model: 'gpt-3.5-turbo', temperature: 0.7, maxTokens: 100, - }); - - await chatModel.invoke('Tell me a joke', { + apiKey: 'mock-api-key', + configuration: { fetch: mockFetch }, callbacks: [callbackHandler], }); - // Test 2: Chain invocation - const chain = new MockChain('my_test_chain'); - await chain.invoke( - { input: 'test input' }, - { - callbacks: [callbackHandler], - }, - ); - - // Test 3: Tool invocation - const tool = new MockTool('search_tool'); - await tool.call('search query', { - callbacks: [callbackHandler], - }); + await model.invoke('Tell me a joke'); return new Response(JSON.stringify({ success: true })); }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts deleted file mode 100644 index 946ae8252dbe..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Mock LangChain types and classes for testing the callback handler - -// Minimal callback handler interface to match LangChain's callback handler signature -export interface CallbackHandler { - handleChatModelStart?: ( - llm: unknown, - messages: unknown, - runId: string, - parentRunId?: string, - extraParams?: Record, - tags?: string[] | Record, - metadata?: Record, - runName?: string, - ) => unknown; - handleLLMEnd?: (output: unknown, runId: string) => unknown; - handleChainStart?: (chain: { name?: string }, inputs: Record, runId: string) => unknown; - handleChainEnd?: (outputs: unknown, runId: string) => unknown; - handleToolStart?: (tool: { name?: string }, input: string, runId: string) => unknown; - handleToolEnd?: (output: unknown, runId: string) => unknown; -} - -export interface LangChainMessage { - role: string; - content: string; -} - -export interface LangChainLLMResult { - generations: Array< - Array<{ - text: string; - generationInfo?: Record; - }> - >; - llmOutput?: { - tokenUsage?: { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - }; - }; -} - -export interface InvocationParams { - model: string; - temperature?: number; - maxTokens?: number; -} - -// Mock LangChain Chat Model -export class MockChatModel { - private _model: string; - private _temperature?: number; - private _maxTokens?: number; - - public constructor(params: InvocationParams) { - this._model = params.model; - this._temperature = params.temperature; - this._maxTokens = params.maxTokens; - } - - public async invoke( - messages: LangChainMessage[] | string, - options?: { callbacks?: CallbackHandler[] }, - ): Promise { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Get invocation params to match LangChain's signature - const invocationParams = { - model: this._model, - temperature: this._temperature, - max_tokens: this._maxTokens, - }; - - // Create serialized representation similar to LangChain - const serialized = { - lc: 1, - type: 'constructor', - id: ['langchain', 'anthropic', 'anthropic'], // Third element is used as system provider - kwargs: invocationParams, - }; - - // Call handleChatModelStart - // Pass tags as a record with invocation_params for proper extraction - // The callback handler's getInvocationParams utility accepts both string[] and Record - for (const callback of callbacks) { - if (callback.handleChatModelStart) { - await callback.handleChatModelStart( - serialized, - messages, - runId, - undefined, - undefined, - { invocation_params: invocationParams }, - { ls_model_name: this._model, ls_provider: 'anthropic' }, - ); - } - } - - // Create mock result - const result: LangChainLLMResult = { - generations: [ - [ - { - text: 'Mock response from LangChain!', - generationInfo: { - finish_reason: 'stop', - }, - }, - ], - ], - llmOutput: { - tokenUsage: { - promptTokens: 10, - completionTokens: 15, - totalTokens: 25, - }, - }, - }; - - // Call handleLLMEnd - for (const callback of callbacks) { - if (callback.handleLLMEnd) { - await callback.handleLLMEnd(result, runId); - } - } - - return result; - } -} - -// Mock LangChain Chain -export class MockChain { - private _name: string; - - public constructor(name: string) { - this._name = name; - } - - public async invoke( - inputs: Record, - options?: { callbacks?: CallbackHandler[] }, - ): Promise> { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Call handleChainStart - for (const callback of callbacks) { - if (callback.handleChainStart) { - await callback.handleChainStart({ name: this._name }, inputs, runId); - } - } - - const outputs = { result: 'Chain execution completed!' }; - - // Call handleChainEnd - for (const callback of callbacks) { - if (callback.handleChainEnd) { - await callback.handleChainEnd(outputs, runId); - } - } - - return outputs; - } -} - -// Mock LangChain Tool -export class MockTool { - private _name: string; - - public constructor(name: string) { - this._name = name; - } - - public async call(input: string, options?: { callbacks?: CallbackHandler[] }): Promise { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Call handleToolStart - for (const callback of callbacks) { - if (callback.handleToolStart) { - await callback.handleToolStart({ name: this._name }, input, runId); - } - } - - const output = `Tool ${this._name} executed with input: ${input}`; - - // Call handleToolEnd - for (const callback of callbacks) { - if (callback.handleToolEnd) { - await callback.handleToolEnd(output, runId); - } - } - - return output; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index c663fc6b3289..927057035ccc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -1,73 +1,61 @@ import { expect, it } from 'vitest'; -import type { SerializedStreamedSpan } from '@sentry/core'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, - GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@langchain/openai` model (backed by the +// `openai` SDK, with a canned fetch) on the Workers runtime to verify the +// LangChain callback instrumentation works end-to-end on Cloudflare. -it('traces langchain chat model, chain, and tool invocations', async ({ signal }) => { +it('traces a LangChain chat model invocation', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(3); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'chain my_test_chain', - 'chat claude-3-5-sonnet-20241022', - 'execute_tool search_tool', - ]); + expect(container.items).toHaveLength(1); - const chatSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'chat claude-3-5-sonnet-20241022', - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.status).toBe('ok'); - expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(chatSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(chatSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(chatSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-5-sonnet-20241022', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gpt-3.5-turbo', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.langchain', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'openai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: { value: '["stop"]', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'chatcmpl-mock123', type: 'string' }, + [GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]: { value: 'stop', type: 'string' }, + }, }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 100 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 25 }); - - const chainSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'chain my_test_chain'); - expect(chainSpan).toBeDefined(); - expect(chainSpan!.status).toBe('ok'); - expect(chainSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(chainSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.invoke_agent' }); - expect(chainSpan!.attributes['langchain.chain.name']).toEqual({ type: 'string', value: 'my_test_chain' }); - - const toolSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'execute_tool search_tool'); - expect(toolSpan).toBeDefined(); - expect(toolSpan!.status).toBe('ok'); - expect(toolSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(toolSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.execute_tool' }); - expect(toolSpan!.attributes[GEN_AI_TOOL_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'search_tool' }); }) .start(signal); await runner.makeRequest('get', '/'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc index 48aaa79409a3..25dbb603b7a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "langchain-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts index ba5ca07ce9e9..9ef8eabc831e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts @@ -1,15 +1,32 @@ import * as Sentry from '@sentry/cloudflare'; -import { MockOpenAi } from './mocks'; +import OpenAI from 'openai'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockOpenAi({ - apiKey: 'mock-api-key', -}); +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); -const client = Sentry.instrumentOpenAiClient(mockClient); +const client = Sentry.instrumentOpenAiClient(new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch })); export default Sentry.withSentry( (env: Env) => ({ @@ -19,11 +36,7 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - // The mock client types `chat` loosely (`Record`), so narrow it to the shape used here. - const completions = ( - client.chat as { completions?: { create: (args: Record) => Promise } } - )?.completions; - const response = await completions?.create({ + const response = await client.chat.completions.create({ model: 'gpt-3.5-turbo', messages: [ { role: 'system', content: 'You are a helpful assistant.' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts deleted file mode 100644 index cca72d5bd37d..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { OpenAiClient } from '@sentry/core'; - -export class MockOpenAi implements OpenAiClient { - public chat?: Record; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - this.chat = { - completions: { - create: async (...args: unknown[]) => { - const params = args[0] as { model: string; stream?: boolean }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - id: 'chatcmpl-mock123', - object: 'chat.completion', - created: 1677652288, - model: params.model, - system_fingerprint: 'fp_44709d6fcb', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Hello from OpenAI mock!', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 15, - total_tokens: 25, - }, - }; - }, - }, - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 76288214e9f2..87e11a18e618 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -13,49 +13,44 @@ import { } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `openai` SDK on the Workers runtime (with a canned +// fetch) to verify the instrumentation works end-to-end on Cloudflare, not just +// against a hand-written mock client. -it('traces a basic chat completion request', async ({ signal }) => { +it('traces a basic chat completion request with the openai SDK', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - // [0] chat gpt-3.5-turbo - expect(firstSpan!.name).toBe('chat gpt-3.5-turbo'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(firstSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(firstSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.openai' }); - expect(firstSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'openai' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ type: 'string', value: 'gpt-3.5-turbo' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gpt-3.5-turbo', - }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'chatcmpl-mock123', - }); - expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 25 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]).toEqual({ - type: 'string', - value: '["stop"]', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gpt-3.5-turbo', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.openai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'openai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'chatcmpl-mock123', type: 'string' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: { value: '["stop"]', type: 'string' }, + }, }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc index 48aaa79409a3..409081a63462 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "openai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts index f37d654d9831..77218af60fa1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -5,7 +5,17 @@ interface Env { SENTRY_DSN: string; } -class MySubWorkerEntrypointBase extends WorkerEntrypoint { +interface Props { + accountId: string; +} + +class BaseEntrypoint extends WorkerEntrypoint { + inherited(value: string): string { + return value; + } +} + +class MySubWorkerEntrypointBase extends BaseEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -20,13 +30,39 @@ class MySubWorkerEntrypointBase extends WorkerEntrypoint { return new Response('Not found', { status: 404 }); } + + get(key: string): { argumentCount: number; key: string } { + Sentry.setTag('key', key); + return { argumentCount: arguments.length, key }; + } + + throwError(): never { + throw new Error('custom RPC receiver failed'); + } } -export default Sentry.withSentry( +export const BindingEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, enableRpcTracePropagation: true, + initialScope: { tags: { initial_scope: 'applied' } }, + beforeSend(event) { + event.tags = { ...event.tags, before_send: 'applied' }; + return event; + }, + transportOptions: { fetch: fetch.bind(globalThis) }, }), MySubWorkerEntrypointBase, ); + +export const NoPropagationEntrypoint = Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + transportOptions: { fetch: fetch.bind(globalThis) }, + }), + MySubWorkerEntrypointBase, +); + +export default BindingEntrypoint; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts index e46d7ffd4daf..b37c2ab1ffe5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -1,10 +1,29 @@ import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; - SUB_WORKER: Fetcher; + SUB_WORKER: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + inherited(value: string): Promise; + throwError(): Promise; + }; + SUB_WORKER_NO_PROPAGATION: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + }; } +class LoopbackEntrypointBase extends WorkerEntrypoint { + throwError(): never { + throw new Error('loopback RPC receiver failed'); + } +} + +export const LoopbackEntrypoint = Sentry.withSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 }), + LoopbackEntrypointBase, +); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -12,7 +31,7 @@ export default Sentry.withSentry( enableRpcTracePropagation: true, }), { - async fetch(request, env) { + async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === '/call-entrypoint') { @@ -27,6 +46,35 @@ export default Sentry.withSentry( return new Response(text); } + if (url.pathname === '/call-entrypoint-rpc') { + const result = await env.SUB_WORKER.get('feature-key'); + const inherited = await env.SUB_WORKER.inherited('base-value'); + return Response.json({ inherited, ...result }); + } + + if (url.pathname === '/call-entrypoint-rpc-error') { + try { + await env.SUB_WORKER.throwError(); + } catch { + return new Response('fallback'); + } + } + + if (url.pathname === '/call-entrypoint-rpc-no-propagation') { + const result = await env.SUB_WORKER_NO_PROPAGATION.get('no-prop-key'); + return Response.json(result); + } + + if (url.pathname === '/call-loopback-rpc-error') { + try { + await ( + ctx as unknown as { exports: { LoopbackEntrypoint: { throwError(): Promise } } } + ).exports.LoopbackEntrypoint.throwError(); + } catch { + return new Response('fallback'); + } + } + return new Response('Not found', { status: 404 }); }, } satisfies ExportedHandler, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts index 3b76e28e9e88..9309d0f4f97b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -127,3 +127,175 @@ it('propagates trace for request with query params from Worker to WorkerEntrypoi expect(entrypointParentSpanId).toBeDefined(); expect(entrypointParentSpanId).toBe(workerSpanId); }); + +it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadata', async ({ signal }) => { + let callerTraceId: string | undefined; + let callerSpanId: string | undefined; + let receiverGetTraceId: string | undefined; + let receiverGetParentSpanId: 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: 'http.server', + origin: 'auto.http.cloudflare', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + }), + }), + transaction: 'GET /call-entrypoint-rpc', + }), + ); + callerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + callerSpanId = transactionEvent.contexts?.trace?.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: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'get', + }), + ); + receiverGetTraceId = transactionEvent.contexts?.trace?.trace_id as string; + receiverGetParentSpanId = 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: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'inherited', + }), + ); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; inherited: string; key: string }>( + 'get', + '/call-entrypoint-rpc', + ); + expect(response).toEqual({ argumentCount: 1, inherited: 'base-value', key: 'feature-key' }); + + await runner.completed(); + + expect(receiverGetTraceId).toBeDefined(); + expect(callerTraceId).toBeDefined(); + expect(receiverGetTraceId).toBe(callerTraceId); + + expect(receiverGetParentSpanId).toBeDefined(); + expect(callerSpanId).toBeDefined(); + expect(receiverGetParentSpanId).toBe(callerSpanId); +}); + +it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('custom RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + expect(event.tags?.initial_scope).toBe('applied'); + expect(event.tags?.before_send).toBe('applied'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('throwError'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-entrypoint-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); + +it('does not inject RPC trace metadata into receiver calls when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-entrypoint-rpc-no-propagation', + }), + ); + }) + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; key: string }>( + 'get', + '/call-entrypoint-rpc-no-propagation', + ); + expect(response).toEqual({ argumentCount: 1, key: 'no-prop-key' }); + + await runner.completed(); +}); + +it('captures errors from loopback WorkerEntrypoint RPC without trace propagation', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('loopback RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-loopback-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-loopback-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc index 1638b8a00a18..bd19f421dcff 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -2,11 +2,17 @@ "name": "cloudflare-worker-workerentrypoint-rpc", "main": "index.ts", "compatibility_date": "2025-06-17", - "compatibility_flags": ["nodejs_als"], + "compatibility_flags": ["nodejs_als", "enable_ctx_exports"], "services": [ { "binding": "SUB_WORKER", "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "BindingEntrypoint", + }, + { + "binding": "SUB_WORKER_NO_PROPAGATION", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "NoPropagationEntrypoint", }, ], } diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts new file mode 100644 index 000000000000..9064ea1cbd4e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/cloudflare'; +import { instrumentWorkersAiClient } from '@sentry/core'; +import { MockAi } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +const ai = instrumentWorkersAiClient(new MockAi()); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + // Keep gen_ai spans embedded in the transaction (instead of streamed as a + // separate envelope container) so they can be asserted on `transaction.spans`. + streamGenAiSpans: false, + }), + { + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname === '/error') { + // The Workers AI integration deliberately does not call `captureException` itself. + // A failing `run` must bubble up out of the handler so the top-level Cloudflare + // instrumentation reports it instead — showing up in Sentry exactly once. + const result = await ai.run('error-model', { prompt: 'Hello' }); + return new Response(JSON.stringify(result)); + } + + if (url.pathname === '/stream') { + const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [{ role: 'user', content: 'What is the capital of France?' }], + stream: true, + })) as ReadableStream; + + const text = await new Response(stream).text(); + return new Response(text); + } + + const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + return new Response(JSON.stringify(result)); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts new file mode 100644 index 000000000000..bce096d4ae75 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -0,0 +1,45 @@ +import { simulateReadableStream } from 'ai'; + +function createSseStream(events: string[]): ReadableStream { + const encoder = new TextEncoder(); + return simulateReadableStream({ + initialDelayInMs: 0, + chunkDelayInMs: 0, + chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)), + }); +} + +/** + * Minimal mock of the Cloudflare Workers AI binding (`env.AI`). + */ +export class MockAi { + public async run(model: string, inputs: Record): Promise { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (model === 'error-model') { + const error = new Error('Model not found'); + (error as unknown as { status: number }).status = 404; + throw error; + } + + if (inputs?.stream === true) { + return createSseStream([ + '{"response":"The capital "}', + '{"response":"of France "}', + '{"response":"is Paris."}', + '{"response":"","usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}', + '[DONE]', + ]); + } + + return { + response: 'The capital of France is Paris.', + usage: { + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + }, + }; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts new file mode 100644 index 000000000000..e5c835f37508 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -0,0 +1,148 @@ +import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the core unit tests and we merely want to test +// that the instrumentation does not break in our cloudflare SDK. + +it('traces a basic Workers AI text generation request', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + // The transaction event is framework-generated and carries non-deterministic fields + // (random ports, ids, timestamps, sdk version), so we assert the stable subset. + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /', + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + status: 'ok', + }), + }), + spans: [ + expect.objectContaining({ + description: 'chat @cf/meta/llama-3.1-8b-instruct', + op: 'gen_ai.chat', + origin: 'auto.ai.cloudflare.workers_ai', + data: { + 'sentry.origin': 'auto.ai.cloudflare.workers_ai', + 'sentry.op': 'gen_ai.chat', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: 0.7, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: 100, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, + }, + }), + ], + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('traces a streaming Workers AI text generation request', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /stream', + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + status: 'ok', + }), + }), + spans: [ + expect.objectContaining({ + description: 'chat @cf/meta/llama-3.1-8b-instruct', + op: 'gen_ai.chat', + origin: 'auto.ai.cloudflare.workers_ai', + data: { + 'sentry.origin': 'auto.ai.cloudflare.workers_ai', + 'sentry.op': 'gen_ai.chat', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true, + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, + }, + }), + ], + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/stream'); + await runner.completed(); +}); + +// The Workers AI integration deliberately does not call `captureException` itself. +// When a `run` call fails, the error must bubble up out of the fetch handler and be +// reported by the top-level Cloudflare instrumentation instead — so it shows up in +// Sentry exactly once, with the `auto.http.cloudflare` mechanism. +it('bubbles up Workers AI errors to be captured by the top-level handler', async ({ signal }) => { + const runner = createRunner(__dirname) + // A failing run still produces a (sampled) transaction; we only care about the error event here. + .ignore('transaction') + .expect(envelope => { + const errorEvent = envelope[1]?.[0]?.[1] as any; + + expect(errorEvent).toEqual( + expect.objectContaining({ + level: 'error', + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: 'Model not found', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }), + ], + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.any(String), + }), + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc new file mode 100644 index 000000000000..2cdb43e06500 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "workers-ai-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json index aeb882821dd2..5d39c4e4b7c8 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0" + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts index 45ea0ec5ac96..bdd5bd22b8c0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts @@ -34,17 +34,20 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith }, spans: expect.arrayContaining([ expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - description: expect.stringMatching(/^SELECT /), - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.summary': expect.any(String), - 'db.query.text': expect.any(String), - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.durable_object.sql', - }), + data: { + 'db.operation.name': 'get', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_get', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }), ]), start_timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-ioredis.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-ioredis.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-ioredis.tsx rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-ioredis.tsx diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-mysql.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-mysql.tsx rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-mysql.tsx diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml similarity index 86% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml index b7d5ec8898f0..6875b0a63363 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml @@ -2,7 +2,7 @@ services: db: image: mysql:8.0 restart: always - container_name: e2e-tests-remix-orchestrion-mysql + container_name: e2e-tests-create-remix-app-v2-mysql # The `mysql` 2.x driver doesn't speak MySQL 8's default # `caching_sha2_password` auth, so force the legacy plugin. command: ['--default-authentication-plugin=mysql_native_password'] @@ -20,7 +20,7 @@ services: redis: image: redis:7 restart: always - container_name: e2e-tests-remix-orchestrion-redis + container_name: e2e-tests-create-remix-app-v2-redis ports: - '6379:6379' healthcheck: diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs similarity index 90% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs index f944033877f3..148cb4782e4c 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs @@ -13,7 +13,7 @@ export default async function globalSetup() { // recognize a leftover container from a previous (e.g. interrupted) run as // part of the same project - but the container names are fixed, so the daemon // still refuses to create new ones. Force-remove any stale leftovers first. - for (const container of ['e2e-tests-remix-orchestrion-mysql', 'e2e-tests-remix-orchestrion-redis']) { + for (const container of ['e2e-tests-create-remix-app-v2-mysql', 'e2e-tests-create-remix-app-v2-redis']) { try { execSync(`docker rm -f ${container}`, { stdio: 'ignore' }); } catch { diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-teardown.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-teardown.mjs diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs index 6d211cac4592..f0e22b321751 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs @@ -1,5 +1,15 @@ const Sentry = require('@sentry/remix'); +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + +if (injectOrchestrion) { + // Opt into diagnostics-channel-based auto-instrumentation. This registers the + // channel subscribers (e.g. for mysql and ioredis) that turn the + // diagnostics-channel events - injected at build time by the orchestrion Vite + // plugin (see vite.config.ts) - into Sentry spans. Must run before Sentry.init(). + Sentry.experimentalUseDiagnosticsChannelInjection(); +} + Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! environment: 'qa', // dynamic sampling bias to keep transactions diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json index fd57d2920d5a..978f2abbd4d7 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json @@ -1,6 +1,7 @@ { "private": true, "sideEffects": false, + "type": "module", "scripts": { "build": "remix vite:build && pnpm typecheck", "dev": "remix vite:dev", @@ -8,15 +9,20 @@ "typecheck": "tsc", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm playwright test" + "test:assert": "pnpm playwright test", + "test:build:orchestrion": "INJECT_ORCHESTRION=true pnpm test:build", + "test:assert:orchestrion": "INJECT_ORCHESTRION=true pnpm test:assert" }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "@remix-run/css-bundle": "2.17.4", "@remix-run/node": "2.17.4", "@remix-run/react": "2.17.4", "@remix-run/serve": "2.17.4", "isbot": "^3.6.8", + "ioredis": "5.10.1", + "mysql": "^2.18.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -25,6 +31,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils", "@remix-run/dev": "2.17.4", "@remix-run/eslint-config": "2.17.4", + "@types/mysql": "^2.15.26", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.34", "@types/prop-types": "15.7.7", @@ -36,6 +43,15 @@ "resolutions": { "@types/react": "18.2.22" }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:orchestrion", + "assert-command": "pnpm test:assert:orchestrion", + "label": "create-remix-app-v2 (orchestrion)" + } + ] + }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs index 31f2b913b58b..8ab515926536 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs @@ -1,7 +1,20 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { fileURLToPath } from 'url'; -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, -}); +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + // The orchestrion variant exercises real MySQL/Redis. Boot them before the tests run, + // outside the webServer startup-timeout window. In the default variant no DB is needed. + injectOrchestrion + ? { + globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), + globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)), + } + : {}, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js deleted file mode 100644 index cb3c8c7a9fb7..000000000000 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - ignoredRouteFiles: ['**/.*'], - // appDirectory: 'app', - // assetsBuildDirectory: 'public/build', - // serverBuildPath: 'build/index.js', - // publicPath: '/build/', - serverModuleFormat: 'cjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts new file mode 100644 index 000000000000..cfa27164a765 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +// The `db.test.ts` runtime assertions prove orchestrion spans appear, but spans alone +// don't prove they came from the BUILD-time transform: if the Vite plugin silently +// failed to load, the deps would stay external and the runtime `--require` hook would +// inject the channels at runtime instead - the span tests would still pass. These +// assertions inspect the built server bundle directly so a broken plugin can't hide +// behind that runtime fallback. Only relevant in the orchestrion variant. +test.describe('orchestrion build-time injection', () => { + test.skip(process.env.INJECT_ORCHESTRION !== 'true', 'Only runs in the orchestrion variant'); + + const serverBundle = readFileSync(path.join(process.cwd(), 'build/server/index.js'), 'utf8'); + + test('force-bundles the instrumented deps instead of externalizing them', () => { + // The plugin adds mysql/ioredis to `ssr.noExternal` so the transform sees their + // source. Without it they'd be left as bare imports or `require(...)` calls resolved + // from node_modules at runtime - untouched, with no channels injected. + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + }); + + test('injects the diagnostics-channel publishers into the bundled deps', () => { + // The transform wraps each instrumented function with a `tracingChannel("")` + // publisher whose channel name is a string literal. The subscriber side passes the + // channel name as a variable, so a literal-arg match is unique to the injected + // publisher and proves the build-time transform ran. + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('injects the diagnostics-channel publishers into @remix-run/server-runtime', () => { + // Remix's own instrumentation is orchestrion-based too: the transform force-bundles + // and injects channels into `@remix-run/server-runtime` + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:requestHandler["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:matchServerRoutes["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:callRouteLoader["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:callRouteAction["']\)/, + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts new file mode 100644 index 000000000000..48b21d38b30f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// These assertions only hold in the orchestrion variant (INJECT_ORCHESTRION=true), which +// force-bundles + transforms mysql/ioredis and boots the databases via docker-compose. +test.describe('orchestrion DB instrumentation', () => { + test.skip(process.env.INJECT_ORCHESTRION !== 'true', 'Only runs in the orchestrion variant'); + + test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis') + ); + }); + + await fetch(`${baseURL}/db-ioredis`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'get test-key', + }), + }), + ); + }); + + test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql') + ); + }); + + await fetch(`${baseURL}/db-mysql`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts index d4d7f23895c1..a0a15e923f76 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts @@ -1,15 +1,22 @@ import { vitePlugin as remix } from '@remix-run/dev'; import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + export default defineConfig({ plugins: [ remix({ ignoredRouteFiles: ['**/.*'], - serverModuleFormat: 'cjs', }), sentryRemixVitePlugin(), + // In the orchestrion variant, run the orchestrion code transform over the SSR + // server bundle and force-bundle the instrumented deps (mysql, ioredis, + // @remix-run/server-runtime, …) so their diagnostics-channel calls are injected + // at build time. + ...(injectOrchestrion ? [sentryOrchestrionPlugin()] : []), tsconfigPaths(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index f6d3029db39a..e79589dcac76 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -115,7 +115,7 @@ const SEGMENT_SPAN = { type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts index 481e7264c62d..082c46c467a7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts @@ -39,7 +39,7 @@ test('should send error for faulty click handlers', async ({ page }) => { expect(frames).toContainEqual( expect.objectContaining({ filename: expect.stringMatching( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/, ), in_app: false, }), @@ -48,7 +48,7 @@ test('should send error for faulty click handlers', async ({ page }) => { expect(frames).not.toContainEqual( expect.objectContaining({ filename: expect.stringMatching( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/, ), in_app: true, }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx new file mode 100644 index 000000000000..c0b3dc70edec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx @@ -0,0 +1,12 @@ +export default function ClickErrorPage() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts new file mode 100644 index 000000000000..af0e1af509d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should mark nextjs internal frames as not in_app for faulty click handlers', async ({ page }) => { + // The internal-chunk detection matches webpack chunk names (e.g. `webpack-.js`), which only exist in + // production webpack builds. Turbopack and dev builds emit differently-named chunks, so this is only relevant there. + test.skip(process.env.TEST_ENV !== 'production', 'Only relevant for production webpack builds'); + + const errorPromise = waitForError('nextjs-15', async errorEvent => { + return errorEvent.exception?.values?.[0].value === 'click error'; + }); + + await page.goto('/42/click-error'); + await page.click('#error-button'); + + const errorEvent = await errorPromise; + + expect(errorEvent).toBeDefined(); + + const frames = errorEvent?.exception?.values?.[0]?.stacktrace?.frames; + + const internalChunkRegex = + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/; + + const internalFrames = frames?.filter(frame => frame.filename && internalChunkRegex.test(frame.filename)) ?? []; + + expect(internalFrames.length).toBeGreaterThan(0); + expect(internalFrames.filter(frame => frame.in_app !== false)).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 14dedc28363d..7e0873355c65 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -31,6 +31,9 @@ const NODE_EXPORTS_IGNORE = [ 'diagnosticsChannelInjectionIntegrations', // Companion to the above two, same reasoning (Next.js re-exports it via `export * from '@sentry/node'`) 'isDiagnosticsChannelInjectionEnabled', + // Helper for SDKs that build their own default-integration set (e.g. aws-serverless) + // to apply the diagnostics-channel integration swap; not surfaced elsewhere. + 'applyDiagnosticsChannelInjectionIntegrations', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index 53803a8882e6..f86901e0dee4 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -65,6 +65,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { { data: { 'koa.name': 'bodyParser', + 'code.function.name': 'bodyParser', 'koa.type': 'middleware', 'sentry.op': 'middleware.koa', 'sentry.origin': 'auto.http.otel.koa', @@ -82,6 +83,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { { data: { 'koa.name': '', + 'code.function.name': '', 'koa.type': 'middleware', 'sentry.origin': 'auto.http.otel.koa', 'sentry.op': 'middleware.koa', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts deleted file mode 100644 index 4cd518b88974..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { createResolver, defineNuxtModule } from '@nuxt/kit'; - -const SERVER_CONFIG_FILENAME = 'sentry.server.config'; - -/** - * Local demo module — NOT part of `@sentry/nuxt`. - * - * The orchestrion bundler approach needs the Sentry init to run inside the - * server build (so the diagnostics-channel subscribers are registered before - * the first request) WITHOUT relying on `node --import`. - */ -export default defineNuxtModule({ - meta: { name: 'sentry-server-init' }, - setup(_options, nuxt) { - nuxt.hooks.hook('nitro:init', nitro => { - nitro.hooks.hook('close', () => { - const entryFilePath = createResolver(nitro.options.output.serverDir).resolve('index.mjs'); - - if (!existsSync(entryFilePath)) { - return; - } - - const topImport = `import './${SERVER_CONFIG_FILENAME}.mjs';\n`; - const data = readFileSync(entryFilePath, 'utf8'); - - if (data.startsWith(topImport)) { - return; - } - - writeFileSync(entryFilePath, topImport + data, 'utf8'); - }); - }); - }, -}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index 4f663643f739..8e7b7a15eedb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -1,21 +1,16 @@ -import codeTransformerRollup from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; - -// Tells the SDK the orchestrion bundler transform ran, so `detectOrchestrionSetup()` -// no-ops the runtime diagnostics-channel hook. Injected via `codeTransformerRollup`'s -// `injectDiagnostics` option (sourcemap-safe) instead of a hand-rolled plugin. -const orchestrionBundlerMarker = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', -].join('\n'); - // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: true }, - modules: ['@sentry/nuxt/module', './modules/sentry-server-init'], + modules: ['@sentry/nuxt/module'], + + sentry: { + _experimental: { + useDiagnosticsChannelInjection: true, + }, + autoInjectServerSentry: 'top-level-import', + }, runtimeConfig: { public: { @@ -24,29 +19,4 @@ export default defineNuxtConfig({ }, }, }, - - nitro: { - // Nuxt's server is built by Nitro (Rollup), not Vite — so the orchestrion - // code transform has to run as a Nitro Rollup plugin to reach `server/api/*` - // routes. Force-bundle the instrumented deps via `externals.inline`; - // externalized deps are `require()`d from `node_modules` at runtime and never - // pass through the transform. - // - // `standard-as-callback` is ioredis' CJS `export default` helper used by - // `connect()`. Left external, Rollup's interop resolves its `.default` to a - // non-function in the bundle; inlining it alongside ioredis links the - // interop consistently. - externals: { - inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'], - }, - rollupConfig: { - plugins: [ - codeTransformerRollup({ - instrumentations: SENTRY_INSTRUMENTATIONS, - injectDiagnostics: () => orchestrionBundlerMarker, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - ], - }, - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json index fe2f8129165d..ca4c3a887d9b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json @@ -8,7 +8,6 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", - "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:prod": "TEST_ENV=production playwright test", @@ -19,7 +18,6 @@ "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "ioredis": "5.10.1", "mysql": "^2.18.1", "nuxt": "^4.4.8", @@ -27,7 +25,6 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts index 712b60d404b0..b58f42b3aec1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts @@ -1,9 +1,8 @@ import * as Sentry from '@sentry/nuxt'; -// Opt into diagnostics-channel-based auto-instrumentation. This registers the -// channel subscribers (e.g. for `mysql`) that turn the diagnostics-channel -// events — injected at build time by the orchestrion Rollup plugin (see -// `nuxt.config.ts`) — into Sentry spans. Must run before `Sentry.init()`. +// The Nuxt module transforms supported Nitro dependencies when enabled in +// `nuxt.config.ts`. In v10, register Node's matching channel subscribers before +// initializing the SDK so those events become spans. Sentry.experimentalUseDiagnosticsChannelInjection(); Sentry.init({ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts index 5fbe4ba8f934..5c05d2062105 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts @@ -7,9 +7,19 @@ const connection = mysql.createConnection({ }); export default defineEventHandler(() => { - return new Promise(resolve => { - connection.query('SELECT 1 + 1 AS solution', () => { - connection.query('SELECT NOW()', ['1', '2'], () => { + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + reject(error); + return; + } + + connection.query('SELECT NOW()', ['1', '2'], nestedError => { + if (nestedError) { + reject(nestedError); + return; + } + resolve({ status: 'ok' }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts new file mode 100644 index 000000000000..3cc7b960feee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readServerBundle(): string { + const serverDir = path.join(process.cwd(), '.output/server'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.mjs')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +function readClientBundle(): string { + const serverDir = path.join(process.cwd(), '.output/public'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readServerBundle(); + const clientBundle = readClientBundle(); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']standard-as-callback["']|require\(["']standard-as-callback["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + expect(clientBundle).not.toContain('__SENTRY_ORCHESTRION__'); + expect(clientBundle).not.toMatch(/orchestrion:/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts index 4aefc07f7c59..2ac8601b0303 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts @@ -8,7 +8,9 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-ioredis`); + const response = await fetch(`${baseURL}/api/db-ioredis`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('test-value'); const transactionEvent = await transactionEventPromise; @@ -50,7 +52,9 @@ test('Instruments mysql automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-mysql`); + const response = await fetch(`${baseURL}/api/db-mysql`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); const transactionEvent = await transactionEventPromise; diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore deleted file mode 100644 index 18268b83a7ef..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules - -/.cache -/build -/public/build -.env - -/test-results/ -/playwright-report/ -/playwright/.cache/ diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx deleted file mode 100644 index 259f7a0ab4b5..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; -import * as Sentry from '@sentry/remix'; -import { StrictMode, startTransition, useEffect } from 'react'; -import { hydrateRoot } from 'react-dom/client'; - -Sentry.init({ - dsn: 'https://username@domain/123', - environment: 'qa', // dynamic sampling bias to keep transactions - integrations: [ - Sentry.browserTracingIntegration({ - useEffect, - useLocation, - useMatches, - }), - ], - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server -}); - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx deleted file mode 100644 index 1435c570796d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { PassThrough } from 'node:stream'; - -import type { AppLoadContext, EntryContext } from '@remix-run/node'; -import { createReadableStreamFromReadable } from '@remix-run/node'; -import { RemixServer } from '@remix-run/react'; -import * as Sentry from '@sentry/remix'; -import isbot from 'isbot'; -import { renderToPipeableStream } from 'react-dom/server'; - -const ABORT_DELAY = 5_000; - -export const handleError = Sentry.wrapHandleErrorWithSentry(() => {}); - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - _loadContext: AppLoadContext, -) { - return isbot(request.headers.get('user-agent')) - ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) - : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx deleted file mode 100644 index 763c7baaf7ed..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react'; -import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; - -export function ErrorBoundary() { - const error = useRouteError(); - const eventId = captureRemixErrorBoundaryError(error); - - return ( -
- ErrorBoundary Error - {eventId} -
- ); -} - -function App() { - return ( - - - - - - - - - - - - - - ); -} - -export default withSentry(App); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx deleted file mode 100644 index 16dec6f9d34d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Index() { - return
home
; -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs deleted file mode 100644 index dd3bb7e4c222..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const Sentry = require('@sentry/remix'); - -// Opt into diagnostics-channel-based auto-instrumentation. This registers the -// channel subscribers (e.g. for mysql and ioredis) that turn the -// diagnostics-channel events - injected at build time by the orchestrion Vite -// plugin (see vite.config.ts) - into Sentry spans. Must run before Sentry.init(). -Sentry.experimentalUseDiagnosticsChannelInjection(); - -Sentry.init({ - dsn: 'https://username@domain/123', - environment: 'qa', // dynamic sampling bias to keep transactions - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json deleted file mode 100644 index 9951d296954d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "remix-orchestrion", - "private": true, - "sideEffects": false, - "type": "module", - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", - "scripts": { - "build": "remix vite:build", - "dev": "remix vite:dev", - "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve ./build/server/index.js", - "proxy": "node start-event-proxy.mjs", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test", - "test": "playwright test" - }, - "dependencies": { - "@remix-run/node": "2.17.4", - "@remix-run/react": "2.17.4", - "@remix-run/serve": "2.17.4", - "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", - "ioredis": "5.10.1", - "isbot": "^3.6.8", - "mysql": "^2.18.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@playwright/test": "~1.58.0", - "@remix-run/dev": "2.17.4", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/react": "^18.2.64", - "@types/react-dom": "^18.2.34", - "typescript": "^5.6.3", - "vite": "^5.4.11", - "vite-tsconfig-paths": "^4.2.1" - }, - "resolutions": { - "@types/react": "18.2.22" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs deleted file mode 100644 index 96503f44f0df..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -import { fileURLToPath } from 'url'; - -const config = getPlaywrightConfig( - { - startCommand: `pnpm start`, - }, - // Boot MySQL and Redis before the tests run, outside the webServer startup-timeout window. - { - globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), - globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)), - }, -); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts deleted file mode 100644 index dcf8c45e1d4c..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs deleted file mode 100644 index fc353ca35ee4..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'remix-orchestrion', -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts deleted file mode 100644 index 4beb26f45415..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from '@playwright/test'; - -// The `db.test.ts` runtime assertions prove orchestrion spans appear, but spans alone -// don't prove they came from the BUILD-time transform: if the Vite plugin silently -// failed to load, the deps would stay external and the runtime `--import` hook would -// inject the channels at runtime instead - the span tests would still pass. These -// assertions inspect the built server bundle directly so a broken plugin can't hide -// behind that runtime fallback. -test.describe('orchestrion build-time injection', () => { - const serverBundle = readFileSync(path.join(process.cwd(), 'build/server/index.js'), 'utf8'); - - test('force-bundles the instrumented deps instead of externalizing them', () => { - // The plugin adds mysql/ioredis to `ssr.noExternal` so the transform sees their - // source. Without it they'd be left as bare imports (this is an ESM server build) - // or `require(...)` calls resolved from node_modules at runtime - untouched, with - // no channels injected. - expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); - expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); - }); - - test('injects the diagnostics-channel publishers into the bundled deps', () => { - // The transform wraps each instrumented function with a `tracingChannel("")` - // publisher whose channel name is a string literal. The subscriber side passes the - // channel name as a variable, so a literal-arg match is unique to the injected - // publisher and proves the build-time transform ran. - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts deleted file mode 100644 index c912f73f88cb..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('remix-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis') - ); - }); - - await fetch(`${baseURL}/db-ioredis`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'set test-key [1 other arguments]', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'set test-key [1 other arguments]', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'get test-key', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'get test-key', - }), - }), - ); -}); - -test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('remix-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql') - ); - }); - - await fetch(`${baseURL}/db-mysql`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json deleted file mode 100644 index cb057906bc27..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "include": ["remix.env.d.ts", "./app/**/*.ts", "./app/**/*.tsx"], - "exclude": ["node_modules", "build"], - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2019"], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "target": "ES2019", - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", - "paths": { - "~/*": ["./app/*"] - }, - "types": ["@remix-run/node", "vite/client"], - - // Remix takes care of building everything in `remix vite:build`. - "noEmit": true - } -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts deleted file mode 100644 index 6cb8b3c54c26..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; -import { defineConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; - -export default defineConfig({ - plugins: [ - remix({ - ignoredRouteFiles: ['**/.*'], - }), - sentryRemixVitePlugin(), - // Runs the orchestrion code transform over the SSR server bundle and - // force-bundles the instrumented deps (mysql, ioredis, …) so the - // diagnostics-channel calls are actually injected at build time. - sentryOrchestrionPlugin(), - tsconfigPaths(), - ], -}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json index 63bdd148f9ac..59069913902e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sveltejs/adapter-auto": "^7.0.1", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte index 8eb402c9eda6..d1fadd2ea5a3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte @@ -1,8 +1,6 @@

Route with dynamic params

- User id: {$page.params.id} + User id: {page.params.id}

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts index 5ee8818ed485..77d185bfdef5 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.describe.skip('client-side errors', () => { +test.describe('client-side errors', () => { test('captures error thrown on click', async ({ page }) => { await waitForInitialPageload(page, { route: '/client-error' }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts index ee17fde7c048..d660cb6198d1 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts @@ -2,12 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; test.describe('server-side errors', () => { - // FIXME(sveltekit-3): the universal load function's frame is reported as `load$1` (not `load`) - // because the SDK still wraps universal `+page.ts` load in the server build. Unlike server-only - // load, this isn't suppressed by native-tracing detection: the wrapper is skipped via - // `config.build.ssr`, which is unreliable under Vite 8's Environment API. Unskip once the SDK - // detects the server environment via the Vite Environment API. - test.skip('captures universal load error', async ({ page }) => { + test('captures universal load error', async ({ page }) => { const errorEventPromise = waitForError('sveltekit-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)'; }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts index 1b504ba34bdf..d47359818c27 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.describe.skip('client-specific performance events', () => { +test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { const navigationTxn1EventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts index 96705ead8ff0..25e2901eb21f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.skip('capture a distributed pageload trace', async ({ page }) => { +test('capture a distributed pageload trace', async ({ page }) => { const clientTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/users/[id]'; }); @@ -60,7 +59,7 @@ test.skip('capture a distributed pageload trace', async ({ page }) => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverKitResolveSpan?.span_id); }); -test.skip('capture a distributed navigation trace', async ({ page }) => { +test('capture a distributed navigation trace', async ({ page }) => { const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; }); @@ -109,7 +108,7 @@ test.skip('capture a distributed navigation trace', async ({ page }) => { expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); }); -test.skip('record client-side universal load fetch span and trace', async ({ page }) => { +test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { @@ -186,7 +185,7 @@ test.skip('record client-side universal load fetch span and trace', async ({ pag }); }); -test.skip('captures a navigation transaction directly after pageload', async ({ page }) => { +test('captures a navigation transaction directly after pageload', async ({ page }) => { const clientPageloadTxnPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.contexts?.trace?.op === 'pageload'; }); @@ -251,7 +250,7 @@ test.skip('captures a navigation transaction directly after pageload', async ({ }); }); -test.skip('captures one navigation transaction per redirect', async ({ page }) => { +test('captures one navigation transaction per redirect', async ({ page }) => { const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; }); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 243f4875add6..7367355d8f1e 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@sentry/vue": "file:../../packed/sentry-vue-packed.tgz", - "pinia": "^3.0.0", + "pinia": "^4.0.0", "vue": "^3.4.15", "vue-router": "^4.2.5" }, diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index 291c611d4ca4..a1a9626d01cf 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -120,6 +120,7 @@ test('sends a pageload transaction with a route name as transaction name if avai 'sentry.source': 'custom', 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', + 'navigation.route.id': 'AboutView', 'url.path': '/about', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/about$/), }, diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 332208a0e592..fe48425e07fb 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -32,7 +32,7 @@ "@opentelemetry/instrumentation-http": "0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", - "@opentelemetry/semantic-conventions": "^1.42.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@sentry/core": "10.66.0", "@sentry/node-core": "10.66.0", "body-parser": "^2.2.2", diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index b3e84ce4fed3..21a6d3f458fb 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -70,7 +70,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-child-span', is_segment: false, @@ -95,7 +94,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, links: [ { @@ -133,7 +131,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-manual-span', is_segment: false, @@ -161,7 +158,7 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 83261f911b58..743afeeebce8 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -40,6 +40,7 @@ "@growthbook/growthbook": "^1.6.5", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.13", + "@koa/router": "^12.0.1", "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.80", "@langchain/openai": "^0.5.0", @@ -76,6 +77,7 @@ "ioredis-5": "npm:ioredis@^5.11.0", "kafkajs": "2.2.4", "knex": "^2.5.1", + "koa": "^2.15.2", "lru-memoizer": "2.3.0", "mongodb": "^3.7.3", "mongodb-memory-server-global": "^11.0.1", diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts index 7491894494eb..a5e4ea13011a 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts @@ -1,7 +1,12 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// See the non-streamed `aws-integration` suite: only the origin differs between the OTel and +// orchestrion diagnostics-channel runs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: // - the current aws-sdk (default, resolved from the workspace) which routes through `@smithy/core` >= 3.24.0 @@ -49,7 +54,7 @@ function assertAwsServiceSpans(spanCcontainer: SerializedStreamedSpanContainer): name: 'S3.PutObject', status: 'ok', attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.otel.aws', type: 'string' }, + 'sentry.origin': { value: ORIGIN, type: 'string' }, 'sentry.op': { value: 'rpc', type: 'string' }, 'rpc.system': { value: 'aws-api', type: 'string' }, 'rpc.method': { value: 'PutObject', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts index a27b5c1a88ff..22d84e5ffb3a 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -1,7 +1,13 @@ import type { TransactionEvent } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: // - the current aws-sdk (default, resolved from the workspace) which routes through `@smithy/core` >= 3.24.0 @@ -40,10 +46,10 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.PutObject', { description: 'S3.PutObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ - 'sentry.origin': 'auto.otel.aws', + 'sentry.origin': ORIGIN, 'sentry.op': 'rpc', 'rpc.system': 'aws-api', 'rpc.method': 'PutObject', @@ -58,7 +64,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (success)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3', 'aws.s3.bucket': 'ot-demo-test' }), }); @@ -67,7 +73,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (error)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'internal_error', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3' }), }); @@ -76,7 +82,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.PutItem', { description: 'DynamoDB.PutItem', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'sentry.op': 'db', 'rpc.method': 'PutItem', @@ -92,7 +98,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.Query', { description: 'DynamoDB.Query', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Query', 'db.operation': 'Query', @@ -105,7 +111,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS SendMessage', { description: 'my-queue send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'SendMessage', 'rpc.service': 'SQS', @@ -121,7 +127,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS ReceiveMessage', { description: 'my-queue receive', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'ReceiveMessage', 'messaging.system': 'aws_sqs', @@ -135,7 +141,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SNS Publish', { description: 'my-topic send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Publish', 'rpc.service': 'SNS', @@ -150,7 +156,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Lambda Invoke', { description: 'my-function Invoke', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Invoke', 'rpc.service': 'Lambda', @@ -164,7 +170,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Kinesis.PutRecord', { description: 'Kinesis.PutRecord', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'PutRecord', @@ -177,7 +183,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SecretsManager.GetSecretValue', { description: 'SecretsManager.GetSecretValue', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'GetSecretValue', 'rpc.service': 'SecretsManager', @@ -189,7 +195,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('StepFunctions.StartExecution', { description: 'SFN.StartExecution', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'StartExecution', 'rpc.service': 'SFN', diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs new file mode 100644 index 000000000000..fe1c5c47983f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/aws-serverless'; +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, +}); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs new file mode 100644 index 000000000000..c1705ff0d403 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { BedrockRuntimeClient, ConverseCommand, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +// Force the HTTP/1 handler so `nock` can intercept the request instead of hitting real AWS. +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import nock from 'nock'; + +nock.disableNetConnect(); + +const region = 'us-east-1'; +const credentials = { accessKeyId: 'aws-test-key', secretAccessKey: 'aws-test-secret' }; +const host = `https://bedrock-runtime.${region}.amazonaws.com`; + +async function converse() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/converse$/) + .reply( + 200, + JSON.stringify({ + output: { message: { role: 'assistant', content: [{ text: 'Hello from Bedrock' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new ConverseCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + inferenceConfig: { maxTokens: 100, temperature: 0.5, topP: 0.9 }, + }), + ); +} + +async function invokeModel() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/invoke$/) + .reply( + 200, + JSON.stringify({ + content: [{ type: 'text', text: 'Hello from Bedrock' }], + stop_reason: 'end_turn', + usage: { input_tokens: 15, output_tokens: 9 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new InvokeModelCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + contentType: 'application/json', + body: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 100, + temperature: 0.5, + top_p: 0.9, + messages: [{ role: 'user', content: 'Hello' }], + }), + }), + ); +} + +async function run() { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await converse(); + await invokeModel(); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts new file mode 100644 index 000000000000..d04c2cf1d7fa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -0,0 +1,75 @@ +import type { TransactionEvent } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same gen_ai spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + +const MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; + +function assertBedrockSpans(transaction: TransactionEvent): void { + const spans = transaction.spans ?? []; + + expect(transaction.transaction).toBe('Test Transaction'); + + // Converse (non-streaming) + expect(spans, 'expected a Bedrock Converse span').toContainEqual( + expect.objectContaining({ + description: `chat ${MODEL_ID}`, + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); + + // InvokeModel (non-streaming, anthropic.claude request/response body) + expect(spans, 'expected a Bedrock InvokeModel span').toContainEqual( + expect.objectContaining({ + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 9, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); +} + +describe('awsIntegration - Bedrock', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ transaction: assertBedrockSpans }).start().completed(); + }); + }, + { additionalDependencies: { '@aws-sdk/client-bedrock-runtime': '^3.1046.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index d64fe0becf9c..83d8ec942c08 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -70,7 +70,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-child-span', is_segment: false, @@ -95,7 +94,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, links: [ { @@ -133,7 +131,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-manual-span', is_segment: false, @@ -161,7 +158,7 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs index 273760a6db39..49f30db2e59e 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs @@ -14,6 +14,14 @@ Sentry.init({ integrations: [eventLoopBlockIntegration()], }); +// Sentry.addBreadcrumb() writes to the isolation scope which is only captured via +// AsyncLocalStorage, so we add to the current scope to test the poll state route +Sentry.getCurrentScope().addBreadcrumb({ + category: 'test', + message: 'blocking event loop soon', + level: 'info', +}); + setTimeout(() => { longWork(); }, 2000); 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 c6729e55c209..a07cc781b3d2 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 @@ -105,7 +105,20 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { test('ESM', async () => { await createRunner(__dirname, 'basic.mjs') .withMockSentryServer() - .expect({ event: ANR_EVENT_WITH_DEBUG_META('basic') }) + .expect({ + event: { + ...ANR_EVENT_WITH_DEBUG_META('basic'), + // Ensures breadcrumbs make it through the poll state rather than via AsyncLocalStorage + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'test', + message: 'blocking event loop soon', + level: 'info', + }, + ], + }, + }) .start() .completed(); }); 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 index 2a3d21716a89..6e692993c9d0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -20,7 +20,7 @@ describe('httpIntegration-streamed', () => { 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' }); + expect(serverSpan?.attributes['sentry.segment.name.source']).toEqual({ type: 'string', value: 'route' }); }, }) .start(); diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs new file mode 100644 index 000000000000..c1c0abc772cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs @@ -0,0 +1,36 @@ +import Router from '@koa/router'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Koa from 'koa'; + +const port = 5698; + +const app = new Koa(); + +// Registered first so it wraps every downstream middleware/route in its try/catch. +Sentry.setupKoaErrorHandler(app); + +// Plain middleware -> produces a `middleware.koa` span named after the function. +app.use(async function simpleMiddleware(ctx, next) { + await next(); +}); + +const router = new Router(); + +router.get('/', ctx => { + ctx.body = 'Hello World!'; +}); + +router.get('/test-param/:id', ctx => { + ctx.body = { id: ctx.params.id }; +}); + +router.get('/error', () => { + throw new Error('Sentry Test Error'); +}); + +app.use(router.routes()).use(router.allowedMethods()); + +app.listen(port, () => { + sendPortToRunner(port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts new file mode 100644 index 000000000000..e3754e233565 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -0,0 +1,104 @@ +import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('koa auto-instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + // `createEsmAndCjsTests` auto-runs this suite with orchestrion on CI. The + // orchestrion path keeps span ops/attributes identical to the OTel path; only + // the origin differs to signal the injection mechanism, so we branch on + // `isOrchestrionEnabled()`. + const origin = isOrchestrionEnabled() ? 'auto.http.orchestrion.koa' : 'auto.http.otel.koa'; + + const EXPECTED_ERROR_EVENT = { + exception: { + values: [ + { + type: 'Error', + value: 'Sentry Test Error', + }, + ], + }, + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should auto-instrument `koa` router and middleware layers.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + spans: expect.arrayContaining([ + // Router layer span (from `@koa/router`), carrying the matched route. + expect.objectContaining({ + description: '/', + op: 'router.koa', + origin, + data: expect.objectContaining({ + 'http.route': '/', + 'koa.type': 'router', + 'koa.name': '/', + 'sentry.op': 'router.koa', + 'sentry.origin': origin, + }), + }), + // Plain middleware span. + expect.objectContaining({ + description: 'simpleMiddleware', + op: 'middleware.koa', + origin, + data: expect.objectContaining({ + 'koa.type': 'middleware', + 'koa.name': 'simpleMiddleware', + 'code.function.name': 'simpleMiddleware', + 'sentry.op': 'middleware.koa', + 'sentry.origin': origin, + }), + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('should assign a parameterized transaction name.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test-param/:id', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: '/test-param/:id', + op: 'router.koa', + origin, + data: expect.objectContaining({ + 'http.route': '/test-param/:id', + 'koa.type': 'router', + 'koa.name': '/test-param/:id', + 'sentry.op': 'router.koa', + 'sentry.origin': origin, + }), + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/test-param/123'); + await runner.completed(); + }); + + test('should capture errors thrown in routes via the koa error handler.', async () => { + const runner = createRunner() + .unordered() + .expect({ transaction: { transaction: 'GET /error' } }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs new file mode 100644 index 000000000000..70a62c9b9295 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. the queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts new file mode 100644 index 000000000000..28f5d5abb64b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 4 so the = 4.0 <6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v4 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '4.17.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs new file mode 100644 index 000000000000..9244d97eba47 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. The queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts new file mode 100644 index 000000000000..6b0c67965da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 5 so the >= 4.0 < 6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v5 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '5.9.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts new file mode 100644 index 000000000000..9ad2263efc0f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts @@ -0,0 +1,73 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 6 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. +describe('MongoDB v6 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '6.21.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts new file mode 100644 index 000000000000..f6c47b9a4f67 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts @@ -0,0 +1,74 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, expect } from 'vitest'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 7 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. mongodb 7 requires Node >= 20.19, so this suite is +// skipped on older Node (on Node 18 the driver throws `ReferenceError: crypto is not defined`). +conditionalTest({ min: 20 })('MongoDB v7 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '7.5.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index 68a32df6181b..5c88a8de18da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -2,9 +2,11 @@ import type { TransactionEvent } from '@sentry/core'; import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; import { assertSentryTransaction } from '../../../utils/assertions'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('MongoDB auto-instrumentation', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -21,7 +23,7 @@ describe('MongoDB auto-instrumentation', () => { const SPAN_FIND_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -35,12 +37,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_INSERT_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -54,12 +56,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_ISMASTER_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -75,12 +77,12 @@ describe('MongoDB auto-instrumentation', () => { description: '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_UPDATE_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -94,13 +96,13 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); // A query the server rejects: same attributes as a successful find, but with an error status. const SPAN_FIND_ERROR_MATCHER = expect.objectContaining({ data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.operation': 'find', @@ -109,13 +111,13 @@ describe('MongoDB auto-instrumentation', () => { }), description: '{"$thisOperatorDoesNotExist":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, status: 'internal_error', }); const SPAN_ENDSESSIONS_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -128,7 +130,7 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts index dbacf38050e4..c5adf265bf52 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts @@ -1,6 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // mongoose >= 9.7.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes @@ -9,6 +9,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // query text, span relationships, and that the legacy IITM patcher does NOT also fire (no double // instrumentation). mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -104,7 +105,7 @@ conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { // the underlying mongodb driver span must parent to the mongoose channel span, // proving the channel span is the active async context for the traced operation const driverChild = spans.find( - span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs new file mode 100644 index 000000000000..0463ed0b3680 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import mongoose from 'mongoose'; + +async function run() { + await mongoose.connect(process.env.MONGO_URL || ''); + + const BlogPostSchema = new mongoose.Schema({ + title: String, + body: String, + date: Date, + }); + + const BlogPost = mongoose.model('BlogPost', BlogPostSchema); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + const post = new BlogPost({ title: 'Test', body: 'Test body', date: new Date() }); + + await post.save(); + + await BlogPost.findOne({}); + + await BlogPost.aggregate([{ $match: {} }]); + + await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); + + await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts new file mode 100644 index 000000000000..9f62692c1b90 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts @@ -0,0 +1,59 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongoose 5.9.7 +// the bottom of the IITM patcher's `>=5.9.7 <9.7.0` range, so the oldest +// supported major is exercised against a real mongoose. +describe('Mongoose v5 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const expectedSpan = (operation: string) => + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': operation, + 'db.system': 'mongoose', + }), + description: `mongoose.BlogPost.${operation}`, + op: 'db', + origin, + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expectedSpan('save'), + expectedSpan('findOne'), + expectedSpan('aggregate'), + expectedSpan('insertMany'), + expectedSpan('bulkWrite'), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongoose` v5.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^5.9.7' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts index dde237210761..ee9cec92cc46 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts @@ -1,9 +1,11 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins mongoose 7 so the `contextCaptureFunctions7` version branch is exercised against a real mongoose. describe('Mongoose v7 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -27,7 +29,7 @@ describe('Mongoose v7 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts index 48e3faefbf0c..b13d715fc3bd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts @@ -1,10 +1,12 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins mongoose 8 (>= 8.21) so the document `updateOne`/`deleteOne` lazy-Query path is exercised // against a real mongoose, guarding the thenable trap that mongoose 6 (the workspace version) can't hit. describe('Mongoose v8 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -30,7 +32,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +42,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.updateOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -50,7 +52,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.deleteOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), ]), }; diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts index 85f5c432983e..cf027f31fc38 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -1,6 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins the highest mongoose 9 below 9.7, the top of the IITM patcher's `>=5.9.7 <9.7.0` range, so the @@ -8,6 +8,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // diagnostics_channel and is covered by the `mongoose-tracing-channel` suite instead. // mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose v9 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -31,7 +32,7 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts index ed20aff13496..2a1bf262ab02 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -1,8 +1,11 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('Mongoose experimental Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -29,7 +32,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +43,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.findOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -51,7 +54,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.aggregate', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -62,7 +65,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.insertMany', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -73,7 +76,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.bulkWrite', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // `remove` is patched only on mongoose 5/6. expect.objectContaining({ @@ -85,7 +88,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.remove', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // A failing operation still produces a span, marked with an error status. expect.objectContaining({ @@ -95,7 +98,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.RequiredDoc.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, status: 'internal_error', }), ]), @@ -115,7 +118,7 @@ describe('Mongoose experimental Test', () => { expect(mongooseSave).toBeDefined(); // the underlying mongodb driver span must be parented to the mongoose span const driverChild = spans.find( - span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 6b4807caec50..63fe04d744b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -263,10 +263,6 @@ describe('mysql auto instrumentation', () => { type: 'string', value: 'task', }, - 'sentry.span.source': { - type: 'string', - value: 'task', - }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 6db43ab26971..a0d7686e912b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -72,10 +72,6 @@ const COMMON_DB_ATTRIBUTES = { type: 'string', value: 'task', }, - 'sentry.span.source': { - type: 'string', - value: 'task', - }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts index 9315adc85cf3..420a92a6caa7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -1,7 +1,10 @@ import { afterAll, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [__dirname] }, () => { + const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.tedious' : 'auto.db.otel.tedious'; + afterAll(() => { cleanupChildProcesses(); }); @@ -9,9 +12,9 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ const dbSpan = (overrides: Record) => expect.objectContaining({ op: 'db', - origin: 'auto.db.otel.tedious', + origin: ORIGIN, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.tedious', + 'sentry.origin': ORIGIN, 'sentry.op': 'db', 'db.system': 'mssql', 'db.name': 'master', @@ -33,7 +36,7 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ expect.objectContaining({ description: 'execBulkLoad test_bulk master', op: 'db', - origin: 'auto.db.otel.tedious', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'db.sql.table': 'test_bulk' }), }), diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 0b91ce62a02c..b759495545cc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -321,28 +321,22 @@ describe('Vercel AI integration (v4)', () => { expect(errorEvent).toBeDefined(); expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' }); - // Trace id is shared between the transaction and the tool error. - expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); - if (orchestrion) { - // The channel subscriber captures the raw tool error and tags it with the tool identity. - expect(errorEvent!.level).toBe('error'); - expect(errorEvent!.tags).toMatchObject({ - 'vercel.ai.tool.name': 'getWeather', - 'vercel.ai.tool.callId': 'call-1', - }); - } else { - // The OTel processor surfaces the SDK's wrapped `AI_ToolExecutionError`. - expect(errorEvent!.exception?.values).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'AI_ToolExecutionError', - value: 'Error executing tool getWeather: Error in tool', - }), - ]), - ); - expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); - } + // The tool error bubbles out of the `ai` call as the SDK's wrapped `AI_ToolExecutionError`. The + // channel subscriber deliberately doesn't self-capture v4 tool errors (that would double-report + // alongside the bubbled error), so a single error event is produced in both modes. + expect(errorEvent!.exception?.values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + ); + // Both paths stamp the operation's call-site span onto the bubbled error, so the global + // unhandled-rejection handler restores it and correlates the report to the transaction's root span. + expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); @@ -352,7 +346,7 @@ describe('Vercel AI integration (v4)', () => { let errorEvent: Event | undefined; const runner = createRunner() - // In orchestrion mode the tool error is captured mid-request, so envelopes can arrive in any order. + // The error and transaction/span envelopes can arrive in either order, so assert content, not order. .unordered() .expect({ transaction: transaction => { @@ -403,24 +397,18 @@ describe('Vercel AI integration (v4)', () => { expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' }); expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); - if (orchestrion) { - // The channel subscriber captures the raw tool error and tags it with the tool identity. - expect(errorEvent!.tags).toMatchObject({ - 'vercel.ai.tool.name': 'getWeather', - 'vercel.ai.tool.callId': 'call-1', - }); - } else { - // The OTel processor surfaces the SDK's wrapped `AI_ToolExecutionError`. - expect(errorEvent!.exception?.values).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'AI_ToolExecutionError', - value: 'Error executing tool getWeather: Error in tool', - }), - ]), - ); - expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); - } + // The tool error bubbles out of the `ai` call as the SDK's wrapped `AI_ToolExecutionError` and is + // captured once by the express error handler — the channel subscriber deliberately doesn't + // self-capture v4 tool errors, so orchestrion and OTel produce the same single error event. + expect(errorEvent!.exception?.values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + ); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 0014616f8071..43e11e36f19c 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -94,6 +94,11 @@ export function makeBaseNPMConfig(options = {}) { // (We don't need it, so why waste the bytes?) freeze: false, + // Assume externals are ESM-shaped (`__esModule` + `.default`), which our own `@sentry/*` + // packages satisfy via `esModule: 'if-default-prop'`. This keeps `import * as x` a live + // reference to the real module rather than an `_interopNamespace` copy — instrumentation code + // relies on that to monkey-patch modules like `fs` in place. Packages that pull in bare-CJS + // third-party deps (no `.default`) override this per-module (see server-utils). interop: 'esModule', }, diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 6293bff213d7..205cfb7e757f 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -63,7 +63,7 @@ describe('sentryMiddleware', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: true, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 71222eb0a6a6..2078044d64a8 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -167,6 +167,8 @@ export { metrics, spanStreamingIntegration, withStreamedSpan, + experimentalUseDiagnosticsChannelInjection, + diagnosticsChannelInjectionIntegrations, } from '@sentry/node'; export { diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index fe069d7ff6f5..9d815a304a22 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,7 +1,11 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, getSDKSource } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { + applyDiagnosticsChannelInjectionIntegrations, + getDefaultIntegrationsWithoutPerformance, + initWithoutDefaultIntegrations, +} from '@sentry/node'; import { envToBool } from '@sentry/node-core'; import { DEBUG_BUILD } from './debug-build'; import { awsIntegration } from './integration/aws'; @@ -49,8 +53,13 @@ function shouldDisableLayerExtensionForProxy(): boolean { */ // NOTE: in awslambda-auto.ts, we also call the original `getDefaultIntegrations` from `@sentry/node` to load performance integrations. // If at some point we need to filter a node integration out for good, we need to make sure to also filter it out there. -export function getDefaultIntegrations(_options: Options): Integration[] { - return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; +export function getDefaultIntegrations(options: Options): Integration[] { + const integrations = [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; + // If the app opted into diagnostics-channel injection, the OTel `Aws` integration is swapped for + // its channel-based equivalent AND the full channel-integration set is appended (mysql, postgres, + // express, ...), giving opted-in apps performance coverage this SDK's defaults otherwise omit. + // No-op otherwise. + return applyDiagnosticsChannelInjectionIntegrations(integrations, options); } export interface AwsServerlessOptions extends NodeOptions { diff --git a/packages/bun/package.json b/packages/bun/package.json index 5a3d5dcacb28..8e79385b3b50 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -49,7 +49,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", "@sentry/server-utils": "10.66.0" diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index c8371fe20a27..c53ce21245bd 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -20,10 +20,14 @@ import type { } from '../core/component-annotation-vite'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; -import type { TransformResult } from 'rollup'; import * as path from 'node:path'; import { createRequire } from 'node:module'; +// The subset of Rollup's `TransformResult` that this plugin's `transform` +// hook actually returns. Defined locally instead of imported from `rollup` +// because `rollup` is an optional dependency. +type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined; + type ViteModule = { parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; }; diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 21be86bf4c59..087c1ad720d9 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -177,9 +177,9 @@ interface BaseCloudflareOptions { * When enabled, trace context (sentry-trace + baggage) is propagated across: * - `stub.fetch()` calls to Durable Objects (via HTTP headers) * - Service binding `fetch()` calls (via HTTP headers) - * - RPC method calls to Durable Objects (via trailing argument) + * - RPC method calls to Durable Objects and WorkerEntrypoints (via trailing argument) * - * When enabled on the **receiver side** (DurableObject), the SDK will also: + * When enabled on the **receiver side** (DurableObject or WorkerEntrypoint), the SDK will also: * - Extract and continue traces from incoming RPC calls * - Create spans for each RPC method invocation * - Capture errors thrown by RPC methods @@ -206,10 +206,40 @@ interface BaseCloudflareOptions { * }), * MyDOBase, * ); + * + * // WorkerEntrypoint side (receiver) + * export const MyEntrypoint = Sentry.withSentry( + * env => ({ dsn: env.SENTRY_DSN, enableRpcTracePropagation: true }), + * MyEntrypointBase, + * ); * ``` */ enableRpcTracePropagation?: boolean; + /** + * Table names that should stay instrumented even though they match the reserved `cf_` prefix used + * by Durable Object frameworks (`agents`, `partyserver`, ...) for their internal SQLite tables. + * + * By default, `exec` queries against `cf_`-prefixed tables are treated as framework noise and no + * `db.query` span is created for them. If one of your own tables happens to use this prefix, add it + * here to opt it back into instrumentation. Entries are matched against each table name in the + * query summary — strings must match exactly, while regular expressions give you prefix/pattern + * matching. + * + * @default [] + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + * }), + * handler, + * ); + * ``` + */ + durableObjectSqlSpanAllowlist?: Array; + /** * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. * diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e312c31b59ad..42bfcab834cf 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -72,9 +72,11 @@ export function instrumentDurableObjectWithSentry< // Any other public methods on the Durable Object instance are RPC calls. + // Bind each built-in handler to this instance before wrapping. + // See https://github.com/getsentry/sentry-javascript/issues/22328 if (obj.fetch && typeof obj.fetch === 'function') { obj.fetch = ensureInstrumented( - obj.fetch, + obj.fetch.bind(obj), original => new Proxy(original, { apply(target, thisArg, args) { @@ -89,26 +91,36 @@ export function instrumentDurableObjectWithSentry< if (obj.alarm && typeof obj.alarm === 'function') { // Alarms are independent invocations, so we start a new trace and link to the previous alarm obj.alarm = wrapMethodWithSentry( - { options, context, spanName: 'alarm', spanOp: 'function', startNewTrace: true }, - obj.alarm, + { + options, + context, + spanName: 'alarm', + spanOp: 'function', + startNewTrace: true, + origin: 'auto.faas.cloudflare.durable_object', + }, + obj.alarm.bind(obj), ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( - { options, context, spanName: 'webSocketMessage' }, - obj.webSocketMessage, + { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, + obj.webSocketMessage.bind(obj), ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { - obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + obj.webSocketClose = wrapMethodWithSentry( + { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, + obj.webSocketClose.bind(obj), + ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( - { options, context, spanName: 'webSocketError' }, - obj.webSocketError, + { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, + obj.webSocketError.bind(obj), (_, error) => captureException(error, { mechanism: { @@ -174,7 +186,7 @@ export function instrumentDurableObjectWithSentry< // Pre-create the traced version const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc' }, + { options, context, spanName: prop, spanOp: 'rpc', origin: 'auto.faas.cloudflare.durable_object' }, boundMethod, undefined, true, diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index 96950dbb4f9e..739a0a7ef14a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -2,9 +2,12 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/core'; +import type { CloudflareClientOptions } from '../client'; +import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -23,9 +26,17 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { return function (this: unknown, ...args: unknown[]) { const [query, ...bindings] = args as [string, ...unknown[]]; + const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery); + const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined) + ?.durableObjectSqlSpanAllowlist; + + if (targetsCloudflareInternalTable(querySummary, allowlist)) { + return (original as (...a: unknown[]) => ReturnType).apply(target, args); + } + return startSpan( { op: 'db.query', diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index e8b2466da821..bb53e124568e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,19 +1,115 @@ +import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; -import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import type { CloudflareOptions } from '../client'; +import { getFinalOptions } from '../options'; +import { instrumentContext } from '../utils/instrumentContext'; +import { extractRpcMeta } from '../utils/rpcMeta'; +import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry'; +import { instrumentEnv } from './worker/instrumentEnv'; import { instrumentWorkerEntrypointFetch } from './worker/instrumentFetch'; import { instrumentWorkerEntrypointQueue } from './worker/instrumentQueue'; import { instrumentWorkerEntrypointScheduled } from './worker/instrumentScheduled'; import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; -import { getFinalOptions } from '../options'; -import { instrumentContext } from '../utils/instrumentContext'; -import { instrumentEnv } from './worker/instrumentEnv'; -export type WorkerEntrypointConstructor = new ( +const WORKER_ENTRYPOINT_ORIGIN = 'auto.faas.cloudflare.worker_entrypoint'; + +type ReservedMethod = + | Exclude, '__WORKER_ENTRYPOINT_BRAND'> + | Extract + | Exclude unknown>, string>, '__RPC_STUB_BRAND'> + | 'constructor'; + +const RESERVED_METHODS = new Set([ + 'connect', + 'constructor', + 'dup', + 'email', + 'fetch', + 'queue', + 'scheduled', + 'tail', + 'tailStream', + 'test', + 'trace', +] satisfies ReservedMethod[]); + +interface CachedMethod { + source: UncheckedMethod; + wrapped: UncheckedMethod; +} + +export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, - env: typeof cloudflareEnv, + // WorkerEntrypoint subclasses can define different `env` shapes, so this constructor must accept all of them. + // oxlint-disable-next-line typescript/no-explicit-any + env: any, ) => InstanceType>; +function isPrototypeMethod(instance: object, prop: string, method: UncheckedMethod): boolean { + let prototype = Object.getPrototypeOf(instance); + + while (prototype && prototype !== Object.prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, prop); + if (descriptor) { + return descriptor.value === method; + } + prototype = Object.getPrototypeOf(prototype); + } + + return false; +} + +function bindToInstance(instance: object, proxy: object, method: UncheckedMethod): UncheckedMethod { + return new Proxy(method, { + apply(target, thisArg, args) { + return Reflect.apply(target, thisArg === proxy ? instance : thisArg, args); + }, + }); +} + +function instrumentMethod( + instance: object, + proxy: object, + prop: PropertyKey, + method: UncheckedMethod, + options: CloudflareOptions, + context: ExecutionContext, +): UncheckedMethod { + const ownDescriptor = Object.getOwnPropertyDescriptor(instance, prop); + if (ownDescriptor && 'value' in ownDescriptor && !ownDescriptor.configurable && !ownDescriptor.writable) { + return method; + } + + const boundMethod = bindToInstance(instance, proxy, method); + + if (typeof prop !== 'string' || RESERVED_METHODS.has(prop) || !isPrototypeMethod(instance, prop, method)) { + return boundMethod; + } + + const captureMethod = wrapMethodWithSentry( + { options, context, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, + boundMethod, + undefined, + true, + ); + + if (!options.enableRpcTracePropagation) { + return captureMethod; + } + + const tracedMethod = wrapMethodWithSentry( + { options, context, spanName: prop, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, + boundMethod, + undefined, + true, + ); + + return (...args: unknown[]) => { + const { rpcMeta } = extractRpcMeta(args); + return rpcMeta ? tracedMethod.call(proxy, ...args) : captureMethod.call(proxy, ...args); + }; +} + /** * Instruments a WorkerEntrypoint class to capture errors and performance data. * @@ -51,10 +147,12 @@ export type WorkerEntrypointConstructor * ); * ``` */ -export function instrumentWorkerEntrypoint( - optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, - WorkerEntrypointClass: T, -): T { +export function instrumentWorkerEntrypoint< + Env, + Props, + T extends InstanceType>, + C extends new (ctx: ExecutionContext, env: Env) => T, +>(optionsCallback: (env: Env) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { // Set up AsyncLocalStorage strategy ONCE at instrumentation time, not per-request // This is critical - calling this per-request would create a new AsyncLocalStorage // each time, breaking scope isolation for concurrent requests @@ -103,12 +201,37 @@ export function instrumentWorkerEntrypoint(); + + const proxy: typeof obj = new Proxy(obj, { + get(instance, prop) { + const value = Reflect.get(instance, prop, instance); + + if (typeof value !== 'function') { + return value; + } + + const method = value as UncheckedMethod; + + const cached = methodCache.get(prop); + if (cached?.source === method) { + return cached.wrapped; + } + + const wrapped = instrumentMethod(instance, proxy, prop, method, options, context); + methodCache.set(prop, { source: method, wrapped }); + return wrapped; + }, + }); - return obj; + return proxy; }, }); } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 5899ee66ada5..474fbf831b86 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,6 +1,15 @@ import { isObjectLike } from '@sentry/core'; +import { instrumentWorkersAiClient } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding'; +import { + isAiBinding, + isD1Database, + isDurableObjectNamespace, + isJSRPC, + isQueue, + isR2Bucket, + isRateLimit, +} from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; @@ -8,6 +17,7 @@ import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instr import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; import { instrumentR2Bucket } from './instrumentR2'; +import { instrumentRateLimit } from './instrumentRateLimit'; function isProxyable(item: unknown): item is object { return isObjectLike(item) || typeof item === 'function'; @@ -24,6 +34,8 @@ const instrumentedBindings = new WeakMap(); * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) + * - Rate limiters (via `limit` duck-typing) + * - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing) * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -69,6 +81,19 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isRateLimit(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentRateLimit(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + if (isAiBinding(item)) { + const instrumented = instrumentWorkersAiClient(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts new file mode 100644 index 000000000000..0b05a99f3518 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts @@ -0,0 +1,31 @@ +import type { RateLimit, RateLimitOptions, RateLimitOutcome } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.rate_limit'; + +/** + * Wraps a Cloudflare rate limiter binding to create a span on each `limit()` call. + */ +export function instrumentRateLimit(rateLimit: T, bindingName: string): T { + return new Proxy(rateLimit, { + get(target, prop, receiver) { + if (prop !== 'limit') { + return Reflect.get(target, prop, receiver); + } + + const original = Reflect.get(target, prop, receiver) as RateLimit['limit']; + + return function (this: unknown, options: RateLimitOptions): Promise { + return startSpan( + { + name: `rate_limit ${bindingName}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + () => Reflect.apply(original, target, [options]), + ); + }; + }, + }); +} diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts new file mode 100644 index 000000000000..977a5a9c05a1 --- /dev/null +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -0,0 +1,38 @@ +import { stringMatchesSomePattern } from '@sentry/core'; + +/** + * Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) manage their + * own internal SQLite tables, all namespaced with a `cf_` prefix — e.g. `cf_agents_schedules`, + * `cf_agent_state`, `cf_ai_chat_stream_chunks`. Queries against them (schedule polling, chat-stream + * persistence, state bookkeeping) are framework implementation details that otherwise flood traces + * with dozens of zero-signal `db.query` spans per request. The exact set of tables even varies + * between framework versions, so we match the reserved prefix rather than an enumerated list. + * + * The `cf_` prefix is a reserved convention for framework-managed tables, so user tables should not + * use it. In case a user table does collide with the prefix, the `durableObjectSqlSpanAllowlist` + * option lets them opt those tables back into instrumentation. + * + * The check operates on the query summary produced by `getSqlQuerySummary` (`{operation} {table} ...`, + * the same value used as the span name), so table targets are already isolated from the rest of the + * query. + */ +export function targetsCloudflareInternalTable( + querySummary: string | undefined, + allowlist?: Array, +): boolean { + if (!querySummary) { + return false; + } + + const [, ...tables] = querySummary.split(' '); + + return tables.some(table => { + if (!table.toLowerCase().startsWith('cf_')) { + return false; + } + + // A table on the allowlist is treated as a user table and stays instrumented, even though it + // matches the reserved prefix. + return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true); + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 88832f375f21..4f4e6d012c74 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database { ); } +/** + * Duck-type check for Workers AI bindings. + * The Ai binding has `run`, `gateway`, and `toMarkdown` methods. + */ +export function isAiBinding(item: unknown): item is Ai { + return ( + item != null && + isNotJSRPC(item) && + typeof item.run === 'function' && + typeof item.gateway === 'function' && + typeof item.toMarkdown === 'function' + ); +} + /** * Duck-type check for R2 Bucket bindings. * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. @@ -95,3 +109,14 @@ export function isR2Bucket(item: unknown): item is R2Bucket { typeof item.createMultipartUpload === 'function' ); } + +/** + * Duck-type check for RateLimit bindings. + * RateLimit only exposes a single `limit` method. Because that is a fairly + * common method name, this check is intentionally run after the more specific + * binding checks (Queue, R2, D1) in `instrumentEnv`, so those win when a binding + * also happens to expose `limit`. + */ +export function isRateLimit(item: unknown): item is RateLimit { + return item != null && isNotJSRPC(item) && typeof item.limit === 'function'; +} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 1479278446fa..4723887dbace 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -15,7 +15,8 @@ import { withScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; +import type { ExecutionContextCompat } from './executionContext'; +import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; import { extractRpcMeta } from './utils/rpcMeta'; @@ -65,6 +66,11 @@ type MethodWrapperOptions = { * @default false */ startNewTrace?: boolean; + /** + * The trace origin identifying which instrumentation created the span, e.g. `auto.faas.cloudflare.durable_object`. + * Used both as the span's `sentry.origin` attribute and as the `mechanism.type` for captured exceptions. + */ + origin: string; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -92,7 +98,7 @@ export function wrapMethodWithSentry( original => new Proxy(original, { apply(target, thisArg, rawArgs: Parameters) { - const { startNewTrace } = wrapperOptions; + const { startNewTrace, origin } = wrapperOptions; // For RPC methods, extract Sentry trace context from the trailing argument. // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; @@ -117,7 +123,10 @@ export function wrapMethodWithSentry( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context: typeof wrapperOptions.context | undefined = wrapperOptions.context; - const waitUntil = context?.waitUntil?.bind?.(context); + // see: https://github.com/getsentry/sentry-javascript/issues/22328 + const waitUntil = context + ? getOriginalWaitUntil(context as ExecutionContextCompat)?.bind(context) + : undefined; const storage = resolveOriginalStorage(context, thisArg); let scopeClient = scope.getClient(); @@ -152,7 +161,7 @@ export function wrapMethodWithSentry( const onRejected = (e: unknown) => { captureException(e, { mechanism: { - type: 'auto.faas.cloudflare.durable_object', + type: origin, handled: false, }, }); @@ -181,7 +190,7 @@ export function wrapMethodWithSentry( const attributes = wrapperOptions.spanOp ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, } : {}; diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 83b473fabe4d..ec0c9e8ec708 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,6 +81,38 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + // Regression for #22328 + // built-in handlers live on the class prototype. + // ensureInstrumented keys its global cache on the original function + // reference, so without per-instance binding a second instance in the + // same isolate reuses the first instance's wrapper. + it('Built-in handlers do not stick to the first instance options across a shared isolate', async () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; + const initCore = vi.spyOn(SentryCore, 'initAndBind'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const options = vi.fn().mockReturnValueOnce({ orgId: 1 }).mockReturnValueOnce({ orgId: 2 }); + + const testClass = class { + webSocketMessage() {} + }; + const Instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + + const instance1 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + const instance2 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + + // Each instance must get its own wrapper, not the first instance's cached proxy. + expect(instance2.webSocketMessage).not.toBe(instance1.webSocketMessage); + + await instance1.webSocketMessage(); + await instance2.webSocketMessage(); + + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); + expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); + }); + it('does not create RPC spans without metadata when both RPC options are set', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index de5ba69f79b0..e9fdb9f5d2ff 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -144,6 +144,45 @@ describe('instrumentSqlStorage', () => { expect(startSpanSpy).toHaveBeenCalledTimes(2); expect(mockSql.exec).toHaveBeenCalledTimes(2); }); + + describe('internal storage queries', () => { + it('does not create a span for Cloudflare-internal queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockCursor = createMockCursor(); + const mockSql = createMockSqlStorage(mockCursor); + const instrumented = instrumentSqlStorage(mockSql); + + const result = instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + expect(result).toBe(mockCursor); + }); + + it('still creates a span for user queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + + it('creates a span for a cf_ table on the durableObjectSqlSpanAllowlist', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + vi.spyOn(sentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ durableObjectSqlSpanAllowlist: ['cf_my_table'] }), + } as unknown as ReturnType); + + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockCursor() { diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9309284abb41..72f9d0774507 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -256,6 +256,77 @@ describe('instrumentEnv', () => { expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); }); + it('wraps RateLimit bindings in a proxy and forwards calls', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const limit = vi.fn().mockResolvedValue({ success: true }); + const rateLimiter = { limit }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_RATE_LIMITER as typeof rateLimiter; + // Wrapped binding is a Proxy, not the original reference + expect(wrapped).not.toBe(rateLimiter); + + const outcome = await wrapped.limit({ key: 'user-123' }); + expect(outcome).toEqual({ success: true }); + expect(limit).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'rate_limit MY_RATE_LIMITER' }), + expect.any(Function), + ); + }); + + it('caches the wrapped RateLimit binding across repeated access', () => { + const rateLimiter = { limit: vi.fn() }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER); + }); + + describe('Workers AI bindings', () => { + function createMockAiBinding() { + return { + run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }), + gateway: vi.fn(), + toMarkdown: vi.fn(), + models: vi.fn(), + autorag: vi.fn(), + }; + } + + it('detects and wraps AI bindings, forwarding run calls unchanged', async () => { + const ai = createMockAiBinding(); + const env = { AI: ai }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.AI as typeof ai; + expect(wrapped).not.toBe(ai); + + const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + + expect(ai.run).toHaveBeenCalledTimes(1); + expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }); + }); + + it('caches the wrapped AI binding across repeated access', () => { + const ai = createMockAiBinding(); + const env = { AI: ai }; + const instrumented = instrumentEnv(env); + + expect(instrumented.AI).toBe(instrumented.AI); + }); + + it('does not treat bindings with only a run method as AI bindings', () => { + const notAi = { run: vi.fn() }; + const env = { RUNNER: notAi }; + const instrumented = instrumentEnv(env); + + expect(instrumented.RUNNER).toBe(notAi); + }); + }); + describe('mTLS Fetcher bindings', () => { function createMtlsFetcherProxy(mockFetch: ReturnType) { return new Proxy( diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 54069e14c251..353eabebe474 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -1,4 +1,5 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstrumented } from '../../src/instrument'; @@ -6,6 +7,7 @@ import { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, } from '../../src/instrumentations/instrumentWorkerEntrypoint'; +import { resetSdk } from '../testUtils'; function createMockExecutionContext(): ExecutionContext { return { @@ -20,6 +22,7 @@ class WorkerEntrypoint {} describe('instrumentWorkerEntrypoint', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('Generic functionality', () => { @@ -235,50 +238,209 @@ describe('instrumentWorkerEntrypoint', () => { expect(testClientFlushCount).toBe(1); }); - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const TestClass = class Hello extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + describe('custom RPC methods', () => { + it('binds non-RPC methods and getters to the original instance', () => { + class TestClass extends WorkerEntrypoint { + #value = 'value'; + + ownMethod = function ownMethod(this: TestClass) { + return this.#value; + }; + + get value() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + expect(obj.ownMethod).toBe(obj.ownMethod); + expect(obj.ownMethod()).toBe('value'); + expect(obj.value).toBe('value'); + expect(obj.toString()).toBe('[object Object]'); + expect(obj.ownMethod.name).toBe('ownMethod'); + + const other = { value: true }; + expect(obj.hasOwnProperty.call(other, 'value')).toBe(true); + }); + + it('handles method replacement and frozen own methods', () => { + const frozenMethod = () => 'frozen'; + class TestClass extends WorkerEntrypoint { + method() { + return 'first'; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + Object.defineProperty(obj, 'frozenMethod', { + configurable: false, + value: frozenMethod, + writable: false, + }); + + expect(obj.method()).toBe('first'); + TestClass.prototype.method = () => 'second'; + expect(obj.method()).toBe('second'); + expect(obj.frozenMethod).toBe(frozenMethod); + }); + + it('preserves `this` for custom RPC methods when RPC trace propagation is enabled', () => { + class TestClass extends WorkerEntrypoint { + #value = 'secret'; + + readValue() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); + + // No propagated trace metadata takes the error-capture-only path. + expect(obj.readValue()).toBe('secret'); + + // Propagated trace metadata takes the traced (span-creating) path. + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + expect(obj.readValue(rpcMeta)).toBe('secret'); + }); + + it('strips RPC metadata even when trace propagation is disabled', () => { + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + const TestClass = class extends WorkerEntrypoint { + inspect(...args: unknown[]) { + return args; } }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + expect(obj.inspect).toBe(obj.inspect); + expect(obj.inspect(rpcMeta)).toEqual([]); }); - it('does not instrument prototype methods when option is false', () => { + it('flushes repeated calls on the same instance', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + async get(value: string) { + SentryCore.captureMessage(value); + return value; } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + await expect(obj.get('first call')).resolves.toBe('first call'); + await Promise.all(waits.splice(0)); + await expect(obj.get('second call')).resolves.toBe('second call'); + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['first call', 'second call']); }); - it('does not instrument prototype methods when option is true (instrumentWorkerEntrypoint does not support instrumentPrototypeMethods)', () => { + it('does not create a second invocation for direct calls from RPC or lifecycle methods', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - methodOne() { - return 'one'; + async outer() { + return this.inner(); } - methodTwo() { - return 'two'; + + async fetch() { + return this.inner(); + } + + async inner(): Promise { + throw new Error('inner failure'); } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, [createMockExecutionContext(), {}]); - - expect(getInstrumented(obj.methodOne)).toBeFalsy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(obj.methodOne()).toBe('one'); - expect(obj.methodTwo()).toBe('two'); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await expect(obj.outer()).rejects.toThrow('inner failure'); + await Promise.all(waits.splice(0)); + expect(events).toHaveLength(1); + + await expect(obj.fetch(new Request('https://example.com'))).rejects.toThrow('inner failure'); + await Promise.all(waits); + expect(events).toHaveLength(2); + }); + + it('only excludes WorkerEntrypoint lifecycle methods from RPC instrumentation', async () => { + const initAndBind = vi.spyOn(SentryCore, 'initAndBind'); + const TestClass = class extends WorkerEntrypoint { + alarm() {} + + fetch() { + return new Response('ok'); + } + + tailStream() {} + + test() {} + + trace() {} + + webSocketMessage() {} + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + const response = await obj.fetch(new Request('https://example.com')); + await response.text(); + obj.tailStream(); + obj.test(); + obj.trace(); + await obj.alarm(); + await obj.webSocketMessage(); + + expect(initAndBind).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts new file mode 100644 index 000000000000..61062355da77 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts @@ -0,0 +1,76 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentRateLimit } from '../../../src/instrumentations/worker/instrumentRateLimit'; + +function createMockRateLimit(success = true): RateLimit { + return { + limit: vi.fn().mockResolvedValue({ success }), + } as unknown as RateLimit; +} + +describe('instrumentRateLimit', () => { + let startSpanSpy: MockInstance; + + beforeEach(() => { + startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('limit', () => { + test('forwards the call and returns the outcome', async () => { + const rateLimit = createMockRateLimit(true); + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: true }); + expect(rateLimit.limit).toHaveBeenCalledTimes(1); + expect(rateLimit.limit).toHaveBeenCalledWith({ key: 'user-123' }); + }); + + test('returns an unsuccessful (rate-limited) outcome unchanged', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(false), 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: false }); + }); + + test('starts a span with the binding name and origin', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'user-123' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'rate_limit MY_RATE_LIMITER', + attributes: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + }, + expect.any(Function), + ); + }); + + test('does not record the rate limit key (avoids leaking PII)', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'super-secret-user-id' }); + + expect(JSON.stringify(startSpanSpy.mock.calls[0]![0])).not.toContain('super-secret-user-id'); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const rateLimit = Object.assign(createMockRateLimit(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as RateLimit & { customMethod: () => string }; + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER') as RateLimit & { customMethod: () => string }; + + expect(wrapped.customMethod()).toBe('hi'); + }); +}); diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts new file mode 100644 index 000000000000..7d119eb8a98f --- /dev/null +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -0,0 +1,106 @@ +import { _INTERNAL_getSqlQuerySummary } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; + +// Builds the summary the same way `instrumentSqlStorage` does, so the test exercises the real +// operation -> summary -> detection path rather than hand-written summaries. +const summarize = (query: string): string | undefined => _INTERNAL_getSqlQuerySummary(query); + +describe('targetsCloudflareInternalTable', () => { + describe('internal queries (cf_ tables)', () => { + it.each([ + ['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'], + ['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'], + ['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'], + ['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'], + ['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'], + ['DROP TABLE', 'DROP TABLE cf_agents_state'], + ['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'], + ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], + ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], + ['schema version', 'SELECT version FROM cf_schema_version'], + ])('returns true for %s on internal tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true for an internal JOIN', () => { + const query = ` + SELECT f.fiber_id, f.status + FROM cf_agents_fibers f + LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id + WHERE f.status IN ('pending', 'running') + `; + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true when an internal table is joined with a user table', () => { + // `.some()` — any internal table present means the query is framework-driven noise. + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')), + ).toBe(true); + }); + + it('handles case-insensitive keywords and prefixes', () => { + expect(targetsCloudflareInternalTable(summarize('select * from CF_AGENTS_STATE'))).toBe(true); + }); + }); + + describe('user queries (must be instrumented)', () => { + it.each([ + ['SELECT', 'SELECT * FROM users WHERE id = ?'], + ['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'], + ['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'], + ['DELETE', 'DELETE FROM sessions WHERE expired = 1'], + ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], + ['table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['table starting with cfg', 'SELECT * FROM cfg_settings'], + ])('returns false for %s on user tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(false); + }); + }); + + describe('allowlist (opt a cf_ table back into instrumentation)', () => { + it('returns false for an allowlisted table matched by exact string', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table'), ['cf_my_table'])).toBe(false); + }); + + it('returns false for an allowlisted table matched by regex', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false); + }); + + it('requires an exact match for string entries', () => { + // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true); + }); + + it('still skips genuine internal tables that are not allowlisted', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_my_table'])).toBe(true); + }); + + it('still skips when an internal table is joined with an allowlisted table', () => { + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id'), [ + 'cf_my_table', + ]), + ).toBe(true); + }); + + it('ignores an empty allowlist', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), [])).toBe(true); + }); + }); + + describe('summaries without a resolvable table target (safe default: instrument)', () => { + it.each([ + ['undefined', undefined], + ['empty', ''], + ['no-table SELECT', 'SELECT 1'], + ['PRAGMA', 'PRAGMA foreign_keys = ON'], + ['bare operation', 'BEGIN'], + ])('returns false for %s', (_label, value) => { + const summary = typeof value === 'string' ? summarize(value) : value; + expect(targetsCloudflareInternalTable(summary)).toBe(false); + }); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..3bfba571363b 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isRateLimit } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -210,3 +210,34 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + +describe('isRateLimit', () => { + it('returns true for an object with a limit method', () => { + expect(isRateLimit({ limit: async () => ({ success: true }) })).toBe(true); + }); + + it('returns false when limit is missing', () => { + expect(isRateLimit({ foo: 'bar' })).toBe(false); + }); + + it('returns false when limit is not a function', () => { + expect(isRateLimit({ limit: 'nope' })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isRateLimit(null)).toBe(false); + expect(isRateLimit(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it returns a function for limit', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isRateLimit(jsrpcProxy)).toBe(false); + }); +}); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index ae2e376cc555..ea154816da09 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -1,5 +1,6 @@ import * as sentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeFlushLock } from '../src/flush'; import { getInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; @@ -93,6 +94,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method and returns its result synchronously (not a Promise)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -108,6 +110,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with spanName and preserves sync behavior', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -124,6 +127,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with startNewTrace and preserves sync behavior (no storage)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext({ hasStorage: false }), spanName: 'test-span', @@ -142,6 +146,7 @@ describe('wrapMethodWithSentry', () => { it('wraps an async method and returns a promise', async () => { const handler = vi.fn().mockResolvedValue('async-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -159,6 +164,7 @@ describe('wrapMethodWithSentry', () => { const context = createMockContext({ hasStorage: true, hasWaitUntil: true }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -185,6 +191,7 @@ describe('wrapMethodWithSentry', () => { } as any; const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -202,6 +209,7 @@ describe('wrapMethodWithSentry', () => { it('marks handler as instrumented', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -216,6 +224,7 @@ describe('wrapMethodWithSentry', () => { it('does not re-wrap already instrumented handler', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -231,6 +240,7 @@ describe('wrapMethodWithSentry', () => { it('does not mark handler when noMark is true', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -245,6 +255,7 @@ describe('wrapMethodWithSentry', () => { it('creates span with spanName when provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -265,6 +276,7 @@ describe('wrapMethodWithSentry', () => { it('does not create span when spanName is not provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -284,6 +296,7 @@ describe('wrapMethodWithSentry', () => { throw error; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -303,6 +316,7 @@ describe('wrapMethodWithSentry', () => { const error = new Error('Test async error'); const handler = vi.fn().mockRejectedValue(error); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -323,6 +337,7 @@ describe('wrapMethodWithSentry', () => { it('uses withIsolationScope when startNewTrace is true', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -338,6 +353,7 @@ describe('wrapMethodWithSentry', () => { it('uses startNewTrace when startNewTrace is true and spanName is set', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -353,6 +369,7 @@ describe('wrapMethodWithSentry', () => { it('does not use startNewTrace when startNewTrace is false', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: false, @@ -384,6 +401,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -418,6 +436,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -461,6 +480,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -496,6 +516,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: false, @@ -524,6 +545,7 @@ describe('wrapMethodWithSentry', () => { callOrder.push('callback'); }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -546,6 +568,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -564,6 +587,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -579,6 +603,7 @@ describe('wrapMethodWithSentry', () => { it('passes arguments to handler', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -595,6 +620,7 @@ describe('wrapMethodWithSentry', () => { return this.name; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -615,6 +641,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -648,6 +675,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -681,6 +709,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -694,3 +723,53 @@ describe('wrapMethodWithSentry', () => { }); }); }); + +describe('wrapMethodWithSentry waitUntil teardown (hibernation regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Regression for #22328 + it('does not deadlock teardown against a concurrent waitUntil task', async () => { + const waitUntilPromises: Array> = []; + const context = { + waitUntil: vi.fn((promise: Promise) => { + waitUntilPromises.push(promise); + }), + } as unknown as ExecutionContext; + + // A prior invocation instrumented context.waitUntil + // (installs the flush lock) + const lock = makeFlushLock(context); + + // A concurrent, in-flight waitUntil task holds the flush lock + let resolveUserTask!: () => void; + const userTask = new Promise(resolve => { + resolveUserTask = resolve; + }); + context.waitUntil(userTask); + + // flush waits for the flush lock to drain. + mocks.flush.mockImplementationOnce(async () => { + await lock.finalize(); + return true; + }); + + const wrapped = wrapMethodWithSentry( + { options: { dsn: 'https://test@sentry.io/123' }, context, spanName: 'webSocketMessage' }, + vi.fn().mockResolvedValue('ok'), + ); + + await wrapped(); + + // Releasing the concurrent task drains the lock + resolveUserTask(); + + await expect(Promise.all(waitUntilPromises)).resolves.toBeDefined(); + expect(mocks.flush).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index 5cfef86732c4..3a797a25908a 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -51,16 +51,31 @@ export function _INTERNAL_resetConsoleInstrumentationOptions(): void { _filter.clear(); } -function instrumentConsole(): void { +// The console levels *this* copy of the SDK has already wrapped. A copy must wrap each level at +// most once: re-wrapping overwrites `originalConsoleMethods[level]` with a function that itself +// reads `originalConsoleMethods[level]`, so calling through to the "original" (here and in +// `consoleSandbox`) re-enters a wrapper indefinitely, ending in a `RangeError: Maximum call stack +// size exceeded`. This can happen e.g. in React Native when instrumentation state is re-initialized +// while the console stays wrapped. See getsentry/sentry-react-native SDK-CRASHES-REACT-NATIVE-5HZ. +// We intentionally track this per copy rather than checking for the shared `__sentry_original__` +// marker: a *different* SDK copy's wrapper (e.g. separate CDN bundles that each inline +// `@sentry/core`) is safe to wrap since its call chain resolves down to the native method, and +// skipping it would silently drop that copy's console handlers (breadcrumbs, `captureConsole`). +const instrumentedLevels = new Set(); + +/** Only exported for tests. */ +export function instrumentConsole(): void { if (!('console' in GLOBAL_OBJ)) { return; } CONSOLE_LEVELS.forEach(function (level: ConsoleLevel): void { - if (!(level in GLOBAL_OBJ.console)) { + if (instrumentedLevels.has(level) || !(level in GLOBAL_OBJ.console)) { return; } + instrumentedLevels.add(level); + fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { originalConsoleMethods[level] = originalConsoleMethod; diff --git a/packages/core/src/integrations/functiontostring.ts b/packages/core/src/integrations/functiontostring.ts index f8768037db6b..86844843664f 100644 --- a/packages/core/src/integrations/functiontostring.ts +++ b/packages/core/src/integrations/functiontostring.ts @@ -2,11 +2,8 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { defineIntegration } from '../integration'; import type { IntegrationFn } from '../types/integration'; -import type { WrappedFunction } from '../types/wrappedfunction'; import { getOriginalFunction } from '../utils/object'; -let originalFunctionToString: () => void; - const INTEGRATION_NAME = 'FunctionToString' as const; const SETUP_CLIENTS = new WeakMap(); @@ -16,17 +13,29 @@ const _functionToStringIntegration = (() => { name: INTEGRATION_NAME, setupOnce() { // eslint-disable-next-line @typescript-eslint/unbound-method - originalFunctionToString = Function.prototype.toString; + const originalFunctionToString = Function.prototype.toString; // intrinsics (like Function.prototype) might be immutable in some environments // e.g. Node with --frozen-intrinsics, XS (an embedded JavaScript engine) or SES (a JavaScript proposal) try { - Function.prototype.toString = function (this: WrappedFunction, ...args: unknown[]): string { - const originalFunction = getOriginalFunction(this); - const context = - SETUP_CLIENTS.has(getClient() as Client) && originalFunction !== undefined ? originalFunction : this; - return originalFunctionToString.apply(context, args); - }; + Function.prototype.toString = new Proxy(originalFunctionToString, { + apply(target, thisArg, args) { + const originalFunction = getOriginalFunction(thisArg); + let context = thisArg; + + try { + if (SETUP_CLIENTS.has(getClient()!) && originalFunction) { + context = originalFunction; + } + } catch { + // Reading the Sentry carrier off `getClient()` can throw a `SecurityError` when `this` (or the global + // object) is a `WindowProxy` whose browsing context was navigated cross-origin. The native + // `toString` never throws here, so fall back to it to avoid turning harmless introspection into noise. + } + + return Reflect.apply(target, context, args); + }, + }); } catch { // ignore errors here, just don't patch this } diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 5dc956c4dd73..27990ad2d6cc 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -56,7 +56,7 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = data: true, headers: dataCollection.httpHeaders.request !== false, ip: dataCollection.userInfo, - query_string: dataCollection.queryParams !== false, + query_string: dataCollection.urlQueryParams !== false, // No dataCollection equivalent — URL is always included url: true, ...options.include, diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 647886d41199..983367c882f9 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -216,12 +216,22 @@ export { instrumentStream as instrumentGoogleGenAIStream } from './tracing/googl export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants'; export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain'; +export { _INTERNAL_getLangChainEmbeddingsSpanOptions } from './tracing/langchain/embeddings'; export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph'; +export { + instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentLangGraph, + instrumentCompiledGraphInvoke, + _INTERNAL_getLangGraphCreateAgentSpanOptions, +} from './tracing/langgraph'; +export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; +export { instrumentWorkersAiClient } from './tracing/workers-ai'; +export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types'; // eslint-disable-next-line typescript/no-deprecated export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; export type { diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 192459811798..8d428ddaa1d4 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -1,4 +1,3 @@ -import { SENTRY_SPAN_SOURCE } from '@sentry/conventions/attributes'; import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; import { getClient } from '../currentScopes'; @@ -144,8 +143,9 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly return attributes; } +/** + * Builds the span options for a LangChain embedding call from the embeddings instance and input. + * + * @internal Exported so the diagnostics-channel (orchestrion) instrumentation can build the same + * span as the prototype-patching path below. + */ +export function _INTERNAL_getLangChainEmbeddingsSpanOptions( + instance: unknown, + input: unknown, + options: LangChainOptions = {}, +): { name: string; op: string; attributes: Record } { + const { recordInputs } = resolveAIRecordingOptions(options); + const attributes = extractEmbeddingAttributes(instance); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown'; + + if (recordInputs && input != null) { + attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input); + } + + return { + name: `embeddings ${modelName}`, + op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE, + attributes: attributes as Record, + }; +} + /** * Wraps a LangChain embedding method (embedQuery or embedDocuments) to create Sentry spans. * @@ -64,35 +90,16 @@ export function instrumentEmbeddingMethod( originalMethod: (...args: unknown[]) => Promise, options: LangChainOptions = {}, ): (...args: unknown[]) => Promise { - const { recordInputs } = resolveAIRecordingOptions(options); - return new Proxy(originalMethod, { apply(target, thisArg, args: unknown[]): Promise { - const attributes = extractEmbeddingAttributes(thisArg); - const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown'; - - if (recordInputs) { - const input = args[0]; - if (input != null) { - attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input); - } - } - - return startSpan( - { - name: `embeddings ${modelName}`, - op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE, - attributes: attributes as Record, - }, - () => { - return Reflect.apply(target, thisArg, args).then(undefined, error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.langchain' }, - }); - throw error; + return startSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(thisArg, args[0], options), () => { + return Reflect.apply(target, thisArg, args).then(undefined, error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.langchain' }, }); - }, - ); + throw error; + }); + }); }, }) as (...args: unknown[]) => Promise; } diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d4db0de3a6f3..daf2f55552ea 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -20,6 +20,7 @@ import { shouldEnableTruncation, } from '../ai/utils'; import { stringify } from '../../utils/string'; +import type { SpanAttributeValue } from '../../types/span'; import { createLangChainCallbackHandler } from '../langchain'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -39,6 +40,34 @@ let _insideCreateReactAgent = false; const SENTRY_PATCHED = '__sentry_patched__'; +/** + * Builds the span options for a LangGraph `create_agent` span. + * + * @internal Exported so the diagnostics-channel (orchestrion) instrumentation can open the same span + * as the prototype-patching path below without re-declaring the semantic attribute keys. + */ +export function _INTERNAL_getLangGraphCreateAgentSpanOptions(agentName?: string): { + op: string; + name: string; + attributes: Record; +} { + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', + }; + + if (agentName) { + attributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName; + } + + return { + op: 'gen_ai.create_agent', + name: agentName ? `create_agent ${agentName}` : 'create_agent', + attributes, + }; +} + /** * Instruments StateGraph's compile method to create spans for agent creation and invocation * @@ -64,53 +93,42 @@ export function instrumentStateGraphCompile( return Reflect.apply(target, thisArg, args); } - return startSpan( - { - op: 'gen_ai.create_agent', - name: 'create_agent', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', - }, - }, - span => { - try { - const compiledGraph = Reflect.apply(target, thisArg, args); - const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - - // Extract graph name - if (compileOptions?.name && typeof compileOptions.name === 'string') { - span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); - span.updateName(`create_agent ${compileOptions.name}`); - } + return startSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(), span => { + try { + const compiledGraph = Reflect.apply(target, thisArg, args); + const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - // Instrument agent invoke method on the compiled graph - const originalInvoke = compiledGraph.invoke; - if (originalInvoke && typeof originalInvoke === 'function') { - compiledGraph.invoke = instrumentCompiledGraphInvoke( - originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, - compiledGraph, - compileOptions, - options, - undefined, - sentryHandler, - ) as typeof originalInvoke; - } + // Extract graph name + if (compileOptions?.name && typeof compileOptions.name === 'string') { + span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); + span.updateName(`create_agent ${compileOptions.name}`); + } - return compiledGraph; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); - throw error; + // Instrument agent invoke method on the compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + options, + undefined, + sentryHandler, + ) as typeof originalInvoke; } - }, - ); + + return compiledGraph; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }); }, }) as (...args: unknown[]) => CompiledGraph; @@ -123,7 +141,7 @@ export function instrumentStateGraphCompile( * * Creates a `gen_ai.invoke_agent` span when invoke() is called */ -function instrumentCompiledGraphInvoke( +export function instrumentCompiledGraphInvoke( originalInvoke: (...args: unknown[]) => Promise, graphInstance: CompiledGraph, compileOptions: Record, diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index dd1ecb1f3fb8..ed837cb82003 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -28,7 +28,6 @@ import { SENTRY_SDK_VERSION, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, - SENTRY_SPAN_SOURCE, SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; @@ -84,12 +83,13 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; - // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. - // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (spanNameSource) { + if (spanJSON.is_segment && spanNameSource) { + // Backfill sentry.segment.name.source from sentry.source. + // TODO(v11): Remove this backfill once we removed setting SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in favour of + // SENTRY_SEGMENT_NAME_SOURCE from @sentry/conventions/attributes only on segment spans. safeSetSpanJSONAttributes(processedSpan, { - [SENTRY_SPAN_SOURCE]: spanNameSource, + ['sentry.segment.name.source']: spanNameSource, }); } diff --git a/packages/core/src/tracing/workers-ai/constants.ts b/packages/core/src/tracing/workers-ai/constants.ts new file mode 100644 index 000000000000..8f532a0a0372 --- /dev/null +++ b/packages/core/src/tracing/workers-ai/constants.ts @@ -0,0 +1,10 @@ +/** + * The provider value for the `gen_ai.provider.name` attribute. + * @see https://developers.cloudflare.com/workers-ai/ + */ +export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai'; + +/** + * The Sentry origin for spans created by the Workers AI instrumentation. + */ +export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai'; diff --git a/packages/core/src/tracing/workers-ai/index.ts b/packages/core/src/tracing/workers-ai/index.ts new file mode 100644 index 000000000000..c6be4824305a --- /dev/null +++ b/packages/core/src/tracing/workers-ai/index.ts @@ -0,0 +1,133 @@ +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; +import type { Span } from '../../types/span'; +import { isObjectLike } from '../../utils/is'; +import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils'; +import { instrumentWorkersAiStream } from './streaming'; +import type { WorkersAiOptions } from './types'; +import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils'; + +// Adapted from /server-utils/src/vercel-ai/util.ts +// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils +// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`. +function isReadableStream(value: unknown): value is ReadableStream { + return ( + isObjectLike(value) && + typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' && + typeof (value as { getReader?: unknown }).getReader === 'function' + ); +} + +/** + * Wrap the `run` method of the Workers AI binding with Sentry tracing. + */ +function instrumentRun( + originalRun: (...args: unknown[]) => Promise, + context: unknown, + options: WorkersAiOptions & Required>, +): (...args: unknown[]) => Promise { + return function instrumentedRun(...args: unknown[]): Promise { + const [model, inputs, runOptions] = args as [unknown, unknown, Record | undefined]; + + const operationName = getOperationName(inputs); + const requestAttributes = extractRequestAttributes(model, inputs, operationName); + const modelName = typeof model === 'string' ? model : 'unknown'; + + const isStreamRequested = + !!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true; + const returnsRawResponse = + !!runOptions && + typeof runOptions === 'object' && + (runOptions.returnRawResponse === true || runOptions.websocket === true); + + const spanConfig = { + name: `${operationName} ${modelName}`, + op: `gen_ai.${operationName}`, + attributes: requestAttributes, + }; + + if (isStreamRequested && !returnsRawResponse) { + return startSpanManual(spanConfig, (span: Span) => { + // `startSpanManual` does not auto-end the span, so we must end it on every exit path, + // including a synchronous throw from `run`. + const handleError = (error: unknown): never => { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + throw error; + }; + + let originalResult: Promise; + + try { + originalResult = originalRun.apply(context, args) as Promise; + } catch (error) { + return handleError(error); + } + + if (options.recordInputs) { + addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + } + + return originalResult.then(result => { + if (isReadableStream(result)) { + return instrumentWorkersAiStream(result, span, options.recordOutputs); + } + + // The model did not actually return a stream — finalize the span eagerly. + addResponseAttributes(span, result, options.recordOutputs); + span.end(); + return result; + }, handleError); + }); + } + + return startSpan(spanConfig, (span: Span) => { + const originalResult = originalRun.apply(context, args) as Promise; + + if (options.recordInputs) { + addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + } + + return originalResult.then(result => { + if (!returnsRawResponse) { + addResponseAttributes(span, result, options.recordOutputs); + } + return result; + }); + }); + }; +} + +/** + * Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing. + * + * This wraps the binding's `run` method to create `gen_ai` spans following the + * Sentry AI Agents conventions. All other methods are passed through untouched. + * + * In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically — + * wrapping manually is only needed to pass custom options. + * + * @example + * ```javascript + * const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true }); + * const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + * ``` + */ +export function instrumentWorkersAiClient(client: T, options?: WorkersAiOptions): T { + const resolvedOptions = resolveAIRecordingOptions(options); + + const instrumented = new Proxy(client, { + get(target: object, prop: string | symbol, receiver: unknown): unknown { + const value = Reflect.get(target, prop, receiver); + + if (prop === 'run' && typeof value === 'function') { + return instrumentRun(value as (...args: unknown[]) => Promise, target, resolvedOptions); + } + + // Bind passed-through functions to the original target to preserve `this` (e.g. private fields). + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value; + }, + }) as T; + + return instrumented; +} diff --git a/packages/core/src/tracing/workers-ai/streaming.ts b/packages/core/src/tracing/workers-ai/streaming.ts new file mode 100644 index 000000000000..5a36d5eff2dc --- /dev/null +++ b/packages/core/src/tracing/workers-ai/streaming.ts @@ -0,0 +1,229 @@ +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types/span'; +import { endStreamSpan, type StreamResponseState } from '../ai/utils'; +import type { WorkersAiUsage } from './types'; +import { setOutputMessagesAttribute } from './utils'; + +interface WorkersAiStreamingToolCall { + index?: number; + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + // Some Workers AI models stream tool calls with the name/arguments at the top + // level of the tool-call object instead of nested under `function`. + name?: string; + arguments?: string; +} + +interface WorkersAiStreamChunk { + // Native Workers AI streaming shape (`env.AI.run` with `stream: true`). + response?: unknown; + tool_calls?: unknown[]; + // OpenAI-compatible streaming shape emitted for models routed through the + // OpenAI-compatible endpoint (e.g. via `workers-ai-provider`). + choices?: Array<{ + delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] }; + finish_reason?: unknown; + }>; + usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; +} + +/** + * Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple + * `choices[].delta.tool_calls` chunks) into the index-keyed accumulator. + */ +function accumulateStreamingToolCalls( + toolCalls: WorkersAiStreamingToolCall[], + accumulator: Record, +): void { + for (const toolCall of toolCalls) { + // Normalize both shapes: name/arguments nested under `function`, or at the top level. + const name = toolCall.function?.name ?? toolCall.name; + const args = toolCall.function?.arguments ?? toolCall.arguments; + + // A tool call must carry at least a name or argument fragment to be meaningful. + if (name == null && args == null) { + continue; + } + + const index = toolCall.index ?? 0; + const existing = accumulator[index]; + + if (!existing) { + accumulator[index] = { + index, + id: toolCall.id, + type: toolCall.type, + function: { + name, + arguments: args ?? '', + }, + }; + } else if (existing.function) { + if (name && !existing.function.name) { + existing.function.name = name; + } + if (args) { + existing.function.arguments = `${existing.function.arguments ?? ''}${args}`; + } + } + } +} + +/** + * Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state. + * + * Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the + * OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because + * the same `run()` call transparently yields either format depending on the model. + */ +function processLine( + line: string, + state: StreamResponseState, + recordOutputs: boolean, + toolCallAccumulator: Record, +): void { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) { + return; + } + + const data = trimmed.slice('data:'.length).trim(); + if (!data || data === '[DONE]') { + return; + } + + let parsed: WorkersAiStreamChunk; + try { + parsed = JSON.parse(data) as WorkersAiStreamChunk; + } catch { + return; + } + + if (parsed.usage) { + if (typeof parsed.usage.prompt_tokens === 'number') { + state.promptTokens = parsed.usage.prompt_tokens; + } + if (typeof parsed.usage.completion_tokens === 'number') { + state.completionTokens = parsed.usage.completion_tokens; + } + if (typeof parsed.usage.total_tokens === 'number') { + state.totalTokens = parsed.usage.total_tokens; + } + } + + if (recordOutputs && typeof parsed.response === 'string') { + state.responseTexts.push(parsed.response); + } + + if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) { + state.toolCalls.push(...parsed.tool_calls); + } + + if (Array.isArray(parsed.choices)) { + for (const choice of parsed.choices) { + if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) { + state.responseTexts.push(choice.delta.content); + } + if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) { + accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator); + } + if (typeof choice.finish_reason === 'string') { + state.finishReasons.push(choice.finish_reason); + } + } + } +} + +/** + * Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can + * accumulate the response text and token usage while passing the original bytes through untouched. + * + * The span is ended once the consumer finishes reading (or cancels) the stream. + */ +export function instrumentWorkersAiStream( + stream: ReadableStream, + span: Span, + recordOutputs: boolean, +): ReadableStream { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + const state: StreamResponseState = { + responseId: '', + responseModel: '', + finishReasons: [], + responseTexts: [], + toolCalls: [], + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + }; + + // OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index; + // accumulate them here and flatten into `state.toolCalls` once the stream ends. + const toolCallAccumulator: Record = {}; + + let buffer = ''; + let spanEnded = false; + + const finish = (): void => { + if (spanEnded) { + return; + } + spanEnded = true; + + if (recordOutputs) { + const accumulatedToolCalls = Object.values(toolCallAccumulator); + if (accumulatedToolCalls.length > 0) { + state.toolCalls.push(...accumulatedToolCalls); + } + + // Set the authoritative `gen_ai.output.messages` alongside the deprecated response + // attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration. + setOutputMessagesAttribute(span, { + responseText: state.responseTexts.join(''), + toolCalls: state.toolCalls, + }); + } + + endStreamSpan(span, state, recordOutputs); + }; + + const flushBuffer = (isDone: boolean): void => { + const lines = buffer.split('\n'); + // Keep the last (potentially incomplete) line in the buffer unless the stream is done. + buffer = isDone ? '' : (lines.pop() ?? ''); + for (const line of lines) { + processLine(line, state, recordOutputs, toolCallAccumulator); + } + }; + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + + if (done) { + buffer += decoder.decode(); + flushBuffer(true); + finish(); + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + flushBuffer(false); + controller.enqueue(value); + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + finish(); + controller.error(error); + } + }, + async cancel(reason) { + finish(); + await reader.cancel(reason); + }, + }); +} diff --git a/packages/core/src/tracing/workers-ai/types.ts b/packages/core/src/tracing/workers-ai/types.ts new file mode 100644 index 000000000000..33334a6ed1bf --- /dev/null +++ b/packages/core/src/tracing/workers-ai/types.ts @@ -0,0 +1,58 @@ +import type { AIRecordingOptions } from '../ai/utils'; + +export interface WorkersAiOptions extends AIRecordingOptions { + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; +} + +/** + * Minimal shape of the Cloudflare Workers AI binding (`env.AI`). + * We only rely on the `run` method, everything else is passed through untouched. + * @see https://developers.cloudflare.com/workers-ai/configuration/bindings/ + */ +export interface WorkersAiClient { + run: (model: string, inputs: Record, options?: Record) => Promise; + [key: string]: unknown; +} + +/** + * The token usage reported by Workers AI text generation models. + */ +export interface WorkersAiUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; +} + +/** + * The (subset of) inputs accepted by Workers AI `run` calls that we read from. + * @see https://developers.cloudflare.com/workers-ai/models/ + */ +export interface WorkersAiInput { + // Text generation + prompt?: string; + messages?: Array<{ role?: string; content?: unknown }>; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + tools?: unknown; + functions?: unknown; + // Text embeddings + text?: string | string[]; +} + +/** + * The (subset of) outputs returned by Workers AI text generation models that we read from. + */ +export interface WorkersAiOutput { + response?: unknown; + tool_calls?: unknown[]; + usage?: WorkersAiUsage; +} diff --git a/packages/core/src/tracing/workers-ai/utils.ts b/packages/core/src/tracing/workers-ai/utils.ts new file mode 100644 index 000000000000..23eac0eaa6fc --- /dev/null +++ b/packages/core/src/tracing/workers-ai/utils.ts @@ -0,0 +1,225 @@ +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_K, + GEN_AI_REQUEST_TOP_P, + GEN_AI_PROVIDER_NAME, + GEN_AI_SYSTEM_INSTRUCTIONS, +} from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { Span, SpanAttributeValue } from '../../types/span'; +import { + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { extractSystemInstructions, getTruncatedJsonString, setTokenUsageAttributes } from '../ai/utils'; +import { stringify } from '../../utils/string'; +import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; +import type { WorkersAiInput, WorkersAiOutput } from './types'; + +/** + * Determine the gen_ai operation name from the inputs passed to `AI.run`. + * Workers AI exposes a single `run` method, so we infer the operation from the input shape. + */ +export function getOperationName(inputs: unknown): string { + if (inputs && typeof inputs === 'object') { + if ('messages' in inputs || 'prompt' in inputs) { + return 'chat'; + } + if ('text' in inputs) { + return 'embeddings'; + } + } + return 'chat'; +} + +/** + * Extract the request attributes (model, request parameters, system, origin) from a `run` call. + */ +export function extractRequestAttributes( + model: unknown, + inputs: unknown, + operationName: string, +): Record { + const attributes: Record = { + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: operationName, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: typeof model === 'string' ? model : 'unknown', + }; + + if (inputs && typeof inputs === 'object') { + const params = inputs as WorkersAiInput; + + if (typeof params.temperature === 'number') { + attributes[GEN_AI_REQUEST_TEMPERATURE] = params.temperature; + } + if (typeof params.max_tokens === 'number') { + attributes[GEN_AI_REQUEST_MAX_TOKENS] = params.max_tokens; + } + if (typeof params.top_p === 'number') { + attributes[GEN_AI_REQUEST_TOP_P] = params.top_p; + } + if (typeof params.top_k === 'number') { + attributes[GEN_AI_REQUEST_TOP_K] = params.top_k; + } + if (typeof params.frequency_penalty === 'number') { + attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY] = params.frequency_penalty; + } + if (typeof params.presence_penalty === 'number') { + attributes[GEN_AI_REQUEST_PRESENCE_PENALTY] = params.presence_penalty; + } + if (params.stream === true) { + attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = true; + } + } + + return attributes; +} + +/** + * Record the request inputs (messages/prompt/embeddings input) on the span. + * Only called when `recordInputs` is enabled. + */ +export function addRequestAttributes( + span: Span, + inputs: unknown, + operationName: string, + enableTruncation: boolean, +): void { + if (!inputs || typeof inputs !== 'object') { + return; + } + const params = inputs as WorkersAiInput; + + // Store embeddings input on a separate attribute and do not truncate it + if (operationName === 'embeddings') { + const text = params.text; + + if (text == null || (typeof text === 'string' && text.length === 0) || (Array.isArray(text) && text.length === 0)) { + return; + } + + span.setAttribute(GEN_AI_EMBEDDINGS_INPUT, typeof text === 'string' ? text : JSON.stringify(text)); + return; + } + + const src = params.messages ?? params.prompt; + + if (src == null || (Array.isArray(src) && src.length === 0)) { + return; + } + + const { systemInstructions, filteredMessages } = extractSystemInstructions(src); + + if (systemInstructions) { + span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + } + + span.setAttribute( + GEN_AI_INPUT_MESSAGES, + enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), + ); + + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + Array.isArray(filteredMessages) ? filteredMessages.length : 1, + ); +} + +/** + * Build the `gen_ai.output.messages` value (a single assistant message with text and/or + * tool-call parts) from the response text and tool calls. + * + * We set this in addition to the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls` + * attributes because Sentry's product reads the model output from `gen_ai.output.messages` first. + * Relay migrates `gen_ai.response.text` into `gen_ai.output.messages`, but the tool-calls half of + * that migration is lossy — so tool-call turns would otherwise render an empty Output. Emitting the + * normalized message here (mirroring the Vercel AI integration) keeps tool calls visible. + */ +export function setOutputMessagesAttribute( + span: Span, + { responseText, toolCalls }: { responseText?: string; toolCalls?: unknown[] }, +): void { + const parts: Array> = []; + + if (typeof responseText === 'string' && responseText.length > 0) { + parts.push({ type: 'text', content: responseText }); + } + + if (Array.isArray(toolCalls)) { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== 'object') { + continue; + } + const call = toolCall as { + id?: unknown; + function?: { name?: unknown; arguments?: unknown }; + name?: unknown; + arguments?: unknown; + }; + // Normalize both the OpenAI-compatible shape (name/arguments nested under `function`) + // and the native Workers AI shape (name/arguments at the top level). + const name = call.function?.name ?? call.name; + const args = call.function?.arguments ?? call.arguments; + parts.push({ + type: 'tool_call', + id: call.id, + name, + arguments: typeof args === 'string' ? args : JSON.stringify(args ?? {}), + }); + } + } + + if (parts.length > 0) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, JSON.stringify([{ role: 'assistant', parts }])); + } +} + +/** + * Record the response attributes (token usage, response text, tool calls) on the span. + */ +export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { + if ( + !result || + typeof result !== 'object' || + // Raw `Response` objects (from `returnRawResponse`/`websocket`) cannot be introspected without consuming them. + (typeof Response !== 'undefined' && result instanceof Response) + ) { + return; + } + + const response = result as WorkersAiOutput; + + if (response.usage) { + setTokenUsageAttributes(span, response.usage.prompt_tokens, response.usage.completion_tokens); + } + + if (recordOutputs) { + let responseText: string | undefined; + if (typeof response.response === 'string') { + responseText = response.response; + span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, response.response); + } else if (response.response != null) { + responseText = JSON.stringify(response.response); + span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, responseText); + } + + const toolCalls = + Array.isArray(response.tool_calls) && response.tool_calls.length > 0 ? response.tool_calls : undefined; + if (toolCalls) { + span.setAttribute(GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, JSON.stringify(toolCalls)); + } + + setOutputMessagesAttribute(span, { responseText, toolCalls }); + } +} diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index 88af5b354ebd..b160170761d7 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -44,11 +44,14 @@ export interface DataCollection { */ httpBodies?: HttpBodyCollectionTarget[]; + /** @deprecated Use `urlQueryParams` instead. */ + queryParams?: CollectBehavior; + /** - * Controls query parameter collection and sensitive value filtering. + * Controls URL query parameter collection and sensitive value filtering. * @default true */ - queryParams?: CollectBehavior; + urlQueryParams?: CollectBehavior; /** * Allow collection of GraphQL operation data when using the SDK's GraphQL integrations @@ -102,7 +105,8 @@ export interface DataCollection { /** * Fully resolved `DataCollection` with all defaults applied. */ -export type ResolvedDataCollection = Required & { +// todo(v11): change `Omit` to just `DataCollection` +export type ResolvedDataCollection = Required> & { httpHeaders: Required>; graphQL: Required>; genAI: Required>; diff --git a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts index b1338bf6a777..b70cddc267f4 100644 --- a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts @@ -15,7 +15,7 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -27,7 +27,7 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve cookies: { deny: PII_HEADER_SNIPPETS }, httpHeaders: { request: { deny: PII_HEADER_SNIPPETS }, response: { deny: PII_HEADER_SNIPPETS } }, httpBodies: [], - queryParams: { deny: PII_HEADER_SNIPPETS }, + urlQueryParams: { deny: PII_HEADER_SNIPPETS }, // The GraphQL document has literal values redacted at collection time, so it was historically // always attached regardless of `sendDefaultPii`; keep it on to preserve that behavior. graphQL: { document: true, variables: true }, diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index 26da8ac0c1f5..4c558737903e 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -6,7 +6,7 @@ const DEFAULTS: ResolvedDataCollection = { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -44,7 +44,8 @@ export function resolveDataCollectionOptions(options: { response: dc.httpHeaders?.response ?? base.httpHeaders.response, }, httpBodies: dc.httpBodies ?? base.httpBodies, - queryParams: dc.queryParams ?? base.queryParams, + // oxlint-disable-next-line typescript/no-deprecated + urlQueryParams: dc.urlQueryParams ?? dc.queryParams ?? base.urlQueryParams, graphQL: { document: dc.graphQL?.document ?? base.graphQL.document, variables: dc.graphQL?.variables ?? base.graphQL.variables, diff --git a/packages/core/test/lib/instrument/console.test.ts b/packages/core/test/lib/instrument/console.test.ts index f67f7da1a1a6..4b65af9c73f1 100644 --- a/packages/core/test/lib/instrument/console.test.ts +++ b/packages/core/test/lib/instrument/console.test.ts @@ -1,19 +1,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - _INTERNAL_resetConsoleInstrumentationOptions, - addConsoleInstrumentationFilter, - addConsoleInstrumentationHandler, -} from '../../../src/instrument/console'; +import { CONSOLE_LEVELS } from '../../../src/utils/debug-logger'; import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; -import { debug, originalConsoleMethods } from '../../../src/utils/debug-logger'; -import { resetInstrumentationHandlers } from '../../../src/instrument/handlers'; + +// Capture the pristine console methods once, before any instrumentation wraps them. Each test gets a +// fresh copy of the instrumentation modules (and therefore fresh module-scoped state such as +// `instrumentedLevels`) via `vi.resetModules()`, but the global console object is shared across the +// module registry, so we must also restore it between tests - otherwise a fresh `instrumentConsole()` +// would wrap an already-wrapped method. +const nativeConsoleMethods = Object.fromEntries( + CONSOLE_LEVELS.map(level => [level, GLOBAL_OBJ.console[level]]), +) as Record; + +function restoreNativeConsole(): void { + for (const level of CONSOLE_LEVELS) { + GLOBAL_OBJ.console[level] = nativeConsoleMethods[level] as (typeof GLOBAL_OBJ.console)[typeof level]; + } +} + +// Re-import the instrumentation modules so each test gets fresh module-scoped state (e.g. +// `instrumentedLevels`). `vi.resetModules()` does not reset the shared global console, so we also +// restore the native methods first, ensuring a fresh `instrumentConsole()` never wraps an +// already-wrapped method. +async function loadInstrumentationModules() { + vi.resetModules(); + restoreNativeConsole(); + return { + consoleModule: await import('../../../src/instrument/console'), + debugLoggerModule: await import('../../../src/utils/debug-logger'), + }; +} describe('addConsoleInstrumentationHandler', () => { - let _originalConsoleMethods: typeof originalConsoleMethods = {}; + let consoleModule: Awaited>['consoleModule']; + let debugLoggerModule: Awaited>['debugLoggerModule']; + + beforeEach(async () => { + ({ consoleModule, debugLoggerModule } = await loadInstrumentationModules()); + }); afterEach(() => { - Object.assign(originalConsoleMethods, _originalConsoleMethods); - resetInstrumentationHandlers(); + restoreNativeConsole(); vi.restoreAllMocks(); }); @@ -21,11 +47,7 @@ describe('addConsoleInstrumentationHandler', () => { // Due to `fill` being called // So instead, we need to call this each time after calling `addConsoleInstrumentationHandler` function mockConsoleMethods() { - // Re-store this with the current implementation - Object.assign(_originalConsoleMethods, originalConsoleMethods); - - // Overwrite with mock console methods - Object.assign(originalConsoleMethods, { + Object.assign(debugLoggerModule.originalConsoleMethods, { log: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -38,72 +60,92 @@ describe('addConsoleInstrumentationHandler', () => { 'calls registered handler when console.%s is called', level => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationHandler(handler); mockConsoleMethods(); GLOBAL_OBJ.console[level]('test message'); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test message'], level })); - expect(originalConsoleMethods[level]).toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods[level]).toHaveBeenCalledWith('test message'); }, ); it('calls through to the underlying console method without throwing', () => { - addConsoleInstrumentationHandler(vi.fn()); + consoleModule.addConsoleInstrumentationHandler(vi.fn()); mockConsoleMethods(); expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow(); }); - describe('filter', () => { - afterEach(() => { - _INTERNAL_resetConsoleInstrumentationOptions(); - }); + it('does not recurse infinitely when the same SDK copy re-instruments an already-wrapped console', () => { + // Re-running `instrumentConsole()` within the same copy of `@sentry/core` (e.g. instrumentation + // state re-initialized in React Native while the console stays wrapped) must not re-wrap our own + // wrapper. Otherwise the second pass stores that wrapper into `originalConsoleMethods[level]`, + // which the wrapper reads on every call, so calling through to the "original" re-enters the + // wrapper forever -> `RangeError: Maximum call stack size exceeded`. + const handler = vi.fn(); + consoleModule.addConsoleInstrumentationHandler(handler); + mockConsoleMethods(); + + const wrapperAfterFirstPass = GLOBAL_OBJ.console.error; + + // Force a second instrumentation pass on the same copy to mimic re-initialized state. + consoleModule.instrumentConsole(); + + // The second pass must be a no-op: our own wrapper is left in place (identity unchanged), + // so `originalConsoleMethods[level]` keeps pointing at the real method rather than a wrapper. + expect(GLOBAL_OBJ.console.error).toBe(wrapperAfterFirstPass); + expect(() => GLOBAL_OBJ.console.error('boom')).not.toThrow(); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['boom'], level: 'error' })); + expect(debugLoggerModule.originalConsoleMethods.error).toHaveBeenCalledWith('boom'); + }); + + describe('filter', () => { describe('when debug is disabled', () => { beforeEach(() => { - vi.spyOn(debug, 'isEnabled').mockImplementation(() => false); + vi.spyOn(debugLoggerModule.debug, 'isEnabled').mockImplementation(() => false); }); it('filters out messages that match the filter', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('test message'); - expect(originalConsoleMethods.log).not.toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods.log).not.toHaveBeenCalledWith('test message'); expect(handler).not.toHaveBeenCalled(); }); it('does not filter out messages that do not match the filter', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('other message'); expect(handler).toHaveBeenCalled(); - expect(originalConsoleMethods.log).toHaveBeenCalledWith('other message'); + expect(debugLoggerModule.originalConsoleMethods.log).toHaveBeenCalledWith('other message'); }); }); describe('when debug is enabled', () => { beforeEach(() => { - vi.spyOn(debug, 'isEnabled').mockImplementation(() => true); + vi.spyOn(debugLoggerModule.debug, 'isEnabled').mockImplementation(() => true); }); it('logs filtered messages but does not call the handler for them', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('test message'); expect(handler).not.toHaveBeenCalled(); - expect(originalConsoleMethods.log).toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods.log).toHaveBeenCalledWith('test message'); }); }); }); diff --git a/packages/core/test/lib/integrations/functiontostring.test.ts b/packages/core/test/lib/integrations/functiontostring.test.ts index 7c40de8b06f2..1e992cbd93cb 100644 --- a/packages/core/test/lib/integrations/functiontostring.test.ts +++ b/packages/core/test/lib/integrations/functiontostring.test.ts @@ -1,16 +1,27 @@ -import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../src/currentScopes'; import { fill, getClient, getCurrentScope, setCurrentClient } from '../../../src'; import { functionToStringIntegration } from '../../../src/integrations/functiontostring'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +vi.mock('../../../src/currentScopes', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, getClient: vi.fn(actual.getClient) }; +}); + describe('FunctionToString', () => { beforeEach(() => { const testClient = new TestClient(getDefaultTestClientOptions({})); setCurrentClient(testClient); }); + afterEach(() => { + vi.mocked(currentScopes.getClient).mockClear(); + }); + afterAll(() => { getCurrentScope().setClient(undefined); + vi.restoreAllMocks(); }); it('it works as expected', () => { @@ -55,4 +66,24 @@ describe('FunctionToString', () => { expect(foo.bar.toString()).not.toBe(originalFunction); }); + + it('falls back to native toString and does not throw when the carrier read throws', () => { + const foo = { + bar(wat: boolean): boolean { + return wat; + }, + }; + const nativeString = foo.bar.toString(); + + const fts = functionToStringIntegration(); + getClient()?.addIntegration(fts); + + // Simulate a `SecurityError` thrown while reading the Sentry carrier off a cross-origin `WindowProxy`. + vi.mocked(currentScopes.getClient).mockImplementation(() => { + throw new Error('SecurityError: Blocked a frame from accessing a cross-origin frame.'); + }); + + expect(() => foo.bar.toString()).not.toThrow(); + expect(foo.bar.toString()).toBe(nativeString); + }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index d1b0c0aab237..2a34ee6eeb8a 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -16,7 +16,7 @@ export function createMockClient(userInfo = true, genAI?: { inputs: boolean; out cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: genAIOptions, databaseQueryData: true, diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 6d69aeabca04..28a3f0d48e0b 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -24,7 +24,6 @@ import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, - SENTRY_SPAN_SOURCE, SENTRY_SDK_NAME, SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, @@ -94,7 +93,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -195,7 +194,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -295,7 +294,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -365,7 +364,7 @@ describe('captureSpan', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SEGMENT_NAME]: { value: 'my-span', type: 'string' }, [SENTRY_SEGMENT_ID]: { value: span.spanContext().spanId, type: 'string' }, - [SENTRY_SPAN_SOURCE]: { value: 'custom', type: 'string' }, + ['sentry.segment.name.source']: { value: 'custom', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { value: 'custom', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, diff --git a/packages/core/test/lib/tracing/workers-ai-streaming.test.ts b/packages/core/test/lib/tracing/workers-ai-streaming.test.ts new file mode 100644 index 000000000000..8de48172d6d8 --- /dev/null +++ b/packages/core/test/lib/tracing/workers-ai-streaming.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import type { Span } from '../../../src'; +import { + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../src/tracing/ai/gen-ai-attributes'; +import { instrumentWorkersAiStream } from '../../../src/tracing/workers-ai/streaming'; + +function createMockSpan(): { span: Span; attributes: Record; ended: () => boolean } { + const attributes: Record = {}; + let isEnded = false; + const span = { + isRecording: () => !isEnded, + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + setAttributes: (attrs: Record) => { + Object.assign(attributes, attrs); + }, + setStatus: () => {}, + end: () => { + isEnded = true; + }, + } as unknown as Span; + return { span, attributes, ended: () => isEnded }; +} + +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + const queued = [...chunks]; + return new ReadableStream({ + pull(controller) { + const next = queued.shift(); + if (next === undefined) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(next)); + }, + }); +} + +describe('instrumentWorkersAiStream', () => { + it('passes the original bytes through untouched', async () => { + const { span } = createMockSpan(); + const raw = 'data: {"response":"Hello"}\n\ndata: [DONE]\n\n'; + + const instrumented = instrumentWorkersAiStream(streamFromChunks([raw]), span, true); + const passedThrough = await new Response(instrumented).text(); + + expect(passedThrough).toBe(raw); + }); + + it('accumulates response text and usage across SSE events split across read boundaries', async () => { + const { span, attributes, ended } = createMockSpan(); + // The second event is deliberately split mid-line across two reads. + const chunks = [ + 'data: {"response":"The capital "}\n\ndata: {"respo', + 'nse":"of France "}\n\ndata: {"response":"is Paris."', + ',"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('The capital of France is Paris.'); + expect(attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toBe(12); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(7); + expect(attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toBe(19); + expect(ended()).toBe(true); + }); + + it('records usage but not response text when recordOutputs is false', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"response":"secret"}\n\n', + 'data: {"response":"","usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, false); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(1); + }); + + it('ignores malformed SSE payloads without throwing', async () => { + const { span, attributes, ended } = createMockSpan(); + const chunks = ['data: not-json\n\n', 'data: {"response":"ok"}\n\ndata: [DONE]\n\n']; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('ok'); + expect(ended()).toBe(true); + }); + + // Models routed through the OpenAI-compatible endpoint (e.g. via `workers-ai-provider`, + // which the Cloudflare Agents SDK uses) stream `choices[].delta.content` instead of the + // native top-level `response` field. Usage still arrives top-level, which is why the + // pre-fix parser captured tokens but dropped the response text entirely. + it('accumulates response text from OpenAI-compatible choices[].delta.content chunks', async () => { + const { span, attributes, ended } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"content":"The capital "},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{"content":"of France "},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{"content":"is Paris."},"finish_reason":"stop"}]}\n\n', + 'data: {"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('The capital of France is Paris.'); + expect(attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toBe(12); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(7); + expect(attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toBe(19); + expect(ended()).toBe(true); + }); + + it('assembles fragmented OpenAI-compatible tool calls from choices[].delta.tool_calls', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"getRepoInfo","arguments":"{\\"owner\\":"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"cloudflare\\",\\"name\\":\\"agents\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string); + expect(toolCalls).toEqual([ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare","name":"agents"}' }, + }, + ]); + + // The product reads model output from `gen_ai.output.messages`; tool calls must appear there too. + expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([ + { + role: 'assistant', + parts: [ + { + type: 'tool_call', + id: 'call_1', + name: 'getRepoInfo', + arguments: '{"owner":"cloudflare","name":"agents"}', + }, + ], + }, + ]); + }); + + // Some Workers AI models (e.g. `@cf/moonshotai/kimi-k2.6`) stream tool calls with the + // name/arguments at the top level of the tool-call object rather than nested under `function`. + it('assembles tool calls whose name/arguments are at the top level of the delta', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","name":"getRepoInfo","arguments":"{\\"owner\\":"}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"arguments":"\\"cloudflare\\"}"}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string); + expect(toolCalls).toEqual([ + { + index: 0, + id: 'call_1', + type: undefined, + function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' }, + }, + ]); + + expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' }], + }, + ]); + }); + + it('does not record OpenAI-compatible output when recordOutputs is false', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"content":"secret"}}]}\n\n', + 'data: {"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, false); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(1); + }); + + it('ends the span when the consumer cancels the stream', async () => { + const { span, attributes, ended } = createMockSpan(); + + const instrumented = instrumentWorkersAiStream(streamFromChunks(['data: {"response":"partial"}\n\n']), span, true); + + const reader = instrumented.getReader(); + await reader.read(); + await reader.cancel('no longer needed'); + + expect(ended()).toBe(true); + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + }); +}); diff --git a/packages/core/test/lib/tracing/workers-ai.test.ts b/packages/core/test/lib/tracing/workers-ai.test.ts new file mode 100644 index 000000000000..d9c563b59962 --- /dev/null +++ b/packages/core/test/lib/tracing/workers-ai.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { instrumentWorkersAiClient } from '../../../src/tracing/workers-ai'; + +describe('instrumentWorkersAiClient', () => { + it('passes through non-run methods bound to the original client', () => { + class FakeAi { + #internal = 'secret'; + + public run = vi.fn().mockResolvedValue({ response: 'ok' }); + + public readInternal(): string { + return this.#internal; + } + } + + const client = new FakeAi(); + const instrumented = instrumentWorkersAiClient(client); + + expect(instrumented.readInternal()).toBe('secret'); + }); + + it('creates a span and forwards arguments for run calls', async () => { + const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) }; + const instrumented = instrumentWorkersAiClient(client); + + const result = await instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + + expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + expect(result).toEqual({ response: 'Paris' }); + }); +}); diff --git a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts index 55449201d664..c76dc1dccb4b 100644 --- a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts @@ -8,7 +8,7 @@ describe('defaultPiiToCollectionOptions', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -26,7 +26,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, @@ -44,7 +44,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, @@ -62,7 +62,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 0deee7654d09..9f554a1897e2 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -7,7 +7,7 @@ describe('resolveDataCollectionOptions', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -49,7 +49,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.cookies).toBe(true); expect(result.httpHeaders).toEqual({ request: true, response: true }); expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); - expect(result.queryParams).toBe(true); + expect(result.urlQueryParams).toBe(true); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.databaseQueryData).toBe(true); @@ -96,7 +96,7 @@ describe('resolveDataCollectionOptions', () => { // Everything else is spec default expect(result.cookies).toBe(true); expect(result.httpHeaders).toEqual({ request: true, response: true }); - expect(result.queryParams).toBe(true); + expect(result.urlQueryParams).toBe(true); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.databaseQueryData).toBe(true); @@ -147,14 +147,14 @@ describe('resolveDataCollectionOptions', () => { expect(result.cookies).toEqual({ deny: ['x-custom'] }); }); - it('supports turning off query params', () => { + it('supports turning off URL query params', () => { const result = resolveDataCollectionOptions({ dataCollection: { - queryParams: false, + urlQueryParams: false, }, }); - expect(result.queryParams).toBe(false); + expect(result.urlQueryParams).toBe(false); }); it('supports turning off database query data', () => { @@ -179,7 +179,7 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('httpHeaders.request'); expect(result).toHaveProperty('httpHeaders.response'); expect(result).toHaveProperty('httpBodies'); - expect(result).toHaveProperty('queryParams'); + expect(result).toHaveProperty('urlQueryParams'); expect(result).toHaveProperty('graphQL'); expect(result).toHaveProperty('graphQL.document'); expect(result).toHaveProperty('graphQL.variables'); @@ -191,4 +191,36 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('frameContextLines'); }); }); + + describe('deprecated queryParams alias', () => { + it('honors deprecated queryParams when urlQueryParams is not set', () => { + expect(resolveDataCollectionOptions({ dataCollection: { queryParams: false } }).urlQueryParams).toBe(false); + + expect( + resolveDataCollectionOptions({ dataCollection: { queryParams: { deny: ['token'] } } }).urlQueryParams, + ).toEqual({ deny: ['token'] }); + }); + + it('prefers urlQueryParams over the deprecated queryParams when both are set', () => { + // new field wins, even when it is the "off" value + expect( + resolveDataCollectionOptions({ dataCollection: { urlQueryParams: false, queryParams: true } }).urlQueryParams, + ).toBe(false); + + expect( + resolveDataCollectionOptions({ dataCollection: { urlQueryParams: true, queryParams: false } }).urlQueryParams, + ).toBe(true); + }); + + it('falls back to the default when neither is set', () => { + // dataCollection provided → spec default (collect) + expect(resolveDataCollectionOptions({ dataCollection: {} }).urlQueryParams).toBe(true); + }); + + it('does not leak the deprecated queryParams key into the resolved output', () => { + const result = resolveDataCollectionOptions({ dataCollection: { queryParams: false } }); + expect(result).not.toHaveProperty('queryParams'); + expect(Object.keys(result)).toHaveLength(10); + }); + }); }); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index cf0ffe3ac231..4783f98be9f0 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -873,7 +873,7 @@ describe('request utils', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, diff --git a/packages/core/test/lib/utils/workers-ai-utils.test.ts b/packages/core/test/lib/utils/workers-ai-utils.test.ts new file mode 100644 index 000000000000..3eefe0bf79bc --- /dev/null +++ b/packages/core/test/lib/utils/workers-ai-utils.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../../src'; +import type { Span } from '../../../src'; +import { + GEN_AI_PROVIDER_NAME, + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_K, + GEN_AI_REQUEST_TOP_P, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, +} from '../../../src/tracing/ai/gen-ai-attributes'; +import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from '../../../src/tracing/workers-ai/constants'; +import { + addRequestAttributes, + addResponseAttributes, + extractRequestAttributes, + getOperationName, +} from '../../../src/tracing/workers-ai/utils'; + +const MODEL = '@cf/meta/llama-3.1-8b-instruct'; + +function createMockSpan(): { span: Span; attributes: Record } { + const attributes: Record = {}; + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + setAttributes: (attrs: Record) => { + Object.assign(attributes, attrs); + }, + } as unknown as Span; + return { span, attributes }; +} + +describe('workers-ai utils', () => { + describe('getOperationName', () => { + it('returns "chat" for prompt inputs', () => { + expect(getOperationName({ prompt: 'Hello' })).toBe('chat'); + }); + + it('returns "chat" for messages inputs', () => { + expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }] })).toBe('chat'); + }); + + it('returns "embeddings" for text inputs', () => { + expect(getOperationName({ text: 'embed me' })).toBe('embeddings'); + }); + + it('prefers "chat" when both messages and text are present', () => { + expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }], text: 'embed me' })).toBe('chat'); + }); + + it('falls back to "chat" for null, undefined and empty inputs', () => { + expect(getOperationName(null)).toBe('chat'); + expect(getOperationName(undefined)).toBe('chat'); + expect(getOperationName({})).toBe('chat'); + }); + }); + + describe('extractRequestAttributes', () => { + it('sets exactly the base attributes for a minimal request', () => { + expect(extractRequestAttributes(MODEL, { prompt: 'Hello' }, 'chat')).toEqual({ + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: MODEL, + }); + }); + + it('maps all supported request parameters', () => { + expect( + extractRequestAttributes( + MODEL, + { + prompt: 'Hello', + temperature: 0.5, + max_tokens: 100, + top_p: 0.9, + top_k: 40, + frequency_penalty: 0.1, + presence_penalty: 0.2, + stream: true, + }, + 'chat', + ), + ).toEqual({ + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: MODEL, + [GEN_AI_REQUEST_TEMPERATURE]: 0.5, + [GEN_AI_REQUEST_MAX_TOKENS]: 100, + [GEN_AI_REQUEST_TOP_P]: 0.9, + [GEN_AI_REQUEST_TOP_K]: 40, + [GEN_AI_REQUEST_FREQUENCY_PENALTY]: 0.1, + [GEN_AI_REQUEST_PRESENCE_PENALTY]: 0.2, + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true, + }); + }); + + it('does not set the stream attribute when stream is false', () => { + const attrs = extractRequestAttributes(MODEL, { prompt: 'Hello', stream: false }, 'chat'); + expect(attrs[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined(); + }); + + it('falls back to "unknown" model when model is not a string', () => { + const attrs = extractRequestAttributes(undefined, { prompt: 'Hello' }, 'chat'); + expect(attrs[GEN_AI_REQUEST_MODEL]).toBe('unknown'); + }); + }); + + describe('addRequestAttributes', () => { + it('records messages and extracts system instructions', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes( + span, + { + messages: [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hi' }, + ], + }, + 'chat', + false, + ); + + expect(attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe( + JSON.stringify([{ type: 'text', content: 'You are helpful.' }]), + ); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe(JSON.stringify([{ role: 'user', content: 'Hi' }])); + }); + + it('records the prompt string directly', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { prompt: 'Hello world' }, 'chat', false); + + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe('Hello world'); + }); + + it('records embeddings input on a dedicated attribute', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { text: ['embed a', 'embed b'] }, 'embeddings', false); + + expect(attributes).toEqual({ [GEN_AI_EMBEDDINGS_INPUT]: JSON.stringify(['embed a', 'embed b']) }); + }); + + it('records nothing for an empty messages array', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { messages: [] }, 'chat', false); + + expect(attributes).toEqual({}); + }); + + it('records nothing for empty embeddings input', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { text: '' }, 'embeddings', false); + + expect(attributes).toEqual({}); + }); + + it('records nothing when inputs are missing', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, undefined, 'chat', false); + + expect(attributes).toEqual({}); + }); + }); + + describe('addResponseAttributes', () => { + it('sets token usage and computes the total from input and output tokens', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris', usage: { prompt_tokens: 12, completion_tokens: 7 } }, false); + + expect(attributes).toEqual({ + [GEN_AI_USAGE_INPUT_TOKENS]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS]: 19, + }); + }); + + it('does not record response text when recordOutputs is false', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris' }, false); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + }); + + it('records response text when recordOutputs is true', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris' }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('Paris'); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Paris' }] }]), + ); + }); + + it('records tool calls when recordOutputs is true', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [{ id: 'call_1', name: 'lookup', arguments: { city: 'Paris' } }]; + + addResponseAttributes(span, { tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBe(JSON.stringify(toolCalls)); + // The product reads model output from `gen_ai.output.messages`; tool calls must appear there too. + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'lookup', arguments: JSON.stringify({ city: 'Paris' }) }], + }, + ]), + ); + }); + + it('normalizes OpenAI-compatible tool calls (name/arguments nested under function) into output messages', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [ + { id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"city":"Paris"}' } }, + ]; + + addResponseAttributes(span, { tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'lookup', arguments: '{"city":"Paris"}' }], + }, + ]), + ); + }); + + it('records both response text and tool calls without overwriting each other', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [{ id: 'call_1', name: 'lookup', arguments: { city: 'Paris' } }]; + + addResponseAttributes(span, { response: 'Looking that up', tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('Looking that up'); + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBe(JSON.stringify(toolCalls)); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [ + { type: 'text', content: 'Looking that up' }, + { type: 'tool_call', id: 'call_1', name: 'lookup', arguments: JSON.stringify({ city: 'Paris' }) }, + ], + }, + ]), + ); + }); + + it('does not set output messages when recordOutputs is false', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris', tool_calls: [{ name: 'lookup' }] }, false); + + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBeUndefined(); + }); + + it('serializes non-string response payloads as JSON', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: { translated_text: 'Bonjour' } }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe(JSON.stringify({ translated_text: 'Bonjour' })); + }); + + it('ignores raw Response objects', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, new Response('raw'), true); + + expect(attributes).toEqual({}); + }); + + it('ignores non-object results', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, null, true); + + expect(attributes).toEqual({}); + }); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 44cfd21ebcb8..58611921021d 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -116,6 +116,9 @@ export { denoPostgresIntegration } from './integrations/postgres'; export { denoAmqplibIntegration } from './integrations/amqplib'; export { denoDataloaderIntegration } from './integrations/dataloader'; export { denoKnexIntegration } from './integrations/knex'; +export { denoKoaIntegration } from './integrations/koa'; +export { denoMongoIntegration } from './integrations/mongo'; +export { denoMongooseIntegration } from './integrations/mongoose'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/koa.ts b/packages/deno/src/integrations/koa.ts new file mode 100644 index 000000000000..7f41fd166c43 --- /dev/null +++ b/packages/deno/src/integrations/koa.ts @@ -0,0 +1,31 @@ +import { koaChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { KoaChannelIntegrationOptions } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoKoa' as const; + +/** + * Create spans for `koa` middleware/router layers under Deno. Requires the + * `@sentry/deno/import` loader. Delegates to the shared subscriber in + * `@sentry/server-utils`, adding Deno's `AsyncLocalStorage` context strategy so + * spans nest under the active HTTP server span. + */ +const _denoKoaIntegration = ((options: KoaChannelIntegrationOptions = {}) => { + const inner = koaChannelIntegration(options); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoKoaIntegration = defineIntegration(_denoKoaIntegration) as ( + options?: KoaChannelIntegrationOptions, +) => Integration & { + name: 'DenoKoa'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/integrations/mongo.ts b/packages/deno/src/integrations/mongo.ts new file mode 100644 index 000000000000..15d6d1e9e028 --- /dev/null +++ b/packages/deno/src/integrations/mongo.ts @@ -0,0 +1,34 @@ +import { mongodbChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoMongo' as const; + +/** + * Create spans for `mongodb` queries under Deno. + * + * `mongodb` channels are injected by the orchestrion runtime hook at load time. + * The `@sentry/deno/import` loader must be active for this integration to + * record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's + * `AsyncLocalStorage` context strategy (so spans nest under the active + * span and survive mongodb's internal callback dispatch) before delegating. + */ +const _denoMongoIntegration = (() => { + const inner = mongodbChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongoIntegration = defineIntegration(_denoMongoIntegration) as () => Integration & { + name: 'DenoMongo'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/integrations/mongoose.ts b/packages/deno/src/integrations/mongoose.ts new file mode 100644 index 000000000000..86e079cd37e6 --- /dev/null +++ b/packages/deno/src/integrations/mongoose.ts @@ -0,0 +1,34 @@ +import { mongooseChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoMongoose' as const; + +/** + * Create spans for `mongoose` queries under Deno. + * + * `mongoose` channels are injected by the orchestrion runtime hook at load + * time. The `@sentry/deno/import` loader must be active for this integration + * to record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's `AsyncLocalStorage` + * context strategy (so spans nest under the active span and survive mongoose's + * internal callback dispatch) before delegating. + */ +const _denoMongooseIntegration = (() => { + const inner = mongooseChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongooseIntegration = defineIntegration(_denoMongooseIntegration) as () => Integration & { + name: 'DenoMongoose'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index f67b545966de..d9a75982b667 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -24,6 +24,9 @@ import { import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; +import { denoKoaIntegration } from './integrations/koa'; +import { denoMongoIntegration } from './integrations/mongo'; +import { denoMongooseIntegration } from './integrations/mongoose'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; import { denoRedisIntegration } from './integrations/redis'; @@ -62,7 +65,14 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // (or in parallel to) loading the SDK, so we only gate on whether the // feature is possible. If they're never loaded, it'll just be a no-op. ...(MODULE_REGISTER_HOOKS_SUPPORTED - ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration()] + ? [ + denoAmqplibIntegration(), + denoKoaIntegration(), + denoMongoIntegration(), + denoMongooseIntegration(), + denoMysqlIntegration(), + denoPostgresIntegration(), + ] : []), contextLinesIntegration(), normalizePathsIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 3b344fa68eff..7da66392eb30 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -115,9 +115,12 @@ snapshot[`captureException 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", + "DenoAmqplib", + "DenoKoa", + "DenoMongo", + "DenoMongoose", "DenoMysql", "DenoPostgres", - "DenoAmqplib", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -193,9 +196,12 @@ snapshot[`captureMessage 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", + "DenoAmqplib", + "DenoKoa", + "DenoMongo", + "DenoMongoose", "DenoMysql", "DenoPostgres", - "DenoAmqplib", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -278,9 +284,12 @@ snapshot[`captureMessage twice 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", + "DenoAmqplib", + "DenoKoa", + "DenoMongo", + "DenoMongoose", "DenoMysql", "DenoPostgres", - "DenoAmqplib", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -370,9 +379,12 @@ snapshot[`captureMessage twice 2`] = ` "DenoServe", "DenoHttp", "DenoRedis", + "DenoAmqplib", + "DenoKoa", + "DenoMongo", + "DenoMongoose", "DenoMysql", "DenoPostgres", - "DenoAmqplib", "ContextLines", "NormalizePaths", "GlobalHandlers", diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index 063c564f7267..2d72eb28e5e8 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -128,9 +128,6 @@ export function subscribeToNestChannels(): void { // `start`, makes it the active context for the bootstrap, and ends it // on `asyncEnd` (or `end` if `create` throws synchronously). // - // `captureError: false`: a failed bootstrap surfaces to the caller. - // We just annotate the span. - // // `bindTracingChannelToSpan` uses `bindStore`, which needs the // async-context binding registered after integration `setupOnce`; defer // until it's available. Only this bind is deferred (it fires at @@ -138,14 +135,10 @@ export function subscribeToNestChannels(): void { // calls below stay synchronous because the decorator channels fire at // module-load time, which a deferred subscription could miss. waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), - data => { - const moduleCls = data.arguments?.[0] as { name?: string } | undefined; - return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); + }); }); // request_context + request_handler. `RouterExecutionContext.create` diff --git a/packages/nextjs/src/client/clientNormalizationIntegration.ts b/packages/nextjs/src/client/clientNormalizationIntegration.ts index 30c57ee8fc02..56fbf252d4cc 100644 --- a/packages/nextjs/src/client/clientNormalizationIntegration.ts +++ b/packages/nextjs/src/client/clientNormalizationIntegration.ts @@ -1,6 +1,9 @@ import { defineIntegration } from '@sentry/core'; import { rewriteFramesIntegration } from '@sentry/react'; +const NEXTJS_INTERNAL_CHUNK_REGEX = + /\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/; + export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( ({ assetPrefix, @@ -58,28 +61,13 @@ export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( if (frame.filename?.includes('/_next')) { frame.filename = decodeURI(frame.filename); } + } else if (frame.filename?.startsWith('app:///_next')) { + frame.filename = decodeURI(frame.filename); + } - if ( - frame.filename?.match( - /\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } - } else { - if (frame.filename?.startsWith('app:///_next')) { - frame.filename = decodeURI(frame.filename); - } - - if ( - frame.filename?.match( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } + if (frame.filename?.match(NEXTJS_INTERNAL_CHUNK_REGEX)) { + // We don't care about these frames. It's Next.js internal code. + frame.in_app = false; } return frame; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 94db1cca621d..30532b354360 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -433,7 +433,7 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { - newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); + newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); } return newConfig; diff --git a/packages/nextjs/test/clientNormalizationIntegration.test.ts b/packages/nextjs/test/clientNormalizationIntegration.test.ts new file mode 100644 index 000000000000..55336b8611fc --- /dev/null +++ b/packages/nextjs/test/clientNormalizationIntegration.test.ts @@ -0,0 +1,79 @@ +import type { StackFrame } from '@sentry/core'; +import * as SentryReact from '@sentry/react'; +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { nextjsClientStackFrameNormalizationIntegration } from '../src/client/clientNormalizationIntegration'; + +const dom = new JSDOM(undefined, { url: 'https://example.com/' }); +const originalWindow = (global as { window?: unknown }).window; +Object.defineProperty(global, 'window', { value: dom.window, writable: true, configurable: true }); + +afterAll(() => { + Object.defineProperty(global, 'window', { value: originalWindow, writable: true, configurable: true }); +}); + +type Iteratee = (frame: StackFrame) => StackFrame; + +function captureIteratee(options: Parameters[0]): Iteratee { + let iteratee: Iteratee | undefined; + const spy = vi.spyOn(SentryReact, 'rewriteFramesIntegration').mockImplementation(rewriteFramesOptions => { + iteratee = rewriteFramesOptions?.iteratee as Iteratee; + return { name: 'RewriteFrames', processEvent: e => e }; + }); + + nextjsClientStackFrameNormalizationIntegration(options); + spy.mockRestore(); + + if (!iteratee) { + throw new Error('rewriteFramesIntegration was not called with an iteratee'); + } + return iteratee; +} + +describe('nextjsClientStackFrameNormalizationIntegration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('marks Next.js internal chunks as not in_app', () => { + const iteratee = () => + captureIteratee({ + rewriteFramesAssetPrefixPath: '', + experimentalThirdPartyOriginStackFrames: false, + }); + + it.each([ + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js', + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js:1', + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js:1:234', + 'app:///_next/static/chunks/main-app-abc123.js:10:5', + 'app:///_next/static/chunks/framework.deadbeef.js:2', + ])('sets in_app=false for %s', filename => { + const frame = iteratee()({ filename }); + expect(frame.in_app).toBe(false); + }); + + it('leaves app chunks as in_app', () => { + const frame = iteratee()({ filename: 'app:///_next/static/chunks/pages/index-deadbeef.js:1:2' }); + expect(frame.in_app).toBeUndefined(); + }); + }); + + describe('experimentalThirdPartyOriginStackFrames', () => { + const iteratee = () => + captureIteratee({ + rewriteFramesAssetPrefixPath: '', + experimentalThirdPartyOriginStackFrames: true, + assetPrefix: 'https://cdn.example.com', + }); + + it.each([ + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js', + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js:1', + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js:1:234', + ])('sets in_app=false for %s', filename => { + const frame = iteratee()({ filename }); + expect(frame.in_app).toBe(false); + }); + }); +}); diff --git a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts index c4ae4897678d..9b28ae3eccb5 100644 --- a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts +++ b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts @@ -1,5 +1,5 @@ declare module '@apm-js-collab/tracing-hooks' { - import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + import type { InstrumentationConfig } from '@sentry/server-utils'; type PatchConfig = { instrumentations: InstrumentationConfig[] }; diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index e48c73835f6e..853bcbf35a6e 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -225,6 +225,21 @@ function getExceptionAndThreads( }; } +/** + * Rehydrates a Scope from serialized ScopeData that has been passed over a JSON serialization boundary. + * + * `Scope.update()` only handles `ScopeContext` fields, so breadcrumbs have to be carried over separately. + * Attachments are deliberately not carried over since their binary data does not survive JSON serialization. + */ +function hydrateScope(data: Partial | undefined): Scope { + const scope = new Scope(); + if (data) { + scope.update(data); + data.breadcrumbs?.forEach(breadcrumb => scope.addBreadcrumb(breadcrumb)); + } + return scope; +} + function applyScopeToEvent(event: Event, scope: ScopeData): void { applyScopeDataToEvent(event, scope); @@ -274,9 +289,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise { ...getExceptionAndThreads(crashedThreadId, threads), }; - const scope = crashedThread.pollState?.scope - ? new Scope().update(crashedThread.pollState.scope).getScopeData() - : new Scope().getScopeData(); + const scope = hydrateScope(crashedThread.pollState?.scope).getScopeData(); if (crashedThread?.asyncState?.isolationScope) { // We need to rehydrate the scope from the serialized object with properties beginning with _user, etc diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 61c80f44794f..1ad92fd21b8e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -46,6 +46,7 @@ export { getDefaultIntegrations, getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, + applyDiagnosticsChannelInjectionIntegrations, } from './sdk'; export { experimentalUseDiagnosticsChannelInjection, diff --git a/packages/node/src/integrations/tracing/koa/vendored/utils.ts b/packages/node/src/integrations/tracing/koa/vendored/utils.ts index 1c9d9a8067a9..070aa7eb965d 100644 --- a/packages/node/src/integrations/tracing/koa/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/koa/vendored/utils.ts @@ -11,7 +11,7 @@ import { KoaLayerType, type KoaInstrumentationConfig } from './types'; import type { KoaContext, KoaMiddleware } from './internal-types'; import { AttributeNames } from './enums/AttributeNames'; import type { Attributes } from '@opentelemetry/api'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { CODE_FUNCTION_NAME, HTTP_ROUTE } from '@sentry/conventions/attributes'; export const getMiddlewareMetadata = ( context: KoaContext, @@ -25,7 +25,7 @@ export const getMiddlewareMetadata = ( if (isRouter) { return { attributes: { - [AttributeNames.KOA_NAME]: layerPath?.toString(), + [AttributeNames.KOA_NAME]: layerPath?.toString(), // TODO(v11): remove, replaced by http.route [AttributeNames.KOA_TYPE]: KoaLayerType.ROUTER, [HTTP_ROUTE]: layerPath?.toString(), }, @@ -34,8 +34,9 @@ export const getMiddlewareMetadata = ( } else { return { attributes: { - [AttributeNames.KOA_NAME]: layer.name ?? 'middleware', + [AttributeNames.KOA_NAME]: layer.name ?? 'middleware', // TODO(v11): remove, replaced by code.function.name [AttributeNames.KOA_TYPE]: KoaLayerType.MIDDLEWARE, + [CODE_FUNCTION_NAME]: layer.name ?? 'middleware', }, name: `middleware - ${layer.name}`, }; diff --git a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts index 1d1e7ef20cc7..ea29755c32d5 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts @@ -18,9 +18,8 @@ import type { V4ConnectionPool, WireProtocolInternal, } from './internal-types'; -import { MongodbCommandType } from './internal-types'; import { - getCommandType, + getV3CommandOperation, getV3SpanAttributes, getV4SpanAttributes, patchEnd, @@ -82,8 +81,7 @@ export function getV3PatchCommand() { } } - const commandType = getCommandType(cmd); - const operationName = commandType === MongodbCommandType.UNKNOWN ? undefined : commandType; + const operationName = getV3CommandOperation(cmd as unknown as Record); const span = startMongoSpan(getV3SpanAttributes(ns, server, cmd, operationName)); const patchedCallback = patchEnd(span, resultHandler); diff --git a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts b/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts deleted file mode 100644 index 1a4c1680e84f..000000000000 --- a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb - * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 - */ - -/* - * This file contains a copy of unstable semantic convention definitions - * used by this package. - * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv - */ - -/** - * Deprecated, use `server.address`, `server.port` attributes instead. - * - * @deprecated Replaced by `server.address` and `server.port`. - */ -export const ATTR_DB_CONNECTION_STRING = 'db.connection_string' as const; - -/** - * Deprecated, use `db.collection.name` instead. - * - * @deprecated Replaced by `db.collection.name`. - */ -export const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection' as const; - -/** - * Enum value "mongodb" for attribute `db.system`. - */ -export const DB_SYSTEM_VALUE_MONGODB = 'mongodb' as const; diff --git a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts index 6c1430c0ce16..22876deae063 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts @@ -6,205 +6,33 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs + * - The db/net attribute extraction, `db.statement` scrubbing and span + * builder are shared with the orchestrion mongodb integration in + * `@sentry/server-utils` so the two emit an identical span shape. + * Only the OTel-specific callback/context helpers below remain here. */ import type { Span, SpanAttributes } from '@sentry/core'; +import { getActiveSpan, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; import { - getActiveSpan, - isObjectLike, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - SPAN_STATUS_ERROR, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; -import { - DB_NAME, - DB_OPERATION, - DB_STATEMENT, - DB_SYSTEM, - NET_PEER_NAME, - NET_PEER_PORT, -} from '@sentry/conventions/attributes'; -import { ATTR_DB_CONNECTION_STRING, ATTR_DB_MONGODB_COLLECTION, DB_SYSTEM_VALUE_MONGODB } from './semconv'; -import type { MongodbNamespace, MongoInternalCommand, MongoInternalTopology } from './internal-types'; -import { MongodbCommandType } from './internal-types'; + getV3CommandOperation, + getV3SpanAttributes as sharedGetV3SpanAttributes, + getV4SpanAttributes as sharedGetV4SpanAttributes, + startMongoSpan, +} from '@sentry/server-utils'; const ORIGIN = 'auto.db.otel.mongo'; -/** - * Replaces values in the command object with '?', hiding PII and helping grouping. - */ -function serializeDbStatement(commandObj: Record): string { - return JSON.stringify(scrubStatement(commandObj)); -} - -function scrubStatement(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(element => scrubStatement(element)); - } - - if (isCommandObj(value)) { - const initial: Record = {}; - return Object.entries(value) - .map(([key, element]) => [key, scrubStatement(element)]) - .reduce((prev, current) => { - if (isCommandEntry(current)) { - prev[current[0]] = current[1]; - } - return prev; - }, initial); - } - - // A value like string or number, possibly contains PII, scrub it - return '?'; -} - -function isCommandObj(value: Record | unknown): value is Record { - return isObjectLike(value) && !isBuffer(value); -} - -function isBuffer(value: unknown): boolean { - return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); -} - -function isCommandEntry(value: [string, unknown] | unknown): value is [string, unknown] { - return Array.isArray(value); -} - -/** - * Get the mongodb command type from the object. - */ -export function getCommandType(command: MongoInternalCommand): MongodbCommandType { - if (command.createIndexes !== undefined) { - return MongodbCommandType.CREATE_INDEXES; - } else if (command.findandmodify !== undefined) { - return MongodbCommandType.FIND_AND_MODIFY; - } else if (command.ismaster !== undefined) { - return MongodbCommandType.IS_MASTER; - } else if (command.count !== undefined) { - return MongodbCommandType.COUNT; - } else if (command.aggregate !== undefined) { - return MongodbCommandType.AGGREGATE; - } else { - return MongodbCommandType.UNKNOWN; - } -} - -/** - * Determine a span's attributes by fetching related metadata from the v4 connection context. - */ -export function getV4SpanAttributes( - connectionCtx: any, - ns: MongodbNamespace, - command?: any, - operation?: string, -): SpanAttributes { - let host, port: undefined | string; - if (connectionCtx) { - const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; - if (hostParts.length === 2) { - host = hostParts[0]; - port = hostParts[1]; - } - } - let commandObj: Record; - if (command?.documents && command.documents[0]) { - commandObj = command.documents[0]; - } else if (command?.cursors) { - commandObj = command.cursors; - } else { - commandObj = command; - } - - return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation); -} - -/** - * Determine a span's attributes by fetching related metadata from the v3 topology. - */ -export function getV3SpanAttributes( - ns: string, - topology: MongoInternalTopology, - command?: MongoInternalCommand, - operation?: string | undefined, -): SpanAttributes { - let host: undefined | string; - let port: undefined | string; - if (topology?.s) { - host = topology.s.options?.host ?? topology.s.host; - port = (topology.s.options?.port ?? topology.s.port)?.toString(); - if (host == null || port == null) { - const address = topology.description?.address; - if (address) { - const addressSegments = address.split(':'); - host = addressSegments[0]; - port = addressSegments[1]; - } - } - } - - // The namespace is a combination of the database name and the name of the - // collection or index, like so: [database-name].[collection-or-index-name]. - // It could be a string or an instance of MongoDBNamespace, as such we - // always coerce to a string to extract db and collection. - const [dbName, dbCollection] = ns.toString().split('.'); - const commandObj = command?.query ?? command?.q ?? command; - - return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation); -} - -function getSpanAttributes( - dbName?: string, - dbCollection?: string, - host?: undefined | string, - port?: undefined | string, - commandObj?: any, - operation?: string | undefined, -): SpanAttributes { - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - // eslint-disable-next-line typescript/no-deprecated - [DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, - // eslint-disable-next-line typescript/no-deprecated - [DB_NAME]: dbName, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_MONGODB_COLLECTION]: dbCollection, - // eslint-disable-next-line typescript/no-deprecated - [DB_OPERATION]: operation, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, - }; - - if (host && port) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_NAME] = host; - const portNumber = parseInt(port, 10); - if (!isNaN(portNumber)) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_PORT] = portNumber; - } - } - - if (commandObj) { - try { - // eslint-disable-next-line typescript/no-deprecated - attributes[DB_STATEMENT] = serializeDbStatement(commandObj); - } catch { - // ignore serialization errors — the statement is best-effort metadata - } - } +export { getV3CommandOperation, startMongoSpan }; - return attributes; +/** Determine a span's attributes from the v4 connection context (OTel origin). */ +export function getV4SpanAttributes(connectionCtx: any, ns: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV4SpanAttributes(connectionCtx, ns, command, operation, ORIGIN); } -export function startMongoSpan(attributes: SpanAttributes): Span { - return startInactiveSpan({ - // eslint-disable-next-line typescript/no-deprecated - name: `mongodb.${attributes[DB_OPERATION] || 'command'}`, - kind: SPAN_KIND.CLIENT, - attributes, - }); +/** Determine a span's attributes from the v3 topology (OTel origin). */ +export function getV3SpanAttributes(ns: string, topology: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV3SpanAttributes(ns, topology, command, operation, ORIGIN); } /** diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 8144b38ef801..579408acf6c5 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -16,18 +16,11 @@ import { type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; -import { DB_OPERATION, DB_SYSTEM } from '@sentry/conventions/attributes'; -import type { Span, SpanAttributes } from '@sentry/core'; -import { - getActiveSpan, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { getActiveSpan, SDK_VERSION, withActiveSpan } from '@sentry/core'; +import { startMongooseLegacySpan } from '@sentry/server-utils'; import type * as mongoose from './mongoose-types'; -import { getAttributesFromCollection, handleCallbackResponse, handlePromiseResponse } from './utils'; +import { handleCallbackResponse, handlePromiseResponse } from './utils'; const PACKAGE_NAME = '@sentry/instrumentation-mongoose'; const ORIGIN = 'auto.db.otel.mongoose'; @@ -190,7 +183,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function exec(this: any, callback?: Function) { const parentSpan = this[_STORED_PARENT_SPAN]; - const span = self._startSpan(this._model.collection, this._model?.modelName, 'aggregate', parentSpan); + const span = startMongooseLegacySpan({ + collection: this._model.collection, + modelName: this._model?.modelName, + operation: 'aggregate', + origin: ORIGIN, + parentSpan, + }); return self._handleResponse(span, originalAggregate, this, arguments, callback); }; @@ -207,7 +206,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function method(this: any, options?: any, callback?: Function) { - const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op); + const span = startMongooseLegacySpan({ + collection: this.constructor.collection, + modelName: this.constructor.modelName, + operation: op, + origin: ORIGIN, + }); if (options instanceof Function) { // oxlint-disable-next-line no-param-reassign @@ -243,7 +253,12 @@ export class MongooseInstrumentation extends InstrumentationBase !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; + } + } + return integrations; +} + /** * Initialize Sentry for Node. */ diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index b6936ab1e269..203d1c9568cb 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,3 +1,4 @@ +import type { TracerProvider } from '@opentelemetry/api'; import { context, propagation, trace } from '@opentelemetry/api'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; @@ -26,6 +27,61 @@ import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracin // About 277h - this must fit into new Array(len)! const MAX_MAX_SPAN_WAIT_DURATION = 1_000_000; +// The global registry of @opentelemetry/api 1.x, shared across all copies of the package +const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1'); + +/** + * Registers the given tracer provider as the global tracer provider, recreating the OpenTelemetry + * API registry when it pre-exists with a different `@opentelemetry/api` version. + * + * Some host runtimes (e.g. Neon Functions) pre-create the registry with their own API version. + * `registerGlobal` requires an exact version match, so every registration through the SDK's copy + * of `@opentelemetry/api` is rejected and tracing is silently disabled + * (https://github.com/getsentry/sentry-javascript/issues/22338). This case is identified by a + * failed registration with an empty `trace` slot: `registerGlobal` checks the slot before the + * version, so an empty slot means the version gate rejected us and recreating the registry + * clobbers no other tracer provider. If the slot is occupied (another provider registered first, + * e.g. a second `Sentry.init()` call), the registry is left untouched and registration fails. + * + * Slots Sentry does not claim itself (e.g. `diag`, `metrics`) are carried over into the recreated + * registry: reads resolve via a semver-compatibility check rather than the exact-match write gate, + * so they keep working for the copy that registered them. `propagation` and `context` are not + * carried over because Sentry registers its own right after this. + */ +function registerGlobalTracerProvider(provider: TracerProvider): boolean { + if (trace.setGlobalTracerProvider(provider)) { + return true; + } + + // @opentelemetry/api stores the registry under a `Symbol.for` key that no public type + // describes, so `typeof globalThis` can only be narrowed to it by casting through `unknown`. + const otelGlobal = globalThis as unknown as Record | undefined>; + const registry = otelGlobal[OTEL_API_GLOBAL_KEY]; + if (registry && !registry.trace) { + DEBUG_BUILD && + coreDebug.warn( + 'Replaced a pre-existing OpenTelemetry API registry that was created by a different @opentelemetry/api version and would have blocked tracing. If you want to manage OpenTelemetry yourself, set `skipOpenTelemetrySetup: true` in `Sentry.init()`.', + ); + otelGlobal[OTEL_API_GLOBAL_KEY] = undefined; + + if (!trace.setGlobalTracerProvider(provider)) { + return false; + } + + // The cast is needed because TS still has the slot narrowed to `undefined` from the reset + // above and cannot know the registration call just recreated the registry. + const recreatedRegistry = otelGlobal[OTEL_API_GLOBAL_KEY] as Record | undefined; + if (recreatedRegistry) { + const { propagation: _propagation, context: _context, ...carriedOverSlots } = registry; + otelGlobal[OTEL_API_GLOBAL_KEY] = { ...carriedOverSlots, ...recreatedRegistry }; + } + + return true; + } + + return false; +} + interface AdditionalOpenTelemetryOptions { /** Additional SpanProcessor instances that should be used. */ spanProcessors?: SpanProcessor[]; @@ -118,7 +174,7 @@ export function setupOtel( }); // Register as globals - trace.setGlobalTracerProvider(provider); + registerGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); const ctxManager = new SentryContextManager(); @@ -132,7 +188,7 @@ function setupSentryTracerProvider( ): [SentryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); - if (!trace.setGlobalTracerProvider(provider)) { + if (!registerGlobalTracerProvider(provider)) { DEBUG_BUILD && coreDebug.warn( 'Could not register SentryTracerProvider because another OpenTelemetry tracer provider is already registered.', diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 1dd01361a2ab..95d9b9db1de9 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -14,6 +14,8 @@ declare var global: any; const PUBLIC_DSN = 'https://username@domain/123'; +const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1'); + class MockIntegration implements Integration { public name: string; public setupOnce: Mock = vi.fn(); @@ -231,6 +233,73 @@ describe('init()', () => { expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); }); + it('recreates the OTel API registry when it pre-exists with a different @opentelemetry/api version', () => { + // Simulate a host runtime (e.g. Neon Functions) pre-creating the registry with its own api version + global[OTEL_API_GLOBAL_KEY] = { version: '0.0.1' }; + + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(client?.traceProvider).toBeInstanceOf(SentryOpentelemetry.SentryTracerProvider); + expect(registry?.version).not.toBe('0.0.1'); + expect(registry?.trace).toBeDefined(); + }); + + it('recreates a version-mismatched OTel API registry also for the OpenTelemetry SDK tracer provider', () => { + global[OTEL_API_GLOBAL_KEY] = { version: '0.0.1' }; + + init({ dsn: PUBLIC_DSN, openTelemetryBasicTracerProvider: true }); + + const client = getClient(); + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); + expect(registry?.version).not.toBe('0.0.1'); + expect(registry?.trace).toBeDefined(); + }); + + it('carries non-Sentry slots of a version-mismatched OTel API registry over into the recreated one', () => { + // Must be a complete DiagLogger: once carried over, the SDK's api copy resolves it and + // calls it for its own diag output. + const diagLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + const meterProvider = { getMeter: vi.fn() }; + const propagator = { inject: vi.fn() }; + global[OTEL_API_GLOBAL_KEY] = { + version: '0.0.1', + diag: diagLogger, + metrics: meterProvider, + propagation: propagator, + }; + + init({ dsn: PUBLIC_DSN }); + + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(registry?.trace).toBeDefined(); + expect(registry?.diag).toBe(diagLogger); + expect(registry?.metrics).toBe(meterProvider); + // propagation is claimed by Sentry's own propagator, not carried over + expect(registry?.propagation).not.toBe(propagator); + }); + + it('does not recreate the OTel API registry when another tracer provider is already registered', () => { + const existingProvider = { getTracer: vi.fn() }; + const existingRegistry = { version: '0.0.1', trace: existingProvider }; + global[OTEL_API_GLOBAL_KEY] = existingRegistry; + + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + + expect(client?.traceProvider).not.toBeDefined(); + expect(global[OTEL_API_GLOBAL_KEY]).toBe(existingRegistry); + expect(existingRegistry.trace).toBe(existingProvider); + + global[OTEL_API_GLOBAL_KEY] = undefined; + }); + it('does not mark SentryTracerProvider as set up when global registration fails', () => { // Simulate another OpenTelemetry tracer provider already being registered. const setGlobalSpy = vi.spyOn(trace, 'setGlobalTracerProvider').mockReturnValue(false); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index afad87708909..9f855cc0cb13 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -60,6 +60,7 @@ "@sentry/node": "10.66.0", "@sentry/node-core": "10.66.0", "@sentry/rollup-plugin": "^5.3.0", + "@sentry/server-utils": "10.66.0", "@sentry/vite-plugin": "^5.3.0", "@sentry/vue": "10.66.0", "local-pkg": "^1.1.2" diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 7c84775e6820..fbc9604cb310 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -183,6 +183,24 @@ export type SentryNuxtModuleOptions = BuildTimeOptionsBase & { */ enabled?: boolean; + // todo(v11): Update this JSDoc (and remove from experimental) + /** + * Experimental build-time options that may change or be removed without notice. + */ + _experimental?: { + /** + * Enables build-time diagnostics-channel instrumentation for supported dependencies bundled into the Nitro server. + * + * 1. Call `Sentry.experimentalUseDiagnosticsChannelInjection()` just before `Sentry.init()` + * 2. Remove `--import ./.output/server/sentry.server.config.mjs` from your `start`script + * 3. Add `sentry.autoInjectServerSentry: 'top-level-import'` in `nuxt.config.ts` so Sentry's server configuration is automatically imported + * + * @default false + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; + }; + /** * Options for the Sentry Vite plugin to customize the source maps upload process. * diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 72ab409ba384..4cf39d16cdbc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -13,6 +13,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; +import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; @@ -84,6 +85,10 @@ export default defineNuxtModule({ const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { + if (moduleOptions._experimental?.useDiagnosticsChannelInjection) { + setupOrchestrion(nuxt); + } + if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts new file mode 100644 index 000000000000..5bc3d3be1a12 --- /dev/null +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -0,0 +1,35 @@ +import type { Nuxt } from '@nuxt/schema'; +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import type { NitroConfig } from 'nitropack'; + +// ioredis requires this CommonJS helper to be bundled with it. Leaving it +// external makes Nitro resolve the default export as a namespace object. +const IORedisDependencies = ['standard-as-callback']; + +/** + * Configures Nitro to bundle and transform dependencies that publish tracing + * events through diagnostics channels. + */ +export function setupOrchestrion(nuxt: Nuxt): void { + nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { + if (nuxt.options?._prepare) { + return; + } + + nitroConfig.rollupConfig ??= {}; + + if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { + nitroConfig.rollupConfig.plugins = []; + } else if (!Array.isArray(nitroConfig.rollupConfig.plugins)) { + nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; + } + + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin()); + + const externals = (nitroConfig.externals ||= {}); + const inline = externals.inline; + const existingInline = Array.isArray(inline) ? inline : inline ? [inline] : []; + externals.inline = [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])]; + }); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index b249b29f4cd3..bba2e6440c46 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -94,9 +94,7 @@ export function setupSourceMaps( nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { - if (!nitroConfig.rollupConfig) { - nitroConfig.rollupConfig = {}; - } + nitroConfig.rollupConfig ??= {}; if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { nitroConfig.rollupConfig.plugins = []; diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index c19bcc1ed099..e84b6aeb461b 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -46,7 +46,7 @@ describe('wrapMiddlewareHandlerWithSentry', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: true, diff --git a/packages/nuxt/test/vite/buildOptions.test-d.ts b/packages/nuxt/test/vite/buildOptions.test-d.ts index 1ad1560e54ec..0eca417d46f0 100644 --- a/packages/nuxt/test/vite/buildOptions.test-d.ts +++ b/packages/nuxt/test/vite/buildOptions.test-d.ts @@ -54,6 +54,9 @@ describe('Sentry Nuxt build-time options type', () => { // --- SentryNuxtModuleOptions specific options --- enabled: true, + _experimental: { + useDiagnosticsChannelInjection: true, + }, autoInjectServerSentry: 'experimental_dynamic-import', configDir: '~/custom-config', experimental_entrypointWrappedFunctions: ['default', 'handler', 'server', 'customExport'], diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts new file mode 100644 index 000000000000..1d0f8d105627 --- /dev/null +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -0,0 +1,84 @@ +import type { Nuxt } from '@nuxt/schema'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSentryOrchestrionPlugin = vi.fn(() => ({ name: 'sentry-orchestrion-plugin' })); + +function createMockNuxt(options: { _prepare?: boolean } = {}) { + const hooks: Record void | Promise>> = {}; + + return { + options: { _prepare: options._prepare ?? false }, + hook: (name: string, callback: (...args: any[]) => void | Promise) => { + hooks[name] = hooks[name] || []; + hooks[name].push(callback); + }, + triggerHook: async (name: string, ...args: any[]) => { + for (const callback of hooks[name] || []) { + await callback(...args); + } + }, + }; +} + +describe('setupOrchestrion', () => { + beforeAll(() => { + vi.doMock('@sentry/server-utils/orchestrion/config', () => ({ + INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], + })); + vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ + sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, + })); + }); + + afterAll(() => { + vi.doUnmock('@sentry/server-utils/orchestrion/config'); + vi.doUnmock('@sentry/server-utils/orchestrion/rollup'); + }); + + beforeEach(() => { + mockSentryOrchestrionPlugin.mockClear(); + }); + + it('adds the transformer and preserves existing inline dependencies', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const existingPlugin = { name: 'existing-plugin' }; + const nitroConfig = { + rollupConfig: { plugins: existingPlugin }, + externals: { inline: ['ioredis', 'custom-dependency'] }, + }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); + expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]); + expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); + }); + + it('initializes absent Nitro configuration', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = {}; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig).toEqual({ + rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }] }, + externals: { inline: ['mysql', 'ioredis', 'standard-as-callback'] }, + }); + }); + + it('does not change Nitro configuration in prepare mode', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt({ _prepare: true }); + const nitroConfig = { rollupConfig: { plugins: [] } }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled(); + expect(nitroConfig).toEqual({ rollupConfig: { plugins: [] } }); + }); +}); diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 42993182cd55..ea6e9a3ea1f2 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -12,7 +12,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core/browser'; import type { VendoredTanstackRouter, VendoredTanstackRouterRouteMatch } from './vendor/tanstackrouter-types'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; interface TanstackRouterLocation { pathname: string; @@ -188,8 +194,8 @@ function routeMatchToParamSpanAttributes(match: VendoredTanstackRouterRouteMatch const paramAttributes: Record = {}; Object.entries(match.params).forEach(([key, value]) => { paramAttributes[`url.path.params.${key}`] = value; // TODO(v11): remove attribute which does not adhere to Sentry's semantic convention - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/remix/package.json b/packages/remix/package.json index 891507dc35cd..6db2393669e6 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -72,6 +72,7 @@ "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", "@sentry/react": "10.66.0", + "@sentry/server-utils": "10.66.0", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 7650cdc6e7ea..3ce2aa4a2caf 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -145,3 +145,4 @@ export { init, getRemixDefaultIntegrations } from './sdk'; export { captureRemixServerException } from './errors'; export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; export { generateSentryServerTimingHeader } from './serverTimingTracePropagation'; +export { remixIntegration } from './integrations/RemixIntegration'; diff --git a/packages/remix/src/server/integrations/RemixIntegration.ts b/packages/remix/src/server/integrations/RemixIntegration.ts new file mode 100644 index 000000000000..f70926fa9add --- /dev/null +++ b/packages/remix/src/server/integrations/RemixIntegration.ts @@ -0,0 +1,39 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, getClient } from '@sentry/core'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; +import { instrumentRemix } from './tracing-channel'; +import { addRemixSpanAttributes, instrumentRemixWithOpenTelemetry } from './opentelemetry'; +import type { RemixOptions } from '../../utils/remixOptions'; + +const INTEGRATION_NAME = 'Remix' as const; + +const _remixIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + const client = getClient(); + const options = client?.getOptions() as RemixOptions | undefined; + const actionFormDataAttributes = client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') + ? options?.captureActionFormDataKeys + : undefined; + + if (isOrchestrionInjected()) { + instrumentRemix(actionFormDataAttributes); + } else { + instrumentRemixWithOpenTelemetry({ actionFormDataAttributes }); + } + }, + setup(client) { + if (!isOrchestrionInjected()) { + client.on('spanStart', span => { + addRemixSpanAttributes(span); + }); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Instrument server-side Remix requests to emit spans. + */ +export const remixIntegration = defineIntegration(_remixIntegration); diff --git a/packages/remix/src/server/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts index b143edd473a8..ceaa9349c706 100644 --- a/packages/remix/src/server/integrations/opentelemetry.ts +++ b/packages/remix/src/server/integrations/opentelemetry.ts @@ -1,7 +1,6 @@ -import type { Client, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node'; -import type { RemixOptions } from '../../utils/remixOptions'; +import type { Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { generateInstrumentOnce, spanToJSON } from '@sentry/node'; import { RemixInstrumentation } from '../../vendor/instrumentation'; const INTEGRATION_NAME = 'Remix'; @@ -10,33 +9,14 @@ interface RemixInstrumentationOptions { actionFormDataAttributes?: Record; } -const instrumentRemix = generateInstrumentOnce(INTEGRATION_NAME, (options?: RemixInstrumentationOptions) => { - return new RemixInstrumentation(options); -}); +export const instrumentRemixWithOpenTelemetry = generateInstrumentOnce( + INTEGRATION_NAME, + (options?: RemixInstrumentationOptions) => { + return new RemixInstrumentation(options); + }, +); -const _remixIntegration = (() => { - return { - name: 'Remix' as const, - setupOnce() { - const client = getClient(); - const options = client?.getOptions() as RemixOptions | undefined; - - instrumentRemix({ - actionFormDataAttributes: client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') - ? options?.captureActionFormDataKeys - : undefined, - }); - }, - - setup(client: Client) { - client.on('spanStart', span => { - addRemixSpanAttributes(span); - }); - }, - }; -}) satisfies IntegrationFn; - -const addRemixSpanAttributes = (span: Span): void => { +export function addRemixSpanAttributes(span: Span): void { const attributes = spanToJSON(span).data; // this is one of: loader, action, requestHandler @@ -57,9 +37,4 @@ const addRemixSpanAttributes = (span: Span): void => { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.remix', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, }); -}; - -/** - * Instrumentation for aws-sdk package - */ -export const remixIntegration = defineIntegration(_remixIntegration); +} diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts new file mode 100644 index 000000000000..624ab8dad39c --- /dev/null +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -0,0 +1,255 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + getActiveSpan, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { bindTracingChannelToSpan } from '@sentry/server-utils'; +import { CODE_FUNCTION, HTTP_METHOD, HTTP_ROUTE, HTTP_STATUS_CODE, HTTP_URL } from '@sentry/conventions/attributes'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +const ORIGIN = 'auto.http.orchestrion.remix'; + +const NOOP = (): void => {}; + +// `match.route.id` / `match.params.*` mirror `RemixSemanticAttributes` from the vendored +// `RemixInstrumentation` this integration replaces. +const MATCH_ROUTE_ID = 'match.route.id'; +const MATCH_PARAMS = 'match.params'; + +/** + * The shape orchestrion's transform attaches to a tracing-channel `context` object. Documented here + * rather than imported because orchestrion's runtime doesn't export it. + */ +interface ChannelContext { + // The live `arguments` of the wrapped call. + arguments: unknown[]; + result?: unknown; + error?: unknown; +} + +// `callRouteLoader`/`callRouteAction` receive a single options object as `arguments[0]`. +interface RouteCallParams { + request?: Request; + params?: Record; + routeId?: string; +} + +// The in-flight form-data read, started at span start (before the action consumes the body) so it +// overlaps the action's execution rather than starting after it settles. +interface ActionChannelContext extends ChannelContext { + _sentryFormData?: Promise; +} + +// Minimal shape of a `matchServerRoutes` entry we read. +interface RouteMatch { + route?: { path?: string; id?: string }; +} + +function getRequestAttributes(request: unknown): SpanAttributes { + if (!isObjectLike(request)) { + return {}; + } + const { method, url } = request as Partial; + const attributes: SpanAttributes = {}; + if (typeof method === 'string') { + // oxlint-disable-next-line typescript/no-deprecated + attributes[HTTP_METHOD] = method; + } + if (typeof url === 'string') { + // oxlint-disable-next-line typescript/no-deprecated + attributes[HTTP_URL] = url; + } + return attributes; +} + +function getMatchAttributes(params: RouteCallParams): SpanAttributes { + const attributes: SpanAttributes = {}; + if (params.routeId) { + attributes[MATCH_ROUTE_ID] = params.routeId; + } + for (const [name, value] of Object.entries(params.params ?? {})) { + attributes[`${MATCH_PARAMS}.${name}`] = value || '(undefined)'; + } + return attributes; +} + +// The route handlers return a `Response` (or, with single-fetch, a naked object without `status`). +function setResponseStatus(span: Span, result: unknown): void { + if (!isObjectLike(result)) { + return; + } + const status = (result as { status?: unknown }).status; + if (typeof status === 'number') { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_STATUS_CODE, status); + } +} + +/** + * `matchServerRoutes` opens no span of its own; it enriches the enclosing request span with the + * matched route (used to derive the `http.server` transaction name), mirroring the vendored + * instrumentation's patch. + */ +function enrichActiveSpanWithRoute(result: unknown): void { + const span = getActiveSpan(); + if (!span) { + return; + } + + const matches = Array.isArray(result) ? (result as RouteMatch[]) : []; + const route = matches[matches.length - 1]?.route; + + if (route?.path) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_ROUTE, route.path); + span.updateName(`remix.request ${route.path}`); + } + if (route?.id) { + span.setAttribute(MATCH_ROUTE_ID, route.id); + } +} + +function subscribeRequestHandler(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_REQUEST_HANDLER), + data => + startInactiveSpan({ + name: 'remix.request', + kind: SPAN_KIND.SERVER, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [CODE_FUNCTION]: 'requestHandler', + ...getRequestAttributes(data.arguments[0]), + }, + }), + { + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + }, + ); +} + +function subscribeMatchServerRoutes(): void { + // `matchServerRoutes` is synchronous, so only the `end` event carries a result; the rest are + // no-ops. `subscribe` types demand a handler for each channel. + diagnosticsChannel.tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).subscribe({ + start: NOOP, + end(data) { + enrichActiveSpanWithRoute(data.result); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function subscribeCallRouteLoader(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER), + data => { + const params = (data.arguments[0] ?? {}) as RouteCallParams; + return startInactiveSpan({ + name: `LOADER ${params.routeId}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'loader.remix', + [CODE_FUNCTION]: 'loader', + ...getRequestAttributes(params.request), + ...getMatchAttributes(params), + }, + }); + }, + { + requiresParentSpan: true, + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + }, + ); +} + +function subscribeCallRouteAction(actionFormDataAttributes: Record | undefined): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION), + data => { + const params = (data.arguments[0] ?? {}) as RouteCallParams; + // Start reading the form data now (from a clone taken before the action consumes the body), so + // it overlaps the action's execution. Unlike the patched instrumentation, a channel can't + // delay the action promise, so reading only after it settles would race the parent + // `requestHandler` span flushing the transaction. Reading here means the promise is (virtually + // always) already resolved by `asyncEnd`, so ending the span costs a single microtask. + if (actionFormDataAttributes && params.request) { + const formData = params.request.clone().formData(); + // Attach a handler so an unconsumed rejection (e.g. the action errored) isn't unhandled. + formData.catch(() => undefined); + data._sentryFormData = formData; + } + return startInactiveSpan({ + name: `ACTION ${params.routeId}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'action.remix', + [CODE_FUNCTION]: 'action', + ...getRequestAttributes(params.request), + ...getMatchAttributes(params), + }, + }); + }, + { + requiresParentSpan: true, + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + // Hold the span end until the (already in-flight) form-data read resolves, then apply the + // attributes and end (which sets the response status via `beforeSpanEnd`). On error, or when + // capture isn't configured, let the helper end the span normally. + deferSpanEnd: ({ span, data, end }) => { + const formData = data._sentryFormData; + if (!actionFormDataAttributes || !formData || 'error' in data) { + return false; + } + + formData + .then(resolved => applyFormDataAttributes(span, resolved, actionFormDataAttributes)) + // Silently continue on any error. Typically happens because the action body cannot be + // processed into FormData, in which case we should just continue. + .catch(() => undefined) + .finally(() => end()); + + return true; + }, + }, + ); +} + +function applyFormDataAttributes( + span: Span, + formData: FormData, + actionFormDataAttributes: Record, +): void { + formData.forEach((value, key) => { + const mapped = actionFormDataAttributes[key]; + if (mapped && typeof value === 'string') { + const keyName = mapped === true ? key : mapped; + span.setAttribute(`formData.${keyName}`, value); + } + }); +} + +export function instrumentRemix(actionFormDataAttributes: Record | undefined): void { + // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + subscribeRequestHandler(); + subscribeMatchServerRoutes(); + subscribeCallRouteLoader(); + // Always instrument actions; `actionFormDataAttributes` only gates the optional form-data + // attribute extraction, not whether ACTION spans are created. + subscribeCallRouteAction(actionFormDataAttributes); + }); +} diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index f191a336cfbb..d20727caa0a8 100644 --- a/packages/remix/src/server/sdk.ts +++ b/packages/remix/src/server/sdk.ts @@ -6,7 +6,7 @@ import { DEBUG_BUILD } from '../utils/debug-build'; import type { RemixOptions } from '../utils/remixOptions'; import { instrumentServer } from './instrumentServer'; import { httpIntegration } from './integrations/http'; -import { remixIntegration } from './integrations/opentelemetry'; +import { remixIntegration } from './integrations/RemixIntegration'; /** * Returns the default Remix integrations. diff --git a/packages/remix/test/server/errors.test.ts b/packages/remix/test/server/errors.test.ts index 4f58e280c3ee..6288d4df8238 100644 --- a/packages/remix/test/server/errors.test.ts +++ b/packages/remix/test/server/errors.test.ts @@ -16,7 +16,7 @@ function createMockClient(httpBodies: string[] = []): Client { cookies: true, httpHeaders: { request: true, response: true }, httpBodies, - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, diff --git a/packages/remix/test/server/remix-integration-otel.test.ts b/packages/remix/test/server/remix-integration-otel.test.ts new file mode 100644 index 000000000000..c2ef63d0a678 --- /dev/null +++ b/packages/remix/test/server/remix-integration-otel.test.ts @@ -0,0 +1,59 @@ +import * as SentryCore from '@sentry/core'; +import type { NodeClient } from '@sentry/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Force the OpenTelemetry branch of `remixIntegration`. `isOrchestrionInjected` is the only export +// the loaded graph needs (the tracing-channel module is mocked below), so a minimal mock suffices. +vi.mock('@sentry/server-utils/orchestrion', () => ({ + isOrchestrionInjected: () => false, +})); + +// Replace both instrument helpers so we can assert the call shape without OTel/channel side effects. +vi.mock('../../src/server/integrations/opentelemetry', () => ({ + instrumentRemixWithOpenTelemetry: vi.fn(), + addRemixSpanAttributes: vi.fn(), +})); +vi.mock('../../src/server/integrations/tracing-channel', () => ({ + instrumentRemix: vi.fn(), +})); + +import { remixIntegration } from '../../src/server/integrations/RemixIntegration'; +import { instrumentRemixWithOpenTelemetry } from '../../src/server/integrations/opentelemetry'; +import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; + +function mockClient( + captureActionFormDataKeys: Record | undefined, + httpBodies: string[], +): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies }), + } as unknown as NodeClient); +} + +describe('remixIntegration (OpenTelemetry-based)', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('wraps the opted-in form-data keys in the RemixInstrumentation options object', () => { + mockClient({ username: true }, ['incomingRequest']); + + remixIntegration().setupOnce?.(); + + // Must be wrapped as `{ actionFormDataAttributes }` — passing the bare map would leave + // RemixInstrumentation's default `{ _action: 'actionType' }` mapping in place. + expect(instrumentRemixWithOpenTelemetry).toHaveBeenCalledWith({ actionFormDataAttributes: { username: true } }); + expect(instrumentRemix).not.toHaveBeenCalled(); + }); + + it('passes undefined attributes when form-data capture is not opted into', () => { + // `httpBodies` without `incomingRequest` means capture is off, regardless of the configured keys. + mockClient({ username: true }, []); + + remixIntegration().setupOnce?.(); + + expect(instrumentRemixWithOpenTelemetry).toHaveBeenCalledWith({ actionFormDataAttributes: undefined }); + }); +}); diff --git a/packages/remix/test/server/tracing-channel-no-form-data.test.ts b/packages/remix/test/server/tracing-channel-no-form-data.test.ts new file mode 100644 index 000000000000..a8dbd64bfd1f --- /dev/null +++ b/packages/remix/test/server/tracing-channel-no-form-data.test.ts @@ -0,0 +1,68 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { + makeRequest, + makeSpan, + setupRemixInstrumentation, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +// Runs in its own file so the channel subscriptions register with NO form-data capture configured - +// the default for most apps. `captureActionFormDataKeys` gates only the optional attribute +// extraction, so ACTION spans must still be created. +describe('remixIntegration with orchestrion (no form-data capture configured)', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + setupRemixInstrumentation(undefined); + }); + + afterAll(() => { + teardownTestAsyncContextStrategy(); + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + startInactiveSpanSpy.mockRestore(); + getActiveSpanSpy.mockRestore(); + }); + + it('callRouteAction: still builds an ACTION span and sets the response status', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/submit', + request: makeRequest({ method: 'POST', url: 'http://localhost/submit', formEntries: { _action: 'create' } }), + params: {}, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'ACTION routes/submit', + attributes: expect.objectContaining({ + 'sentry.op': 'action.remix', + 'code.function': 'action', + 'http.method': 'POST', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201); + // No form-data capture configured, so no `formData.*` attribute is set. + expect(span.setAttribute).not.toHaveBeenCalledWith('formData.actionType', expect.anything()); + expect(span.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/remix/test/server/tracing-channel-test-utils.ts b/packages/remix/test/server/tracing-channel-test-utils.ts new file mode 100644 index 000000000000..37d409a25841 --- /dev/null +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -0,0 +1,110 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import type { NodeClient } from '@sentry/node'; +import { vi } from 'vitest'; +import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +// `waitForTracingChannelBinding`) when an async-context strategy exposes a +// `getTracingChannelBinding`. Install a minimal one so the channel subscriptions +// actually register in this unit-test context (no SDK `init`). +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +export function teardownTestAsyncContextStrategy(): void { + setAsyncContextStrategy(undefined); + vi.restoreAllMocks(); +} + +export function makeSpan(): Span { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttributes: vi.fn(), + setAttribute: vi.fn(), + updateName: vi.fn(), + } as unknown as Span; +} + +export function makeRequest( + overrides: { method?: string; url?: string; formEntries?: Record } = {}, +): Request { + const { method = 'GET', url = 'http://localhost/test', formEntries } = overrides; + return { + method, + url, + clone: () => ({ + formData: async () => { + const fd = new FormData(); + for (const [key, value] of Object.entries(formEntries ?? {})) { + fd.append(key, value); + } + return fd; + }, + }), + } as unknown as Request; +} + +/** + * Install the async-context strategy, mock the client with the given form-data config, and orchestrion-based remix instrumentation + * so the channel subscriptions register. + * `captureActionFormDataKeys` left undefined mimics an app that hasn't opted into form-data capture. + */ +export function setupRemixInstrumentation(captureActionFormDataKeys?: Record): void { + installTestAsyncContextStrategy(); + vi.spyOn(SentryNode, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies: captureActionFormDataKeys ? ['incomingRequest'] : [] }), + } as unknown as NodeClient); + + instrumentRemix(captureActionFormDataKeys); +} diff --git a/packages/remix/test/server/tracing-channel.test.ts b/packages/remix/test/server/tracing-channel.test.ts new file mode 100644 index 000000000000..ae675a15f1a6 --- /dev/null +++ b/packages/remix/test/server/tracing-channel.test.ts @@ -0,0 +1,152 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { + makeRequest, + makeSpan, + setupRemixInstrumentation, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +describe('remixIntegration (Orchestrion-based)', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + // Configure form-data capture so the ACTION span also extracts the mapped keys. + setupRemixInstrumentation({ _action: 'actionType' }); + }); + + afterAll(() => { + teardownTestAsyncContextStrategy(); + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + // A truthy active span by default, so the `requiresParentSpan` gate passes. + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + startInactiveSpanSpy.mockRestore(); + getActiveSpanSpy.mockRestore(); + }); + + it('requestHandler: builds the http.server span and sets the response status', async () => { + const ctx = { arguments: [makeRequest({ method: 'GET', url: 'http://localhost/users' })] }; + + await tracingChannel(remixChannels.REMIX_REQUEST_HANDLER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'remix.request', + kind: SentryCore.SPAN_KIND.SERVER, + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.orchestrion.remix', + 'sentry.op': 'http.server', + 'code.function': 'requestHandler', + 'http.method': 'GET', + 'http.url': 'http://localhost/users', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 200); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('matchServerRoutes: enriches the active request span with the matched route', () => { + getActiveSpanSpy.mockReturnValue(span); + const ctx = { + arguments: [[], '/users/123'], + result: [{ route: { path: 'users/:userId', id: 'routes/users.$userId' } }], + }; + + tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + + expect(span.setAttribute).toHaveBeenCalledWith('http.route', 'users/:userId'); + expect(span.setAttribute).toHaveBeenCalledWith('match.route.id', 'routes/users.$userId'); + expect(span.updateName).toHaveBeenCalledWith('remix.request users/:userId'); + }); + + it('matchServerRoutes: does nothing when there is no active span', () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx = { arguments: [], result: [{ route: { path: 'users/:userId', id: 'x' } }] }; + + tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('callRouteLoader: builds a LOADER span with request + match attributes', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/users.$userId', + request: makeRequest({ method: 'GET', url: 'http://localhost/users/123' }), + params: { userId: '123' }, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'LOADER routes/users.$userId', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.orchestrion.remix', + 'sentry.op': 'loader.remix', + 'code.function': 'loader', + 'http.method': 'GET', + 'http.url': 'http://localhost/users/123', + 'match.route.id': 'routes/users.$userId', + 'match.params.userId': '123', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 200); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('callRouteLoader: does not create a span without an active parent span', async () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx = { arguments: [{ routeId: 'x', request: makeRequest(), params: {} }] }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + + it('callRouteAction: builds an ACTION span and captures the configured form-data keys', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/submit', + request: makeRequest({ method: 'POST', url: 'http://localhost/submit', formEntries: { _action: 'create' } }), + params: {}, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'ACTION routes/submit', + attributes: expect.objectContaining({ + 'sentry.op': 'action.remix', + 'code.function': 'action', + 'http.method': 'POST', + }), + }), + ); + // The span ends only after the async form-data read resolves. + await vi.waitFor(() => expect(span.end).toHaveBeenCalledTimes(1)); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201); + expect(span.setAttribute).toHaveBeenCalledWith('formData.actionType', 'create'); + }); +}); diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 44efb1f6f9c9..ed5c062f21c7 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -298,13 +298,25 @@ export class Replay implements Integration { /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown + * + * @param options.flush - Whether to flush the pending replay segment when stopping. + * When recording in `session` mode, `stop()` flushes the buffered-but-unsent segment by + * default (`flush: true`), matching the previous behavior. Pass `flush: false` to stop + * recording without sending that pending segment — useful when a user withdraws consent + * and no further data should leave the browser. + * + * Note: `flush: false` only prevents the *pending* (final) segment from being sent. It does + * **not** retract segments that were already sent earlier during `session` recording. */ - public stop(): Promise { + public stop(options?: { flush?: boolean }): Promise { if (!this._replay) { return Promise.resolve(); } - return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session', reason: 'manual' }); + return this._replay.stop({ + forceFlush: options?.flush ?? this._replay.recordingMode === 'session', + reason: 'manual', + }); } /** diff --git a/packages/replay-internal/test/integration/stop.test.ts b/packages/replay-internal/test/integration/stop.test.ts index 43c08857a5fd..5f3058b64c5f 100644 --- a/packages/replay-internal/test/integration/stop.test.ts +++ b/packages/replay-internal/test/integration/stop.test.ts @@ -153,6 +153,45 @@ describe('Integration | stop', () => { }); }); + it('does not flush the pending segment when stopped with `{ flush: false }` in session mode', async function () { + // Default mock SDK records in `session` mode (replaysSessionSampleRate: 1.0) + expect(replay.recordingMode).toBe('session'); + + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); + addEvent(replay, TEST_EVENT, true); + expect(replay.eventBuffer?.hasEvents).toBe(true); + expect(mockRunFlush).toHaveBeenCalledTimes(0); + + // stop without force-flushing the pending segment (e.g. on consent withdrawal) + await integration.stop({ flush: false }); + + // the buffered segment must not have been flushed/sent + expect(mockRunFlush).toHaveBeenCalledTimes(0); + expect(replay).not.toHaveLastSentReplay(); + + // recording is still torn down as usual + expect(replay.eventBuffer).toBe(null); + expect(replay.session).toEqual(undefined); + }); + + it('flushes the pending segment when stopped with `{ flush: true }` in session mode', async function () { + expect(replay.recordingMode).toBe('session'); + + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); + addEvent(replay, TEST_EVENT, true); + expect(replay.eventBuffer?.hasEvents).toBe(true); + expect(mockRunFlush).toHaveBeenCalledTimes(0); + + // explicitly force-flush the pending segment + await integration.stop({ flush: true }); + + expect(mockRunFlush).toHaveBeenCalledTimes(1); + expect(replay.eventBuffer).toBe(null); + expect(replay).toHaveLastSentReplay({ + recordingData: JSON.stringify([TEST_EVENT]), + }); + }); + it('does not call core SDK `addClickKeypressInstrumentationHandler` after initial setup', async function () { // NOTE: We clear mockAddDomInstrumentationHandler after every test await integration.stop(); diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index b9cc17f4f7db..278b74fa803e 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -116,8 +116,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", - "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.66.0" diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 0b6412cee679..f1b3a19655a7 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -44,6 +44,14 @@ export default [ exports: 'named', // set preserveModules to true because we don't want to bundle everything into one file. preserveModules: true, + // `@apm-js-collab/code-transformer-bundler-plugins` ships CJS entries as bare + // `module.exports = fn` with no `__esModule`/`.default`. The repo default + // `interop: 'esModule'` assumes ESM-shaped externals and would dereference a nonexistent + // `.default`, so a default import compiles to `codeTransformer.default(...)` → "not a + // function". Use 'auto' for just these so Rollup emits its interop helper. Scoped here (not + // repo-wide) because 'auto' also turns `import * as x` into a copy, which breaks in-place + // monkey-patching that other packages (e.g. the OTel fs instrumentation) depend on. + interop: id => (id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ? 'auto' : 'esModule'), }, }, }), diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index c7a1784f3e94..7030a0cca557 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -5,7 +5,15 @@ */ export { graphqlIntegration } from './graphql'; -export { mongooseIntegration } from './mongoose'; +export { mongooseIntegration, startMongooseLegacySpan } from './mongoose'; +export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose'; +export { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from './mongodb/mongodb-span'; +export type { MongodbNamespace, MongoV3Topology } from './mongodb/mongodb-span'; export { mysql2Integration } from './mysql2'; export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; @@ -20,7 +28,7 @@ export type { TracingChannelPayloadWithSpan, } from './tracing-channel'; export { vercelAiIntegration } from './vercel-ai'; - +export type { InstrumentationConfig } from './orchestrion'; export { fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts new file mode 100644 index 000000000000..bf290fc443b7 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts @@ -0,0 +1,59 @@ +/* + * AWS SDK for JavaScript + * Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * This product includes software developed at + * Amazon Web Services, Inc. (http://aws.amazon.com/). + */ + +/* + These are slightly modified and simplified versions of the actual SQS/SNS types included + in the official distribution: + https://github.com/aws/aws-sdk-js/blob/master/clients/sqs.d.ts + These are brought here to avoid having users install the `aws-sdk` whenever they + require this instrumentation. +*/ + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface Blob {} +type Binary = Buffer | Uint8Array | Blob | string; + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SNS { + interface MessageAttributeValue { + DataType: string; + StringValue?: string; + BinaryValue?: Binary; + } + + export type MessageAttributeMap = { [key: string]: MessageAttributeValue }; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SQS { + type StringList = string[]; + type BinaryList = Binary[]; + interface MessageAttributeValue { + StringValue?: string; + BinaryValue?: Binary; + StringListValues?: StringList; + BinaryListValues?: BinaryList; + DataType: string; + } + + export type MessageBodyAttributeMap = { + [key: string]: MessageAttributeValue; + }; + + type MessageSystemAttributeMap = { [key: string]: string }; + + export interface Message { + MessageId?: string; + ReceiptHandle?: string; + MD5OfBody?: string; + Body?: string; + Attributes?: MessageSystemAttributeMap; + MD5OfMessageAttributes?: string; + MessageAttributes?: MessageBodyAttributeMap; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts new file mode 100644 index 000000000000..1f6fc9b28031 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -0,0 +1,22 @@ +/** + * AWS-specific span constants used by the aws-sdk channel integration that are NOT covered by + * `@sentry/conventions/attributes` (attribute names that exist there are imported from there + * directly). These are either Sentry-specific (the span origin), attribute *values* (not keys), or + * obsolete OTel conventions with no `@sentry/conventions` export. + */ + +/** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */ +export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk'; + +/** DynamoDB `db.system` value (an attribute value, not a key, so not covered by conventions). */ +export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; + +// Messaging (obsolete OTel convention with no `@sentry/conventions` export, kept for parity) +// TODO(v11): import from `@sentry/conventions` once a release including it ships (added in +// getsentry/sentry-conventions#509), and drop this local constant. +export const ATTR_MESSAGING_DESTINATION_KIND = 'messaging.destination_kind'; +export const MESSAGING_DESTINATION_KIND_VALUE_TOPIC = 'topic'; + +// Bedrock (gen_ai) attribute values (not keys, so not covered by conventions) +export const GEN_AI_OPERATION_NAME_VALUE_CHAT = 'chat'; +export const GEN_AI_SYSTEM_VALUE_AWS_BEDROCK = 'aws.bedrock'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts new file mode 100644 index 000000000000..4b3d85700653 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -0,0 +1,272 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { + _AWS_REQUEST_ID as AWS_REQUEST_ID, + AWS_REQUEST_EXTENDED_ID, + CLOUD_REGION, + HTTP_STATUS_CODE, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import type { TracingChannelLifeCycleOptions } from '../../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { AWS_SDK_ORIGIN } from './constants'; +import { ServicesExtensions } from './services'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; +import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; + +// Same name as the OTel `Aws` integration by design, so enabling injection swaps this in for it. +const INTEGRATION_NAME = 'Aws' as const; + +// The context orchestrion's transform attaches to the channel: `arguments` is the live args of the +// wrapped `Client.prototype.send` call (`[command, ...]`), `self` the client, `result`/`error` the +// settled value. The `_sentry*` fields are stashed by us across the call's lifecycle. +interface AwsSendChannelContext { + arguments: unknown[]; + self?: { config?: AwsClientConfig; constructor?: { name?: string } }; + result?: unknown; + error?: unknown; + _sentryNormalizedRequest?: NormalizedRequest; + _sentryRequestMetadata?: RequestMetadata; + _sentryRegion?: { settled: boolean; promise: Promise }; +} + +interface AwsClientConfig { + serviceId?: string; + region?: () => string | Promise | undefined; +} + +interface AwsV3Command { + input?: Record; + constructor?: { name?: string }; +} + +/** Runs a span-building callback so a throw inside it can never break the user's aws-sdk call. */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error building span', error); + return undefined; + } +} + +// `metadata` is smithy's `ResponseMetadata`, read off the untyped channel result/error (`any` for the +// same reason as `CommandInput`, see types.ts). +function setMetadataAttributes(span: Span, metadata: Record | undefined): void { + if (!metadata) { + return; + } + if (metadata.requestId) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(AWS_REQUEST_ID, metadata.requestId); + } + if (metadata.httpStatusCode) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_STATUS_CODE, metadata.httpStatusCode); + } + if (metadata.extendedRequestId) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(AWS_REQUEST_EXTENDED_ID, metadata.extendedRequestId); + } +} + +const _awsChannelIntegration = (() => { + const servicesExtensions = new ServicesExtensions(); + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + const getSpan = (data: AwsSendChannelContext): Span | undefined => + safe(() => { + const command = data.arguments[0] as AwsV3Command | undefined; + const commandName = command?.constructor?.name; + if (!command || !commandName) { + // Not a recognizable v3 command call; leave the active context untouched. + return undefined; + } + + const clientConfig = data.self?.config; + const serviceName = + clientConfig?.serviceId ?? + // `clientName` isn't available at the `send` boundary; fall back to the client's + // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. + removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); + + // Commands with all-optional members can be constructed without an input (`new + // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. + // The default is assigned back onto the command (not kept detached) because service hooks + // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those + // writes must reach the serialized request. Current smithy clients already default `input` + // to `{}` in the command constructor; this only affects older clients in our range. + if (!command.input) { + command.input = {}; + } + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); + const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + + const span = startInactiveSpan({ + name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, + kind: requestMetadata.spanKind ?? SPAN_KIND.CLIENT, + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; + // service extensions override it where inference yields a different op (DynamoDB: `db`). + op: requestMetadata.spanOp || 'rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, + ...extractAttributesFromNormalizedRequest(normalizedRequest), + ...requestMetadata.spanAttributes, + }, + }); + + data._sentryNormalizedRequest = normalizedRequest; + data._sentryRequestMetadata = requestMetadata; + + // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay + // the traced call the way the OTel middleware does). Backfill it onto the span and the + // normalized request once available; `deferSpanEnd` holds the span open until this settles + // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). + // + // The provider call is guarded separately: the span is already started, so a synchronous + // throw bubbling into the enclosing `safe` would discard it without ending it (a leaked + // open span). + let regionResult: string | Promise | undefined; + try { + regionResult = clientConfig?.region?.(); + } catch { + // Nothing to do; continue without a region. + } + // The `.finally` self-reference is safe: the callback only runs after initialization. + const regionHolder: { settled: boolean; promise: Promise } = { + settled: false, + promise: Promise.resolve(regionResult) + .then(region => { + if (region) { + normalizedRequest.region = region; + span.setAttribute(CLOUD_REGION, region); + } + }) + .catch(() => { + // Nothing to do; continue without a region. + }) + .finally(() => { + regionHolder.settled = true; + }), + }; + data._sentryRegion = regionHolder; + + // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before + // `send` proceeds, so the mutated `commandInput` is used to build the request. + safe(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); + + return span; + }); + + const opts: TracingChannelLifeCycleOptions = { + deferSpanEnd({ span, data, end }) { + const normalizedRequest = data._sentryNormalizedRequest; + const requestMetadata = data._sentryRequestMetadata; + if (!normalizedRequest) { + return false; + } + + const failed = 'error' in data; + + // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). + safe(() => { + if (failed) { + const err = data.error as + | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } + | undefined; + const errMetadata = err?.$metadata; + // Like the OTel path, read RequestId/extendedRequestId off the error itself, with + // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't + // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. + setMetadataAttributes(span, { + requestId: err?.RequestId ?? errMetadata?.requestId, + httpStatusCode: errMetadata?.httpStatusCode, + extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, + }); + return; + } + + const output = data.result as { $metadata?: Record } | undefined; + setMetadataAttributes(span, output?.$metadata); + + const normalizedResponse: NormalizedResponse = { + data: output, + request: normalizedRequest, + requestId: output?.$metadata?.requestId, + }; + servicesExtensions.responseHook(normalizedResponse, span); + }); + + // Streaming responses end the span when their wrapped stream is consumed (see + // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. + if (requestMetadata?.isStream && !failed) { + return true; + } + + // Normally the region settles long before `send` does (the SDK awaits it internally to + // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span + // open until the region backfill lands. The error status was already applied by the + // helper's `error` subscriber, so a plain `end()` suffices. + const region = data._sentryRegion; + if (region && !region.settled) { + void region.promise.then(() => end()); + return true; + } + + return false; + }, + }; + + // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the + // transform injects one channel per package. Only the package hosting the app's client fires, so + // subscribing to all of them is safe and never double-instruments a single call. + const awsSendChannels = [ + CHANNELS.AWS_SMITHY_CORE_SEND, + CHANNELS.AWS_SMITHY_CLIENT_SEND, + CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, + ] as const; + + DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); + + waitForTracingChannelBinding(() => { + for (const channelName of awsSendChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + getSpan, + opts, + ); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven aws-sdk (v3) integration. + * + * Subscribes to the `orchestrion:@smithy/smithy-client:send` (and equivalent) diagnostics_channel + * the orchestrion code transform injects into the AWS SDK's smithy `Client.prototype.send`, emitting + * spans identical to the OTel `@opentelemetry/instrumentation-aws-sdk` integration (with a distinct + * `auto.aws.orchestrion.aws_sdk` origin). Requires the orchestrion runtime hook or bundler plugin — + * wire it up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const awsChannelIntegration = defineIntegration(_awsChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts new file mode 100644 index 000000000000..80258914f788 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts @@ -0,0 +1,69 @@ +import type { SerializedTraceData } from '@sentry/core'; +import { debug, uniq } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import type { SNS, SQS } from '../aws-sdk.types'; + +// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html +export const MAX_MESSAGE_ATTRIBUTES = 10; + +// Sentry trace-propagation headers written into / read from AWS message attributes. +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const BAGGAGE_HEADER = 'baggage'; +const PROPAGATION_FIELDS = [SENTRY_TRACE_HEADER, BAGGAGE_HEADER]; + +export interface AwsSdkContextObject { + [key: string]: { + StringValue?: string; + Value?: string; + }; +} + +/** + * Inject trace-propagation headers (from `getTraceData({ span })`) into an SQS/SNS message-attribute + * map, so the consumer can continue the trace. Respects the SQS 10-attribute quota. Mirrors the OTel + * integration's `injectPropagationContext`, but writes Sentry's `sentry-trace`/`baggage` instead of + * W3C headers. Callers pass the precomputed headers so batch sends serialize them only once. + */ +export function injectPropagationContext( + attributesMap: SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap | undefined, + traceData: SerializedTraceData, +): SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap { + const attributes = attributesMap ?? {}; + const headerKeys = Object.keys(traceData) as (keyof SerializedTraceData)[]; + + if (Object.keys(attributes).length + headerKeys.length <= MAX_MESSAGE_ATTRIBUTES) { + for (const key of headerKeys) { + const value = traceData[key]; + if (value) { + // Index-assigning into the SQS/SNS map union needs one concrete map type; the written value + // shape is valid for both. + (attributes as SQS.MessageBodyAttributeMap)[key] = { DataType: 'String', StringValue: value }; + } + } + } else { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on SQS/SNS message due to maximum amount of MessageAttributes', + ); + } + return attributes; +} + +/** Read the propagation headers back off a received SQS message, if present. */ +export function extractPropagationHeaders( + message: SQS.Message, +): { sentryTrace?: string; baggage?: string } | undefined { + const carrier = (message.MessageAttributes ?? {}) as AwsSdkContextObject; + const sentryTrace = carrier[SENTRY_TRACE_HEADER]?.StringValue ?? carrier[SENTRY_TRACE_HEADER]?.Value; + if (!sentryTrace) { + return undefined; + } + return { + sentryTrace, + baggage: carrier[BAGGAGE_HEADER]?.StringValue ?? carrier[BAGGAGE_HEADER]?.Value, + }; +} + +export function addPropagationFieldsToAttributeNames(messageAttributeNames: string[] = []): string[] { + return uniq([...messageAttributeNames, ...PROPAGATION_FIELDS]); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts new file mode 100644 index 000000000000..071f9005b8f0 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts @@ -0,0 +1,22 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; + +export type { RequestMetadata }; + +export interface ServiceExtension { + // called before the request is sent, and before the span is started + requestPreSpanHook: (request: NormalizedRequest) => RequestMetadata; + + // called before the request is sent, and after the span is started. `span` is the started span, + // used to derive trace-propagation headers injected into outgoing messages. + requestPostSpanHook?: (request: NormalizedRequest, span: Span) => void; + + // Called after the response is received. Unlike the OTel middleware patch, a tracing-channel + // subscriber cannot replace the value the caller's promise resolves with: the injected settle + // handler returns the captured result, not `ctx.result`. It does however publish `asyncEnd` + // synchronously BEFORE the caller's continuations run, and `response.data` is the same object the + // caller receives, so extensions that need to alter the response (e.g. wrap a stream) must mutate + // `response.data` in place; the mutation is guaranteed to be visible to the caller. Same idiom as + // the vercel-ai subscribers' `result.stream` tap. + responseHook?: (response: NormalizedResponse, span: Span) => void; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts new file mode 100644 index 000000000000..36dfcc884892 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -0,0 +1,46 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import { BedrockRuntimeServiceExtension } from './bedrock-runtime'; +import { DynamodbServiceExtension } from './dynamodb'; +import { KinesisServiceExtension } from './kinesis'; +import { LambdaServiceExtension } from './lambda'; +import { S3ServiceExtension } from './s3'; +import { SecretsManagerServiceExtension } from './secretsmanager'; +import type { ServiceExtension } from './ServiceExtension'; +import { SnsServiceExtension } from './sns'; +import { SqsServiceExtension } from './sqs'; +import { StepFunctionsServiceExtension } from './stepfunctions'; + +export class ServicesExtensions implements ServiceExtension { + // Per-service extensions, keyed by the client's `serviceId` (e.g. `'S3'`). Services without a + // registered extension still get the base rpc span from the subscriber. + private _services: Map = new Map([ + ['SecretsManager', new SecretsManagerServiceExtension()], + ['SFN', new StepFunctionsServiceExtension()], + ['SQS', new SqsServiceExtension()], + ['SNS', new SnsServiceExtension()], + ['DynamoDB', new DynamodbServiceExtension()], + ['Lambda', new LambdaServiceExtension()], + ['S3', new S3ServiceExtension()], + ['Kinesis', new KinesisServiceExtension()], + ['BedrockRuntime', new BedrockRuntimeServiceExtension()], + ]); + + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const serviceExtension = this._services.get(request.serviceName); + if (!serviceExtension) { + return {}; + } + return serviceExtension.requestPreSpanHook(request); + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + const serviceExtension = this._services.get(request.serviceName); + serviceExtension?.requestPostSpanHook?.(request, span); + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const serviceExtension = this._services.get(response.request.serviceName); + serviceExtension?.responseHook?.(response, span); + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts new file mode 100644 index 000000000000..94cab0159c18 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts @@ -0,0 +1,494 @@ +import type { Span } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_STOP_SEQUENCES as ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import { GEN_AI_OPERATION_NAME_VALUE_CHAT, GEN_AI_SYSTEM_VALUE_AWS_BEDROCK } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +// Simplified types inlined from @aws-sdk/client-bedrock-runtime +// Only the fields accessed by this instrumentation are included +interface TokenUsage { + inputTokens: number | undefined; + outputTokens: number | undefined; + totalTokens: number | undefined; +} + +interface ConverseStreamOutput { + messageStop?: { stopReason?: string }; + metadata?: { usage?: TokenUsage }; + [key: string]: unknown; +} + +// Streamed `InvokeModel` chunks and response bodies are model-family-specific JSON (titan, nova, +// claude, llama, cohere, mistral); the record helpers probe the shapes defensively, so `any` instead +// of one structural type per family. +type ParsedChunk = any; + +const textDecoder = new TextDecoder(); + +export class BedrockRuntimeServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + switch (request.commandName) { + case 'Converse': + return this._requestPreSpanHookConverse(request, false); + case 'ConverseStream': + return this._requestPreSpanHookConverse(request, true); + case 'InvokeModel': + return this._requestPreSpanHookInvokeModel(request, false); + case 'InvokeModelWithResponseStream': + return this._requestPreSpanHookInvokeModel(request, true); + } + + return {}; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const commandName = response.request.commandName; + + if (!span.isRecording()) { + // Streaming spans are ended by the wrapped stream (the subscriber's `deferSpanEnd` suppresses + // the helper's own `end()`). When the span isn't recording we skip wrapping, so end it here to + // avoid leaking an open span. Non-streaming commands are ended by the helper, so leave them. + if (commandName === 'ConverseStream' || commandName === 'InvokeModelWithResponseStream') { + span.end(); + } + return; + } + + switch (commandName) { + case 'Converse': + return this._responseHookConverse(response, span); + case 'ConverseStream': + return this._responseHookConverseStream(response, span); + case 'InvokeModel': + return this._responseHookInvokeModel(response, span); + case 'InvokeModelWithResponseStream': + return this._responseHookInvokeModelWithResponseStream(response, span); + } + } + + private _requestPreSpanHookConverse(request: NormalizedRequest, isStream: boolean): RequestMetadata { + let spanName = GEN_AI_OPERATION_NAME_VALUE_CHAT; + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + [GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT, + }; + + const modelId = request.commandInput.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + if (spanName) { + spanName += ` ${modelId}`; + } + } + + const inferenceConfig = request.commandInput.inferenceConfig; + if (inferenceConfig) { + const { maxTokens, temperature, topP, stopSequences } = inferenceConfig; + if (maxTokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = maxTokens; + } + if (temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = temperature; + } + if (topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = topP; + } + if (stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = stopSequences; + } + } + + return { + spanName, + isStream, + spanAttributes, + }; + } + + private _requestPreSpanHookInvokeModel(request: NormalizedRequest, isStream: boolean): RequestMetadata { + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + }; + + const modelId = request.commandInput?.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + } + + if (request.commandInput?.body) { + const requestBody = JSON.parse(request.commandInput.body); + if (modelId.includes('amazon.titan')) { + if (requestBody.textGenerationConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.textGenerationConfig.temperature; + } + if (requestBody.textGenerationConfig?.topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.textGenerationConfig.topP; + } + if (requestBody.textGenerationConfig?.maxTokenCount !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.textGenerationConfig.maxTokenCount; + } + if (requestBody.textGenerationConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.textGenerationConfig.stopSequences; + } + } else if (modelId.includes('amazon.nova')) { + if (requestBody.inferenceConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.inferenceConfig.temperature; + } + if (requestBody.inferenceConfig?.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.inferenceConfig.top_p; + } + if (requestBody.inferenceConfig?.max_new_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.inferenceConfig.max_new_tokens; + } + if (requestBody.inferenceConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.inferenceConfig.stopSequences; + } + } else if (modelId.includes('anthropic.claude')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('meta.llama')) { + if (requestBody.max_gen_len !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_gen_len; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + // request for meta llama models does not contain stop_sequences field + } else if (modelId.includes('cohere.command-r')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.message !== undefined) { + // NOTE: We approximate the token count since this value is not directly available in the body. + // According to Bedrock docs they use (total_chars / 6) to approximate token count for pricing. + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.message.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('cohere.command')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('mistral')) { + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop; + } + } + } + + return { + isStream, + spanAttributes, + }; + } + + private _responseHookConverse(response: NormalizedResponse, span: Span): void { + const { stopReason, usage } = response.data; + + setStopReason(span, stopReason); + setUsage(span, usage); + } + + private _responseHookConverseStream(response: NormalizedResponse, span: Span): void { + // Wrap and replace the response stream in place to process events into telemetry + // before yielding to the user. + response.data.stream = wrapConverseStreamResponse(response.data.stream, span); + } + + private _responseHookInvokeModel(response: NormalizedResponse, span: Span): void { + const currentModelId = response.request.commandInput?.modelId; + if (response.data?.body) { + const decodedResponseBody = textDecoder.decode(response.data.body); + const responseBody = JSON.parse(decodedResponseBody); + if (currentModelId.includes('amazon.titan')) { + if (responseBody.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.inputTextTokenCount); + } + if (responseBody.results?.[0]?.tokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.results[0].tokenCount); + } + if (responseBody.results?.[0]?.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.results[0].completionReason]); + } + } else if (currentModelId.includes('amazon.nova')) { + if (responseBody.usage !== undefined) { + if (responseBody.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.inputTokens); + } + if (responseBody.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.outputTokens); + } + } + if (responseBody.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stopReason]); + } + } else if (currentModelId.includes('anthropic.claude')) { + if (responseBody.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.input_tokens); + } + if (responseBody.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.output_tokens); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('meta.llama')) { + if (responseBody.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.prompt_token_count); + } + if (responseBody.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.generation_token_count); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('cohere.command-r')) { + if (responseBody.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.text.length / 6)); + } + if (responseBody.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.finish_reason]); + } + } else if (currentModelId.includes('cohere.command')) { + if (responseBody.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.generations[0].text.length / 6)); + } + if (responseBody.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.generations[0].finish_reason]); + } + } else if (currentModelId.includes('mistral')) { + if (responseBody.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.outputs[0].text.length / 6)); + } + if (responseBody.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.outputs[0].stop_reason]); + } + } + } + } + + private _responseHookInvokeModelWithResponseStream(response: NormalizedResponse, span: Span): void { + const stream = response.data?.body; + const modelId = response.request.commandInput?.modelId; + if (!stream || !modelId) { + return; + } + + // Resolved once: the model family is fixed for the whole stream, and for unrecognized families + // the chunks don't need to be parsed at all. + const recordAttributes = resolveStreamRecorder(modelId); + + // Replace the original response body with our instrumented stream, deferring span.end() until the + // entire stream is consumed. Downstream consumers still receive the full stream. + response.data.body = (async function* () { + try { + for await (const chunk of stream) { + if (recordAttributes) { + const parsedChunk = parseChunk(chunk?.chunk?.bytes); + if (parsedChunk) { + recordAttributes(parsedChunk, span); + } + } + yield chunk; + } + } finally { + span.end(); + } + })(); + } +} + +function resolveStreamRecorder(modelId: string): ((parsedChunk: ParsedChunk, span: Span) => void) | undefined { + if (modelId.includes('amazon.titan')) return recordTitanAttributes; + if (modelId.includes('anthropic.claude')) return recordClaudeAttributes; + if (modelId.includes('amazon.nova')) return recordNovaAttributes; + if (modelId.includes('meta.llama')) return recordLlamaAttributes; + if (modelId.includes('cohere.command-r')) return recordCohereRAttributes; + if (modelId.includes('cohere.command')) return recordCohereAttributes; + if (modelId.includes('mistral')) return recordMistralAttributes; + return undefined; +} + +async function* wrapConverseStreamResponse( + stream: AsyncIterable, + span: Span, +): AsyncGenerator { + try { + let usage: TokenUsage | undefined; + for await (const item of stream) { + setStopReason(span, item.messageStop?.stopReason); + usage = item.metadata?.usage; + yield item; + } + setUsage(span, usage); + } finally { + span.end(); + } +} + +function setStopReason(span: Span, stopReason: string | undefined): void { + if (stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [stopReason]); + } +} + +function setUsage(span: Span, usage: TokenUsage | undefined): void { + if (usage) { + const { inputTokens, outputTokens } = usage; + if (inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, inputTokens); + } + if (outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens); + } + } +} + +function parseChunk(bytes?: Uint8Array): ParsedChunk { + if (!bytes || !(bytes instanceof Uint8Array)) { + return null; + } + try { + const str = Buffer.from(bytes).toString('utf-8'); + return JSON.parse(str); + } catch (err) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] failed to parse streamed bedrock chunk', err); + return null; + } +} + +function recordNovaAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.metadata?.usage !== undefined) { + if (parsedChunk.metadata?.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.metadata.usage.inputTokens); + } + if (parsedChunk.metadata?.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.metadata.usage.outputTokens); + } + } + if (parsedChunk.messageStop?.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.messageStop.stopReason]); + } +} + +function recordClaudeAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.message?.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.message.usage.input_tokens); + } + if (parsedChunk.message?.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.message.usage.output_tokens); + } + if (parsedChunk.delta?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.delta.stop_reason]); + } +} + +function recordTitanAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.inputTextTokenCount); + } + if (parsedChunk.totalOutputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.totalOutputTextTokenCount); + } + if (parsedChunk.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.completionReason]); + } +} + +function recordLlamaAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.prompt_token_count); + } + if (parsedChunk.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.generation_token_count); + } + if (parsedChunk.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.stop_reason]); + } +} + +function recordMistralAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.outputs[0].text.length / 6)); + } + if (parsedChunk.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.outputs[0].stop_reason]); + } +} + +function recordCohereAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.generations[0].text.length / 6)); + } + if (parsedChunk.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.generations[0].finish_reason]); + } +} + +function recordCohereRAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.text.length / 6)); + } + if (parsedChunk.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.finish_reason]); + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts new file mode 100644 index 000000000000..f9276fc20d96 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts @@ -0,0 +1,188 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { + AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS as ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS, + AWS_DYNAMODB_CONSISTENT_READ as ATTR_AWS_DYNAMODB_CONSISTENT_READ, + AWS_DYNAMODB_CONSUMED_CAPACITY as ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + AWS_DYNAMODB_COUNT as ATTR_AWS_DYNAMODB_COUNT, + AWS_DYNAMODB_EXCLUSIVE_START_TABLE as ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE, + AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES as ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES, + AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES as ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES, + AWS_DYNAMODB_INDEX_NAME as ATTR_AWS_DYNAMODB_INDEX_NAME, + AWS_DYNAMODB_ITEM_COLLECTION_METRICS as ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + AWS_DYNAMODB_LIMIT as ATTR_AWS_DYNAMODB_LIMIT, + AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES as ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES, + AWS_DYNAMODB_PROJECTION as ATTR_AWS_DYNAMODB_PROJECTION, + AWS_DYNAMODB_PROVISIONED_READ_CAPACITY as ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY, + AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY as ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY, + AWS_DYNAMODB_SCAN_FORWARD as ATTR_AWS_DYNAMODB_SCAN_FORWARD, + AWS_DYNAMODB_SCANNED_COUNT as ATTR_AWS_DYNAMODB_SCANNED_COUNT, + AWS_DYNAMODB_SEGMENT as ATTR_AWS_DYNAMODB_SEGMENT, + AWS_DYNAMODB_SELECT as ATTR_AWS_DYNAMODB_SELECT, + AWS_DYNAMODB_TABLE_COUNT as ATTR_AWS_DYNAMODB_TABLE_COUNT, + AWS_DYNAMODB_TABLE_NAMES as ATTR_AWS_DYNAMODB_TABLE_NAMES, + AWS_DYNAMODB_TOTAL_SEGMENTS as ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS, + DB_NAME, + DB_OPERATION, + DB_SYSTEM, +} from '@sentry/conventions/attributes'; +import { DB_SYSTEM_VALUE_DYNAMODB } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +function toArray(values: T | T[]): T[] { + return Array.isArray(values) ? values : [values]; +} + +export class DynamodbServiceExtension implements ServiceExtension { + public requestPreSpanHook(normalizedRequest: NormalizedRequest): RequestMetadata { + const operation = normalizedRequest.commandName; + const tableName = normalizedRequest.commandInput?.TableName; + + const spanAttributes: Record = {}; + + /* oxlint-disable typescript/no-deprecated -- old-semconv db.* attributes, matched to the OTel aws-sdk integration */ + spanAttributes[DB_SYSTEM] = DB_SYSTEM_VALUE_DYNAMODB; + spanAttributes[DB_NAME] = tableName; + spanAttributes[DB_OPERATION] = operation; + /* oxlint-enable typescript/no-deprecated */ + + // `RequestItems` is undefined when no table names are returned; its keys are the table names. + if (normalizedRequest.commandInput?.TableName) { + // Necessary for commands with only 1 table name (e.g. CreateTable). Attribute is `TableName`, not keys of `RequestItems`. + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = [normalizedRequest.commandInput.TableName]; + } else if (normalizedRequest.commandInput?.RequestItems) { + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = Object.keys(normalizedRequest.commandInput.RequestItems); + } + + if (operation === 'CreateTable' || operation === 'UpdateTable') { + // only check for ProvisionedThroughput since ReadCapacityUnits and WriteCapacityUnits are required attributes + if (normalizedRequest.commandInput?.ProvisionedThroughput) { + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.ReadCapacityUnits; + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.WriteCapacityUnits; + } + } + + if (operation === 'GetItem' || operation === 'Scan' || operation === 'Query') { + if (normalizedRequest.commandInput?.ConsistentRead) { + spanAttributes[ATTR_AWS_DYNAMODB_CONSISTENT_READ] = normalizedRequest.commandInput.ConsistentRead; + } + } + + if (operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.ProjectionExpression) { + spanAttributes[ATTR_AWS_DYNAMODB_PROJECTION] = normalizedRequest.commandInput.ProjectionExpression; + } + } + + if (operation === 'CreateTable') { + if (normalizedRequest.commandInput?.GlobalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexes, + ).map((x: unknown) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.LocalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.LocalSecondaryIndexes, + ).map((x: unknown) => JSON.stringify(x)); + } + } + + if (operation === 'ListTables' || operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.Limit) { + spanAttributes[ATTR_AWS_DYNAMODB_LIMIT] = normalizedRequest.commandInput.Limit; + } + } + + if (operation === 'ListTables') { + if (normalizedRequest.commandInput?.ExclusiveStartTableName) { + spanAttributes[ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE] = + normalizedRequest.commandInput.ExclusiveStartTableName; + } + } + + if (operation === 'Query') { + if (normalizedRequest.commandInput?.ScanIndexForward) { + spanAttributes[ATTR_AWS_DYNAMODB_SCAN_FORWARD] = normalizedRequest.commandInput.ScanIndexForward; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'Scan') { + if (normalizedRequest.commandInput?.Segment) { + spanAttributes[ATTR_AWS_DYNAMODB_SEGMENT] = normalizedRequest.commandInput?.Segment; + } + + if (normalizedRequest.commandInput?.TotalSegments) { + spanAttributes[ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS] = normalizedRequest.commandInput?.TotalSegments; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'UpdateTable') { + if (normalizedRequest.commandInput?.AttributeDefinitions) { + spanAttributes[ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS] = toArray( + normalizedRequest.commandInput.AttributeDefinitions, + ).map((x: unknown) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.GlobalSecondaryIndexUpdates) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexUpdates, + ).map((x: unknown) => JSON.stringify(x)); + } + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + // Matches what the exporter infers from `db.system` for the OTel DynamoDB spans. + spanOp: 'db', + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.data?.ConsumedCapacity) { + span.setAttribute( + ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + toArray(response.data.ConsumedCapacity).map((x: unknown) => JSON.stringify(x)), + ); + } + + if (response.data?.ItemCollectionMetrics) { + span.setAttribute( + ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + toArray(response.data.ItemCollectionMetrics).map((x: unknown) => JSON.stringify(x)), + ); + } + + if (response.data?.TableNames) { + span.setAttribute(ATTR_AWS_DYNAMODB_TABLE_COUNT, response.data?.TableNames.length); + } + + if (response.data?.Count) { + span.setAttribute(ATTR_AWS_DYNAMODB_COUNT, response.data?.Count); + } + + if (response.data?.ScannedCount) { + span.setAttribute(ATTR_AWS_DYNAMODB_SCANNED_COUNT, response.data?.ScannedCount); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts new file mode 100644 index 000000000000..ef438e065534 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts @@ -0,0 +1 @@ +export { ServicesExtensions } from './ServicesExtensions'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts new file mode 100644 index 000000000000..22599882d443 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts @@ -0,0 +1,21 @@ +import { SPAN_KIND } from '@sentry/core'; +import { _AWS_KINESIS_STREAM_NAME as AWS_KINESIS_STREAM_NAME } from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class KinesisServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const streamName = request.commandInput?.StreamName; + const spanAttributes: Record = {}; + + if (streamName) { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv aws.kinesis.stream.name, matched to the OTel aws-sdk integration + spanAttributes[AWS_KINESIS_STREAM_NAME] = streamName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts new file mode 100644 index 000000000000..e1a7cd1fce43 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts @@ -0,0 +1,85 @@ +import type { Span } from '@sentry/core'; +import { debug, getTraceData, SPAN_KIND } from '@sentry/core'; +import { + FAAS_EXECUTION as ATTR_FAAS_EXECUTION, + FAAS_INVOKED_NAME as ATTR_FAAS_INVOKED_NAME, + FAAS_INVOKED_PROVIDER as ATTR_FAAS_INVOKED_PROVIDER, + FAAS_INVOKED_REGION as ATTR_FAAS_INVOKED_REGION, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +const INVOKE_COMMAND = 'Invoke'; + +export class LambdaServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const functionName = request.commandInput?.FunctionName; + + const spanAttributes: Record = {}; + let spanName: string | undefined; + + if (request.commandName === INVOKE_COMMAND) { + spanAttributes[ATTR_FAAS_INVOKED_NAME] = functionName; + spanAttributes[ATTR_FAAS_INVOKED_PROVIDER] = 'aws'; + spanName = `${functionName} ${INVOKE_COMMAND}`; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === INVOKE_COMMAND && request.commandInput) { + request.commandInput.ClientContext = injectLambdaPropagationContext(request.commandInput.ClientContext, span); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.request.commandName === INVOKE_COMMAND) { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv faas.execution, matched to the OTel aws-sdk integration + span.setAttribute(ATTR_FAAS_EXECUTION, response.requestId); + // Region resolves asynchronously after `requestPreSpanHook`, so it's backfilled onto the + // normalized request and read here (same timing as `cloud.region`). + if (response.request.region) { + span.setAttribute(ATTR_FAAS_INVOKED_REGION, response.request.region); + } + } + } +} + +function injectLambdaPropagationContext(clientContext: string | undefined, span: Span): string | undefined { + try { + const propagatedContext = getTraceData({ span }); + + const parsedClientContext = clientContext ? JSON.parse(Buffer.from(clientContext, 'base64').toString('utf8')) : {}; + + const updatedClientContext = { + ...parsedClientContext, + custom: { + ...parsedClientContext.custom, + ...propagatedContext, + }, + }; + + const encodedClientContext = Buffer.from(JSON.stringify(updatedClientContext)).toString('base64'); + + // The length of client context is capped at 3583 bytes of base64 encoded data + // (https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_RequestSyntax) + if (encodedClientContext.length > 3583) { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on lambda invoke parameters due to ClientContext length limitations.', + ); + return clientContext; + } + + return encodedClientContext; + } catch (e) { + DEBUG_BUILD && debug.log('[orchestrion:aws-sdk] failed to set trace propagation on lambda ClientContext', e); + return clientContext; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts new file mode 100644 index 000000000000..24062efe3cd8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts @@ -0,0 +1,20 @@ +import { SPAN_KIND } from '@sentry/core'; +import { AWS_S3_BUCKET } from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class S3ServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const bucketName = request.commandInput?.Bucket; + const spanAttributes: Record = {}; + + if (bucketName) { + spanAttributes[AWS_S3_BUCKET] = bucketName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts new file mode 100644 index 000000000000..b73318ef23b8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts @@ -0,0 +1,27 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { AWS_SECRETSMANAGER_SECRET_ARN as ATTR_AWS_SECRETSMANAGER_SECRET_ARN } from '@sentry/conventions/attributes'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SecretsManagerServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const secretId = request.commandInput?.SecretId; + const spanAttributes: Record = {}; + if (typeof secretId === 'string' && secretId.startsWith('arn:aws:secretsmanager:')) { + spanAttributes[ATTR_AWS_SECRETSMANAGER_SECRET_ARN] = secretId; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const secretArn = response.data?.ARN; + if (secretArn) { + span.setAttribute(ATTR_AWS_SECRETSMANAGER_SECRET_ARN, secretArn); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts new file mode 100644 index 000000000000..ca12a69c54aa --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts @@ -0,0 +1,75 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { getTraceData, SPAN_KIND } from '@sentry/core'; +import { + AWS_SNS_TOPIC_ARN as ATTR_AWS_SNS_TOPIC_ARN, + MESSAGING_DESTINATION as ATTR_MESSAGING_DESTINATION, + MESSAGING_DESTINATION_NAME, + MESSAGING_SYSTEM, +} from '@sentry/conventions/attributes'; +import { ATTR_MESSAGING_DESTINATION_KIND, MESSAGING_DESTINATION_KIND_VALUE_TOPIC } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { injectPropagationContext } from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SnsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName = `SNS ${request.commandName}`; + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws.sns', + }; + + if (request.commandName === 'Publish') { + spanKind = SPAN_KIND.PRODUCER; + + spanAttributes[ATTR_MESSAGING_DESTINATION_KIND] = MESSAGING_DESTINATION_KIND_VALUE_TOPIC; + const { TopicArn, TargetArn, PhoneNumber } = request.commandInput; + const destinationName = extractDestinationName(TopicArn, TargetArn, PhoneNumber); + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv messaging.destination, matched to the OTel aws-sdk integration + spanAttributes[ATTR_MESSAGING_DESTINATION] = destinationName; + spanAttributes[MESSAGING_DESTINATION_NAME] = TopicArn || TargetArn || PhoneNumber || 'unknown'; + + spanName = `${PhoneNumber ? 'phone_number' : destinationName} send`; + } + + const topicArn = request.commandInput?.TopicArn; + if (topicArn) { + spanAttributes[ATTR_AWS_SNS_TOPIC_ARN] = topicArn; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === 'Publish') { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext(origMessageAttributes, getTraceData({ span })); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const topicArn = response.data?.TopicArn; + if (topicArn) { + span.setAttribute(ATTR_AWS_SNS_TOPIC_ARN, topicArn); + } + } +} + +function extractDestinationName(topicArn: string, targetArn: string, phoneNumber: string): string { + if (topicArn || targetArn) { + const arn = topicArn ?? targetArn; + try { + return arn.substring(arn.lastIndexOf(':') + 1); + } catch { + return arn; + } + } else if (phoneNumber) { + return phoneNumber; + } else { + return 'unknown'; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts new file mode 100644 index 000000000000..da1ec52ff013 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts @@ -0,0 +1,149 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { getTraceData, propagationContextFromHeaders, SPAN_KIND } from '@sentry/core'; +import { + MESSAGING_BATCH_MESSAGE_COUNT, + MESSAGING_DESTINATION_NAME, + MESSAGING_MESSAGE_ID, + MESSAGING_OPERATION_TYPE, + MESSAGING_SYSTEM, + URL_FULL, +} from '@sentry/conventions/attributes'; +import type { SQS } from '../aws-sdk.types'; +import type { CommandInput, NormalizedRequest, NormalizedResponse } from '../types'; +import { + addPropagationFieldsToAttributeNames, + extractPropagationHeaders, + injectPropagationContext, +} from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SqsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const queueUrl = extractQueueUrl(request.commandInput); + const queueName = extractQueueNameFromUrl(queueUrl); + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName: string | undefined; + + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws_sqs', + [MESSAGING_DESTINATION_NAME]: queueName, + [URL_FULL]: queueUrl, + }; + + switch (request.commandName) { + case 'ReceiveMessage': + { + spanKind = SPAN_KIND.CONSUMER; + spanName = `${queueName} receive`; + spanAttributes[MESSAGING_OPERATION_TYPE] = 'receive'; + + request.commandInput.MessageAttributeNames = addPropagationFieldsToAttributeNames( + request.commandInput.MessageAttributeNames, + ); + } + break; + + case 'SendMessage': + case 'SendMessageBatch': + spanKind = SPAN_KIND.PRODUCER; + spanName = `${queueName} send`; + break; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + switch (request.commandName) { + case 'SendMessage': + { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext( + origMessageAttributes, + getTraceData({ span }), + ); + } + break; + + case 'SendMessageBatch': + { + const entries = request.commandInput?.Entries; + if (Array.isArray(entries)) { + // Serialized once; the headers are identical for every entry of the batch. + const traceData = getTraceData({ span }); + entries.forEach((messageParams: { MessageAttributes: SQS.MessageBodyAttributeMap }) => { + messageParams.MessageAttributes = injectPropagationContext( + messageParams.MessageAttributes ?? {}, + traceData, + ); + }); + } + } + break; + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + switch (response.request.commandName) { + case 'SendMessage': + span.setAttribute(MESSAGING_MESSAGE_ID, response?.data?.MessageId); + break; + + case 'SendMessageBatch': + break; + + case 'ReceiveMessage': { + const messages: SQS.Message[] = response?.data?.Messages || []; + + span.setAttribute(MESSAGING_BATCH_MESSAGE_COUNT, messages.length); + + for (const message of messages) { + linkReceivedMessageToProducer(span, message); + } + break; + } + } + } +} + +/** + * Link a received SQS message's span back to the producer trace carried in its propagation headers. + * No-op when the message has no (valid) `sentry-trace`/`baggage` attributes. + */ +function linkReceivedMessageToProducer(span: Span, message: SQS.Message): void { + const headers = extractPropagationHeaders(message); + if (!headers) { + return; + } + + const { parentSpanId, traceId, sampled } = propagationContextFromHeaders(headers.sentryTrace, headers.baggage); + if (traceId && parentSpanId) { + span.addLink({ + context: { + traceId, + spanId: parentSpanId, + traceFlags: sampled ? 1 : 0, + }, + attributes: { + [MESSAGING_MESSAGE_ID]: message.MessageId, + }, + }); + } +} + +function extractQueueUrl(commandInput: CommandInput): string { + return commandInput?.QueueUrl; +} + +function extractQueueNameFromUrl(queueUrl: string): string | undefined { + if (!queueUrl) return undefined; + + const segments = queueUrl.split('/'); + if (segments.length === 0) return undefined; + + return segments[segments.length - 1]; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts new file mode 100644 index 000000000000..05afa34d6a39 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts @@ -0,0 +1,28 @@ +import { SPAN_KIND } from '@sentry/core'; +import { + AWS_STEP_FUNCTIONS_ACTIVITY_ARN as ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN, + AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN as ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN, +} from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class StepFunctionsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const stateMachineArn = request.commandInput?.stateMachineArn; + const activityArn = request.commandInput?.activityArn; + const spanAttributes: Record = {}; + + if (stateMachineArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN] = stateMachineArn; + } + + if (activityArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN] = activityArn; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts new file mode 100644 index 000000000000..93d1ebade30d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -0,0 +1,36 @@ +import type { SpanKindValue } from '@sentry/core'; + +// Command inputs are service-specific shapes from hundreds of AWS APIs; typing them would require +// depending on the `@aws-sdk/*` client types. The per-service hooks read fields defensively instead. +export type CommandInput = Record; + +/** + * These are normalized request and response. They organize the relevant data in one interface which + * can be processed in a uniform manner in the per-service hooks. + */ +export interface NormalizedRequest { + serviceName: string; + commandName: string; + commandInput: CommandInput; + region?: string; +} + +export interface NormalizedResponse { + // The command output, shaped per service/command (see `CommandInput` on why this isn't typed). + data: any; + request: NormalizedRequest; + requestId?: string; +} + +/** Span metadata a per-service extension returns for the subscriber to build the span from. */ +export interface RequestMetadata { + // If true, then the response is a stream so the subscriber must not end the span when `send` settles. + // The service extension ends the span itself, generally by wrapping the stream and ending after it is + // consumed. + isStream?: boolean; + spanAttributes?: Record; + spanKind?: SpanKindValue; + spanName?: string; + // Overrides the default `rpc` span op (e.g. `db` for DynamoDB). + spanOp?: string; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts new file mode 100644 index 000000000000..2186350d5aed --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -0,0 +1,31 @@ +import { CLOUD_REGION, RPC_METHOD, RPC_SERVICE, RPC_SYSTEM } from '@sentry/conventions/attributes'; +import type { CommandInput, NormalizedRequest } from './types'; + +export function removeSuffixFromStringIfExists(str: string, suffixToRemove: string): string { + const suffixLength = suffixToRemove.length; + return str?.slice(-suffixLength) === suffixToRemove ? str.slice(0, -suffixLength) : str; +} + +export function normalizeV3Request( + serviceName: string, + commandNameWithSuffix: string, + commandInput: CommandInput, + region: string | undefined, +): NormalizedRequest { + return { + serviceName: serviceName?.replace(/\s+/g, ''), + commandName: removeSuffixFromStringIfExists(commandNameWithSuffix, 'Command'), + commandInput, + region, + }; +} + +export function extractAttributesFromNormalizedRequest(normalizedRequest: NormalizedRequest): Record { + return { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv rpc.system, matched to the OTel aws-sdk integration + [RPC_SYSTEM]: 'aws-api', + [RPC_METHOD]: normalizedRequest.commandName, + [RPC_SERVICE]: normalizedRequest.serviceName, + [CLOUD_REGION]: normalizedRequest.region, + }; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts index d3cfe0dc13ec..19db7a815d14 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts @@ -60,12 +60,7 @@ export function instrumentExpress( // matched route can be reconstructed with its parameters intact at request // time. Only the `end` event matters (the layer is on the router's stack by // then); the others are required by the subscriber type, so no-op them. - for (const channelName of [ - CHANNELS.EXPRESS_ROUTE, - CHANNELS.EXPRESS_USE, - CHANNELS.ROUTER_ROUTE, - CHANNELS.ROUTER_USE, - ]) { + for (const channelName of [CHANNELS.EXPRESS_REGISTER, CHANNELS.ROUTER_REGISTER]) { tracingChannel(channelName).subscribe({ start: NOOP, asyncStart: NOOP, diff --git a/packages/server-utils/src/integrations/tracing-channel/koa.ts b/packages/server-utils/src/integrations/tracing-channel/koa.ts new file mode 100644 index 000000000000..982faee9765e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/koa.ts @@ -0,0 +1,262 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + getDefaultIsolationScope, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startSpan, +} from '@sentry/core'; +// oxlint-disable-next-line typescript/no-deprecated +import { CODE_FUNCTION_NAME, HTTP_ROUTE, KOA_NAME, KOA_TYPE } from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// Same name as the OTel integration. When enabled, the OTel 'Koa' integration is omitted from the default set. +const INTEGRATION_NAME = 'Koa' as const; + +const ORIGIN = 'auto.http.orchestrion.koa'; + +const LAYER_TYPE = { + ROUTER: 'router', + MIDDLEWARE: 'middleware', +} as const; +type KoaLayerType = (typeof LAYER_TYPE)[keyof typeof LAYER_TYPE]; + +// Keeps wrapping idempotent — the same middleware instance can be registered on +// multiple routes (mirrors the vendored OTel instrumentation's symbol). +const kLayerPatched: unique symbol = Symbol('sentry.koa.layer-patched'); + +// Core dedupes `setupOnce` by integration name, but the Deno SDK also runs this +// under the name `DenoKoa` (via `extendIntegration`), so guard against a second +// subscription here. +let subscribed = false; + +type Next = () => Promise; + +interface KoaContext { + [key: string]: unknown; + _matchedRoute?: string | RegExp; + _matchedRouteName?: string; + request?: { method?: string }; +} + +interface Layer { + path: string | RegExp; + stack: KoaMiddleware[]; +} + +interface Router { + stack: Layer[]; +} + +type KoaMiddleware = ((context: KoaContext, next: Next) => unknown) & { + router?: Router; + [kLayerPatched]?: boolean; +}; + +type KoaSpanAttributes = Record; + +interface KoaMiddlewareMetadata { + attributes: KoaSpanAttributes; + name: string; +} + +interface KoaUseContext { + arguments: unknown[]; +} + +export interface KoaChannelIntegrationOptions { + /** Ignore layers of the specified types (`'middleware'` and/or `'router'`). */ + ignoreLayersType?: Array<'middleware' | 'router'>; +} + +const _koaChannelIntegration = ((options: KoaChannelIntegrationOptions = {}) => { + const ignoreLayersType = options.ignoreLayersType ?? []; + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + DEBUG_BUILD && debug.log(`[orchestrion:koa] subscribing to channel "${CHANNELS.KOA_USE}"`); + + diagnosticsChannel.tracingChannel(CHANNELS.KOA_USE).subscribe({ + start(rawCtx) { + handleUse(rawCtx as KoaUseContext, ignoreLayersType); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + }, + }; +}) satisfies IntegrationFn; + +function handleUse(ctx: KoaUseContext, ignoreLayersType: KoaLayerType[]): void { + const middleware = ctx.arguments[0]; + if (typeof middleware === 'function') { + ctx.arguments[0] = patchUse(middleware as KoaMiddleware, ignoreLayersType); + } +} + +/** + * Wrap a registered koa middleware. A `@koa/router` dispatch layer + * (`router.routes()`) exposes a `.router`, in which case we patch each routed + * middleware in its stack (in place) and leave the dispatch itself unwrapped; + * everything else is a plain middleware. + */ +function patchUse(middleware: KoaMiddleware, ignoreLayersType: KoaLayerType[]): KoaMiddleware { + return middleware.router + ? patchRouterDispatch(middleware, ignoreLayersType) + : patchLayer(middleware, false, ignoreLayersType); +} + +/** + * Patches the dispatch function used by `@koa/router`, wrapping each routed + * middleware in the router's stack so routed spans carry their matched path. + */ +function patchRouterDispatch(dispatchLayer: KoaMiddleware, ignoreLayersType: KoaLayerType[]): KoaMiddleware { + const router = dispatchLayer.router; + const routesStack = router?.stack ?? []; + for (const pathLayer of routesStack) { + const path = pathLayer.path; + const pathStack = pathLayer.stack; + pathStack.forEach((routedMiddleware, j) => { + pathStack[j] = patchLayer(routedMiddleware, true, ignoreLayersType, path); + }); + } + return dispatchLayer; +} + +/** + * Wraps an individual middleware layer so it opens a span when invoked. No span + * is created when there is no active (parent) span, matching the vendored OTel + * instrumentation's `api.trace.getSpan(api.context.active())` guard. + */ +function patchLayer( + middlewareLayer: KoaMiddleware, + isRouter: boolean, + ignoreLayersType: KoaLayerType[], + layerPath?: string | RegExp, +): KoaMiddleware { + const layerType = isRouter ? LAYER_TYPE.ROUTER : LAYER_TYPE.MIDDLEWARE; + // Skip patching the layer if it's ignored by config or already wrapped. + if (middlewareLayer[kLayerPatched] === true || ignoreLayersType.includes(layerType)) { + return middlewareLayer; + } + + if ( + middlewareLayer.constructor.name === 'GeneratorFunction' || + middlewareLayer.constructor.name === 'AsyncGeneratorFunction' + ) { + return middlewareLayer; + } + + middlewareLayer[kLayerPatched] = true; + + return (context: KoaContext, next: Next) => { + if (!getActiveSpan()) { + return middlewareLayer(context, next); + } + const metadata = getMiddlewareMetadata(context, middlewareLayer, isRouter, layerPath); + + if (context._matchedRoute) { + setHttpServerSpanRouteAttribute(context._matchedRoute.toString()); + } + + // oxlint-disable-next-line typescript/no-deprecated + const koaName = metadata.attributes[KOA_NAME]; + // Somehow, name is sometimes `''` for middleware spans. + // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 + const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name; + + return startSpan( + { + name, + op: `${layerType}.koa`, + attributes: { + ...metadata.attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + () => { + const route = metadata.attributes[HTTP_ROUTE]; + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + } else if (route) { + const method = context.request?.method?.toUpperCase() || 'GET'; + getIsolationScope().setTransactionName(`${method} ${route}`); + } + return middlewareLayer(context, next); + }, + ); + }; +} + +function getMiddlewareMetadata( + context: KoaContext, + layer: KoaMiddleware, + isRouter: boolean, + layerPath?: string | RegExp, +): KoaMiddlewareMetadata { + if (isRouter) { + return { + attributes: { + // oxlint-disable-next-line typescript/no-deprecated + [KOA_NAME]: layerPath?.toString(), // TODO(v11): remove, replaced by http.route + [KOA_TYPE]: LAYER_TYPE.ROUTER, + [HTTP_ROUTE]: layerPath?.toString(), + }, + name: context._matchedRouteName || `router - ${layerPath}`, + }; + } + return { + attributes: { + // oxlint-disable-next-line typescript/no-deprecated + [KOA_NAME]: layer.name || 'middleware', // TODO(v11): remove, replaced by code.function.name + [KOA_TYPE]: LAYER_TYPE.MIDDLEWARE, + [CODE_FUNCTION_NAME]: layer.name || 'middleware', + }, + name: `middleware - ${layer.name}`, + }; +} + +/** + * Set the `http.route` attribute on the root HTTP server span for the current trace. + * + * No-op when there is no active span, no root span, or the root span is not an + * `http.server` span — so this can be called unconditionally without risking + * attribute pollution on non-HTTP root spans. + */ +function setHttpServerSpanRouteAttribute(route: string): void { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) { + return; + } + if (spanToJSON(rootSpan).data[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'http.server') { + return; + } + rootSpan.setAttribute(HTTP_ROUTE, route); +} + +/** + * EXPERIMENTAL — orchestrion-driven koa integration. Subscribes to the + * `orchestrion:koa:use` channel injected into `Application.prototype.use` and + * wraps each registered middleware/router layer in a span-creating proxy. + * Requires the orchestrion runtime hook or bundler plugin. + */ +export const koaChannelIntegration = defineIntegration(_koaChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/langchain.ts b/packages/server-utils/src/integrations/tracing-channel/langchain.ts new file mode 100644 index 000000000000..008d52638241 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/langchain.ts @@ -0,0 +1,121 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, LangChainOptions, Span } from '@sentry/core'; +import { + _INTERNAL_getLangChainEmbeddingsSpanOptions, + _INTERNAL_mergeLangChainCallbackHandler, + _INTERNAL_skipAiProviderWrapping, + ANTHROPIC_AI_INTEGRATION_NAME, + createLangChainCallbackHandler, + debug, + defineIntegration, + GOOGLE_GENAI_INTEGRATION_NAME, + LANGCHAIN_INTEGRATION_NAME, + OPENAI_INTEGRATION_NAME, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { langchainEmbeddingsChannels } from '../../orchestrion/config/langchain'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// Same name as the OTel integration by design: when enabled, the OTel 'LangChain' integration is +// dropped from the default set (see the Node opt-in loader). +const INTEGRATION_NAME = LANGCHAIN_INTEGRATION_NAME; + +// LangChain drives the underlying AI provider SDKs itself, so while it's active those providers must +// not also instrument, or every call would produce two spans (mirrors the OTel path's skip list). +const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME]; + +// The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. +interface RunnableChannelContext { + arguments: unknown[]; +} + +// The embeddings channels carry the instance (`self`) and the `embedQuery(text)` / `embedDocuments(texts)` args. +interface EmbeddingsChannelContext { + self?: unknown; + arguments: unknown[]; +} + +let subscribed = false; + +// Registered lazily on the first LangChain call (not at `setupOnce`) so a direct provider call made +// before any LangChain call still gets its own span — matches the OTel patch-on-import timing. It +// also stops the underlying SDK from double-instrumenting embeddings, whose `embedQuery`/ +// `embedDocuments` call the provider SDK (e.g. `openai`) internally. +function markProvidersSkipped(): void { + _INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS); +} + +const _langChainChannelIntegration = ((options: LangChainOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + + // One stateful handler tracks spans across the whole run tree, just like the OTel path. + const sentryHandler = createLangChainCallbackHandler(options); + + // Chat models: inject the Sentry callback handler into the call options (arg 1). LangChain's own + // callback dispatch then creates the spans, exactly as in the OTel path, so no span is opened + // here — a `start` subscriber (which also makes orchestrion wrap the function) is enough. + const injectHandler = (message: unknown): void => { + markProvidersSkipped(); + + const args = (message as RunnableChannelContext).arguments; + if (!Array.isArray(args)) { + return; + } + + let callOptions = args[1] as Record | undefined; + if (!callOptions || typeof callOptions !== 'object' || Array.isArray(callOptions)) { + callOptions = {}; + args[1] = callOptions; + } + + callOptions.callbacks = _INTERNAL_mergeLangChainCallbackHandler(callOptions.callbacks, sentryHandler); + }; + + for (const channelName of [CHANNELS.LANGCHAIN_CHAT_MODEL_INVOKE, CHANNELS.LANGCHAIN_CHAT_MODEL_STREAM]) { + DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); + diagnosticsChannel.tracingChannel(channelName).start.subscribe(injectHandler); + } + + // Embeddings don't use the callback system — the OTel path wraps the method in its own span, so + // do the same here. `bindTracingChannelToSpan` needs the async-context binding that + // `initOpenTelemetry()` registers after `setupOnce`, so wait for it before subscribing. + waitForTracingChannelBinding(() => { + for (const channelName of langchainEmbeddingsChannels) { + DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => createEmbeddingsSpan(data, options), + { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, + ); + } + }); + }, + }; +}) satisfies IntegrationFn; + +function createEmbeddingsSpan(data: EmbeddingsChannelContext, options: LangChainOptions): Span { + // `embedQuery`/`embedDocuments` call the provider SDK internally, so skip that SDK's own + // instrumentation before its channel fires (the producer runs at the embeddings channel's `start`). + markProvidersSkipped(); + + const input = (data.arguments ?? [])[0]; + + return startInactiveSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(data.self, input, options)); +} + +/** + * EXPERIMENTAL — orchestrion-driven LangChain integration. Subscribes to the diagnostics_channels + * injected into `@langchain/core`'s `BaseChatModel` (to inject the Sentry callback handler) and into + * `@langchain/openai`'s embedding methods, so it requires the orchestrion runtime hook or bundler plugin. + */ +export const langChainChannelIntegration = defineIntegration(_langChainChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts new file mode 100644 index 000000000000..c5e601c61d9d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts @@ -0,0 +1,163 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { CompiledGraph, IntegrationFn, LangGraphOptions } from '@sentry/core'; +import { + _INTERNAL_getLangGraphCreateAgentSpanOptions, + createLangChainCallbackHandler, + debug, + defineIntegration, + extractAgentNameFromParams, + extractLLMFromParams, + instrumentCompiledGraphInvoke, + LANGGRAPH_INTEGRATION_NAME, + resolveAIRecordingOptions, + startInactiveSpan, + waitForTracingChannelBinding, + wrapToolsWithSpans, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// Same name as the OTel integration by design: when enabled, the OTel 'LangGraph' integration is +// dropped from the default set (see the Node opt-in loader). +const INTEGRATION_NAME = LANGGRAPH_INTEGRATION_NAME; + +interface CompileChannelContext { + arguments: unknown[]; + result?: unknown; +} + +interface CreateReactAgentChannelContext { + arguments: unknown[]; + result?: unknown; +} + +let subscribed = false; + +// `createReactAgent` compiles a `StateGraph` internally; suppress the `create_agent` span for that +// nested compile so a react agent gets a single `invoke_agent` span, matching the OTel path. +let insideCreateReactAgent = false; + +const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); + + // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers + // after `setupOnce` runs, so wait for it before subscribing. + waitForTracingChannelBinding(() => { + // StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`. + DEBUG_BUILD && + debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE}"`); + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE), + data => { + if (insideCreateReactAgent) { + return undefined; + } + const compileOptions = getFirstArgObject(data.arguments); + const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined; + + return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); + }, + { + beforeSpanEnd: (_span, data) => { + wrapCompiledGraphInvoke( + data.result, + getFirstArgObject(data.arguments) ?? {}, + resolvedOptions, + null, + sentryHandler, + ); + }, + }, + ); + + // createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned + // graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`. + DEBUG_BUILD && + debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_CREATE_REACT_AGENT}"`); + const reactAgentChannel = diagnosticsChannel.tracingChannel( + CHANNELS.LANGGRAPH_CREATE_REACT_AGENT, + ); + reactAgentChannel.start.subscribe(message => { + // `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag + // must be on for the duration and off by `end`. It's set here (never in a branch that can + // throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor + // stay off during this call's nested compile. Tool wrapping is guarded for the same reason. + insideCreateReactAgent = true; + try { + const { arguments: args } = message as CreateReactAgentChannelContext; + const params = getFirstArgObject(args); + if (params && Array.isArray(params.tools) && params.tools.length > 0) { + wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined); + } + } catch (error) { + DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error); + } + }); + reactAgentChannel.end.subscribe(message => { + insideCreateReactAgent = false; + const { arguments: args, result } = message as CreateReactAgentChannelContext; + const agentName = extractAgentNameFromParams(args) ?? undefined; + const compileOptions = agentName ? { name: agentName } : {}; + wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler); + }); + // Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on. + reactAgentChannel.error.subscribe(() => { + insideCreateReactAgent = false; + }); + }); + }, + }; +}) satisfies IntegrationFn; + +function getFirstArgObject(args: unknown[] | undefined): Record | undefined { + const first = (args ?? [])[0]; + + return typeof first === 'object' && first !== null ? (first as Record) : undefined; +} + +/** + * Wrap the compiled graph's `invoke` with the shared `invoke_agent` instrumentation, exactly as the + * OTel path does on the returned graph. + */ +function wrapCompiledGraphInvoke( + graph: unknown, + compileOptions: Record, + options: LangGraphOptions, + llm: ReturnType, + sentryHandler: unknown, +): void { + if (!graph || typeof graph !== 'object') { + return; + } + + const compiledGraph = graph as CompiledGraph; + const originalInvoke = compiledGraph.invoke; + if (typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph), + compiledGraph, + compileOptions, + options, + llm, + sentryHandler, + ); + } +} + +/** + * EXPERIMENTAL — orchestrion-driven LangGraph integration. Subscribes to the diagnostics_channels + * injected into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`, so it requires + * the orchestrion runtime hook or bundler plugin. + */ +export const langGraphChannelIntegration = defineIntegration(_langGraphChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/mongodb.ts b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts new file mode 100644 index 000000000000..85899d910792 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts @@ -0,0 +1,183 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { MongodbNamespace, MongoV3Topology } from '../../mongodb/mongodb-span'; +import { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from '../../mongodb/mongodb-span'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +const INTEGRATION_NAME = 'Mongo' as const; + +const ORIGIN = 'auto.db.orchestrion.mongo'; + +/** + * what orchestrion's transform attaches to a channel context: + * `self` is the `this`, plus args. + */ +interface MongoChannelContext { + self?: { address?: string }; + arguments?: unknown[]; +} + +// Details extracted from a v3 wireprotocol call's arguments to build its span. +interface V3CallInfo { + topology: MongoV3Topology | undefined; + ns: string; + command: Record | undefined; + operation: string | undefined; +} + +// Command doc first-keys whose span the `v3_command` channel must suppress. +// The `insert`/`update`/`remove`/`query`/`getMore` functions call the shared +// `command` function internally. Orchestrion transforms the `command` *source* +// (unlike the vendored OTel patch, which wraps the module's exported reference +// and misses internal calls), so `v3_command` would double-span the ones that +// already have their own dedicated channel (`v3_insert`/`update`/`remove`/ +// `query`/`get_more`) without this guard. +// +// `killCursors` has no dedicated channel, but is suppressed too for OTel +// parity: OTel wraps `wireprotocol/index.js`'s `command` property, which never +// intercepts `kill_cursors.js`'s direct `require('./command')` call — so OTel +// emits no killCursors span, and neither should we. +const V3_DEDICATED_COMMANDS = new Set(['insert', 'update', 'delete', 'find', 'getMore', 'killCursors']); + +const _mongodbChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + subscribeV4Command(); + subscribeV4Checkout(); + subscribeV3Wireprotocol(); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * `Connection.prototype.command` (mongodb >=4.0) one span per command. + * Handles both the >=6.4 promise form and the >=4.0 <6.4 callback form + * (both publish to this channel). + */ +function subscribeV4Command(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_COMMAND), + data => { + const args = data.arguments ?? []; + const ns = args[0] as MongodbNamespace | undefined; + const cmd = args[1] as Record | undefined; + // Skip handshake/heartbeat commands (matches otel). + if (!ns || !cmd || typeof cmd !== 'object' || cmd.ismaster || cmd.hello) { + return undefined; + } + const operation = Object.keys(cmd)[0]; + return startMongoSpan(getV4SpanAttributes(data.self, ns, cmd, operation, ORIGIN)); + }, + // Matches otel's `shouldSkipInstrumentation`: only trace when there is + // an active parent span, to avoid emitting orphaned mongodb spans. + { requiresParentSpan: true }, + ); +} + +/** + * `ConnectionPool.prototype.checkOut` (mongodb 4.0 - 6.3, callback form). + * Creates no span (`getSpan` returns `undefined`) but the binding re-runs + * the checkout callback under the caller's captured context, so the pooled + * `command()` invoked inside it re-inherits the active span. + */ +function subscribeV4Checkout(): void { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_CHECKOUT), () => undefined); +} + +/** + * mongodb >=3.3 <4 had no unified `command`; each operation is a separate + * `lib/core/wireprotocol` function with its own argument layout, so each + * channel extracts topology/namespace/command/op differently before building + * the span. + */ +function subscribeV3Wireprotocol(): void { + for (const operation of ['insert', 'update', 'remove'] as const) { + const channel = + operation === 'insert' + ? CHANNELS.MONGODB_V3_INSERT + : operation === 'update' + ? CHANNELS.MONGODB_V3_UPDATE + : CHANNELS.MONGODB_V3_REMOVE; + bindV3(channel, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as Record[] | undefined)?.[0], + operation, + })); + } + + // `command`(server, ns, cmd, options, callback) operation derived from the + // command doc. Skips commands that have a dedicated channel. See set above. + bindV3(CHANNELS.MONGODB_V3_COMMAND, args => { + const command = args[2] as Record | undefined; + const type = command ? Object.keys(command)[0] : undefined; + if (type && V3_DEDICATED_COMMANDS.has(type)) { + return undefined; + } + return { + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command, + operation: command ? getV3CommandOperation(command) : undefined, + }; + }); + + // `query`(server, ns, cmd, cursorState, options, callback). a find. + bindV3(CHANNELS.MONGODB_V3_QUERY, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: args[2] as Record | undefined, + operation: 'find', + })); + + // `getMore`(server, ns, cursorState, batchSize, options, callback) + // command doc is `cursorState.cmd`. + bindV3(CHANNELS.MONGODB_V3_GET_MORE, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as { cmd?: Record } | undefined)?.cmd, + operation: 'getMore', + })); +} + +function bindV3(channelName: string, extract: (args: unknown[]) => V3CallInfo | undefined): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const args = data.arguments; + if (!args) { + return undefined; + } + const info = extract(args); + if (!info || typeof info.ns !== 'string') { + return undefined; + } + return startMongoSpan(getV3SpanAttributes(info.ns, info.topology, info.command, info.operation, ORIGIN)); + }, + { requiresParentSpan: true }, + ); +} + +/** + * EXPERIMENTAL: orchestrion-driven mongodb integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongodb` span shape + * (legacy db/net semantic conventions, `mongodb.` names, scrubbed + * `db.statement`) via the `orchestrion:mongodb:*` diagnostics_channels + * injected by the orchestrion code transform. + */ +export const mongodbChannelIntegration = defineIntegration(_mongodbChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/mongoose.ts b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts new file mode 100644 index 000000000000..4dc77d0224d0 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts @@ -0,0 +1,201 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, waitForTracingChannelBinding } from '@sentry/core'; +import { subscribeMongooseDiagnosticChannels } from '../../mongoose/mongoose-dc-subscriber'; +import type { MongooseLegacyCollection } from '../../mongoose/mongoose-legacy-span'; +import { startMongooseLegacySpan } from '../../mongoose/mongoose-legacy-span'; +import { CHANNELS } from '../../orchestrion/channels'; +import { MONGOOSE_CONTEXT_CAPTURE_CHANNELS } from '../../orchestrion/config/mongoose'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { SentryTracingChannel } from '../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +const INTEGRATION_NAME = 'Mongoose' as const; + +// Origin distinguishes the orchestrion path from the OTel/IITM path +// (`auto.db.otel.mongoose`) and the native diagnostics_channel path +// (`auto.db.mongoose.diagnostic_channel`). +const ORIGIN = 'auto.db.orchestrion.mongoose'; + +interface MongooseQuery { + mongooseCollection?: MongooseLegacyCollection; + model?: { modelName?: string }; + op?: string; +} + +interface MongooseAggregate { + _model?: { collection?: MongooseLegacyCollection; modelName?: string }; +} + +interface MongooseModelStatic { + collection?: MongooseLegacyCollection; + modelName?: string; +} + +interface MongooseDocument { + constructor: { collection?: MongooseLegacyCollection; modelName?: string }; +} + +/** + * The shape orchestrion's transform attaches to the tracing-channel context + * object. `self` is the `this` of the traced method + */ +interface MongooseChannelContext { + self?: object; + arguments?: unknown[]; + result?: unknown; + error?: unknown; + moduleVersion?: string; +} + +// The active span captured when a query/aggregate was *built*, keyed by the +// query/aggregate object. +// +// Used as the parent for the span created at `exec()` so a query parents to +// where it was built, not where it happens to be awaited. A WeakMap avoids +// mutating mongoose's own objects. +const STORED_PARENT_SPAN = new WeakMap(); + +let orchestrionSubscribed = false; + +const _mongooseChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + // mongoose >= 9.7 publishes via its own diagnostics_channel; reuse + // the shared native subscriber so this single integration covers all + // versions after it replaces the OTel one. Idempotent and inert on + // < 9.7 (those channels just never get published to) + subscribeMongooseDiagnosticChannels(diagnosticsChannel.tracingChannel); + + // mongoose < 9.7 is covered by the orchestrion-injected channels. + subscribeOrchestrionMongooseChannels(); + }); + }, + }; +}) satisfies IntegrationFn; + +function subscribeOrchestrionMongooseChannels(): void { + if (orchestrionSubscribed) { + return; + } + orchestrionSubscribed = true; + + DEBUG_BUILD && debug.log('[orchestrion:mongoose] subscribing to injected channels'); + + // Context-capture builders: stash the active span at build time so `exec()` + // can parent to it. + for (const channelName of MONGOOSE_CONTEXT_CAPTURE_CHANNELS) { + channel(channelName).subscribe({ + start(message) { + stashParentSpan(message.self); + }, + }); + } + + // `Model.aggregate()` returns the aggregate it builds; stash the parent on + // the returned object. + channel(CHANNELS.MONGOOSE_MODEL_AGGREGATE).subscribe({ + end(message) { + const result = message.result; + if (result && typeof result === 'object') { + stashParentSpan(result); + } + }, + }); + + // Query execution. + bindExecSpan(CHANNELS.MONGOOSE_QUERY_EXEC, self => { + const query = self as MongooseQuery; + return startSpan( + query.mongooseCollection, + query.model?.modelName, + query.op ?? 'exec', + STORED_PARENT_SPAN.get(self), + ); + }); + + // Aggregation execution. + bindExecSpan(CHANNELS.MONGOOSE_AGGREGATE_EXEC, self => { + const model = (self as MongooseAggregate)._model; + return startSpan(model?.collection, model?.modelName, 'aggregate', STORED_PARENT_SPAN.get(self)); + }); + + // `doc.save()` (and the `$save` alias). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_SAVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'save'); + }); + + // `doc.remove()` (mongoose 5/6). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_REMOVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'remove'); + }); + + // Static batch operations. `self` is the Model. + bindExecSpan(CHANNELS.MONGOOSE_MODEL_INSERT_MANY, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'insertMany'); + }); + bindExecSpan(CHANNELS.MONGOOSE_MODEL_BULK_WRITE, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'bulkWrite'); + }); +} + +function startSpan( + collection: MongooseLegacyCollection | undefined, + modelName: string | undefined, + operation: string, + parentSpan?: Span, +): Span { + return startMongooseLegacySpan({ collection, modelName, operation, origin: ORIGIN, parentSpan }); +} + +// `SentryTracingChannel` relaxes Node's subscriber type to a `Partial`, so a +// `start`-only (or `end`-only) subscriber for the context-capture channels +// is accepted. +function channel(channelName: string): SentryTracingChannel { + return diagnosticsChannel.tracingChannel( + channelName, + ) as unknown as SentryTracingChannel; +} + +function bindExecSpan(channelName: string, getSpan: (self: object) => Span): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const self = data.self; + if (!self) { + return undefined; + } + return getSpan(self); + }, + ); +} + +function stashParentSpan(self: object | undefined): void { + const active = getActiveSpan(); + if (self && active) { + STORED_PARENT_SPAN.set(self, active); + } +} + +/** + * EXPERIMENTAL: orchestrion-driven mongoose integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongoose` span + * shape (legacy db/net semantic conventions, `mongoose..` names, + * build-time span parenting) via the `orchestrion:mongoose:*` + * diagnostics_channels injected into mongoose `< 9.7` by the orchestrion + * code transform. For mongoose `>= 9.7` it also drives the native + * diagnostics_channel subscription, so this single integration covers every + * supported version once it replaces the OTel one. + */ +export const mongooseChannelIntegration = defineIntegration(_mongooseChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index e1498db89e43..b172c6924300 100644 --- a/packages/server-utils/src/integrations/tracing-channel/openai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/openai.ts @@ -29,9 +29,7 @@ const ORIGIN = 'auto.ai.orchestrion.openai'; // Each instrumented `create` method maps to the gen_ai operation its span reports. const INSTRUMENTED_CHANNELS = [ { channel: CHANNELS.OPENAI_CHAT, operation: 'chat' }, - { channel: CHANNELS.OPENAI_RESPONSES, operation: 'chat' }, { channel: CHANNELS.OPENAI_EMBEDDINGS, operation: 'embeddings' }, - { channel: CHANNELS.OPENAI_CONVERSATIONS, operation: 'chat' }, ] as const; /** diff --git a/packages/server-utils/src/integrations/tracing-channel/redis.ts b/packages/server-utils/src/integrations/tracing-channel/redis.ts index 6a1d5e4d779e..6dedde81bafb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/redis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -232,7 +232,6 @@ function bindNodeRedisCommandChannel( return startCommandSpan(commandName, wireArgs.slice(1), nodeRedisAttributes(options)); }, { - captureError: false, beforeSpanEnd(span, data) { if ('error' in data || !responseHook) { return; @@ -269,18 +268,14 @@ function getExecutorArgs(data: CommandContext): Array | undefin function bindNodeRedisConnectChannel(): void { const channel = diagnosticsChannel.tracingChannel(CHANNELS.NODE_REDIS_CONNECT); - bindTracingChannelToSpan( - channel, - data => { - const options = (data.self as NodeRedisClient | undefined)?.options; - return startInactiveSpan({ - name: 'redis-connect', - kind: SPAN_KIND.CLIENT, - attributes: { ...nodeRedisAttributes(options), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(channel, data => { + const options = (data.self as NodeRedisClient | undefined)?.options; + return startInactiveSpan({ + name: 'redis-connect', + kind: SPAN_KIND.CLIENT, + attributes: { ...nodeRedisAttributes(options), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }, + }); + }); } // Batch (multi/pipeline): one span per `exec`. Batched commands bypass `sendCommand`, so @@ -288,27 +283,23 @@ function bindNodeRedisConnectChannel(): void { // mirrors the native `node-redis:batch` span (see `redis-dc-subscriber.ts`). function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: CommandContext) => string): void { const channel = diagnosticsChannel.tracingChannel(channelName); - bindTracingChannelToSpan( - channel, - data => { - const commands = data.arguments?.[0]; - const size = Array.isArray(commands) ? commands.length : undefined; - const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; - return startInactiveSpan({ - name: getOperation(data), - kind: SPAN_KIND.CLIENT, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', - [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, - ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), - ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), - ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(channel, data => { + const commands = data.arguments?.[0]; + const size = Array.isArray(commands) ? commands.length : undefined; + const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; + return startInactiveSpan({ + name: getOperation(data), + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, + ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), + ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), + ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), + }, + }); + }); } const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { diff --git a/packages/server-utils/src/integrations/tracing-channel/tedious.ts b/packages/server-utils/src/integrations/tracing-channel/tedious.ts new file mode 100644 index 000000000000..99a927f5dd7e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/tedious.ts @@ -0,0 +1,261 @@ +// The `@sentry/conventions` db/net attribute keys are deprecated (superseded by newer semconv), but we +// emit them deliberately to preserve parity with what `@opentelemetry/instrumentation-tedious` produced. +/* oxlint-disable typescript/no-deprecated */ + +import { EventEmitter } from 'node:events'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + SPAN_STATUS_ERROR, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { + DB_NAME, + DB_STATEMENT, + DB_SYSTEM, + DB_USER, + NET_PEER_NAME, + NET_PEER_PORT, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active, +// `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name). +const INTEGRATION_NAME = 'Tedious' as const; +const ORIGIN = 'auto.db.orchestrion.tedious'; + +// OTel db/net semantic-convention values/keys not exported by `@sentry/conventions`, inlined to match +// what `@opentelemetry/instrumentation-tedious` emitted. +const DB_SYSTEM_VALUE_MSSQL = 'mssql'; +const ATTR_DB_SQL_TABLE = 'db.sql.table'; + +// Tracks the connection's active database (updated on `databaseChange`), read into `db.name` when a query +// runs. Mirrors the `CURRENT_DATABASE` symbol the vendored OTel instrumentation stashed on the connection. +const currentDatabaseSymbol = Symbol('sentry.orchestrion.tedious.current-database'); + +type UnknownFunction = (...args: unknown[]) => unknown; + +interface TediousConnectionConfig { + server?: string; + userName?: string; + authentication?: { options?: { userName?: string } }; + options?: { database?: string; port?: number }; +} + +interface TediousConnection extends EventEmitter { + config?: TediousConnectionConfig; + [currentDatabaseSymbol]?: string; +} + +interface TediousRequest extends EventEmitter { + sqlTextOrProcedure?: string; + callback?: UnknownFunction; + table?: string; + parametersByName?: Record; +} + +/** Context orchestrion attaches to the query channels (wrapping the `Connection` request methods). */ +interface TediousQueryChannelContext { + // `arguments[0]` is the `Request` (or `BulkLoad` for `execBulkLoad`), both `EventEmitter`s. + arguments: [TediousRequest?, ...unknown[]]; + self?: TediousConnection; + moduleVersion?: string; +} + +/** Context orchestrion attaches to the `Connection.connect` channel. */ +interface TediousConnectChannelContext { + arguments: unknown[]; + self?: TediousConnection; +} + +// Used both to seed the initial database and as the `databaseChange` listener, where `this` is the +// connection (a non-arrow listener). Keeping one shared reference lets `removeListener` find it again. +function setDatabase(this: TediousConnection, databaseName: string | undefined): void { + Object.defineProperty(this, currentDatabaseSymbol, { value: databaseName, writable: true, configurable: true }); +} + +// The `end` cleanup listener, where `this` is the connection (a non-arrow listener). Named (like +// `setDatabase`) so repeated `connect` calls can `removeListener` it rather than accumulate anonymous ones. +function removeDatabaseListener(this: TediousConnection): void { + this.removeListener('databaseChange', setDatabase); +} + +function subscribeConnect(): void { + diagnosticsChannel.tracingChannel(CHANNELS.TEDIOUS_CONNECT).start.subscribe(message => { + const connection = (message as TediousConnectChannelContext).self; + if (!connection) { + return; + } + + setDatabase.call(connection, connection.config?.options?.database); + + // Remove first in case `connect` runs more than once on the same connection, so neither listener + // accumulates across reconnects. + connection.removeListener('databaseChange', setDatabase); + connection.on('databaseChange', setDatabase); + connection.removeListener('end', removeDatabaseListener); + connection.once('end', removeDatabaseListener); + }); +} + +function subscribeQuery(channelName: string, operation: string): void { + diagnosticsChannel.tracingChannel(channelName).start.subscribe(message => { + const data = message as TediousQueryChannelContext; + const connection = data.self; + const request = data.arguments[0]; + + // The vendored instrumentation only traced when the first argument is an `EventEmitter` (a `Request` + // or `BulkLoad`); anything else is left untouched. + if (!connection || !(request instanceof EventEmitter)) { + return; + } + + let procCount = 0; + let statementCount = 0; + const incrementStatementCount = (): void => { + statementCount++; + }; + const incrementProcCount = (): void => { + procCount++; + }; + + const databaseName = connection[currentDatabaseSymbol]; + const sql = extractSql(request); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [DB_SYSTEM]: DB_SYSTEM_VALUE_MSSQL, + [DB_NAME]: databaseName, + // `>=4` uses the `authentication` object; older versions expose `userName` directly. + [DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName, + [DB_STATEMENT]: sql, + [ATTR_DB_SQL_TABLE]: request.table, + [NET_PEER_NAME]: connection.config?.server, + [NET_PEER_PORT]: connection.config?.options?.port, + }; + + const span = startInactiveSpan({ + name: getSpanName(operation, databaseName, sql, request.table), + kind: SPAN_KIND.CLIENT, + op: 'db', + attributes, + }); + + const endSpan = once((err?: { message?: string }): void => { + request.removeListener('done', incrementStatementCount); + request.removeListener('doneInProc', incrementStatementCount); + request.removeListener('doneProc', incrementProcCount); + request.removeListener('error', endSpan); + connection.removeListener('end', endSpan); + + span.setAttribute('tedious.procedure_count', procCount); + span.setAttribute('tedious.statement_count', statementCount); + if (err) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: err.message }); + } + + span.end(); + }); + + request.on('done', incrementStatementCount); + request.on('doneInProc', incrementStatementCount); + request.on('doneProc', incrementProcCount); + request.once('error', endSpan); + connection.on('end', endSpan); + + // tedious invokes `request.callback` when the request settles (passing the error, if any). Wrapping it + // here (at `start`, before the method body dispatches) is the completion signal. A failed non-preparing + // request reports its error only through this callback, not via an `'error'` event. + if (typeof request.callback === 'function') { + const originalCallback = request.callback; + request.callback = function (this: unknown, ...args: unknown[]): unknown { + endSpan(args[0] as { message?: string } | undefined); + + return originalCallback.apply(this, args); + }; + } + }); +} + +function extractSql(request: TediousRequest): string | undefined { + // Required for <11.0.9: the SQL for a prepared statement is carried in the `stmt` parameter. + if (request.sqlTextOrProcedure === 'sp_prepare' && request.parametersByName?.stmt?.value != null) { + const value = request.parametersByName.stmt.value; + + return typeof value === 'string' ? value : undefined; + } + + return request.sqlTextOrProcedure; +} + +/** + * The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames + * the span description off `db.statement` when present. Mirrors the vendored OTel `getSpanName`. + */ +function getSpanName( + operation: string, + db: string | undefined, + sql: string | undefined, + bulkLoadTable: string | undefined, +): string { + if (operation === 'execBulkLoad' && bulkLoadTable && db) { + return `${operation} ${bulkLoadTable} ${db}`; + } + if (operation === 'callProcedure') { + // `sql` refers to the procedure name for `callProcedure`. + return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`; + } + // Avoid `sql` in the general case because of its high cardinality. + return db ? `${operation} ${db}` : operation; +} + +function once(fn: (...args: Args) => void): (...args: Args) => void { + let called = false; + + return (...args: Args): void => { + if (called) { + return; + } + called = true; + fn(...args); + }; +} + +const _tediousChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && debug.log(`[orchestrion:tedious] subscribing to channel "${CHANNELS.TEDIOUS_EXEC_SQL}"`); + + waitForTracingChannelBinding(() => { + subscribeConnect(); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch'); + subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad'); + subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare'); + subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute'); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL - orchestrion-driven tedious integration. + * + * Subscribes to the `orchestrion:tedious:*` diagnostics_channels that the orchestrion code transform + * injects into tedious's `Connection` request methods (each traced as one db span) and `Connection.connect` + * (active-database bookkeeping). Requires the orchestrion runtime hook or bundler plugin to be active. + */ +export const tediousChannelIntegration = defineIntegration(_tediousChannelIntegration); diff --git a/packages/server-utils/src/mongodb/mongodb-span.ts b/packages/server-utils/src/mongodb/mongodb-span.ts new file mode 100644 index 000000000000..3904274a99f3 --- /dev/null +++ b/packages/server-utils/src/mongodb/mongodb-span.ts @@ -0,0 +1,229 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongodb` span shape so the orchestrion +// spans match the OTel ones byte-for-byte. Inlined as literals to avoid +// importing the deprecated convention constants. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const DB_SYSTEM_VALUE_MONGODB = 'mongodb'; + +/** + * The db/collection namespace mongodb (v4+) passes to `Connection.command` + */ +export interface MongodbNamespace { + db: string; + collection?: string; +} + +interface V4Command { + documents?: unknown[]; + cursors?: unknown; + [key: string]: unknown; +} + +/** + * Replaces every leaf value in the command object with '?', keeping keys + * and Mongo operators. hides PII and improves grouping. Reproduced from + * the OTel instrumentation. + */ +export function serializeDbStatement(commandObj: Record): string { + return JSON.stringify(scrubStatement(commandObj)); +} + +function scrubStatement(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(element => scrubStatement(element)); + } + + if (isCommandObj(value)) { + const initial: Record = {}; + return Object.entries(value) + .map(([key, element]) => [key, scrubStatement(element)]) + .reduce((prev, current) => { + if (isCommandEntry(current)) { + prev[current[0]] = current[1]; + } + return prev; + }, initial); + } + + // A value like string or number, possibly contains PII, scrub it. + return '?'; +} + +function isCommandObj(value: unknown): value is Record { + return isObjectLike(value) && !isBuffer(value); +} + +function isBuffer(value: unknown): boolean { + return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); +} + +function isCommandEntry(value: unknown): value is [string, unknown] { + return Array.isArray(value); +} + +/** + * Build span attributes for a mongodb v4+ command from the connection + * context, namespace and command. + */ +export function getV4SpanAttributes( + connectionCtx: { address?: string } | undefined, + ns: MongodbNamespace, + command: V4Command | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (connectionCtx) { + const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; + if (hostParts.length === 2) { + host = hostParts[0]; + port = hostParts[1]; + } + } + + let commandObj: Record | undefined; + if (command?.documents?.[0]) { + commandObj = command.documents[0] as Record; + } else if (command?.cursors) { + commandObj = command.cursors as Record; + } else { + commandObj = command; + } + + return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation, origin); +} + +/** + * Shared attribute builder used by both the v3 topology path and the v4+ + * connection path. + */ +export function getSpanAttributes( + dbName: string | undefined, + dbCollection: string | undefined, + host: string | undefined, + port: string | undefined, + commandObj: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, + [ATTR_DB_NAME]: dbName, + [ATTR_DB_MONGODB_COLLECTION]: dbCollection, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, + }; + + if (host && port) { + attributes[ATTR_NET_PEER_NAME] = host; + const portNumber = parseInt(port, 10); + if (!isNaN(portNumber)) { + attributes[ATTR_NET_PEER_PORT] = portNumber; + } + } + + if (commandObj) { + try { + attributes[ATTR_DB_STATEMENT] = serializeDbStatement(commandObj); + } catch { + // ignore serialization errors — the statement is best-effort metadata + } + } + + return attributes; +} + +/** The v3 driver topology, from which host/port are read (mongodb 3.x). */ +export interface MongoV3Topology { + s?: { + options?: { host?: string; port?: number }; + host?: string; + port?: number; + }; + description?: { address?: string }; +} + +/** + * Determine the operation name for a mongodb v3 `command` from the command + * document, mirroring the vendored instrumentation. Returns `undefined` for + * commands it doesn't classify (e.g. `endSessions`). + */ +export function getV3CommandOperation(command: Record): string | undefined { + if (command.createIndexes !== undefined) { + return 'createIndexes'; + } else if (command.findandmodify !== undefined) { + return 'findAndModify'; + } else if (command.ismaster !== undefined) { + return 'isMaster'; + } else if (command.count !== undefined) { + return 'count'; + } else if (command.aggregate !== undefined) { + return 'aggregate'; + } + return undefined; +} + +/** + * Build span attributes for a mongodb v3 operation from the topology, + * namespace string and command. + */ +export function getV3SpanAttributes( + ns: string, + topology: MongoV3Topology | undefined, + command: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (topology?.s) { + host = topology.s.options?.host ?? topology.s.host; + port = (topology.s.options?.port ?? topology.s.port)?.toString(); + if (host == null || port == null) { + const address = topology.description?.address; + if (address) { + const segments = address.split(':'); + host = segments[0]; + port = segments[1]; + } + } + } + + // The namespace is `[db].[collection-or-index]`; coerce to string + // (might be a MongoDBNamespace) + const [dbName, dbCollection] = ns.toString().split('.'); + const commandObj = + (command?.query as Record | undefined) ?? + (command?.q as Record | undefined) ?? + command; + + return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation, origin); +} + +/** + * Start a mongodb client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * `op: 'db'` is set explicitly rather than relying on `inferDbSpanData`, + * to support platforms that lack it (ie, Deno). + */ +export function startMongoSpan(attributes: SpanAttributes): Span { + return startInactiveSpan({ + name: `mongodb.${attributes[ATTR_DB_OPERATION] || 'command'}`, + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + }); +} diff --git a/packages/server-utils/src/mongoose/index.ts b/packages/server-utils/src/mongoose/index.ts index ace42bb7a122..c16ee5811751 100644 --- a/packages/server-utils/src/mongoose/index.ts +++ b/packages/server-utils/src/mongoose/index.ts @@ -2,6 +2,9 @@ import { defineIntegration, type IntegrationFn, waitForTracingChannelBinding } f import * as dc from 'node:diagnostics_channel'; import { subscribeMongooseDiagnosticChannels } from './mongoose-dc-subscriber'; +export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose-legacy-span'; +export { startMongooseLegacySpan } from './mongoose-legacy-span'; + const _mongooseIntegration = (() => { return { name: 'Mongoose', diff --git a/packages/server-utils/src/mongoose/mongoose-legacy-span.ts b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts new file mode 100644 index 000000000000..301b9be210d7 --- /dev/null +++ b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts @@ -0,0 +1,64 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongoose` span shape. Inlined as literals to +// avoid importing the deprecated convention constants. +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_USER = 'db.user'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_SYSTEM = 'db.system'; + +/** The subset of mongoose's `Collection` that the legacy span shape reads. */ +export interface MongooseLegacyCollection { + name?: string; + conn?: { name?: string; user?: string; host?: string; port?: number }; +} + +export interface StartMongooseLegacySpanOptions { + collection: MongooseLegacyCollection | undefined; + modelName: string | undefined; + operation: string; + /** Span origin: distinguishes the OTel/IITM caller from orchestrion */ + origin: string; + parentSpan?: Span; +} + +/** + * Start a mongoose client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * Shared by the vendored OTel/IITM instrumentation (`@sentry/node`) and the + * orchestrion channel subscriber so the two emit an identical span shape, + * differing only by `origin`. + */ +export function startMongooseLegacySpan({ + collection, + modelName, + operation, + origin, + parentSpan, +}: StartMongooseLegacySpanOptions): Span { + const attributes: SpanAttributes = { + [ATTR_DB_MONGODB_COLLECTION]: collection?.name, + [ATTR_DB_NAME]: collection?.conn?.name, + [ATTR_DB_USER]: collection?.conn?.user, + [ATTR_NET_PEER_NAME]: collection?.conn?.host, + [ATTR_NET_PEER_PORT]: collection?.conn?.port, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_SYSTEM]: 'mongoose', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + }; + + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + // Set this explicitly, for platforms lacking `inferDbSpanData` + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + parentSpan, + }); +} diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 2fa610d02b51..c536482df346 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,12 +1,22 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -import type codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; +import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; export type PluginOptions = { /** * Additional instrumentations to include with the default instrumentation. */ instrumentations?: InstrumentationConfig[]; + /** + * Custom transforms that can be applied using the `transform` option in each `InstrumentationConfig`. + */ + customTransforms?: Record; + /** + * Whether to inject the global diagnostics. + * + * Defaults to `true`. + */ + shouldInjectDiagnostics?: boolean; }; /** @@ -18,9 +28,20 @@ export type PluginOptions = { * bundler path ran (rather than relying on a build-time flag that wouldn't be * visible to the runtime). */ -export function orchestrionTransformOptions(options: PluginOptions): Parameters[0] { +export function orchestrionTransformOptions(options: PluginOptions): CodeTransformerPluginOptions { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])]; + const customTransforms = options.customTransforms; + + if (options.shouldInjectDiagnostics === false) { + return { + instrumentations, + customTransforms, + }; + } + return { - instrumentations: [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])], + instrumentations, + customTransforms, injectDiagnostics: (diag: { transformedModules: string[]; failedModules: string[] }) => { return `(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}).bundler=${JSON.stringify(diag.transformedModules)};`; }, diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 04eb56a2c6b9..7e2f584b1819 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -3,8 +3,11 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +import codeTransformerWebpack from '@apm-js-collab/code-transformer-bundler-plugins/webpack'; +import type { PluginOptions } from './options'; +import { orchestrionTransformOptions } from './options'; // Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this // module don't emit a "Critical dependency" warning. @@ -42,14 +45,8 @@ export function getSentryInstrumentations(): InstrumentationConfig[] { } /** - * The code-transform webpack plugin, pre-fed the instrumentation config. Unlike the Vite plugin it - * does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker — that would disable the runtime - * module hook, which externalized packages still need (hybrid setup). + * The code-transform webpack plugin, pre-fed the instrumentation config */ -export function sentryOrchestrionWebpackPlugin(): unknown { - const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { - default?: (options: { instrumentations: InstrumentationConfig[] }) => unknown; - }; - const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); - return codeTransformerWebpack({ instrumentations: SENTRY_INSTRUMENTATIONS }); +export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): ReturnType { + return codeTransformerWebpack(orchestrionTransformOptions(options)); } diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 83413cfa0d34..d51de03ceb95 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,5 +1,6 @@ import { amqplibChannels } from './config/amqplib'; import { anthropicAiChannels } from './config/anthropic-ai'; +import { awsSdkChannels } from './config/aws-sdk'; import { dataloaderChannels } from './config/dataloader'; import { expressChannels } from './config/express'; import { firebaseChannels } from './config/firebase'; @@ -10,6 +11,7 @@ import { hapiChannels } from './config/hapi'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; import { knexChannels } from './config/knex'; +import { koaChannels } from './config/koa'; import { langchainChannels } from './config/langchain'; import { langgraphChannels } from './config/langgraph'; import { lruMemoizerChannels } from './config/lru-memoizer'; @@ -47,6 +49,7 @@ import { vercelAiChannels } from './config/vercel-ai'; export const CHANNELS = { ...amqplibChannels, ...anthropicAiChannels, + ...awsSdkChannels, ...dataloaderChannels, ...expressChannels, ...firebaseChannels, @@ -57,6 +60,7 @@ export const CHANNELS = { ...ioredisChannels, ...kafkajsChannels, ...knexChannels, + ...koaChannels, ...langchainChannels, ...langgraphChannels, ...lruMemoizerChannels, diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 75b5ced78cae..5f2082859347 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index e5032d88a209..0b5f0e0ecf10 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const anthropicAiConfig = [ // One entry each for CJS/ESM diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts new file mode 100644 index 000000000000..fdcc4d9e5682 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -0,0 +1,34 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +// The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which +// package hosts that `Client` class changed across versions, so we target all of them; only the one +// the app's client actually extends is ever invoked. +// +// - `@smithy/core` >= 3.24.0: the `Client` class moved into the `client` submodule bundle. +// - `@smithy/smithy-client`: the `Client` class for aws-sdk v3.363.0+ (pre-`@smithy/core` stack). +// - `@aws-sdk/smithy-client`: the `Client` class for older aws-sdk v3 releases. +// +// `send` is `async send(command, options)` (returns a promise), so `kind: 'Async'`. +export const awsSdkConfig = [ + { + channelName: 'send', + module: { name: '@smithy/core', versionRange: '>=3.24.0 <4', filePath: 'dist-cjs/submodules/client/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@smithy/smithy-client', versionRange: '>=1.0.3 <5', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@aws-sdk/smithy-client', versionRange: '^3.1.0', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const awsSdkChannels = { + AWS_SMITHY_CORE_SEND: 'orchestrion:@smithy/core:send', + AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', + AWS_SDK_SMITHY_CLIENT_SEND: 'orchestrion:@aws-sdk/smithy-client:send', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index 5c479f9e000d..cd1f0879bdc3 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index b7290dd2a7b4..81fe89947e3d 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -32,25 +32,28 @@ export const expressConfig = [ // handler, `use`'s trailing function argument is a registration payload, not a // callback — so `Callback` would misclassify it and never fire `end`. // + // `route` and `use` share one `register` channel because the subscriber handles + // them identically, saving a channel per module. + // // Express v4 ships its own router in `express/lib/router/index.js`. { - channelName: 'route', + channelName: 'register', module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, functionQuery: { expressionName: 'route', kind: 'Sync' }, }, { - channelName: 'use', + channelName: 'register', module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, // Express v5 delegates routing to the standalone `router` package. { - channelName: 'route', + channelName: 'register', module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'route', kind: 'Sync' }, }, { - channelName: 'use', + channelName: 'register', module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, @@ -66,8 +69,6 @@ export const expressChannels = { // Layer *registration* (`Router.prototype.route`/`.use`), used to capture each // layer's registered path pattern so the matched route can be reconstructed // with its parameters intact (`req.baseUrl` only exposes the resolved prefix). - EXPRESS_ROUTE: 'orchestrion:express:route', - EXPRESS_USE: 'orchestrion:express:use', - ROUTER_ROUTE: 'orchestrion:router:route', - ROUTER_USE: 'orchestrion:router:use', + EXPRESS_REGISTER: 'orchestrion:express:register', + ROUTER_REGISTER: 'orchestrion:router:register', } as const; diff --git a/packages/server-utils/src/orchestrion/config/firebase.ts b/packages/server-utils/src/orchestrion/config/firebase.ts index b2f809e4383c..fa8606eb1ff6 100644 --- a/packages/server-utils/src/orchestrion/config/firebase.ts +++ b/packages/server-utils/src/orchestrion/config/firebase.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `firebase` orchestrion integration (ports `FirebaseInstrumentation`). export const firebaseConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 83866505162f..724451207ea2 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index d46854b88c76..693e8ba039bc 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index be5a7809a0a5..13257755934c 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 755161717c63..7c11aac6911a 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index d8f1c15bad6c..aee58e185b71 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,8 +1,9 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; import { uniq } from '@sentry/core'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; +import { awsSdkConfig } from './aws-sdk'; import { dataloaderConfig } from './dataloader'; import { expressConfig } from './express'; import { firebaseConfig } from './firebase'; @@ -13,6 +14,7 @@ import { hapiConfig } from './hapi'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; +import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; @@ -43,6 +45,7 @@ import { vercelAiConfig } from './vercel-ai'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...amqplibConfig, ...anthropicAiConfig, + ...awsSdkConfig, ...dataloaderConfig, ...expressConfig, ...firebaseConfig, @@ -53,6 +56,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...ioredisConfig, ...kafkajsConfig, ...knexConfig, + ...koaConfig, ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, @@ -84,7 +88,12 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ * externalized and their transform never runs. */ export function instrumentedModuleNames(instrumentations: InstrumentationConfig[] = []): string[] { - return uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)); + return [ + ...uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)), + // Additional things that need to be bundled but are not covered by the above + // Remix needs to bundle this so @remix-run/server-runtime is _also_ bundled + '@remix-run/node', + ]; } /** The instrumented module names from the default Sentry config, with no custom additions. */ diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index f6d6392d72b8..35d2d44cd79b 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index e584ddd46299..0d653e0db0eb 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const kafkajsConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 5e9ef9d7c7c0..8c4c2d068563 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; const MODULE_NAME = 'knex'; diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts new file mode 100644 index 000000000000..b0d8ad516a37 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -0,0 +1,13 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const koaConfig = [ + { + channelName: 'use', + module: { name: 'koa', versionRange: '>=2.0.0 <4', filePath: 'lib/application.js' }, + functionQuery: { className: 'Application', methodName: 'use', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const koaChannels = { + KOA_USE: 'orchestrion:koa:use', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index a115273b22aa..df6247c91d1c 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,6 +1,73 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `langchain` orchestrion integration (ports `SentryLangChainInstrumentation`). -export const langchainConfig: InstrumentationConfig[] = []; +// `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the +// matcher compares `filePath` exactly, so each hook is declared once per built file. -export const langchainChannels = {} as const; +// LangChain's chat model methods live on `BaseChatModel` in `@langchain/core` and are inherited by +// every provider class (`ChatAnthropic`, `ChatOpenAI`, …), so a single hook there covers all +// providers. `invoke` also backs `.batch()` (which calls `invoke` per item); `_streamIterator` +// backs `.stream()`. The vendored OTel instrumentation instead patched each provider package to +// dodge `@langchain/core` being bundled, but orchestrion transforms its source directly regardless +// of bundling. +const chatModelConfig = ['dist/language_models/chat_models.cjs', 'dist/language_models/chat_models.js'].flatMap( + filePath => { + const module = { name: '@langchain/core', versionRange: '>=0.1.0 <2.0.0', filePath }; + + return [ + { + channelName: 'chatModelInvoke', + module, + functionQuery: { className: 'BaseChatModel', methodName: 'invoke', kind: 'Async' as const }, + }, + { + channelName: 'chatModelStream', + module, + functionQuery: { className: 'BaseChatModel', methodName: '_streamIterator', kind: 'Async' as const }, + }, + ]; + }, +); + +// Embeddings have no shared concrete method on the base class (each provider implements +// `embedQuery`/`embedDocuments`), so they're hooked per package. These are the provider packages the +// vendored OTel instrumentation covered that actually ship an `Embeddings` subclass: `@langchain/openai`, +// `@langchain/google-genai` and `@langchain/mistralai` define the two methods on their own class, while +// `@langchain/google-vertexai` inherits them from the shared `@langchain/google-common` base, so that base +// is the module hooked for it. (anthropic and groq are chat-only — they ship no embeddings class.) The +// `embedQuery`/`embedDocuments` channel names are per-method; orchestrion prefixes them with the module +// name, so the full channel strings stay distinct across packages. +const EMBED_QUERY = 'embedQuery'; +const EMBED_DOCUMENTS = 'embedDocuments'; + +const EMBEDDINGS_PROVIDERS = [ + { name: '@langchain/openai', versionRange: '>=0.1.0 <2.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + { name: '@langchain/google-genai', versionRange: '>=0.1.0 <3.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + { name: '@langchain/mistralai', versionRange: '>=0.1.0 <2.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + // `@langchain/google-vertexai` inherits its embed methods from this shared base. The base's + // `embedQuery` delegates to `embedDocuments`, so hooking only `embedDocuments` still traces both + // entry points as a single span each, instead of emitting a nested duplicate for `embedQuery`. + { name: '@langchain/google-common', versionRange: '>=0.1.0 <3.0.0', methods: [EMBED_DOCUMENTS] }, +]; + +const embeddingsConfig = EMBEDDINGS_PROVIDERS.flatMap(({ name, versionRange, methods }) => + ['dist/embeddings.cjs', 'dist/embeddings.js'].flatMap(filePath => + methods.map(method => ({ + channelName: method, + module: { name, versionRange, filePath }, + functionQuery: { methodName: method, kind: 'Async' as const }, + })), + ), +); + +export const langchainConfig = [...chatModelConfig, ...embeddingsConfig] satisfies InstrumentationConfig[]; + +// The embeddings channel strings the subscriber binds to, derived from the provider list above so that +// adding a provider is a single edit that both instruments it and subscribes the listener to it. +export const langchainEmbeddingsChannels = EMBEDDINGS_PROVIDERS.flatMap(({ name, methods }) => + methods.map(method => `orchestrion:${name}:${method}`), +); + +export const langchainChannels = { + LANGCHAIN_CHAT_MODEL_INVOKE: 'orchestrion:@langchain/core:chatModelInvoke', + LANGCHAIN_CHAT_MODEL_STREAM: 'orchestrion:@langchain/core:chatModelStream', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index ce93f1e993d5..666e4ac3ef12 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,6 +1,34 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `langgraph` orchestrion integration (ports `SentryLangGraphInstrumentation`). -export const langgraphConfig: InstrumentationConfig[] = []; +// `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the +// matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` +// and `createReactAgent` both return synchronously; the subscriber wraps the returned compiled graph's +// `invoke` (mirroring the vendored OTel instrumentation, which patched these on the module exports). +const module = (filePath: string): InstrumentationConfig['module'] => ({ + name: '@langchain/langgraph', + versionRange: '>=0.0.0 <2.0.0', + filePath, +}); -export const langgraphChannels = {} as const; +const compileConfig = ['dist/graph/state.cjs', 'dist/graph/state.js'].map(filePath => ({ + channelName: 'stateGraphCompile', + module: module(filePath), + functionQuery: { className: 'StateGraph', methodName: 'compile', kind: 'Sync' as const }, +})); + +// `createReactAgent` is a single function declaration re-exported from both `@langchain/langgraph` and +// `@langchain/langgraph/prebuilt`; hooking its definition file covers every import path. +const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/prebuilt/react_agent_executor.js'].map( + filePath => ({ + channelName: 'createReactAgent', + module: module(filePath), + functionQuery: { functionName: 'createReactAgent', kind: 'Sync' as const }, + }), +); + +export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[]; + +export const langgraphChannels = { + LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', + LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index 578317e612a8..e186136d05d1 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const lruMemoizerConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index f99e5b70cea7..ee44b79b0bba 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,6 +1,72 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `mongodb` orchestrion integration (ports `@opentelemetry/instrumentation-mongodb`). -export const mongodbConfig: InstrumentationConfig[] = []; +// The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel +// instrumentation's version bands: +// - >= 6.4: promise-based `Connection.prototype.command` +// - >= 4.0 < 6.4: callback-based `Connection.prototype.command` + `ConnectionPool.checkOut` +// (the pool runs the checkout callback in a detached context, so the channel re-propagates the +// caller's async context to it — see the subscriber) +// - >= 3.3 < 4: the `lib/core/wireprotocol` module functions +const module = { name: 'mongodb' } as const; -export const mongodbChannels = {} as const; +export const mongodbConfig = [ + // Band 1: mongodb >= 6.4 — promise-based command. + // `methodName`-only (no `className`): the code-transformer's `className` matcher throws on classes + // containing ES2022 `static {}` blocks (mongodb 7.x's `Connection`/`ConnectionPool` have them — see + // `transformer-bug.md`), and `methodName` alone matches exactly the base method across all versions. + { + channelName: 'command', + module: { ...module, versionRange: '>=6.4.0 <8', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Async' }, + }, + // Band 2: mongodb >= 4.0 < 6.4 — callback-based command (same `command` channel, different kind). + { + channelName: 'command', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Callback' }, + }, + // Band 2: the pool runs the checkout callback in a detached async context, so the operation's + // `command()` (invoked inside it) loses the caller's active span. Hooking `checkOut` re-propagates + // that context to the callback (the subscriber creates no span — see `getSpan` returning undefined). + // Only needed < 6.4; from 6.4 `checkOut` is promise-based and the context survives natively. + { + channelName: 'checkout', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection_pool.js' }, + functionQuery: { methodName: 'checkOut', kind: 'Callback' }, + }, + // Band 3: mongodb >= 3.3 < 4 — the driver had no unified `command`; each operation is a separate + // `lib/core/wireprotocol` function, all callback-style. `insert`/`update`/`remove` are named + // function expressions in the `index.js` `module.exports` object (matched by `expressionName`); + // `command`/`query`/`getMore` are single-function modules (matched by `functionName`). + ...(['insert', 'update', 'remove'] as const).map(op => ({ + channelName: `v3_${op}`, + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/index.js' }, + functionQuery: { expressionName: op, kind: 'Callback' as const }, + })), + { + channelName: 'v3_command', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/command.js' }, + functionQuery: { functionName: 'command', kind: 'Callback' }, + }, + { + channelName: 'v3_query', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/query.js' }, + functionQuery: { functionName: 'query', kind: 'Callback' }, + }, + { + channelName: 'v3_get_more', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/get_more.js' }, + functionQuery: { functionName: 'getMore', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; + +export const mongodbChannels = { + MONGODB_COMMAND: 'orchestrion:mongodb:command', + MONGODB_CHECKOUT: 'orchestrion:mongodb:checkout', + MONGODB_V3_INSERT: 'orchestrion:mongodb:v3_insert', + MONGODB_V3_UPDATE: 'orchestrion:mongodb:v3_update', + MONGODB_V3_REMOVE: 'orchestrion:mongodb:v3_remove', + MONGODB_V3_COMMAND: 'orchestrion:mongodb:v3_command', + MONGODB_V3_QUERY: 'orchestrion:mongodb:v3_query', + MONGODB_V3_GET_MORE: 'orchestrion:mongodb:v3_get_more', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index d3186a0aed57..efc952184ba1 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,6 +1,126 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `mongoose` orchestrion integration (ports `@opentelemetry/instrumentation-mongoose`). -export const mongooseConfig: InstrumentationConfig[] = []; +// mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by +// `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting +// two spans per operation. The lower bound mirrors the vendored OTel IITM patcher's supported range. +const module = { name: 'mongoose', versionRange: '>=5.9.7 <9.7.0' } as const; -export const mongooseChannels = {} as const; +// mongoose defines its prototype/static methods as assignments +// (`Query.prototype.find = function(...)`, most of them anonymous), so the +// transform matches the assigned property name via `expressionName` +// rather than the function name. Entries whose method does not exist in a +// given major are simply inert. + +// Builder methods that run synchronously and return a `Query`/`Aggregate`. +// They don't get their own span; the subscriber only reads the active span +// at build time and stashes it on the query so the later `exec()` can parent +// to where the query was *built*, not where it is awaited. `kind: 'Sync'` +// so the returned thenable isn't mistaken for the traced operation's result. +const CONTEXT_CAPTURE_QUERY_METHODS = [ + 'find', + 'findOne', + 'deleteOne', + 'deleteMany', + 'estimatedDocumentCount', + 'countDocuments', + 'distinct', + 'where', + '$where', + 'findOneAndUpdate', + 'findOneAndDelete', + 'findOneAndReplace', + // 5/6/7 only (removed in 8), inert in recent versions + 'remove', + 'count', + 'findOneAndRemove', +] as const; + +export const mongooseConfig = [ + // Query execution + // the span for most read/write operations. `op`, collection and model are + // read off the `Query` at exec time. + { + channelName: 'query_exec', + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // Aggregation pipeline execution. + { + channelName: 'aggregate_exec', + module: { ...module, filePath: 'lib/aggregate.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // `doc.save()` (and its `$save` alias, which mongoose points at `save` on + // require. the alias picks up the transformed body automatically, so no + // separate entry is needed). + { + channelName: 'model_save', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'save', kind: 'Auto' }, + }, + // Static batch operations. + { + channelName: 'model_insert_many', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'insertMany', kind: 'Auto' }, + }, + { + channelName: 'model_bulk_write', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'bulkWrite', kind: 'Auto' }, + }, + // `doc.remove()` (a document method, deprecated in 6 and removed in 7) + // `expressionName: 'remove'` also matches the sibling `Model.remove` + // *static* in this file, which no matcher can tell apart from the prototype + // method; that static is deprecated and would just produce a redundant span + // so the collision is accepted rather than dropping the doc-method span. + { + channelName: 'model_remove', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'remove', kind: 'Auto' }, + }, + // NOTE: document `updateOne`/`deleteOne` (mongoose 8.21+) are deliberately + // NOT hooked here. The vendored OTel/IITM patcher wraps + // `Model.prototype.updateOne`/`deleteOne`, but those delegate to + // `Query.exec`, which the `query_exec` channel above already instruments + // (its `this.op` is the right operation). Verified by the `mongoose-v8` + // suite against a real mongoose 8.21+ under orchestrion. A dedicated hook + // is also not possible cleanly: `expressionName: 'updateOne'` in + // `lib/model.js` can't be told apart from the same-named `Model.updateOne` + // *static* (the common query-builder form), so hooking it would double-span + // every `Model.updateOne(...)` call. + // + // `Model.aggregate()` builds an `Aggregate` with no method to hook for + // context capture, so hook the static itself and stash the active span + // on the returned aggregate. `Sync`: it returns the aggregate. + { + channelName: 'model_aggregate', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'aggregate', kind: 'Sync' }, + }, + ...CONTEXT_CAPTURE_QUERY_METHODS.map(methodName => ({ + channelName: `ctx_${methodName}`, + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: methodName, kind: 'Sync' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const mongooseChannels = { + MONGOOSE_QUERY_EXEC: 'orchestrion:mongoose:query_exec', + MONGOOSE_AGGREGATE_EXEC: 'orchestrion:mongoose:aggregate_exec', + MONGOOSE_MODEL_SAVE: 'orchestrion:mongoose:model_save', + MONGOOSE_MODEL_INSERT_MANY: 'orchestrion:mongoose:model_insert_many', + MONGOOSE_MODEL_BULK_WRITE: 'orchestrion:mongoose:model_bulk_write', + MONGOOSE_MODEL_REMOVE: 'orchestrion:mongoose:model_remove', + MONGOOSE_MODEL_AGGREGATE: 'orchestrion:mongoose:model_aggregate', +} as const; + +/** + * Fully-qualified names of the context-capture channels, derived from the + * same method list the transform config uses so the two can't drift. The + * subscriber subscribes to all of them uniformly to stash the build-time + * parent span (see `mongooseChannels` for the span-creating channels). + */ +export const MONGOOSE_CONTEXT_CAPTURE_CHANNELS: string[] = CONTEXT_CAPTURE_QUERY_METHODS.map( + methodName => `orchestrion:mongoose:ctx_${methodName}`, +); diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index 589e384bd6a3..c686f435a9f7 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const mysqlConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 796361164756..bac26e323053 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 70525177e4d3..44900addc39e 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,4 +1,4 @@ -import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -12,7 +12,7 @@ function astQueryInstrumentation(config: { channelName: string; module: InstrumentationConfig['module']; astQuery: string; - functionQuery: { kind: FunctionKind }; + functionQuery: { kind: 'Sync' | 'Async' | 'Callback' | 'Auto' }; }): InstrumentationConfig { return config as unknown as InstrumentationConfig; } diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index e8091003fc58..d29c12623e24 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -11,7 +11,7 @@ export const openaiConfig = [ })), // OpenAI responses API — same `create(body, options)` shape as chat completions. ...['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ - channelName: 'responses', + channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, })), @@ -23,15 +23,15 @@ export const openaiConfig = [ })), // OpenAI conversations API — same `create(body, options)` shape as chat completions. ...['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ - channelName: 'conversations', + channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, })), ] satisfies InstrumentationConfig[]; export const openaiChannels = { + // Chat completions, the responses API, and the conversations API all report a `chat` operation with + // identical span handling, so they share one channel. OPENAI_CHAT: 'orchestrion:openai:chat', - OPENAI_RESPONSES: 'orchestrion:openai:responses', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', - OPENAI_CONVERSATIONS: 'orchestrion:openai:conversations', } as const; diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index 7bfa05a82661..d000e423068a 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const pgConfig = [ // `pg` (node-postgres). diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 7710a7132c6a..245a939cd7e7 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts index d2b3f35ebbce..eace86695028 100644 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ b/packages/server-utils/src/orchestrion/config/prisma.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `prisma` orchestrion integration (ports `@prisma/instrumentation`). export const prismaConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/react-router.ts b/packages/server-utils/src/orchestrion/config/react-router.ts index 50f45bc837c5..f21348123ca0 100644 --- a/packages/server-utils/src/orchestrion/config/react-router.ts +++ b/packages/server-utils/src/orchestrion/config/react-router.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `react-router` orchestrion integration (ports `ReactRouterInstrumentation`). export const reactRouterConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index c704eb43d08f..55db5e45c4e8 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index eaac1a6e390b..893efa781e5a 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,6 +1,59 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `remix` orchestrion integration (ports `RemixInstrumentation`). -export const remixConfig: InstrumentationConfig[] = []; +// Four concepts, one channel each: +// - `requestHandler` → the async handler returned by `createRequestHandler` (the server span) +// - `matchServerRoutes` → sync route match; enriches the active span (creates no span of its own) +// - `callRouteLoader` → LOADER span. Remix 2.0–2.8 named it `callRouteLoaderRR` +// - `callRouteAction` → ACTION span. Remix 2.0–2.8 named it `callRouteActionRR` +// +// Emitted for both the CJS (`dist/*`) and ESM (`dist/esm/*`) builds, which share function shapes. +const remixInstrumentationConfig = (dir: string): InstrumentationConfig[] => [ + // `createRequestHandler` returns `async function requestHandler(request, loadContext)` — the main + // server span. We target the returned handler (so the span wraps each request, not the one-time + // handler construction). It's a *named function expression*, which name-based `functionQuery` + // can't match (that only sees declarations), so we select it with `astQuery`; `functionQuery` + // then just carries the behaviour (`kind: 'Async'`). + { + channelName: 'requestHandler', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <3', filePath: `${dir}/server.js` }, + astQuery: 'FunctionExpression[id.name="requestHandler"]', + functionQuery: { kind: 'Async' }, + }, + // Sync; the subscriber reads its result to set `http.route` on the active request span. + { + channelName: 'matchServerRoutes', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <3', filePath: `${dir}/routeMatching.js` }, + functionQuery: { functionName: 'matchServerRoutes', kind: 'Sync' }, + }, + // Remix >= 2.9.0 + { + channelName: 'callRouteLoader', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.9.0 <3', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteLoader', kind: 'Async' }, + }, + { + channelName: 'callRouteAction', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.9.0 <3', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteAction', kind: 'Async' }, + }, + // Remix 2.0.0 – 2.8.x: the same functions were suffixed `…RR`. Same channels as above. + { + channelName: 'callRouteLoader', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <2.9.0', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteLoaderRR', kind: 'Async' }, + }, + { + channelName: 'callRouteAction', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <2.9.0', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteActionRR', kind: 'Async' }, + }, +]; -export const remixChannels = {} as const; +export const remixConfig = ['dist', 'dist/esm'].flatMap(remixInstrumentationConfig); + +export const remixChannels = { + REMIX_REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', + REMIX_MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', + REMIX_CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', + REMIX_CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index 8c9ee313d23c..c8ac5129e807 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,6 +1,31 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `tedious` orchestrion integration (ports `@opentelemetry/instrumentation-tedious`). -export const tediousConfig: InstrumentationConfig[] = []; +const MODULE_NAME = 'tedious'; -export const tediousChannels = {} as const; +// `Connection` has lived in `lib/connection.js` across the whole supported range (matches the vendored +// OTel `supportedVersions`). Orchestrion never matches a file that doesn't exist, so a single entry is +// safe even for versions that shipped extra layouts. +const FILE_PATH = 'lib/connection.js'; +const VERSION_RANGE = '>=1.11.0 <20'; + +// `Connection` methods that dispatch a request (each traced as one db span) plus `connect`, which the +// subscriber wraps for bookkeeping only (tracking the connection's active database, read into `db.name`). +// All return synchronously; the request completes later via its callback/events, so the subscriber owns +// span-ending rather than the channel lifecycle. +const METHODS = ['connect', 'execSql', 'execSqlBatch', 'callProcedure', 'execBulkLoad', 'prepare', 'execute'] as const; + +export const tediousConfig: InstrumentationConfig[] = METHODS.map(methodName => ({ + channelName: methodName, + module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: FILE_PATH }, + functionQuery: { className: 'Connection', methodName, kind: 'Sync' }, +})); + +export const tediousChannels = { + TEDIOUS_CONNECT: 'orchestrion:tedious:connect', + TEDIOUS_EXEC_SQL: 'orchestrion:tedious:execSql', + TEDIOUS_EXEC_SQL_BATCH: 'orchestrion:tedious:execSqlBatch', + TEDIOUS_CALL_PROCEDURE: 'orchestrion:tedious:callProcedure', + TEDIOUS_EXEC_BULK_LOAD: 'orchestrion:tedious:execBulkLoad', + TEDIOUS_PREPARE: 'orchestrion:tedious:prepare', + TEDIOUS_EXECUTE: 'orchestrion:tedious:execute', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 082be1a135bc..3c7d15fa0be7 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index cf0302556d6a..1e2f15b1a422 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,5 +1,6 @@ import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; +import { awsChannelIntegration } from '../integrations/tracing-channel/aws-sdk'; import { dataloaderChannelIntegration } from '../integrations/tracing-channel/dataloader'; import { genericPoolChannelIntegration } from '../integrations/tracing-channel/generic-pool'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; @@ -8,15 +9,21 @@ import { graphqlDiagnosticsChannelIntegration, } from '../integrations/tracing-channel/graphql'; import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; +import { koaChannelIntegration } from '../integrations/tracing-channel/koa'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; +import { langChainChannelIntegration } from '../integrations/tracing-channel/langchain'; +import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +import { mongodbChannelIntegration } from '../integrations/tracing-channel/mongodb'; +import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; +import { tediousChannelIntegration } from '../integrations/tracing-channel/tedious'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; import { expressChannelIntegration } from '../integrations/tracing-channel/express'; @@ -24,30 +31,42 @@ export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; // The `@nestjs/*` channel names live here alongside their transform config; the // listener that subscribes to them lives in `@sentry/nestjs`, which imports this. export { nestjsChannels } from './config/nestjs'; +// The remix channel names live here alongside their transform config; the +// listener that subscribes to them lives in `@sentry/remix`, which imports this. +export { remixChannels } from './config/remix'; export { amqplibChannelIntegration, anthropicChannelIntegration, + awsChannelIntegration, dataloaderChannelIntegration, genericPoolChannelIntegration, googleGenAIChannelIntegration, graphqlChannelIntegration, hapiChannelIntegration, + koaChannelIntegration, ioredisChannelIntegration, kafkajsChannelIntegration, knexChannelIntegration, + langChainChannelIntegration, + langGraphChannelIntegration, lruMemoizerChannelIntegration, + mongodbChannelIntegration, + mongooseChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, openaiChannelIntegration, postgresChannelIntegration, postgresJsChannelIntegration, + tediousChannelIntegration, vercelAiChannelIntegration, expressChannelIntegration, }; +export type { KoaChannelIntegrationOptions } from '../integrations/tracing-channel/koa'; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; +export type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer-bundler-plugins/core'; // The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s // vendored OTel graphql instrumentation (re-exported from here so the two can't drift). @@ -78,17 +97,24 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types'; export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, + mongoIntegration: mongodbChannelIntegration, mysqlIntegration: mysqlChannelIntegration, mysql2Integration: mysql2ChannelIntegration, genericPoolIntegration: genericPoolChannelIntegration, + mongooseIntegration: mongooseChannelIntegration, lruMemoizerIntegration: lruMemoizerChannelIntegration, openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration, googleGenAIIntegration: googleGenAIChannelIntegration, + langChainIntegration: langChainChannelIntegration, + langGraphIntegration: langGraphChannelIntegration, vercelAiIntegration: vercelAiChannelIntegration, amqplibIntegration: amqplibChannelIntegration, hapiIntegration: hapiChannelIntegration, + koaIntegration: koaChannelIntegration, expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, + tediousIntegration: tediousChannelIntegration, + awsIntegration: awsChannelIntegration, } as const; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 1e63c4c6ddeb..bd559b2a6e48 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -2,16 +2,21 @@ import { debug, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { DEBUG_BUILD } from '../../debug-build'; +import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import type { register } from 'node:module'; +import type { InstrumentationConfig } from '..'; + +type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; type TracingHooksSync = { initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; resolve: Function; load: Function; - setDiagnosticsHook: (callback: (event: { url: string; moduleName: string; error: Error }) => void) => void; +}; + +type TracingHooksDiagnostics = { + setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; }; type NodeModule = { @@ -58,6 +63,18 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { + // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a + // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader + // thread it spawns for `Module.register()`, so this function runs there too — but that thread + // never executes app code, and its `register()` implementation (the in-thread `Hooks` class) + // has no `transferList` parameter: our transfer array lands in its `isInternal` parameter and + // Node crashes trying to load the hook as an internal builtin. User-created workers always + // have a `parentPort` and register through `CustomizedModuleLoader`, which handles + // `transferList` correctly, so they proceed and get their own instrumented loader. + if (!isMainThread && !parentPort) { + return; + } + if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { return; } @@ -100,26 +117,42 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // incompatibility) we warn (DEBUG only) and continue without channel // injection try { + // `lib/diagnostics.js` is plain CJS, so unlike the ESM hook entry points it can be + // require()d on every supported Node version. It holds the hook state shared by + // everything that can transform a module on this thread (the sync ESM hooks and the + // `_compile` patch), so setting the hook once here covers both branches below. + const { setDiagnosticsHook } = ( + requireFromHooksDir + ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics.js`) + : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics.js') + ) as TracingHooksDiagnostics; + + const onDiagnostics = ({ moduleName, error }: DiagnosticsEvent): void => { + if (error) { + debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); + } else { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + } + }; + + setDiagnosticsHook(onDiagnostics); + if (typeof mod.registerHooks === 'function' && stableSyncHooks) { // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. - // We require() the module here so that we can synchronously load it, - // including from a CommonJS Sentry build, without bundlers pulling in. - // All versions in stableSyncHooks support this. - const { initialize, resolve, load, setDiagnosticsHook } = ( + // We require() this ESM module so that we can synchronously load it, + // including from a CommonJS Sentry build; all versions in + // stableSyncHooks support require(esm). + const { initialize, resolve, load } = ( requireFromHooksDir ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') ) as TracingHooksSync; - setDiagnosticsHook(event => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(event.moduleName); - }); - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); - DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); + debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and @@ -130,9 +163,20 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic const hookSpecifier = tracingHooksDir ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href : '@apm-js-collab/tracing-hooks/hook.mjs'; + + // The `Module.register` hooks run on a loader thread with its own copy of + // `lib/diagnostics.js`, so the hook set above never fires there; the loader thread + // posts diagnostics back over a MessagePort instead. This replicates + // `createDiagnosticsPort` from hook.mjs, which is ESM and therefore not + // synchronously loadable on all Node versions that take this branch. + const { port1, port2 } = new MessageChannel(); + port1.on('message', onDiagnostics); + // The diagnostics channel must not keep the process alive. + port1.unref(); mod.register(hookSpecifier, { parentURL: thisModuleUrl, - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort: port2 }, + transferList: [port2], }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -148,19 +192,16 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic patch: () => void; }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); - DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); + debug.log('Registered diagnostics-channel injection via Module.register()'); } else { - DEBUG_BUILD && - debug.warn('[Sentry] No available Node API to register diagnostics-channel injection hooks; skipping.'); + debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.'); return; } } catch (error) { - DEBUG_BUILD && - debug.warn( - '[Sentry] Failed to register diagnostics-channel injection hooks; channel-based integrations ' + - 'will not record spans.', - error, - ); + debug.warn( + 'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.', + error, + ); return; } diff --git a/packages/server-utils/src/redis/redis-dc-subscriber.ts b/packages/server-utils/src/redis/redis-dc-subscriber.ts index b1736cd74242..860f61138ae8 100644 --- a/packages/server-utils/src/redis/redis-dc-subscriber.ts +++ b/packages/server-utils/src/redis/redis-dc-subscriber.ts @@ -164,9 +164,6 @@ function setupCommandChannel( }); }, { - // Command failures are surfaced to (and usually handled by) the caller; only annotate the - // span so we don't emit a duplicate error event for every failed command. - captureError: false, beforeSpanEnd(span, data) { if ('error' in data) return; runResponseHook(responseHook, span, data.command, getCommandArgs(data), data.result); @@ -180,44 +177,36 @@ function setupBatchChannel( channelName: string, getOperationName: (data: RedisBatchData) => string, ): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - return startInactiveSpan({ - name: getOperationName(data), - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', - [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, - // should only include batch size greater than 1, - // or else it isn't properly considered a "batch" - ...(Number(data.batchSize) > 1 ? { [DB_OPERATION_BATCH_SIZE]: data.batchSize } : {}), - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + return startInactiveSpan({ + name: getOperationName(data), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + // should only include batch size greater than 1, + // or else it isn't properly considered a "batch" + ...(Number(data.batchSize) > 1 ? { [DB_OPERATION_BATCH_SIZE]: data.batchSize } : {}), + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }); } function setupConnectChannel(tracingChannel: RedisTracingChannelFactory, channelName: string): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - return startInactiveSpan({ - name: 'redis-connect', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', - [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + return startInactiveSpan({ + name: 'redis-connect', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }); } function runResponseHook( diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts index 4548ecc73b35..b41290b281e9 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts @@ -1,6 +1,13 @@ /* eslint-disable max-lines */ import type { Span } from '@sentry/core'; -import { debug, getActiveSpan, isObjectLike, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; +import { + addNonEnumerableProperty, + debug, + getActiveSpan, + isObjectLike, + SPAN_STATUS_ERROR, + withActiveSpan, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan, type TracingChannelPayloadWithSpan } from '../tracing-channel'; @@ -100,6 +107,21 @@ const callIdBySpan = new WeakMap(); // child `generate_content` span (whose event would fall back to the global default). v7's channel forwards // these flags on every event, so this keeps v6 identical. const recordingBySpan = new WeakMap>(); +interface OperationErrorInfo { + // The span active when the operation was invoked (its call site, e.g. the enclosing request/`main` span). + // When an operation's error bubbles out unhandled it reaches the global handler outside any span, so — as + // the OTel path does — we stamp this span onto the error (in `beforeSpanEnd`) so the unhandled-rejection + // handler can restore it and correlate the captured error to the operation's trace. + callSiteSpan: Span | undefined; + // Whether a thrown tool error bubbles out of the `ai` call (v4). On v4 a tool-`execute` rejection is + // wrapped in `AI_ToolExecutionError` and re-thrown, so it reaches the user's own error handling (or our + // global handlers) — capturing it at the tool span too would double-report it, matching how the OTel path + // leaves v4 tool errors to bubble. On v5 `executeTools` swallows the rejection into `tool-error` content, + // so it never surfaces and we must capture it ourselves (see `failSpan`). + toolErrorsBubbleToCaller: boolean; +} +// Per-operation error-handling info, keyed by the operation span (see `OperationErrorInfo`). +const operationErrorInfoBySpan = new WeakMap(); // The `experimental_telemetry` objects we swapped in to suppress `ai`'s native OTel spans (see // `suppressNativeTelemetry`). Our skip logic treats `isEnabled === false` as "user disabled telemetry, @@ -220,10 +242,17 @@ function bindOperation( // from the channels, so the SDK's would be duplicates. Reads above have already captured everything // we need off `telemetry`. suppressNativeTelemetry(callOptions, telemetry); + // The span active here is the operation's call site — `bindTracingChannelToSpan` only makes the + // operation span active *after* this returns — so this is the span we later restore for a bubbled error. + const callSiteSpan = getActiveSpan(); const span = createSpanFromMessage(message, options); if (span) { messages.set(data, message); operationSpans.add(span); + // `'v1'` models are v4 (v5/v6 are `'v2'`), where a thrown tool error bubbles out of the call rather + // than being swallowed into `tool-error` content — so its tool spans must not self-capture the error. + const isV4 = isObjectLike(callOptions.model) && callOptions.model.specificationVersion === 'v1'; + operationErrorInfoBySpan.set(span, { callSiteSpan, toolErrorsBubbleToCaller: isV4 }); // v6's `executeToolCall` span: tracked so the v5 tool-`execute` patch can recognize (and skip) // tool calls it runs, since that patch sees this span as its active parent (see `patchToolExecute`). if (message.type === 'executeTool') { @@ -239,13 +268,12 @@ function bindOperation( if (isObjectLike(callOptions.tools)) { patchOperationTools(callOptions.tools, options); } - // v4 has no `resolveLanguageModel` chokepoint — the passed `LanguageModelV1` - // (`specificationVersion: 'v1'`) is called directly — so patch its `doGenerate`/`doStream` - // here, at the operation start, instead. v5/v6 models are `'v2'` and are patched via - // `resolveLanguageModel`, so restricting to `'v1'` keeps this strictly additive and avoids - // double-patching. Embedding models are also `'v1'` but expose no `doGenerate`/`doStream`, so - // the patch is a no-op for `embed`/`embedMany`. - if (isObjectLike(callOptions.model) && callOptions.model.specificationVersion === 'v1') { + // v4 has no `resolveLanguageModel` chokepoint — the passed `LanguageModelV1` is called directly — so + // patch its `doGenerate`/`doStream` here, at the operation start, instead. v5/v6 models are patched via + // `resolveLanguageModel`, so restricting to v4 keeps this strictly additive and avoids double-patching. + // Embedding models are also `'v1'` but expose no `doGenerate`/`doStream`, so the patch is a no-op for + // `embed`/`embedMany`. + if (isV4) { patchModelMethods(callOptions.model as PatchableModel, options); } } @@ -262,7 +290,16 @@ function bindOperation( return; } // The helper's `error` handler already set the span status; only enrich from a successful result. - if (!('error' in data)) { + if ('error' in data) { + // The error bubbles out of the `ai` call. If nothing handles it before our global handler, that + // handler runs outside any span — so stamp the operation's call-site span onto the error, letting + // the unhandled-rejection handler restore it and correlate the report to this trace. This mirrors + // the OTel path; it's a no-op when the error is handled earlier (e.g. a framework error handler). + const callSiteSpan = operationErrorInfoBySpan.get(span)?.callSiteSpan; + if (callSiteSpan && isObjectLike(data.error)) { + addNonEnumerableProperty(data.error, '_sentry_active_span', callSiteSpan); + } + } else { // v6's `executeToolCall` returns the tool result/error object directly, whereas the shared core // (matching v7) expects it nested under `output`; wrap it so tool-error detection works. message.result = message.type === 'executeTool' ? { output: data.result } : data.result; @@ -586,11 +623,17 @@ function patchToolExecute( return original.apply(this, [input, ...rest]); } - // v5's `executeTools` catches a thrown tool error and turns it into `tool-error` content rather - // than rejecting, so the user never sees a rejection — we must capture it here. Rethrow so - // `executeTools` still produces its `tool-error` result and the operation continues normally. + // v5's `executeTools` catches a thrown tool error and turns it into `tool-error` content rather than + // rejecting, so the user never sees a rejection — we must capture it here. On v4 the rejection bubbles + // out of the call and reaches the user's error handling (or our global handlers), so we only mark the + // span and leave the capture to them, avoiding a duplicate report. Rethrow either way so `executeTools` + // still produces its `tool-error` result (v5) and the rejection propagates (v4). const failSpan = (error: unknown): never => { - captureToolError(span, message, error); + if (operationErrorInfoBySpan.get(parent)?.toolErrorsBubbleToCaller) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: error instanceof Error ? error.message : 'tool_error' }); + } else { + captureToolError(span, message, error); + } span.end(); throw error; }; diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 86e56ddd77a7..24a2accc036e 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; import { describe, expect, it } from 'vitest'; import { INSTRUMENTED_MODULE_NAMES, diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 5b609af79ed5..6041253a2d28 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -6,7 +6,13 @@ import { spanToJSON, startBrowserTracingNavigationSpan, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Client, Integration, Span } from '@sentry/core'; import { getClient, @@ -132,8 +138,8 @@ function withSentryRouterRoot(Root: Component): Component', key), value); - rootSpan.setAttribute(PARAMS_KEY.replace('', key), value); + rootSpan.setAttribute(`${URL_PATH_PARAMETER_KEY_BASE}.${key}`, value); + rootSpan.setAttribute(`${PARAMS_KEY_BASE}.${key}`, value); } } } else { diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index c02addfcfbaf..bc03ba841ff6 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -5,7 +5,13 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -182,8 +188,8 @@ function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record< const paramAttributes: Record = {}; Object.entries(match.params as Record).forEach(([key, value]) => { - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index de32230cf81f..20e53135df9b 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -18,6 +18,11 @@ "types": "build/types/index.types.d.ts", "exports": { "./package.json": "./package.json", + "./browser-tracing-variant": { + "types": "./build/types/client/svelte4BrowserTracing.d.ts", + "import": "./build/esm/client/svelte4BrowserTracing.js", + "require": "./build/cjs/client/svelte4BrowserTracing.js" + }, ".": { "types": "./build/types/index.types.d.ts", "worker": { @@ -42,7 +47,7 @@ "access": "public" }, "peerDependencies": { - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "2.x || ^3.0.0-0", "vite": "*" }, "peerDependenciesMeta": { diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index ca0792cb4868..dc6003ab7698 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -7,11 +7,17 @@ export default makeNPMConfigVariants( 'src/index.client.ts', 'src/index.worker.ts', 'src/client/index.ts', + // Browser-tracing variants, kept as standalone entrypoints so the `sentrySvelteKit()` plugin + // (or the `exports` fallback) can select one per SvelteKit version. + 'src/client/svelte4BrowserTracing.ts', + 'src/client/svelte5BrowserTracing.ts', 'src/server/index.ts', 'src/worker/index.ts', ], packageSpecificConfig: { - external: ['$app/stores'], + // Keep the variant subpath external so the transpiled output preserves the import for the + // consumer to resolve (via `exports` or the `sentrySvelteKit()` plugin). + external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant'], output: { dynamicImportInCjs: true, }, diff --git a/packages/sveltekit/src/client/browserTracingIntegration.ts b/packages/sveltekit/src/client/browserTracingIntegration.ts index e33ddd6daa66..3fb9b65bd1a7 100644 --- a/packages/sveltekit/src/client/browserTracingIntegration.ts +++ b/packages/sveltekit/src/client/browserTracingIntegration.ts @@ -1,15 +1,8 @@ -import type { Client, Integration, Span } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { - browserTracingIntegration as originalBrowserTracingIntegration, - getCurrentScope, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, - startInactiveSpan, - WINDOW, -} from '@sentry/svelte'; -import { navigating, page } from '$app/stores'; -import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Integration } from '@sentry/core'; +import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/svelte'; +// The `sentrySvelteKit()` Vite plugin redirects this to the Svelte 4 or Svelte 5 variant per Kit +// version; without the plugin it resolves via `exports` to the Svelte 4 variant, so builds don't break. +import { instrumentSvelteKitTracing } from '@sentry/sveltekit/browser-tracing-variant'; /** * A custom `BrowserTracing` integration for SvelteKit. @@ -29,128 +22,7 @@ export function browserTracingIntegration( ...integration, afterAllSetup: client => { integration.afterAllSetup(client); - - if (options.instrumentPageLoad !== false) { - _instrumentPageload(client); - } - - if (options.instrumentNavigation !== false) { - _instrumentNavigations(client); - } + instrumentSvelteKitTracing(client, options); }, }; } - -function _instrumentPageload(client: Client): void { - const initialPath = WINDOW.location?.pathname; - - const pageloadSpan = startBrowserTracingPageLoadSpan(client, { - name: initialPath, - op: 'pageload', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }, - }); - if (!pageloadSpan) { - return; - } - - // TODO(v11): require svelte 5 or newer to switch to `page` from `$app/state` - // eslint-disable-next-line typescript/no-deprecated - page.subscribe(page => { - if (!page) { - return; - } - - const routeId = page.route?.id; - - if (routeId) { - pageloadSpan.updateName(routeId); - pageloadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); - getCurrentScope().setTransactionName(routeId); - } - }); -} - -/** - * Use the `navigating` store to start a transaction on navigations. - */ -function _instrumentNavigations(client: Client): void { - let routingSpan: Span | undefined; - - // TODO(v11): require svelte 5 or newer to switch to `navigating` from `$app/state` - // eslint-disable-next-line typescript/no-deprecated - navigating.subscribe(navigation => { - if (!navigation) { - // `navigating` emits a 'null' value when the navigation is completed. - // So in this case, we can finish the routing span. If the span was an idle span, - // it will finish automatically and if it was user-created users also need to finish it. - if (routingSpan) { - routingSpan.end(); - routingSpan = undefined; - } - return; - } - - const from = navigation.from; - const to = navigation.to; - - // for the origin we can fall back to window.location.pathname because in this emission, it still is set to the origin path - const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; - - const rawRouteDestination = to?.url.pathname; - - // We don't want to create transactions for navigations of same origin and destination. - // We need to look at the raw URL here because parameterized routes can still differ in their raw parameters. - if (rawRouteOrigin === rawRouteDestination) { - return; - } - - const parameterizedRouteOrigin = from?.route.id; - const parameterizedRouteDestination = to?.route.id; - - if (routingSpan) { - // If a routing span is still open from a previous navigation, we finish it. - // This is important for e.g. redirects when a new navigation root span finishes - // the first root span. If we don't `.end()` the previous span, it will get - // status 'cancelled' which isn't entirely correct. - routingSpan.end(); - } - - const navigationInfo = { - // `navigation.type` denotes the origin of the navigation. e.g.: - // - link (clicking on a link) - // - goto (programmatic via goto() or redirect()) - // - popstate (back/forward navigation) - 'sentry.sveltekit.navigation.type': navigation.type, - 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, - 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, - }; - - startBrowserTracingNavigationSpan( - client, - { - name: parameterizedRouteDestination || rawRouteDestination || 'unknown', - op: 'navigation', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', - ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), - ...navigationInfo, - }, - }, - { url: to?.url.href }, - ); - - routingSpan = startInactiveSpan({ - op: 'ui.sveltekit.routing', - name: 'SvelteKit Route Change', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', - ...navigationInfo, - }, - onlyIfParent: true, - }); - }); -} diff --git a/packages/sveltekit/src/client/navigationState.svelte.ts b/packages/sveltekit/src/client/navigationState.svelte.ts new file mode 100644 index 000000000000..0273dee73bf7 --- /dev/null +++ b/packages/sveltekit/src/client/navigationState.svelte.ts @@ -0,0 +1,52 @@ +import type { Navigation } from '@sveltejs/kit'; +import { navigating, page } from '$app/state'; + +declare const $effect: { + (callback: () => void): void; + root(callback: () => void): () => void; +}; + +/** + * Observes the current page's parameterized route id (`$app/state`'s `page.route.id`). Unlike Kit 2's + * `page` store, the rune isn't populated synchronously when the SDK sets up during `Sentry.init`, so + * we react to it instead of reading it once. Used to upgrade the pageload span from `url` to `route`. + */ +export function onPageRouteChange(callback: (routeId: string | null) => void): () => void { + return $effect.root(() => { + $effect(() => { + callback(page.route.id); + }); + }); +} + +export function onNavigationChange(callback: (navigation: Navigation | null) => void): () => void { + return $effect.root(() => { + $effect(() => { + callback(_snapshot()); + }); + }); +} + +/** + * Reads the current navigation synchronously. `$effect`s are always batched to a microtask, which is + * too late to catch SvelteKit's synchronous data-request `fetch` at navigation start; a synchronous + * rune read lets us start the navigation span before that request so it propagates the right trace. + */ +export function getCurrentNavigation(): Navigation | null { + return _snapshot(); +} + +function _snapshot(): Navigation | null { + if (navigating.type === null) { + return null; + } + + return { + complete: navigating.complete, + delta: navigating.delta, + from: navigating.from, + to: navigating.to, + type: navigating.type, + willUnload: navigating.willUnload, + } as Navigation; +} diff --git a/packages/sveltekit/src/client/svelte4BrowserTracing.ts b/packages/sveltekit/src/client/svelte4BrowserTracing.ts new file mode 100644 index 000000000000..a90de820b6bd --- /dev/null +++ b/packages/sveltekit/src/client/svelte4BrowserTracing.ts @@ -0,0 +1,137 @@ +import type { Client, Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getCurrentScope, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + startInactiveSpan, + WINDOW, +} from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Navigation, Page } from '@sveltejs/kit'; +// eslint-disable-next-line typescript/no-deprecated +import { navigating, page } from '$app/stores'; +import type { Readable } from 'svelte/store'; + +/** + * SvelteKit 2 / Svelte 4 browser tracing (`$app/stores`). Selected at build time, so it's only + * bundled on Kit 2 (where `$app/state` may not exist). + * @internal + */ +export function instrumentSvelteKitTracing( + client: Client, + options: { instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, +): void { + if (options.instrumentPageLoad !== false) { + // eslint-disable-next-line typescript/no-deprecated + _instrumentPageload(client, page); + } + + if (options.instrumentNavigation !== false) { + // eslint-disable-next-line typescript/no-deprecated + _instrumentNavigations(client, navigating); + } +} + +function _instrumentPageload(client: Client, pageStore: Readable): void { + const initialPath = WINDOW.location?.pathname; + + const pageloadSpan = startBrowserTracingPageLoadSpan(client, { + name: initialPath, + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + if (!pageloadSpan) { + return; + } + + pageStore.subscribe(pageState => { + if (!pageState) { + return; + } + + const routeId = pageState.route?.id; + + if (routeId) { + pageloadSpan.updateName(routeId); + pageloadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); + getCurrentScope().setTransactionName(routeId); + } + }); +} + +/** + * Use the `navigating` store to start a transaction on navigations. + */ +function _instrumentNavigations(client: Client, navigatingStore: Readable): void { + let routingSpan: Span | undefined; + + navigatingStore.subscribe(navigation => { + if (!navigation) { + // `navigating` emits a 'null' value when the navigation is completed. + // So in this case, we can finish the routing span. If the span was an idle span, + // it will finish automatically and if it was user-created users also need to finish it. + if (routingSpan) { + routingSpan.end(); + routingSpan = undefined; + } + return; + } + + const from = navigation.from; + const to = navigation.to; + + // for the origin we can fall back to window.location.pathname because in this emission, it still is set to the origin path + const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; + + const rawRouteDestination = to?.url.pathname; + + // We don't want to create transactions for navigations of same origin and destination. + // We need to look at the raw URL here because parameterized routes can still differ in their raw parameters. + if (rawRouteOrigin === rawRouteDestination) { + return; + } + + const parameterizedRouteOrigin = from?.route.id; + const parameterizedRouteDestination = to?.route.id; + + if (routingSpan) { + // If a routing span is still open from a previous navigation, we finish it. + routingSpan.end(); + } + + const navigationInfo = { + 'sentry.sveltekit.navigation.type': navigation.type, + 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, + 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, + }; + + startBrowserTracingNavigationSpan( + client, + { + name: parameterizedRouteDestination || rawRouteDestination || 'unknown', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', + ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), + ...navigationInfo, + }, + }, + { url: to?.url.href }, + ); + + routingSpan = startInactiveSpan({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + ...navigationInfo, + }, + onlyIfParent: true, + }); + }); +} diff --git a/packages/sveltekit/src/client/svelte5BrowserTracing.ts b/packages/sveltekit/src/client/svelte5BrowserTracing.ts new file mode 100644 index 000000000000..166417dd9d64 --- /dev/null +++ b/packages/sveltekit/src/client/svelte5BrowserTracing.ts @@ -0,0 +1,143 @@ +import type { Client, Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getCurrentScope, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + startInactiveSpan, + WINDOW, +} from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Navigation } from '@sveltejs/kit'; +import { getCurrentNavigation, onNavigationChange, onPageRouteChange } from './navigationState.svelte'; + +/** + * SvelteKit 3 / Svelte 5 browser tracing (`$app/state` runes). Selected at build time, so it's only + * bundled on Kit 3. + * @internal + */ +export function instrumentSvelteKitTracing( + client: Client, + options: { instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, +): void { + if (options.instrumentPageLoad !== false) { + _instrumentPageLoad(client); + } + + if (options.instrumentNavigation !== false) { + _instrumentNavigations(client); + } +} + +function _instrumentPageLoad(client: Client): void { + const initialPath = WINDOW.location?.pathname; + + const pageLoadSpan = startBrowserTracingPageLoadSpan(client, { + name: initialPath, + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + if (!pageLoadSpan) { + return; + } + + // `page.route.id` isn't available synchronously when we set up (during `Sentry.init`), so we react + // to it and upgrade the pageload span from `url` to the parameterized `route` once it resolves. + onPageRouteChange(routeId => { + if (routeId) { + pageLoadSpan.updateName(routeId); + pageLoadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); + getCurrentScope().setTransactionName(routeId); + } + }); +} + +function _instrumentNavigations(client: Client): void { + let routingSpan: Span | undefined; + // Deduplicates the two triggers below (the `fetch` wrapper and the `$effect`) so a single + // navigation starts exactly one span, regardless of which fires first. + let activeNavigationId: string | undefined; + + function _startNavigation(navigation: Navigation): void { + const from = navigation.from; + const to = navigation.to; + const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; + const rawRouteDestination = to?.url.pathname; + + if (rawRouteOrigin === rawRouteDestination) { + return; + } + + const navigationId = to?.url.href; + if (navigationId && navigationId === activeNavigationId) { + return; + } + activeNavigationId = navigationId; + + const parameterizedRouteOrigin = from?.route.id; + const parameterizedRouteDestination = to?.route.id; + + routingSpan?.end(); + + const navigationInfo = { + 'sentry.sveltekit.navigation.type': navigation.type, + 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, + 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, + }; + + startBrowserTracingNavigationSpan( + client, + { + name: parameterizedRouteDestination || rawRouteDestination || 'unknown', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', + ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), + ...navigationInfo, + }, + }, + { url: to?.url.href }, + ); + + routingSpan = startInactiveSpan({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + ...navigationInfo, + }, + onlyIfParent: true, + }); + } + + // SvelteKit fires its data request (e.g. `__data.json`) synchronously at navigation start, before + // a `$effect` on `navigating` runs (microtask), so the request would carry the previous trace. We + // wrap `fetch` to read `navigating` synchronously and start the nav span before the request goes. + const originalFetch = WINDOW.fetch?.bind(WINDOW); + if (originalFetch) { + WINDOW.fetch = (...args: Parameters): ReturnType => { + const navigation = getCurrentNavigation(); + if (navigation) { + _startNavigation(navigation); + } + return originalFetch(...args); + }; + } + + onNavigationChange(navigation => { + if (!navigation) { + routingSpan?.end(); + routingSpan = undefined; + activeNavigationId = undefined; + return; + } + + // Fallback for navigations that don't issue an outgoing request (the `fetch` wrapper never fires). + _startNavigation(navigation); + }); +} diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index f97b923ef65d..07e71966beda 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -75,7 +75,13 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio }, async load(id) { - if (onlyInstrumentClient && isServerBuild) { + // On Vite 6+ `config.build.ssr` captured in `configResolved` no longer reliably reflects the per-environment build. + // Prefer the environment of the current build (`this.environment.name === 'ssr'`) and fall back to + // `isServerBuild` for older Vite versions that don't expose environments. + const environmentName = (this as { environment?: { name?: string } }).environment?.name; + const isServerEnvironment = environmentName != null ? environmentName === 'ssr' : !!isServerBuild; + + if (onlyInstrumentClient && isServerEnvironment) { return null; } diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 3d6be5319bed..2377c1e7f65c 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -1,3 +1,6 @@ +import { consoleSandbox } from '@sentry/core'; +import * as fs from 'fs'; +import * as path from 'path'; import type { Plugin } from 'vite'; import type { AutoInstrumentSelection } from './autoInstrument'; import { makeAutoInstrumentationPlugin } from './autoInstrument'; @@ -29,7 +32,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} adapter: options.adapter || (await detectAdapter(svelteConfig, options.debug)), }; - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [makeBrowserTracingVariantResolverPlugin()]; if (mergedOptions.autoInstrument) { // SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there. @@ -71,6 +74,93 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} return sentryPlugins; } +// A bare subpath (not a relative import) so this plugin's `resolveId` can intercept it. +const BROWSER_TRACING_VARIANT_ID = '@sentry/sveltekit/browser-tracing-variant'; + +/** + * Redirects the browser-tracing variant import to the Svelte 4 (`$app/stores`) or Svelte 5 + * (`$app/state`) variant per installed SvelteKit version, so only the matching one is bundled and + * instrumentation runs eagerly (no dynamic import). + */ +function makeBrowserTracingVariantResolverPlugin(): Plugin { + return { + name: 'sentry-sveltekit-browser-tracing-variant', + enforce: 'pre', + // Dev only: esbuild pre-bundles deps before `resolveId` runs, so exclude the SDK to let us + // redirect the import (not needed for build). + config(_config, { command }) { + if (command === 'serve') { + return { optimizeDeps: { exclude: ['@sentry/sveltekit'] } }; + } + return undefined; + }, + async resolveId(id) { + if (id !== BROWSER_TRACING_VARIANT_ID) { + return null; + } + + const variantModule = (await isSvelteKit3(id => this.resolve(id, undefined, { skipSelf: true }))) + ? 'svelte5BrowserTracing' + : 'svelte4BrowserTracing'; + + // Point at the variant file next to the SDK's resolved entry (absolute path, so it stays internal). + const sdkEntry = await this.resolve('@sentry/sveltekit', undefined, { skipSelf: true }); + if (!sdkEntry) { + return null; + } + + return path.join(path.dirname(sdkEntry.id), 'client', `${variantModule}.js`); + }, + }; +} + +/** + * Whether to use the SvelteKit 3 (`$app/state`) variant, from the installed `@sveltejs/kit` version + * (resolved via the bundler, not `process.cwd()`). + * + * If the Kit version can't be read, fall back to the Svelte major and warn (never throw): Svelte < 5 + * can't be Kit 3 (use `$app/stores`); on Svelte 5 use `$app/state`, which works on both Kit 2.12+ and + * Kit 3 — the safe direction, since `$app/stores` hard-breaks on Kit 3. + */ +async function isSvelteKit3(resolve: (id: string) => Promise<{ id: string } | null>): Promise { + const kitMajor = await readPackageMajor(resolve, '@sveltejs/kit/package.json'); + if (kitMajor !== undefined) { + return kitMajor >= 3; + } + + const svelteMajor = await readPackageMajor(resolve, 'svelte/package.json'); + const useKit3Variant = svelteMajor === undefined || svelteMajor >= 5; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + "[@sentry/sveltekit] Couldn't read the installed `@sveltejs/kit` version to set up browser " + + `tracing; falling back to the Svelte ${svelteMajor ?? '?'} based variant ` + + `(${useKit3Variant ? '`$app/state`' : '`$app/stores`'}). ` + + 'If browser tracing misbehaves, please report this to the Sentry SDK team.', + ); + }); + return useKit3Variant; +} + +async function readPackageMajor( + resolve: (id: string) => Promise<{ id: string } | null>, + packageJsonId: string, +): Promise { + try { + const resolved = await resolve(packageJsonId); + if (resolved) { + const { version } = JSON.parse(fs.readFileSync(resolved.id, 'utf8')) as { version: string }; + const major = parseInt(version.split('.')[0] || '', 10); + if (!Number.isNaN(major)) { + return major; + } + } + } catch { + // ignore — caller handles the `undefined` case + } + return undefined; +} + /** * This function creates the options for the custom Sentry Vite plugin. * The options are derived from the Sentry SvelteKit plugin options, where the `_unstable` options take precedence. diff --git a/packages/sveltekit/test/client/browserTracingIntegration.test.ts b/packages/sveltekit/test/client/browserTracingIntegration.test.ts index ab3a1f7a0f3a..c091c2b20f1e 100644 --- a/packages/sveltekit/test/client/browserTracingIntegration.test.ts +++ b/packages/sveltekit/test/client/browserTracingIntegration.test.ts @@ -104,10 +104,11 @@ describe('browserTracingIntegration', () => { }); }); - it("starts a pageload span when it's called with default params", () => { + it("starts a pageload span when it's called with default params", async () => { const integration = browserTracingIntegration(); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(1); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(fakeClient, { @@ -134,17 +135,18 @@ describe('browserTracingIntegration', () => { }); }); - it("doesn't start a pageload span if `instrumentPageLoad` is false", () => { + it("doesn't start a pageload span if `instrumentPageLoad` is false", async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(0); }); - it("updates the current scope's transactionName once it's resolved during pageload", () => { + it("updates the current scope's transactionName once it's resolved during pageload", async () => { const scopeSetTransactionNameSpy = vi.fn(); // @ts-expect-error - only returning a partial scope here, that's fine @@ -157,6 +159,7 @@ describe('browserTracingIntegration', () => { const integration = browserTracingIntegration(); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `page` store to simulate the SvelteKit router lifecycle // TODO(v11): switch to `page` from `$app/state` @@ -169,12 +172,13 @@ describe('browserTracingIntegration', () => { expect(scopeSetTransactionNameSpy).toHaveBeenLastCalledWith('testRoute/:id'); }); - it("doesn't start a navigation span when `instrumentNavigation` is false", () => { + it("doesn't start a navigation span when `instrumentNavigation` is false", async () => { const integration = browserTracingIntegration({ instrumentNavigation: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -189,12 +193,13 @@ describe('browserTracingIntegration', () => { expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); }); - it('starts a navigation span when `startTransactionOnLocationChange` is true', () => { + it('starts a navigation span when `startTransactionOnLocationChange` is true', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -247,12 +252,13 @@ describe('browserTracingIntegration', () => { }); describe('handling same origin and destination navigations', () => { - it("doesn't start a navigation span if the raw navigation origin and destination are equal", () => { + it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -266,12 +272,13 @@ describe('browserTracingIntegration', () => { expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); }); - it('starts a navigation transaction if the raw navigation origin and destination are not equal', () => { + it('starts a navigation transaction if the raw navigation origin and destination are not equal', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // TODO(v11): switch to `navigating` from `$app/state` // @ts-expect-error - navigating is a writable but the types say it's just readable @@ -313,12 +320,13 @@ describe('browserTracingIntegration', () => { }); }); - it('falls back to `window.location.pathname` to determine the raw origin', () => { + it('falls back to `window.location.pathname` to determine the raw origin', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // window.location.pathname is "/" in tests diff --git a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts new file mode 100644 index 000000000000..ec11e78ba70a --- /dev/null +++ b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts @@ -0,0 +1,178 @@ +/** + * @vitest-environment jsdom + */ + +/* eslint-disable @typescript-eslint/unbound-method */ +import type { Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import * as SentrySvelte from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentSvelteKitTracing } from '../../src/client/svelte5BrowserTracing'; + +// The Svelte 5 variant reads navigation state from rune-backed helpers (`navigationState.svelte.ts`), +// which need the Svelte compiler. We mock them here so the variant's orchestration logic (fetch +// wrapper, dedup, pageload upgrade) can be unit-tested; the rune integration itself is covered by the +// `sveltekit-3` e2e app. +let currentNavigation: unknown = null; +let navigationChangeCb: ((navigation: unknown) => void) | undefined; +let pageRouteChangeCb: ((routeId: string | null) => void) | undefined; + +vi.mock('../../src/client/navigationState.svelte', () => ({ + getCurrentNavigation: () => currentNavigation, + onNavigationChange: (cb: (navigation: unknown) => void) => { + navigationChangeCb = cb; + return () => {}; + }, + onPageRouteChange: (cb: (routeId: string | null) => void) => { + pageRouteChangeCb = cb; + return () => {}; + }, +})); + +const navigationTo = (fromId: string, toId: string, toPath: string, href: string): unknown => ({ + from: { route: { id: fromId }, url: { pathname: `/${fromId}` } }, + to: { route: { id: toId }, url: { pathname: toPath, href } }, + type: 'link', +}); + +describe('svelte5 browser tracing', () => { + let createdRootSpan: Partial | undefined; + + const startPageLoadSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingPageLoadSpan') + .mockImplementation((_client, ctx) => { + createdRootSpan = { ...ctx, updateName: vi.fn(), setAttributes: vi.fn() }; + return createdRootSpan as Span; + }); + + const startNavigationSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingNavigationSpan') + .mockImplementation((_client, ctx) => { + createdRootSpan = { ...ctx, updateName: vi.fn(), setAttributes: vi.fn() }; + return createdRootSpan as Span; + }); + + const routingSpan = { end: vi.fn() }; + const startInactiveSpanSpy = vi + .spyOn(SentrySvelte, 'startInactiveSpan') + .mockImplementation(() => routingSpan as unknown as Span); + + const setTransactionNameSpy = vi.fn(); + vi.spyOn(SentrySvelte, 'getCurrentScope').mockImplementation( + () => ({ setTransactionName: setTransactionNameSpy }) as unknown as ReturnType, + ); + + const client = {} as Parameters[0]; + + beforeEach(() => { + vi.clearAllMocks(); + currentNavigation = null; + navigationChangeCb = undefined; + pageRouteChangeCb = undefined; + createdRootSpan = undefined; + // The variant wraps `window.fetch`; give it a resolvable fetch to wrap and reset each test. + window.fetch = vi.fn().mockResolvedValue(undefined); + }); + + describe('pageload', () => { + it('starts a `url`-sourced pageload span and upgrades to `route` once the route id resolves', () => { + instrumentSvelteKitTracing(client, {}); + + expect(startPageLoadSpanSpy).toHaveBeenCalledWith(client, { + name: '/', + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + pageRouteChangeCb?.('/users/[id]'); + + expect(createdRootSpan?.updateName).toHaveBeenCalledWith('/users/[id]'); + expect(createdRootSpan?.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/[id]', + }); + expect(setTransactionNameSpy).toHaveBeenCalledWith('/users/[id]'); + }); + + it("doesn't upgrade the pageload span while the route id is still null", () => { + instrumentSvelteKitTracing(client, {}); + pageRouteChangeCb?.(null); + expect(createdRootSpan?.updateName).not.toHaveBeenCalled(); + }); + + it('respects `instrumentPageLoad: false`', () => { + instrumentSvelteKitTracing(client, { instrumentPageLoad: false }); + expect(startPageLoadSpanSpy).not.toHaveBeenCalled(); + }); + }); + + describe('navigation', () => { + it('starts the navigation span from the outgoing fetch (before the request), sourced by route', async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/', '/users/[id]', '/users/7', 'https://sentry-test.io/users/7'); + + await window.fetch('https://sentry-test.io/users/7/__data.json'); + + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startNavigationSpanSpy).toHaveBeenCalledWith( + client, + expect.objectContaining({ + name: '/users/[id]', + op: 'navigation', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/[id]', + }), + }), + { url: 'https://sentry-test.io/users/7' }, + ); + }); + + it('deduplicates: several fetches within one navigation start exactly one span', async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/', '/a', '/a', 'https://sentry-test.io/a'); + + await window.fetch('https://sentry-test.io/a/__data.json'); + await window.fetch('https://sentry-test.io/a/sub-request'); + + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + }); + + it('falls back to the `onNavigationChange` effect for navigations without a fetch, and ends the routing span on completion', () => { + instrumentSvelteKitTracing(client, {}); + + navigationChangeCb?.(navigationTo('/', '/a', '/a', 'https://sentry-test.io/a')); + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startInactiveSpanSpy).toHaveBeenCalledTimes(1); + + // `navigating` emits null when navigation completes + navigationChangeCb?.(null); + expect(routingSpan.end).toHaveBeenCalledTimes(1); + }); + + it("doesn't start a navigation span when origin and destination raw paths are equal", async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/a', '/a', '/a', 'https://sentry-test.io/a'); + // origin pathname is derived from `from.url.pathname` -> '//a'; make them equal explicitly + (currentNavigation as { from: { url: { pathname: string } } }).from.url.pathname = '/a'; + + await window.fetch('https://sentry-test.io/a'); + + expect(startNavigationSpanSpy).not.toHaveBeenCalled(); + }); + + it('respects `instrumentNavigation: false` (no fetch wrapper span)', async () => { + instrumentSvelteKitTracing(client, { instrumentNavigation: false }); + currentNavigation = navigationTo('/', '/a', '/a', 'https://sentry-test.io/a'); + + await window.fetch('https://sentry-test.io/a'); + + expect(startNavigationSpanSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/sveltekit/test/vite/autoInstrument.test.ts b/packages/sveltekit/test/vite/autoInstrument.test.ts index 46e71c207018..42d805565d4c 100644 --- a/packages/sveltekit/test/vite/autoInstrument.test.ts +++ b/packages/sveltekit/test/vite/autoInstrument.test.ts @@ -293,6 +293,51 @@ describe('makeAutoInstrumentationPlugin()', () => { }); }); }); + + describe('when the server build is detected via the Vite Environment API', () => { + // On Vite 6+ `config.build.ssr` no longer reliably reflects the per-environment + // build, so the plugin relies on the current environment (`this.environment.name`). When + // `onlyInstrumentClient` is `true`, universal load must not be wrapped in the `ssr` environment + // (but should still be wrapped in `client`), even when `config.build.ssr`/`configResolved` + // didn't flag a server build. + it.each(['path/to/+page.ts', 'path/to/+layout.js', 'path/to/+page.server.ts'])( + "doesn't wrap %s in the `ssr` environment", + async (path: string) => { + const plugin = makeAutoInstrumentationPlugin({ + debug: false, + load: true, + serverLoad: true, + onlyInstrumentClient: true, + }); + + // `configResolved` is intentionally not called - `isServerBuild` stays `undefined` + // @ts-expect-error this exists and is callable; bind `this.environment` like Vite does + const loadResult = await plugin.load.call({ environment: { name: 'ssr' } }, path); + + expect(loadResult).toEqual(null); + }, + ); + + it('still wraps universal load in the `client` environment', async () => { + const plugin = makeAutoInstrumentationPlugin({ + debug: false, + load: true, + serverLoad: true, + onlyInstrumentClient: true, + }); + + const path = 'path/to/+page.ts'; + // @ts-expect-error this exists and is callable; bind `this.environment` like Vite does + const loadResult = await plugin.load.call({ environment: { name: 'client' } }, path); + + expect(loadResult).toBe( + 'import { wrapLoadWithSentry } from "@sentry/sveltekit";' + + `import * as userModule from "${path}?sentry-auto-wrap";` + + 'export const load = userModule.load ? wrapLoadWithSentry(userModule.load) : undefined;' + + `export * from "${path}?sentry-auto-wrap";`, + ); + }); + }); }); describe('canWrapLoad', () => { diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 4e8a73e71003..eeb72b111ee5 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -42,14 +42,17 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins(); expect(plugins).toBeInstanceOf(Array); - // 1 auto instrument plugin + 1 global values injection plugin + 1 modified main plugin + 3 custom plugins - expect(plugins).toHaveLength(6); + // 1 browser-tracing variant resolver + 1 auto instrument plugin + 1 global values injection plugin + // + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(7); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { const plugins = await getSentrySvelteKitPlugins(); const pluginNames = plugins.map(plugin => plugin.name); expect(pluginNames).toEqual([ + // browser-tracing variant resolver: + 'sentry-sveltekit-browser-tracing-variant', // auto instrument plugin: 'sentry-auto-instrumentation', // global values injection plugin: @@ -65,7 +68,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(1); // auto instrument + expect(plugins).toHaveLength(2); // browser-tracing variant resolver + auto instrument }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -73,9 +76,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[0]; + const instrumentPlugin = plugins[1]; - expect(plugins).toHaveLength(2); // auto instrument + global values injection + expect(plugins).toHaveLength(3); // browser-tracing variant resolver + auto instrument + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -84,7 +87,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(5); // global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(6); // browser-tracing variant resolver + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -188,7 +191,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[0]!; + const plugin = plugins[1]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/tsconfig.json b/packages/sveltekit/tsconfig.json index bf45a09f2d71..5de06be82324 100644 --- a/packages/sveltekit/tsconfig.json +++ b/packages/sveltekit/tsconfig.json @@ -4,6 +4,10 @@ "include": ["src/**/*"], "compilerOptions": { - // package-specific options + // Resolve the self-referenced variant subpath to source for type-checking, so tsc doesn't resolve + // it via `exports` to a `.d.ts` it's emitting (TS5055/TS2209). Emitted types keep the subpath. + "paths": { + "@sentry/sveltekit/browser-tracing-variant": ["./src/client/svelte4BrowserTracing.ts"] + } } } diff --git a/packages/sveltekit/vite.config.ts b/packages/sveltekit/vite.config.ts index 2b4e927808e7..8cadb91fb423 100644 --- a/packages/sveltekit/vite.config.ts +++ b/packages/sveltekit/vite.config.ts @@ -12,6 +12,15 @@ export default { find: '$app/stores', replacement: resolve(fileURLToPath(dirname(import.meta.url)), '/.empty.js'), }, + { + find: '$app/state', + replacement: resolve(fileURLToPath(dirname(import.meta.url)), '/.empty.js'), + }, + { + // Unit tests target the Svelte 4 variant; the Svelte 5 rune variant is covered by e2e. + find: '@sentry/sveltekit/browser-tracing-variant', + replacement: resolve(dirname(fileURLToPath(import.meta.url)), 'src/client/svelte4BrowserTracing.ts'), + }, ], }, }; diff --git a/packages/vue/package.json b/packages/vue/package.json index 4c255f292690..5a085347eba1 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -57,7 +57,7 @@ }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", - "pinia": "2.x || 3.x", + "pinia": "2.x || 3.x || 4.x", "vue": "2.x || 3.x" }, "peerDependenciesMeta": { diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 91d4a5b6c4f8..9a1c5335d81e 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,5 +1,10 @@ import { captureException, getAbsoluteUrl } from '@sentry/browser'; -import { PARAMS_KEY, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + NAVIGATION_ROUTE_ID, + PARAMS_KEY_BASE, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; import { getActiveSpan, @@ -74,8 +79,8 @@ export function instrumentVueRouter( const attributes: SpanAttributes = {}; for (const key of Object.keys(to.params)) { - attributes[URL_PATH_PARAMETER_KEY.replace('', key)] = to.params[key]; - attributes[PARAMS_KEY.replace('', key)] = to.params[key]; // params.[key] is an alias + attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = to.params[key]; + attributes[`${PARAMS_KEY_BASE}.${key}`] = to.params[key]; // params.[key] is an alias } for (const key of Object.keys(to.query)) { const value = to.query[key]; @@ -101,6 +106,10 @@ export function instrumentVueRouter( attributes[URL_TEMPLATE] = spanName; } + if (to.name) { + attributes[NAVIGATION_ROUTE_ID] = to.name.toString(); + } + getCurrentScope().setTransactionName(spanName); // Update the existing page load span with parametrized route information diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index 6521bbe201a5..e2508f2754df 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -5,7 +5,13 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -191,8 +197,8 @@ function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record< const paramAttributes: Record = {}; Object.entries(match.params as Record).forEach(([key, value]) => { - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 498c41f31ed3..2bd2fe2b5ace 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -2,7 +2,7 @@ import * as SentryBrowser from '@sentry/browser'; import type { Span, SpanAttributes } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { NAVIGATION_ROUTE_ID, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Route } from '../src/router'; import { instrumentVueRouter } from '../src/router'; @@ -465,5 +465,9 @@ function getAttributesForRoute(route: Route, urlTemplate?: string): SpanAttribut attributes[URL_TEMPLATE] = urlTemplate; } + if (route.name) { + attributes[NAVIGATION_ROUTE_ID] = route.name.toString(); + } + return attributes; } diff --git a/yarn.lock b/yarn.lock index 7b70dded22ba..381e90c80f85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,10 +404,10 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.6.1.tgz#29bf79167411b8607f02db177250b8f7c16d93e1" - integrity sha512-TDvypsLUk/W202Y1JBE6I2enQalWjbYXKnxpELWEZn6GLoIcGFii69ljh948hdAzKZJ/wg0Uwivz72a/szoOBQ== +"@apm-js-collab/code-transformer-bundler-plugins@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.1.tgz#f9e7128b87cdabb50e91f2ce3a5c48505b511c41" + integrity sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" es-module-lexer "^2.1.0" @@ -5055,6 +5055,17 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.7.0.tgz#526d437b07cbb41e28df34d487cbfccbe730185b" integrity sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg== +"@koa/router@^12.0.1": + version "12.0.2" + resolved "https://registry.yarnpkg.com/@koa/router/-/router-12.0.2.tgz#286d51959ed611255faa944818a112e35567835a" + integrity sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA== + dependencies: + debug "^4.3.4" + http-errors "^2.0.0" + koa-compose "^4.1.0" + methods "^1.1.2" + path-to-regexp "^6.3.0" + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -6101,10 +6112,10 @@ "@opentelemetry/resources" "2.9.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.42.0": - version "1.42.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.42.0.tgz#38f10edd26f02931bf3b7d664ba59bea6279a384" - integrity sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw== +"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.43.0": + version "1.43.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz#f3f467e36c27332f0e735ec86cdcd78dd6f27865" + integrity sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg== "@oxc-parser/binding-android-arm64@0.76.0": version "0.76.0" @@ -10440,6 +10451,14 @@ abstract-logging@^2.0.1: resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== +accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + accepts@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" @@ -10448,14 +10467,6 @@ accepts@^2.0.0: mime-types "^3.0.0" negotiator "^1.0.0" -accepts@~1.3.4, accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -12575,6 +12586,14 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +cache-content-type@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" + integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== + dependencies: + mime-types "^2.1.18" + ylru "^1.2.0" + calculate-cache-key-for-tree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-2.0.0.tgz#7ac57f149a4188eacb0a45b210689215d3fef8d6" @@ -13020,6 +13039,11 @@ cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0: resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + code-red@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/code-red/-/code-red-1.0.4.tgz#59ba5c9d1d320a4ef795bc10a28bd42bfebe3e35" @@ -13366,14 +13390,14 @@ content-disposition@^1.0.0: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== -content-disposition@~0.5.4: +content-disposition@~0.5.2, content-disposition@~0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== dependencies: safe-buffer "5.2.1" -content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.4, content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -13440,6 +13464,14 @@ cookie@^1.0.1, cookie@^1.0.2, cookie@^1.1.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== +cookies@~0.9.0: + version "0.9.1" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3" + integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + copy-anything@^2.0.1: version "2.0.6" resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-2.0.6.tgz#092454ea9584a7b7ad5573062b2a87f5900fc480" @@ -14001,6 +14033,11 @@ deep-equal@^2.0.5: which-collection "^1.0.1" which-typed-array "^1.1.13" +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -14168,7 +14205,7 @@ destr@^2.0.3, destr@^2.0.5: resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb" integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA== -destroy@1.2.0, destroy@~1.2.0: +destroy@1.2.0, destroy@^1.0.4, destroy@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== @@ -15171,16 +15208,16 @@ enabled@2.0.x: resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== +encodeurl@^1.0.2, encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -18446,6 +18483,14 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" +http-assert@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.8.0" + http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" @@ -18456,6 +18501,17 @@ http-deceiver@^1.2.7: resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= +http-errors@^1.6.3, http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" @@ -19969,6 +20025,13 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -20033,6 +20096,48 @@ knitwork@^1.2.0, knitwork@^1.3.0: resolved "https://registry.yarnpkg.com/knitwork/-/knitwork-1.3.0.tgz#4a0d0b0d45378cac909ee1117481392522bd08a4" integrity sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw== +koa-compose@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" + integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== + +koa-convert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" + integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== + dependencies: + co "^4.6.0" + koa-compose "^4.1.0" + +koa@^2.15.2: + version "2.16.4" + resolved "https://registry.yarnpkg.com/koa/-/koa-2.16.4.tgz#303b996f5c3f2a3bb771c7db5e4303ee05f2265f" + integrity sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw== + dependencies: + accepts "^1.3.5" + cache-content-type "^1.0.0" + content-disposition "~0.5.2" + content-type "^1.0.4" + cookies "~0.9.0" + debug "^4.3.2" + delegates "^1.0.0" + depd "^2.0.0" + destroy "^1.0.4" + encodeurl "^1.0.2" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.3.0" + http-errors "^1.6.3" + is-generator-function "^1.0.7" + koa-compose "^4.1.0" + koa-convert "^2.0.0" + on-finished "^2.3.0" + only "~0.0.2" + parseurl "^1.3.2" + statuses "^1.5.0" + type-is "^1.6.16" + vary "^1.1.2" + kubernetes-types@^1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" @@ -21110,7 +21215,7 @@ meriyah@^6.1.4: resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -23109,7 +23214,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@^2.4.1, on-finished@~2.4.1: +on-finished@^2.3.0, on-finished@^2.4.1, on-finished@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -23177,6 +23282,11 @@ oniguruma-to-es@^2.2.0: regex "^5.1.1" regex-recursion "^5.1.1" +only@~0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" + integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== + open@8.4.0: version "8.4.0" resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" @@ -23725,7 +23835,7 @@ parse5@^7.0.0, parse5@^7.1.2: dependencies: entities "^4.4.0" -parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: +parseurl@^1.3.2, parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -23826,7 +23936,7 @@ path-to-regexp@3.3.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== -path-to-regexp@6.3.0, path-to-regexp@^6.2.1: +path-to-regexp@6.3.0, path-to-regexp@^6.2.1, path-to-regexp@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== @@ -27633,7 +27743,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.4.0 < 2", statuses@~1.5.0: +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0, statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= @@ -28646,7 +28756,7 @@ toad-cache@^3.7.0: resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.1.tgz#33441aab508e15a35fb5292c61ee3322c0853822" integrity sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ== -toidentifier@~1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -28824,6 +28934,11 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -28911,7 +29026,7 @@ type-fest@^5.0.0: dependencies: tagged-tag "^1.0.0" -type-is@^1.6.18, type-is@~1.6.18: +type-is@^1.6.16, type-is@^1.6.18, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -30901,6 +31016,11 @@ yauzl@^3.2.0: buffer-crc32 "~0.2.3" pend "~1.2.0" +ylru@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.4.0.tgz#0cf0aa57e9c24f8a2cbde0cc1ca2c9592ac4e0f6" + integrity sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"