Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(remix): Add orchestrion-based remix instrumentation#22244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
613437a44a175dbf5526f20416ee02c340fc902166ecfffd60b53508cfbb502File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("<name>")` | ||
| // 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["']\)/, | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }), | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }), | ||
| 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(), | ||
| ], | ||
| }); | ||
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.