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/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/packages/remix/package.json b/packages/remix/package.json index cb11b896c38c..16f22202b09d 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -72,6 +72,7 @@ "@sentry/core": "10.65.0", "@sentry/node": "10.65.0", "@sentry/react": "10.65.0", + "@sentry/server-utils": "10.65.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/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/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index d8f1c15bad6c..3a036c09a37b 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -84,7 +84,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/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index eaac1a6e390b..2d91ba645245 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'; -// 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/index.ts b/packages/server-utils/src/orchestrion/index.ts index 112be388a040..369b483334bb 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -23,6 +23,9 @@ 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,