From 28c23980c76eac1c829c335cbcbaca18cfa533c5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 14 Jul 2026 13:30:52 +0200 Subject: [PATCH 1/9] WIP --- .../remix-orchestrion/instrument.server.cjs | 6 +- packages/remix/package.json | 1 + packages/remix/src/server/index.ts | 2 + .../server/integrations/tracing-channel.ts | 292 ++++++++++++++++++ .../remix/test/server/tracing-channel.test.ts | 246 +++++++++++++++ .../src/orchestrion/config/remix.ts | 64 +++- 6 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 packages/remix/src/server/integrations/tracing-channel.ts create mode 100644 packages/remix/test/server/tracing-channel.test.ts 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 index dd3bb7e4c222..f4b2d7cd8ac7 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs +++ b/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs @@ -10,5 +10,9 @@ Sentry.init({ dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: 'http://localhost:3031/', // proxy server, + integrations: [ + // Manually add this here instead of the default one, this overwrites the default one. + Sentry.remixChannelIntegration(), + ], }); 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..cda462324743 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -145,3 +145,5 @@ export { init, getRemixDefaultIntegrations } from './sdk'; export { captureRemixServerException } from './errors'; export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; export { generateSentryServerTimingHeader } from './serverTimingTracePropagation'; +export { remixChannelIntegration } from './integrations/tracing-channel'; +export { remixIntegration } from './integrations/opentelemetry'; 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..43a130abde4d --- /dev/null +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -0,0 +1,292 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; +import { + defineIntegration, + 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 { getClient } from '@sentry/node'; +import type { RemixOptions } from '../../utils/remixOptions'; + +const INTEGRATION_NAME = 'Remix' as const; +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 full `diagnostics_channel` names orchestrion injects for `@remix-run/server-runtime`. Source of +// truth is `remixChannels` in `@sentry/server-utils` (`orchestrion/config/remix.ts`); duplicated here +// as plain strings because that map isn't part of the package's public export surface. +const CHANNELS = { + REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', + MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', + CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', + CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', +} as const; + +/** + * 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; +} + +// A pre-action clone of the request, stashed at span start so the (already consumed) body is still +// readable for form-data extraction when the action settles. +interface ActionChannelContext extends ChannelContext { + _sentryClonedRequest?: Request; +} + +// 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(CHANNELS.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(CHANNELS.MATCH_SERVER_ROUTES).subscribe({ + start: NOOP, + end(data) { + enrichActiveSpanWithRoute(data.result); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function subscribeCallRouteLoader(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.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): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.CALL_ROUTE_ACTION), + data => { + const params = (data.arguments[0] ?? {}) as RouteCallParams; + // Clone the request before the action consumes its body, so the form data is still readable + // when the span ends. + data._sentryClonedRequest = params.request?.clone(); + 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, + // Reading form data is async, so take ownership of when the span ends: apply the response + // status, await the form-data attributes, then end. + deferSpanEnd: ({ span, data, end }) => { + if ('error' in data) { + end(data.error); + return true; + } + + setResponseStatus(span, data.result); + + const clonedRequest = data._sentryClonedRequest; + if (!clonedRequest) { + end(); + return true; + } + + clonedRequest + .formData() + .then(formData => applyFormDataAttributes(span, formData, 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); + } + }); +} + +function instrumentRemix(actionFormDataAttributes: Record | undefined): void { + subscribeRequestHandler(); + subscribeMatchServerRoutes(); + subscribeCallRouteLoader(); + if (actionFormDataAttributes) { + subscribeCallRouteAction(actionFormDataAttributes); + } +} + +const _remixChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + const client = getClient(); + const options = client?.getOptions() as RemixOptions | undefined; + const actionFormDataAttributes = client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') + ? options?.captureActionFormDataKeys + : undefined; + + waitForTracingChannelBinding(() => { + instrumentRemix(actionFormDataAttributes); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Orchestrion-driven Remix integration. + * + * Ports the vendored `RemixInstrumentation` (an OTel `InstrumentationBase`) to diagnostics-channel + * listeners, with orchestrion injecting the channels into `@remix-run/server-runtime`. Creates the + * `remix.request` server span plus `LOADER`/`ACTION` spans, and enriches the request span with the + * matched route. Requires the orchestrion runtime hook or bundler plugin to be active. + */ +export const remixChannelIntegration = defineIntegration(_remixChannelIntegration); 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..45a337161289 --- /dev/null +++ b/packages/remix/test/server/tracing-channel.test.ts @@ -0,0 +1,246 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { remixChannelIntegration } from '../../src/server/integrations/tracing-channel'; + +const CHANNELS = { + REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', + MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', + CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', + CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', +} as const; + +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 }; + }, + }), + }); +} + +function makeSpan(): Span { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttributes: vi.fn(), + setAttribute: vi.fn(), + updateName: vi.fn(), + } as unknown as Span; +} + +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; +} + +describe('remixChannelIntegration', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + installTestAsyncContextStrategy(); + // `setupOnce` reads form-data options off the client; provide one so the action subscription + // (which is gated on `captureActionFormDataKeys`) is installed. + vi.spyOn(SentryNode, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys: { _action: 'actionType' } }), + getDataCollectionOptions: () => ({ httpBodies: ['incomingRequest'] }), + } as unknown as NodeClient); + + remixChannelIntegration().setupOnce?.(); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + vi.restoreAllMocks(); + }); + + 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(CHANNELS.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(CHANNELS.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(CHANNELS.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(CHANNELS.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(CHANNELS.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(CHANNELS.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/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index eaac1a6e390b..b5a9c9ca34ac 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,6 +1,64 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `remix` orchestrion integration (ports `RemixInstrumentation`). -export const remixConfig: InstrumentationConfig[] = []; +// Ports the vendored `RemixInstrumentation` (an OTel `InstrumentationBase` that patched +// `@remix-run/server-runtime`) to orchestrion channel injection. The subscriber lives in +// `@sentry/remix` (`remixChannelIntegration`), because it needs remix-specific SDK options. +// +// Four concepts, one channel each. Where a function was renamed across the supported range, both +// names publish to the same channel so the subscriber only ever knows one name per concept: +// - `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; From 02a91ae5498e960affee6ed212d4d7f6ce9b3595 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 14 Jul 2026 14:33:00 +0200 Subject: [PATCH 2/9] fixes and stuff --- .../app/routes/db-ioredis.tsx | 0 .../app/routes/db-mysql.tsx | 0 .../docker-compose.yml | 4 +- .../global-setup.mjs | 2 +- .../global-teardown.mjs | 0 .../create-remix-app-v2/instrument.server.cjs | 13 +++ .../create-remix-app-v2/package.json | 18 ++- .../create-remix-app-v2/playwright.config.mjs | 19 ++- .../create-remix-app-v2/remix.config.js | 9 -- .../tests/build-injection.test.ts | 29 +++-- .../create-remix-app-v2/tests/db.test.ts | 94 +++++++++++++++ .../create-remix-app-v2/vite.config.ts | 9 +- .../remix-orchestrion/.gitignore | 10 -- .../remix-orchestrion/app/entry.client.tsx | 27 ----- .../remix-orchestrion/app/entry.server.tsx | 110 ------------------ .../remix-orchestrion/app/root.tsx | 34 ------ .../remix-orchestrion/app/routes/_index.tsx | 3 - .../remix-orchestrion/instrument.server.cjs | 18 --- .../remix-orchestrion/package.json | 45 ------- .../remix-orchestrion/playwright.config.mjs | 15 --- .../remix-orchestrion/remix.env.d.ts | 2 - .../remix-orchestrion/start-event-proxy.mjs | 6 - .../remix-orchestrion/tests/db.test.ts | 88 -------------- .../remix-orchestrion/tsconfig.json | 25 ---- .../remix-orchestrion/vite.config.ts | 19 --- .../src/orchestrion/config/index.ts | 7 +- 26 files changed, 178 insertions(+), 428 deletions(-) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/app/routes/db-ioredis.tsx (100%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/app/routes/db-mysql.tsx (100%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/docker-compose.yml (86%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/global-setup.mjs (90%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/global-teardown.mjs (100%) delete mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/tests/build-injection.test.ts (53%) create mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts 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..a54e40902c30 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,8 +1,21 @@ 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 dsn: process.env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', // proxy server + // In the orchestrion variant, the channel-based Remix integration replaces the default + // OpenTelemetry one (same `Remix` name, so it overwrites the default). + integrations: injectOrchestrion ? [Sentry.remixChannelIntegration()] : [], }); 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/remix-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts similarity index 53% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts index 4beb26f45415..f907153ae767 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts @@ -4,18 +4,19 @@ 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 +// 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. +// 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 (this is an ESM server build) - // or `require(...)` calls resolved from node_modules at runtime - untouched, with - // no channels injected. + // 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["']\))/); }); @@ -25,8 +26,20 @@ test.describe('orchestrion build-time injection', () => { // 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["']\)/); + 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` (the subscriber is + // `remixChannelIntegration`). + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:requestHandler["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:callRouteLoader["']\)/, + ); }); }); 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 f4b2d7cd8ac7..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs +++ /dev/null @@ -1,18 +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, - integrations: [ - // Manually add this here instead of the default one, this overwrites the default one. - Sentry.remixChannelIntegration(), - ], -}); 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/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/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. */ From 538a224f9a6b8c98904131ff41d53cf2368ffde5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 09:24:36 +0200 Subject: [PATCH 3/9] fix(remix): Always instrument callRouteAction in channel integration `instrumentRemix` only subscribed to `callRouteAction` when `actionFormDataAttributes` was configured, so ACTION spans were never created unless form-data capture was opted into. The OTel instrumentation always patched `callRouteAction` and used those keys only for optional attribute extraction. Always subscribe; the request clone + async form-data read now happen only when capture is configured. Adds a regression test (separate file so the channel subscribes with no form-data config) asserting ACTION spans are still created. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server/integrations/tracing-channel.ts | 34 +++-- .../tracing-channel-no-form-data.test.ts | 68 ++++++++++ .../test/server/tracing-channel-test-utils.ts | 117 ++++++++++++++++++ .../remix/test/server/tracing-channel.test.ts | 116 ++--------------- 4 files changed, 211 insertions(+), 124 deletions(-) create mode 100644 packages/remix/test/server/tracing-channel-no-form-data.test.ts create mode 100644 packages/remix/test/server/tracing-channel-test-utils.ts diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 43a130abde4d..89ac968282db 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -185,14 +185,16 @@ function subscribeCallRouteLoader(): void { ); } -function subscribeCallRouteAction(actionFormDataAttributes: Record): void { +function subscribeCallRouteAction(actionFormDataAttributes: Record | undefined): void { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(CHANNELS.CALL_ROUTE_ACTION), data => { const params = (data.arguments[0] ?? {}) as RouteCallParams; - // Clone the request before the action consumes its body, so the form data is still readable - // when the span ends. - data._sentryClonedRequest = params.request?.clone(); + // Only clone the request (before the action consumes its body) when form-data capture is + // configured, so the body is still readable when the span ends. + if (actionFormDataAttributes) { + data._sentryClonedRequest = params.request?.clone(); + } return startInactiveSpan({ name: `ACTION ${params.routeId}`, attributes: { @@ -206,20 +208,14 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record setResponseStatus(span, data.result), + // When form-data capture is configured, reading it is async, so take ownership of when the + // span ends: await the form-data attributes, then end (which applies the response status via + // `beforeSpanEnd`). Otherwise let the helper end the span normally. deferSpanEnd: ({ span, data, end }) => { - if ('error' in data) { - end(data.error); - return true; - } - - setResponseStatus(span, data.result); - const clonedRequest = data._sentryClonedRequest; - if (!clonedRequest) { - end(); - return true; + if (!actionFormDataAttributes || !clonedRequest || 'error' in data) { + return false; } clonedRequest @@ -254,9 +250,9 @@ function instrumentRemix(actionFormDataAttributes: Record { 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..20931f7ffccd --- /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 { + CHANNELS, + makeRequest, + makeSpan, + setupRemixChannelIntegration, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; + +// 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('remixChannelIntegration (no form-data capture configured)', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + setupRemixChannelIntegration(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(CHANNELS.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..12bb6f04a991 --- /dev/null +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -0,0 +1,117 @@ +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 { remixChannelIntegration } from '../../src/server/integrations/tracing-channel'; + +export const CHANNELS = { + REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', + MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', + CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', + CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', +} as const; + +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 run the + * integration's `setupOnce` so the channel subscriptions register. `captureActionFormDataKeys` + * left undefined mimics an app that hasn't opted into form-data capture. + */ +export function setupRemixChannelIntegration(captureActionFormDataKeys?: Record): void { + installTestAsyncContextStrategy(); + vi.spyOn(SentryNode, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies: captureActionFormDataKeys ? ['incomingRequest'] : [] }), + } as unknown as NodeClient); + + remixChannelIntegration().setupOnce?.(); +} diff --git a/packages/remix/test/server/tracing-channel.test.ts b/packages/remix/test/server/tracing-channel.test.ts index 45a337161289..da5c5c25d8fa 100644 --- a/packages/remix/test/server/tracing-channel.test.ts +++ b/packages/remix/test/server/tracing-channel.test.ts @@ -1,100 +1,14 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; import { tracingChannel } from 'node:diagnostics_channel'; -import type { Scope, Span } from '@sentry/core'; +import type { Span } from '@sentry/core'; import * as SentryCore 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; -import { remixChannelIntegration } from '../../src/server/integrations/tracing-channel'; - -const CHANNELS = { - REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', - MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', - CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', - CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', -} as const; - -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 }; - }, - }), - }); -} - -function makeSpan(): Span { - return { - end: vi.fn(), - setStatus: vi.fn(), - setAttributes: vi.fn(), - setAttribute: vi.fn(), - updateName: vi.fn(), - } as unknown as Span; -} - -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; -} +import { + CHANNELS, + makeRequest, + makeSpan, + setupRemixChannelIntegration, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; describe('remixChannelIntegration', () => { let startInactiveSpanSpy: MockInstance; @@ -102,20 +16,12 @@ describe('remixChannelIntegration', () => { let span: Span; beforeAll(() => { - installTestAsyncContextStrategy(); - // `setupOnce` reads form-data options off the client; provide one so the action subscription - // (which is gated on `captureActionFormDataKeys`) is installed. - vi.spyOn(SentryNode, 'getClient').mockReturnValue({ - getOptions: () => ({ captureActionFormDataKeys: { _action: 'actionType' } }), - getDataCollectionOptions: () => ({ httpBodies: ['incomingRequest'] }), - } as unknown as NodeClient); - - remixChannelIntegration().setupOnce?.(); + // Configure form-data capture so the ACTION span also extracts the mapped keys. + setupRemixChannelIntegration({ _action: 'actionType' }); }); afterAll(() => { - setAsyncContextStrategy(undefined); - vi.restoreAllMocks(); + teardownTestAsyncContextStrategy(); }); beforeEach(() => { From 22b6f853e6d10633e0db8d538bb3f35c2241654e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 09:30:01 +0200 Subject: [PATCH 4/9] use channels from server-utils package --- .../server/integrations/tracing-channel.ts | 19 +++++-------------- .../server-utils/src/orchestrion/index.ts | 3 +++ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 89ac968282db..67c990f7bf65 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -14,6 +14,7 @@ import { bindTracingChannelToSpan } from '@sentry/server-utils'; import { CODE_FUNCTION, HTTP_METHOD, HTTP_ROUTE, HTTP_STATUS_CODE, HTTP_URL } from '@sentry/conventions/attributes'; import { getClient } from '@sentry/node'; import type { RemixOptions } from '../../utils/remixOptions'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; const INTEGRATION_NAME = 'Remix' as const; const ORIGIN = 'auto.http.orchestrion.remix'; @@ -25,16 +26,6 @@ const NOOP = (): void => {}; const MATCH_ROUTE_ID = 'match.route.id'; const MATCH_PARAMS = 'match.params'; -// The full `diagnostics_channel` names orchestrion injects for `@remix-run/server-runtime`. Source of -// truth is `remixChannels` in `@sentry/server-utils` (`orchestrion/config/remix.ts`); duplicated here -// as plain strings because that map isn't part of the package's public export surface. -const CHANNELS = { - REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', - MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', - CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', - CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', -} as const; - /** * 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. @@ -130,7 +121,7 @@ function enrichActiveSpanWithRoute(result: unknown): void { function subscribeRequestHandler(): void { bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.REQUEST_HANDLER), + diagnosticsChannel.tracingChannel(remixChannels.REMIX_REQUEST_HANDLER), data => startInactiveSpan({ name: 'remix.request', @@ -151,7 +142,7 @@ function subscribeRequestHandler(): void { 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(CHANNELS.MATCH_SERVER_ROUTES).subscribe({ + diagnosticsChannel.tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).subscribe({ start: NOOP, end(data) { enrichActiveSpanWithRoute(data.result); @@ -164,7 +155,7 @@ function subscribeMatchServerRoutes(): void { function subscribeCallRouteLoader(): void { bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.CALL_ROUTE_LOADER), + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER), data => { const params = (data.arguments[0] ?? {}) as RouteCallParams; return startInactiveSpan({ @@ -187,7 +178,7 @@ function subscribeCallRouteLoader(): void { function subscribeCallRouteAction(actionFormDataAttributes: Record | undefined): void { bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.CALL_ROUTE_ACTION), + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION), data => { const params = (data.arguments[0] ?? {}) as RouteCallParams; // Only clone the request (before the action consumes its body) when form-data capture is 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, From 1a3d49119e7a34669416f327d3cfc733c8074751 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 09:42:19 +0200 Subject: [PATCH 5/9] ref stuff --- .../create-remix-app-v2/instrument.server.cjs | 3 - .../tests/build-injection.test.ts | 3 +- packages/remix/src/server/index.ts | 3 +- .../remix/src/server/integrations/index.ts | 39 ++++++++++++ .../src/server/integrations/opentelemetry.ts | 47 ++++----------- .../server/integrations/tracing-channel.ts | 59 +++++-------------- packages/remix/src/server/sdk.ts | 2 +- .../tracing-channel-no-form-data.test.ts | 2 +- .../test/server/tracing-channel-test-utils.ts | 4 +- .../src/orchestrion/config/remix.ts | 2 +- 10 files changed, 72 insertions(+), 92 deletions(-) create mode 100644 packages/remix/src/server/integrations/index.ts 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 a54e40902c30..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 @@ -15,7 +15,4 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', // proxy server - // In the orchestrion variant, the channel-based Remix integration replaces the default - // OpenTelemetry one (same `Remix` name, so it overwrites the default). - integrations: injectOrchestrion ? [Sentry.remixChannelIntegration()] : [], }); 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 index f907153ae767..749361d427ff 100644 --- 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 @@ -33,8 +33,7 @@ test.describe('orchestrion build-time injection', () => { 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` (the subscriber is - // `remixChannelIntegration`). + // and injects channels into `@remix-run/server-runtime` expect(serverBundle).toMatch( /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:requestHandler["']\)/, ); diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index cda462324743..0be336367660 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -145,5 +145,4 @@ export { init, getRemixDefaultIntegrations } from './sdk'; export { captureRemixServerException } from './errors'; export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; export { generateSentryServerTimingHeader } from './serverTimingTracePropagation'; -export { remixChannelIntegration } from './integrations/tracing-channel'; -export { remixIntegration } from './integrations/opentelemetry'; +export { remixIntegration } from './integrations'; diff --git a/packages/remix/src/server/integrations/index.ts b/packages/remix/src/server/integrations/index.ts new file mode 100644 index 000000000000..ab64ef43a561 --- /dev/null +++ b/packages/remix/src/server/integrations/index.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 index 67c990f7bf65..5fa888e0ece9 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -1,7 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; +import type { Span, SpanAttributes } from '@sentry/core'; import { - defineIntegration, getActiveSpan, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -12,11 +11,8 @@ import { } 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 { getClient } from '@sentry/node'; -import type { RemixOptions } from '../../utils/remixOptions'; import { remixChannels } from '@sentry/server-utils/orchestrion'; -const INTEGRATION_NAME = 'Remix' as const; const ORIGIN = 'auto.http.orchestrion.remix'; const NOOP = (): void => {}; @@ -237,43 +233,18 @@ function applyFormDataAttributes( }); } -function instrumentRemix(actionFormDataAttributes: Record | undefined): void { - subscribeRequestHandler(); - subscribeMatchServerRoutes(); - subscribeCallRouteLoader(); - // Always instrument actions; `actionFormDataAttributes` only gates the optional form-data - // attribute extraction, not whether ACTION spans are created. - subscribeCallRouteAction(actionFormDataAttributes); -} - -const _remixChannelIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - const client = getClient(); - const options = client?.getOptions() as RemixOptions | undefined; - const actionFormDataAttributes = client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') - ? options?.captureActionFormDataKeys - : undefined; - - waitForTracingChannelBinding(() => { - instrumentRemix(actionFormDataAttributes); - }); - }, - }; -}) satisfies IntegrationFn; +export function instrumentRemix(actionFormDataAttributes: Record | undefined): void { + // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } -/** - * Orchestrion-driven Remix integration. - * - * Ports the vendored `RemixInstrumentation` (an OTel `InstrumentationBase`) to diagnostics-channel - * listeners, with orchestrion injecting the channels into `@remix-run/server-runtime`. Creates the - * `remix.request` server span plus `LOADER`/`ACTION` spans, and enriches the request span with the - * matched route. Requires the orchestrion runtime hook or bundler plugin to be active. - */ -export const remixChannelIntegration = defineIntegration(_remixChannelIntegration); + 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..8310f7807c75 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'; /** * Returns the default Remix integrations. 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 index 20931f7ffccd..80ca81773301 100644 --- a/packages/remix/test/server/tracing-channel-no-form-data.test.ts +++ b/packages/remix/test/server/tracing-channel-no-form-data.test.ts @@ -13,7 +13,7 @@ import { // 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('remixChannelIntegration (no form-data capture configured)', () => { +describe('remixIntegration with orchestrion (no form-data capture configured)', () => { let startInactiveSpanSpy: MockInstance; let getActiveSpanSpy: MockInstance; let span: Span; diff --git a/packages/remix/test/server/tracing-channel-test-utils.ts b/packages/remix/test/server/tracing-channel-test-utils.ts index 12bb6f04a991..6450910fef7d 100644 --- a/packages/remix/test/server/tracing-channel-test-utils.ts +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -9,7 +9,7 @@ import { import * as SentryNode from '@sentry/node'; import type { NodeClient } from '@sentry/node'; import { vi } from 'vitest'; -import { remixChannelIntegration } from '../../src/server/integrations/tracing-channel'; +import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; export const CHANNELS = { REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', @@ -113,5 +113,5 @@ export function setupRemixChannelIntegration(captureActionFormDataKeys?: Record< getDataCollectionOptions: () => ({ httpBodies: captureActionFormDataKeys ? ['incomingRequest'] : [] }), } as unknown as NodeClient); - remixChannelIntegration().setupOnce?.(); + instrumentRemix(captureActionFormDataKeys); } diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index b5a9c9ca34ac..da2635d73adc 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -2,7 +2,7 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; // Ports the vendored `RemixInstrumentation` (an OTel `InstrumentationBase` that patched // `@remix-run/server-runtime`) to orchestrion channel injection. The subscriber lives in -// `@sentry/remix` (`remixChannelIntegration`), because it needs remix-specific SDK options. +// `@sentry/remix` (`instrumentRemix`), because it needs remix-specific SDK options. // // Four concepts, one channel each. Where a function was renamed across the supported range, both // names publish to the same channel so the subscriber only ever knows one name per concept: From 4e7158089381dd4e542841ebf6fdfdcac8c2d453 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 09:58:45 +0200 Subject: [PATCH 6/9] fix(remix): Pass form-data keys as options to RemixInstrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenTelemetry branch of `remixIntegration` passed the form-data key map straight into `instrumentRemixWithOpenTelemetry`, which expects `{ actionFormDataAttributes: ... }`. As a result the map was treated as the whole instrumentation config, so `RemixInstrumentation`'s default `{ _action: 'actionType' }` mapping stayed in place — applying even when capture wasn't configured, and ignoring the opted-in keys when it was. Wrap the map in the options object. Adds a test for the OTel branch asserting the call shape (both opted-in and not). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../remix/src/server/integrations/index.ts | 5 +- .../server/remix-integration-otel.test.ts | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/remix/test/server/remix-integration-otel.test.ts diff --git a/packages/remix/src/server/integrations/index.ts b/packages/remix/src/server/integrations/index.ts index ab64ef43a561..024eb5f13375 100644 --- a/packages/remix/src/server/integrations/index.ts +++ b/packages/remix/src/server/integrations/index.ts @@ -20,7 +20,10 @@ const _remixIntegration = (() => { if (isOrchestrionInjected()) { instrumentRemix(actionFormDataAttributes); } else { - instrumentRemixWithOpenTelemetry(actionFormDataAttributes); + // `RemixInstrumentation` takes an options object; passing the bare map would leave its + // default `{ _action: 'actionType' }` mapping in place (applying even when capture wasn't + // configured, and ignoring the opted-in keys). + instrumentRemixWithOpenTelemetry({ actionFormDataAttributes }); } }, setup(client) { 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..bbf7555fd7f1 --- /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'; +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 path)', () => { + 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 }); + }); +}); From e5248325e4688bd799099225f6b617739e3605dc Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 12:28:38 +0200 Subject: [PATCH 7/9] test(remix-e2e): Assert all four remix channels in build-injection check The build-injection guard only asserted the `requestHandler` and `callRouteLoader` publishers, but the transform config also injects `matchServerRoutes` and `callRouteAction`. A silent failure to inject either of those would have slipped past the guard, whose whole purpose is to prove build-time injection. Assert all four channels. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../create-remix-app-v2/tests/build-injection.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 749361d427ff..cfa27164a765 100644 --- 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 @@ -37,8 +37,14 @@ test.describe('orchestrion build-time injection', () => { 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["']\)/, + ); }); }); From 0b48e6f7b75b5298b07a776c2f38e812759629d7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 13:05:30 +0200 Subject: [PATCH 8/9] fix(remix): Read action form data concurrently with the action `deferSpanEnd` read the form data only at `asyncEnd`, after `callRouteAction` had already returned. A channel can't delay the action promise the way the vendored instrumentation did, so the parent `requestHandler` span could finish and flush the transaction before the form-data attributes were applied and the ACTION span ended, dropping that data. Start the form-data read at span start (from a clone taken before the action consumes the body) so it overlaps the action's execution. By `asyncEnd` the read is virtually always resolved, so applying the attributes and ending the span costs a single microtask - comfortably inside the response-rendering gap before the parent span ends. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server/integrations/tracing-channel.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 5fa888e0ece9..624ab8dad39c 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -40,10 +40,10 @@ interface RouteCallParams { routeId?: string; } -// A pre-action clone of the request, stashed at span start so the (already consumed) body is still -// readable for form-data extraction when the action settles. +// 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 { - _sentryClonedRequest?: Request; + _sentryFormData?: Promise; } // Minimal shape of a `matchServerRoutes` entry we read. @@ -177,10 +177,16 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record { const params = (data.arguments[0] ?? {}) as RouteCallParams; - // Only clone the request (before the action consumes its body) when form-data capture is - // configured, so the body is still readable when the span ends. - if (actionFormDataAttributes) { - data._sentryClonedRequest = params.request?.clone(); + // 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}`, @@ -196,18 +202,17 @@ function subscribeCallRouteAction(actionFormDataAttributes: Record setResponseStatus(span, data.result), - // When form-data capture is configured, reading it is async, so take ownership of when the - // span ends: await the form-data attributes, then end (which applies the response status via - // `beforeSpanEnd`). Otherwise let the helper end the span normally. + // 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 clonedRequest = data._sentryClonedRequest; - if (!actionFormDataAttributes || !clonedRequest || 'error' in data) { + const formData = data._sentryFormData; + if (!actionFormDataAttributes || !formData || 'error' in data) { return false; } - clonedRequest - .formData() - .then(formData => applyFormDataAttributes(span, formData, actionFormDataAttributes)) + 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) From 7957bd34f41d5a365c77aeb24d95966f22390dbb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 13:56:14 +0200 Subject: [PATCH 9/9] cleanup --- packages/remix/src/server/index.ts | 2 +- .../{index.ts => RemixIntegration.ts} | 3 --- packages/remix/src/server/sdk.ts | 2 +- .../server/remix-integration-otel.test.ts | 4 ++-- .../tracing-channel-no-form-data.test.ts | 8 ++++---- .../test/server/tracing-channel-test-utils.ts | 15 ++++---------- .../remix/test/server/tracing-channel.test.ts | 20 +++++++++---------- .../src/orchestrion/config/remix.ts | 11 +++------- 8 files changed, 25 insertions(+), 40 deletions(-) rename packages/remix/src/server/integrations/{index.ts => RemixIntegration.ts} (84%) diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 0be336367660..3ce2aa4a2caf 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -145,4 +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'; +export { remixIntegration } from './integrations/RemixIntegration'; diff --git a/packages/remix/src/server/integrations/index.ts b/packages/remix/src/server/integrations/RemixIntegration.ts similarity index 84% rename from packages/remix/src/server/integrations/index.ts rename to packages/remix/src/server/integrations/RemixIntegration.ts index 024eb5f13375..f70926fa9add 100644 --- a/packages/remix/src/server/integrations/index.ts +++ b/packages/remix/src/server/integrations/RemixIntegration.ts @@ -20,9 +20,6 @@ const _remixIntegration = (() => { if (isOrchestrionInjected()) { instrumentRemix(actionFormDataAttributes); } else { - // `RemixInstrumentation` takes an options object; passing the bare map would leave its - // default `{ _action: 'actionType' }` mapping in place (applying even when capture wasn't - // configured, and ignoring the opted-in keys). instrumentRemixWithOpenTelemetry({ actionFormDataAttributes }); } }, diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index 8310f7807c75..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'; +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 index bbf7555fd7f1..c2ef63d0a678 100644 --- a/packages/remix/test/server/remix-integration-otel.test.ts +++ b/packages/remix/test/server/remix-integration-otel.test.ts @@ -17,7 +17,7 @@ vi.mock('../../src/server/integrations/tracing-channel', () => ({ instrumentRemix: vi.fn(), })); -import { remixIntegration } from '../../src/server/integrations'; +import { remixIntegration } from '../../src/server/integrations/RemixIntegration'; import { instrumentRemixWithOpenTelemetry } from '../../src/server/integrations/opentelemetry'; import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; @@ -31,7 +31,7 @@ function mockClient( } as unknown as NodeClient); } -describe('remixIntegration (OpenTelemetry path)', () => { +describe('remixIntegration (OpenTelemetry-based)', () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); 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 index 80ca81773301..a8dbd64bfd1f 100644 --- a/packages/remix/test/server/tracing-channel-no-form-data.test.ts +++ b/packages/remix/test/server/tracing-channel-no-form-data.test.ts @@ -3,12 +3,12 @@ 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 { - CHANNELS, makeRequest, makeSpan, - setupRemixChannelIntegration, + 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 @@ -19,7 +19,7 @@ describe('remixIntegration with orchestrion (no form-data capture configured)', let span: Span; beforeAll(() => { - setupRemixChannelIntegration(undefined); + setupRemixInstrumentation(undefined); }); afterAll(() => { @@ -48,7 +48,7 @@ describe('remixIntegration with orchestrion (no form-data capture configured)', ], }; - await tracingChannel(CHANNELS.CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/remix/test/server/tracing-channel-test-utils.ts b/packages/remix/test/server/tracing-channel-test-utils.ts index 6450910fef7d..37d409a25841 100644 --- a/packages/remix/test/server/tracing-channel-test-utils.ts +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -11,13 +11,6 @@ import type { NodeClient } from '@sentry/node'; import { vi } from 'vitest'; import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; -export const CHANNELS = { - REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', - MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', - CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', - CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', -} as const; - interface TestStore { scope: Scope; isolationScope: Scope; @@ -102,11 +95,11 @@ export function makeRequest( } /** - * Install the async-context strategy, mock the client with the given form-data config, and run the - * integration's `setupOnce` so the channel subscriptions register. `captureActionFormDataKeys` - * left undefined mimics an app that hasn't opted into form-data capture. + * 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 setupRemixChannelIntegration(captureActionFormDataKeys?: Record): void { +export function setupRemixInstrumentation(captureActionFormDataKeys?: Record): void { installTestAsyncContextStrategy(); vi.spyOn(SentryNode, 'getClient').mockReturnValue({ getOptions: () => ({ captureActionFormDataKeys }), diff --git a/packages/remix/test/server/tracing-channel.test.ts b/packages/remix/test/server/tracing-channel.test.ts index da5c5c25d8fa..ae675a15f1a6 100644 --- a/packages/remix/test/server/tracing-channel.test.ts +++ b/packages/remix/test/server/tracing-channel.test.ts @@ -3,21 +3,21 @@ 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 { - CHANNELS, makeRequest, makeSpan, - setupRemixChannelIntegration, + setupRemixInstrumentation, teardownTestAsyncContextStrategy, } from './tracing-channel-test-utils'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; -describe('remixChannelIntegration', () => { +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. - setupRemixChannelIntegration({ _action: 'actionType' }); + setupRemixInstrumentation({ _action: 'actionType' }); }); afterAll(() => { @@ -39,7 +39,7 @@ describe('remixChannelIntegration', () => { 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(CHANNELS.REQUEST_HANDLER).tracePromise(async () => ({ status: 200 }), ctx); + await tracingChannel(remixChannels.REMIX_REQUEST_HANDLER).tracePromise(async () => ({ status: 200 }), ctx); expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -65,7 +65,7 @@ describe('remixChannelIntegration', () => { result: [{ route: { path: 'users/:userId', id: 'routes/users.$userId' } }], }; - tracingChannel(CHANNELS.MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + 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'); @@ -76,7 +76,7 @@ describe('remixChannelIntegration', () => { getActiveSpanSpy.mockReturnValue(undefined); const ctx = { arguments: [], result: [{ route: { path: 'users/:userId', id: 'x' } }] }; - tracingChannel(CHANNELS.MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); expect(span.setAttribute).not.toHaveBeenCalled(); }); @@ -92,7 +92,7 @@ describe('remixChannelIntegration', () => { ], }; - await tracingChannel(CHANNELS.CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -116,7 +116,7 @@ describe('remixChannelIntegration', () => { getActiveSpanSpy.mockReturnValue(undefined); const ctx = { arguments: [{ routeId: 'x', request: makeRequest(), params: {} }] }; - await tracingChannel(CHANNELS.CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); expect(startInactiveSpanSpy).not.toHaveBeenCalled(); }); @@ -132,7 +132,7 @@ describe('remixChannelIntegration', () => { ], }; - await tracingChannel(CHANNELS.CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index da2635d73adc..2d91ba645245 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,15 +1,10 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// Ports the vendored `RemixInstrumentation` (an OTel `InstrumentationBase` that patched -// `@remix-run/server-runtime`) to orchestrion channel injection. The subscriber lives in -// `@sentry/remix` (`instrumentRemix`), because it needs remix-specific SDK options. -// -// Four concepts, one channel each. Where a function was renamed across the supported range, both -// names publish to the same channel so the subscriber only ever knows one name per concept: +// 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`. +// - `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[] => [