Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { spawn } from 'node:child_process';
import { expect, test } from '@playwright/test';

// Started with Remix's own loader instead of the Sentry entry, which is what a setup that forgot to
// switch looks like. The imports are hoisted above `Sentry.init()`, so the hook `init()` registers
// comes too late for every Remix module.
test('warns when the app starts without the Sentry --import entry', async () => {
const app = spawn('node', ['--import', 'remix/node-tsx', 'server.ts'], {
cwd: process.cwd(),
env: { ...process.env, NODE_ENV: 'production', PORT: '3062' },
});

let stderr = '';
const warned = new Promise<string>(resolve => {
app.stderr.on('data', chunk => {
stderr += String(chunk);
if (stderr.includes('--import @sentry/remix/v3/node')) {
resolve(stderr);
}
});
});

try {
const output = await Promise.race([
warned,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`no warning in:\n${stderr}`)), 15_000)),
]);
expect(output).toContain(
'[Sentry] Remix 3 is not instrumented: @remix-run/fetch-router was imported before the Sentry module hook was registered',
);
} finally {
app.kill();
}
});
9 changes: 5 additions & 4 deletions packages/remix/src/v3/assetServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { consoleSandbox } from '@sentry/core';
import { remixV3Channels } from '@sentry/server-utils/orchestrion/config';
import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId';
import { getShimUrl, isOrchestrionLoader, orchestrionLoader, resolveShimPath } from './orchestrionLoader';
import { describeError, warnRemixV3 } from './server/checkInstrumentation';

// The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so
// they are restated rather than imported.
Expand Down Expand Up @@ -82,8 +83,8 @@ export function instrumentAssetServer(): void {
const options = context.arguments[0] as AssetServerOptions | undefined;
context._sentryHideSourceMaps = options?.sourceMaps === undefined;
context.arguments[0] = withDebugIdOptions(options);
} catch {
// Ignored on purpose.
} catch (error) {
warnRemixV3(`Could not configure the Remix 3 asset server for debug IDs (${describeError(error)}).`);
}
},
end(data) {
Expand All @@ -92,8 +93,8 @@ export function instrumentAssetServer(): void {
if (result) {
stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) });
}
} catch {
// Ignored on purpose.
} catch (error) {
warnRemixV3(`Could not wrap the Remix 3 asset server for debug IDs (${describeError(error)}).`);
}
},
asyncStart() {},
Expand Down
121 changes: 121 additions & 0 deletions packages/remix/src/v3/server/checkInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { consoleSandbox, GLOBAL_OBJ, parseSemver } from '@sentry/core';
import { remixV3Config } from '@sentry/server-utils/orchestrion/config';

const IMPORT_HINT = 'Start Node with `--import @sentry/remix/v3/node`.';

// Every Remix 3 server app creates a router, so this module decides whether the hook was in place.
// The other packages cannot be checked the same way: `remix` depends on all of them, so they look
// installed whether or not the app imports them.
const ANCHOR_MODULE = '@remix-run/fetch-router';

const warned = new Set<string>();

export function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/** An always-on warning, deduplicated by message. A user who sees no data will not have `debug` on. */
export function warnRemixV3(message: string): void {
if (warned.has(message)) {
return;
}
warned.add(message);
consoleSandbox(() => {
// oxlint-disable-next-line no-console
console.warn(`[Sentry] ${message}`);
});
}

/**
* Warn when the Remix 3 router module did not go through the runtime hook, with the reason.
*
* A module the hook transformed is listed in `__SENTRY_ORCHESTRION__.runtime`. One that is installed
* but not listed was either imported before the hook was registered, or is outside the version range
* the transform matches. Nothing is reported when it is not installed: the app does not use Remix 3.
*/
export function checkRemixV3Instrumentation(getInstalledVersion = readInstalledVersion): void {
const marker = GLOBAL_OBJ.__SENTRY_ORCHESTRION__;

if (marker?.runtimeUnavailable) {
warnRemixV3(`Remix 3 is not instrumented: the module hook could not be registered on Node ${process.version}.`);
return;
}

if (marker?.runtime?.includes(ANCHOR_MODULE)) {
return;
}

const version = getInstalledVersion(ANCHOR_MODULE);
if (version === undefined) {
return;
}

const range = getVersionRange(ANCHOR_MODULE);
if (range && !isInRange(version, range)) {
warnRemixV3(`Remix 3 is not instrumented: ${ANCHOR_MODULE}@${version} is outside the supported range ${range}.`);
return;
}

warnRemixV3(
`Remix 3 is not instrumented: ${ANCHOR_MODULE} was imported before the Sentry module hook was registered. ${IMPORT_HINT}`,
);
}

function getVersionRange(name: string): string | undefined {
return remixV3Config.find(config => config.module.name === name)?.module.versionRange;
}

/** Only the `>=a.b.c <d` form the Remix 3 config uses. Anything else counts as matching. */
function isInRange(version: string, range: string): boolean {
const match = range.match(/^>=(\d+)\.(\d+)\.(\d+) <(\d+)$/);
const { major, minor, patch } = parseSemver(version);
if (!match || major === undefined || minor === undefined || patch === undefined) {
return true;
}
const [, minMajor = '0', minMinor = '0', minPatch = '0', maxMajor = '0'] = match;
const atLeastMin =
major > Number(minMajor) ||
(major === Number(minMajor) &&
(minor > Number(minMinor) || (minor === Number(minMinor) && patch >= Number(minPatch))));
return atLeastMin && major < Number(maxMajor);
}

/**
* The version of a Remix 3 package as the app resolves it, or `undefined` when it is not installed.
* Looked up from the `remix` package's real location: under pnpm the `@remix-run/*` packages are only
* reachable from there, and resolving from the app would find a hoisted copy of a different version.
* A plain `node_modules` walk rather than `require.resolve`, because `remix` exports no `package.json`.
*/
export function readInstalledVersion(name: string, fromDir = process.cwd()): string | undefined {
try {
const remixPackageJson = findPackageJson('remix', fromDir);
if (!remixPackageJson) {
return undefined;
}
const packageJson = findPackageJson(name, dirname(realpathSync(remixPackageJson)));
if (!packageJson) {
return undefined;
}
const { version } = JSON.parse(readFileSync(packageJson, 'utf8')) as { version?: unknown };
return typeof version === 'string' ? version : undefined;
} catch {
return undefined;
}
}

function findPackageJson(name: string, fromDir: string): string | undefined {
let dir = fromDir;
for (;;) {
const candidate = join(dir, 'node_modules', name, 'package.json');
if (existsSync(candidate)) {
return candidate;
}
const parent = dirname(dir);
if (parent === dir) {
return undefined;
}
dir = parent;
}
}
19 changes: 12 additions & 7 deletions packages/remix/src/v3/server/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as diagnosticsChannel from 'node:diagnostics_channel';
import { createMultiMatcher } from 'remix/route-pattern/match';
import { consoleSandbox } from '@sentry/core';
import { remixV3Channels } from '@sentry/server-utils/orchestrion/config';

import type { MatcherLike, RequestListenerOptionsLike, RouterOptionsLike } from '../types';
import { describeError, warnRemixV3 } from './checkInstrumentation';
import { captureRequestError } from './errorFilter';
import { sentryRemixMiddleware } from './middleware';

Expand Down Expand Up @@ -47,8 +49,8 @@ function subscribeToCreateRouter(): void {
// copy that cannot build a matcher all reach here, so the router is left uninstrumented instead.
try {
injectRouterMiddleware(ensureOptions(data.arguments));
} catch {
// Ignored on purpose.
} catch (error) {
warnRemixV3(`Could not add the Sentry middleware to a Remix 3 router (${describeError(error)}).`);
}
},
end: NOOP,
Expand All @@ -70,8 +72,8 @@ function subscribeToCreateRequestListener(): void {
start(data) {
try {
injectOnError(ensureOptions(data.arguments, 1));
} catch {
// Ignored on purpose.
} catch (error) {
warnRemixV3(`Could not hook the Remix 3 request listener's error handler (${describeError(error)}).`);
}
},
end: NOOP,
Expand All @@ -97,9 +99,12 @@ function injectOnError(raw: Record<string, unknown> | undefined): void {
return appOnError(error);
}

// Setting `onError` replaced the listener's default handler, which logs the error.
// oxlint-disable-next-line no-console
console.error(error);
// Setting `onError` replaced the listener's default handler, which logs the error. Sandboxed so
// the SDK's console instrumentation does not report it a second time.
consoleSandbox(() => {
// oxlint-disable-next-line no-console
console.error(error);
});
return undefined;
};
}
Expand Down
6 changes: 6 additions & 0 deletions packages/remix/src/v3/server/integration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineIntegration, type IntegrationFn } from '@sentry/core';

import { checkRemixV3Instrumentation } from './checkInstrumentation';
import { setShouldHandleError, type ShouldHandleError } from './errorFilter';
import { instrumentRemixV3 } from './instrument';

Expand All @@ -23,6 +24,11 @@ const _remixV3Integration = ((options: RemixV3IntegrationOptions = {}) => {
// `init()`, so `--import @sentry/remix/v3/node` has already subscribed. This covers setups that
// register the module hook from `init()` instead.
instrumentRemixV3();

// Deferred: `init()` can run from an `--import`ed file before the app's own modules load. Once
// the timer fires, the import graph has been evaluated and the hook has seen every module.
const timer = setTimeout(() => checkRemixV3Instrumentation(), 0);
timer.unref?.();
},
};
}) satisfies IntegrationFn;
Expand Down
Loading
Loading