From a10178eec4c0de5ef2cb42c69c8c40be587b1cdd Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 10 Apr 2026 09:31:17 +0200 Subject: [PATCH 1/3] feat(node): Add `registerModuleWrapper` utility --- packages/node-core/package.json | 3 +- packages/node-core/src/index.ts | 2 + .../node-core/src/module-wrapper/index.ts | 182 +++++++++++++ .../node-core/src/module-wrapper/semver.ts | 246 ++++++++++++++++++ .../node-core/src/module-wrapper/singleton.ts | 196 ++++++++++++++ .../node-core/src/module-wrapper/version.ts | 33 +++ .../test/module-wrapper/semver.test.ts | 123 +++++++++ yarn.lock | 10 +- 8 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 packages/node-core/src/module-wrapper/index.ts create mode 100644 packages/node-core/src/module-wrapper/semver.ts create mode 100644 packages/node-core/src/module-wrapper/singleton.ts create mode 100644 packages/node-core/src/module-wrapper/version.ts create mode 100644 packages/node-core/test/module-wrapper/semver.test.ts diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 726897c30319..625e689d043a 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -115,7 +115,8 @@ "dependencies": { "@sentry/core": "10.48.0", "@sentry/opentelemetry": "10.48.0", - "import-in-the-middle": "^3.0.0" + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^7.5.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.1", diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index a9633b94c25d..a1149466df21 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -25,6 +25,8 @@ export { processSessionIntegration } from './integrations/processSession'; export type { OpenTelemetryServerRuntimeOptions } from './types'; +export { registerModuleWrapper } from './module-wrapper'; + export { // This needs exporting so the NodeClient can be used without calling init setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy, diff --git a/packages/node-core/src/module-wrapper/index.ts b/packages/node-core/src/module-wrapper/index.ts new file mode 100644 index 000000000000..b880136b174b --- /dev/null +++ b/packages/node-core/src/module-wrapper/index.ts @@ -0,0 +1,182 @@ +/** + * Module wrapper utilities for patching Node.js modules. + * + * This provides a Sentry-owned alternative to OTel's registerInstrumentations(), + * allowing module patching without requiring the full OTel instrumentation infrastructure. + */ + +import { Hook } from 'import-in-the-middle'; +import { satisfies } from './semver'; +import { RequireInTheMiddleSingleton, type OnRequireFn } from './singleton'; +import { extractPackageVersion } from './version'; + +export type { OnRequireFn }; +export { satisfies } from './semver'; +export { extractPackageVersion } from './version'; + +/** Store for module options, keyed by module name */ +const MODULE_OPTIONS = new Map(); + +/** Options for file-level patching within a module */ +export interface ModuleWrapperFileOptions { + /** Relative path within the package (e.g., 'lib/client.js') */ + name: string; + /** Semver ranges for supported versions of the file */ + supportedVersions: string[]; + /** Function to patch the file's exports. Use getOptions() to access current options at runtime. */ + patch: (exports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown; +} + +/** Options for registering a module wrapper */ +export interface ModuleWrapperOptions { + /** Module name to wrap (e.g., 'express', 'pg', '@prisma/client') */ + moduleName: string; + /** Semver ranges for supported versions (e.g., ['>=4.0.0 <5.0.0']) */ + supportedVersions: string[]; + /** Function to patch the module's exports. Use getOptions() to access current options at runtime. */ + patch: (moduleExports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown; + /** Optional array of specific files within the module to patch */ + files?: ModuleWrapperFileOptions[]; + /** Optional configuration options that can be updated on subsequent calls */ + options?: TOptions; +} + +/** + * Register a module wrapper to patch a module when it's required/imported. + * + * This sets up hooks for both CommonJS (via require-in-the-middle) and + * ESM (via import-in-the-middle) module loading. + * + * Calling this multiple times for the same module is safe: + * - The wrapping/hooking only happens once (first call) + * - Options are always updated (subsequent calls replace options) + * - Use `getOptions()` in your patch function to access current options at runtime + * + * @param wrapperOptions - Configuration for the module wrapper + * + * @example + * ```ts + * registerModuleWrapper({ + * moduleName: 'express', + * supportedVersions: ['>=4.0.0 <6.0.0'], + * options: { customOption: true }, + * patch: (moduleExports, getOptions, version) => { + * // getOptions() returns the current options at runtime + * patchExpressModule(moduleExports, getOptions); + * return moduleExports; + * }, + * }); + * ``` + */ +export function registerModuleWrapper(wrapperOptions: ModuleWrapperOptions): void { + const { moduleName, supportedVersions, patch, files, options } = wrapperOptions; + + // Always update the stored options (even if already registered) + MODULE_OPTIONS.set(moduleName, options); + + // If already registered, skip the wrapping - options have been updated above + if (MODULE_OPTIONS.has(moduleName) && options === undefined) { + // This means we've registered before but this call has no new options + // Still skip re-registration + return; + } + + // Create a getter that retrieves current options at runtime + const getOptions = () => MODULE_OPTIONS.get(moduleName) as TOptions; + + // Create the onRequire handler for CJS + const onRequire: OnRequireFn = (exports, name, basedir) => { + // Check if this is the main module or a file within it + const isMainModule = name === moduleName; + + if (isMainModule) { + // Main module - check version and patch + const version = extractPackageVersion(basedir); + if (isVersionSupported(version, supportedVersions)) { + return patch(exports, getOptions, version); + } + } else if (files) { + // Check if this is one of the specified files + for (const file of files) { + const expectedPath = `${moduleName}/${file.name}`; + if (name === expectedPath || name.endsWith(`/${expectedPath}`)) { + const version = extractPackageVersion(basedir); + if (isVersionSupported(version, file.supportedVersions)) { + return file.patch(exports, getOptions, version); + } + } + } + } + + return exports; + }; + + // Register with CJS singleton (require-in-the-middle) + const ritmSingleton = RequireInTheMiddleSingleton.getInstance(); + ritmSingleton.register(moduleName, onRequire); + + // Register file hooks with the singleton as well + if (files) { + for (const file of files) { + const filePath = `${moduleName}/${file.name}`; + ritmSingleton.register(filePath, onRequire); + } + } + + // Register with ESM (import-in-the-middle) + // The ESM loader must be initialized before this (via initializeEsmLoader()) + const moduleNames = [moduleName]; + if (files) { + for (const file of files) { + moduleNames.push(`${moduleName}/${file.name}`); + } + } + + new Hook(moduleNames, { internals: true }, (exports, name, basedir) => { + // Convert void to undefined for compatibility + const baseDirectory = basedir || undefined; + const isMainModule = name === moduleName; + + if (isMainModule) { + const version = extractPackageVersion(baseDirectory); + if (isVersionSupported(version, supportedVersions)) { + return patch(exports, getOptions, version); + } + } else if (files) { + for (const file of files) { + const expectedPath = `${moduleName}/${file.name}`; + if (name === expectedPath || name.endsWith(`/${expectedPath}`)) { + const version = extractPackageVersion(baseDirectory); + if (isVersionSupported(version, file.supportedVersions)) { + return file.patch(exports, getOptions, version); + } + } + } + } + + return exports; + }); +} + +/** + * Check if a version is supported by the given semver ranges. + * + * @param version - The version to check (or undefined if not available) + * @param supportedVersions - Array of semver range strings + * @returns true if the version is supported + */ +function isVersionSupported(version: string | undefined, supportedVersions: string[]): boolean { + // If no version is available (e.g., core modules), we allow patching + if (!version) { + return true; + } + + // Check if the version satisfies any of the supported ranges + for (const range of supportedVersions) { + if (satisfies(version, range)) { + return true; + } + } + + return false; +} diff --git a/packages/node-core/src/module-wrapper/semver.ts b/packages/node-core/src/module-wrapper/semver.ts new file mode 100644 index 000000000000..a1b1b4c64045 --- /dev/null +++ b/packages/node-core/src/module-wrapper/semver.ts @@ -0,0 +1,246 @@ +/** + * Lightweight semantic versioning utilities. + * + * This is a simplified semver implementation that only supports basic comparison + * operators (<, <=, >, >=, =). For module wrapper version checking, these operators + * combined with space-separated AND ranges and || OR ranges are sufficient. + * + * Unsupported patterns (caret ^, tilde ~, hyphen ranges, x-ranges) will log a warning. + */ + +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +const VERSION_REGEXP = + /^(?:v)?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +const COMPARATOR_REGEXP = + /^(?<|>|<=|>=|=)?(?:v)?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/; + +const UNSUPPORTED_PATTERN = /[~^*xX]| - /; + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease?: string[]; +} + +interface ParsedComparator { + op: string; + major: number; + minor: number; + patch: number; + prerelease?: string[]; +} + +/** + * Checks if a given version satisfies a given range expression. + * + * Supported operators: <, <=, >, >=, = (or no operator for exact match) + * Supported combinators: space for AND, || for OR + * + * Examples: + * - ">=1.0.0 <2.0.0" - version must be >= 1.0.0 AND < 2.0.0 + * - ">=1.0.0 || >=2.0.0 <3.0.0" - version must match either range + * + * @param version - The version to check (e.g., "1.2.3") + * @param range - The range expression (e.g., ">=1.0.0 <2.0.0") + * @returns true if the version satisfies the range + */ +export function satisfies(version: string, range: string): boolean { + // Empty range matches everything + if (!range?.trim()) { + return true; + } + + // Parse the version + const parsedVersion = parseVersion(version); + if (!parsedVersion) { + DEBUG_BUILD && debug.warn(`[semver] Invalid version: ${version}`); + return false; + } + + // Warn about unsupported patterns + if (UNSUPPORTED_PATTERN.test(range)) { + DEBUG_BUILD && + debug.warn( + `[semver] Range "${range}" contains unsupported patterns (^, ~, *, x, X, or hyphen ranges). ` + + `Only <, <=, >, >=, = operators are supported. This may not match as expected.`, + ); + } + + // Handle OR ranges (||) + if (range.includes('||')) { + const orParts = range.split('||').map(p => p.trim()); + return orParts.some(part => satisfiesRange(parsedVersion, part)); + } + + return satisfiesRange(parsedVersion, range); +} + +/** + * Check if a version satisfies a single range (no || operators). + */ +function satisfiesRange(version: ParsedVersion, range: string): boolean { + // Split by whitespace for AND conditions + const comparators = range + .trim() + .split(/\s+/) + .filter(c => c.length > 0); + + // All comparators must match + return comparators.every(comp => satisfiesComparator(version, comp)); +} + +/** + * Check if a version satisfies a single comparator. + */ +function satisfiesComparator(version: ParsedVersion, comparator: string): boolean { + const parsed = parseComparator(comparator); + if (!parsed) { + DEBUG_BUILD && debug.warn(`[semver] Invalid comparator: ${comparator}`); + return false; + } + + const cmp = compareVersions(version, parsed); + + switch (parsed.op) { + case '<': + return cmp < 0; + case '<=': + return cmp <= 0; + case '>': + return cmp > 0; + case '>=': + return cmp >= 0; + case '=': + default: + return cmp === 0; + } +} + +/** + * Parse a version string into components. + */ +function parseVersion(version: string): ParsedVersion | undefined { + const match = version.match(VERSION_REGEXP); + if (!match?.groups) { + return undefined; + } + + const { major, minor, patch, prerelease } = match.groups; + if (major === undefined || minor === undefined || patch === undefined) { + return undefined; + } + + return { + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10), + prerelease: prerelease ? prerelease.split('.') : undefined, + }; +} + +/** + * Parse a comparator string into components. + */ +function parseComparator(comparator: string): ParsedComparator | undefined { + const match = comparator.match(COMPARATOR_REGEXP); + if (!match?.groups) { + return undefined; + } + + const { op, major, minor, patch, prerelease } = match.groups; + if (major === undefined || minor === undefined || patch === undefined) { + return undefined; + } + + return { + op: op || '=', + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10), + prerelease: prerelease ? prerelease.split('.') : undefined, + }; +} + +/** + * Compare two versions. + * Returns: -1 if a < b, 0 if a == b, 1 if a > b + */ +function compareVersions(a: ParsedVersion, b: ParsedComparator): number { + // Compare major.minor.patch + if (a.major !== b.major) { + return a.major < b.major ? -1 : 1; + } + if (a.minor !== b.minor) { + return a.minor < b.minor ? -1 : 1; + } + if (a.patch !== b.patch) { + return a.patch < b.patch ? -1 : 1; + } + + // Compare prerelease + // A version without prerelease has higher precedence than one with prerelease + if (!a.prerelease && b.prerelease) { + return 1; + } + if (a.prerelease && !b.prerelease) { + return -1; + } + if (a.prerelease && b.prerelease) { + return comparePrereleases(a.prerelease, b.prerelease); + } + + return 0; +} + +/** + * Compare prerelease identifiers. + */ +function comparePrereleases(a: string[], b: string[]): number { + const len = Math.max(a.length, b.length); + + for (let i = 0; i < len; i++) { + // If a has fewer identifiers, it has lower precedence + if (i >= a.length) { + return -1; + } + // If b has fewer identifiers, a has higher precedence + if (i >= b.length) { + return 1; + } + + // We've already checked bounds above, so these are safe + const aId = a[i]!; + const bId = b[i]!; + + if (aId === bId) { + continue; + } + + const aNum = parseInt(aId, 10); + const bNum = parseInt(bId, 10); + const aIsNum = !isNaN(aNum); + const bIsNum = !isNaN(bNum); + + // Numeric identifiers have lower precedence than string identifiers + if (aIsNum && !bIsNum) { + return -1; + } + if (!aIsNum && bIsNum) { + return 1; + } + + // Both numeric: compare as numbers + if (aIsNum && bIsNum) { + return aNum < bNum ? -1 : 1; + } + + // Both strings: compare lexically + return aId < bId ? -1 : 1; + } + + return 0; +} diff --git a/packages/node-core/src/module-wrapper/singleton.ts b/packages/node-core/src/module-wrapper/singleton.ts new file mode 100644 index 000000000000..87ceb6ab259c --- /dev/null +++ b/packages/node-core/src/module-wrapper/singleton.ts @@ -0,0 +1,196 @@ +/** + * RequireInTheMiddle singleton for efficient CJS module patching. + * + * This is a vendored and modified version of OpenTelemetry's RequireInTheMiddleSingleton + * and ModuleNameTrie. It provides a single RITM hook with trie-based module name matching + * for better performance when using many module wrappers. + * + * OpenTelemetry: Copyright The OpenTelemetry Authors - Apache-2.0 + * Sentry modifications: Copyright Sentry - MIT + */ + +/* eslint-disable no-param-reassign */ + +import * as path from 'node:path'; +import { Hook } from 'require-in-the-middle'; + +/** Separator used in module names and paths */ +export const MODULE_NAME_SEPARATOR = '/'; + +/** Information about a registered module hook */ +export interface ModuleHook { + moduleName: string; + onRequire: OnRequireFn; +} + +/** Function signature for require hooks */ +export type OnRequireFn = (exports: unknown, name: string, basedir: string | undefined) => unknown; + +/** + * Node in the ModuleNameTrie. + * Each node represents a part of a module name (split by '/'). + */ +class ModuleNameTrieNode { + hooks: Array<{ hook: ModuleHook; insertedId: number }> = []; + children: Map = new Map(); +} + +/** Options for searching the trie */ +interface ModuleNameTrieSearchOptions { + /** Whether to return results in insertion order */ + maintainInsertionOrder?: boolean; + /** Whether to return only full matches (not partial/prefix matches) */ + fullOnly?: boolean; +} + +/** + * Trie data structure for efficient module name matching. + * + * Module names are split by '/' and each part becomes a node in the trie. + * This allows efficient matching of both exact module names and sub-paths. + */ +class ModuleNameTrie { + private _trie: ModuleNameTrieNode = new ModuleNameTrieNode(); + private _counter: number = 0; + + /** + * Insert a module hook into the trie. + * + * @param hook - The hook to insert + */ + insert(hook: ModuleHook): void { + let trieNode = this._trie; + + for (const moduleNamePart of hook.moduleName.split(MODULE_NAME_SEPARATOR)) { + let nextNode = trieNode.children.get(moduleNamePart); + if (!nextNode) { + nextNode = new ModuleNameTrieNode(); + trieNode.children.set(moduleNamePart, nextNode); + } + trieNode = nextNode; + } + trieNode.hooks.push({ hook, insertedId: this._counter++ }); + } + + /** + * Search for matching hooks in the trie. + * + * @param moduleName - Module name to search for + * @param options - Search options + * @returns Array of matching hooks + */ + search(moduleName: string, options: ModuleNameTrieSearchOptions = {}): ModuleHook[] { + const { maintainInsertionOrder, fullOnly } = options; + let trieNode = this._trie; + const results: ModuleNameTrieNode['hooks'] = []; + let foundFull = true; + + for (const moduleNamePart of moduleName.split(MODULE_NAME_SEPARATOR)) { + const nextNode = trieNode.children.get(moduleNamePart); + if (!nextNode) { + foundFull = false; + break; + } + if (!fullOnly) { + results.push(...nextNode.hooks); + } + trieNode = nextNode; + } + + if (fullOnly && foundFull) { + results.push(...trieNode.hooks); + } + + if (results.length === 0) { + return []; + } + if (results.length === 1) { + // Safe to access [0] since we just checked length === 1 + return [results[0]!.hook]; + } + if (maintainInsertionOrder) { + results.sort((a, b) => a.insertedId - b.insertedId); + } + return results.map(({ hook }) => hook); + } +} + +/** + * Normalize path separators to forward slash. + * This is needed for Windows where path.sep is backslash. + * + * @param moduleNameOrPath - Module name or path to normalize + * @returns Normalized module name or path with forward slashes + */ +function normalizePathSeparators(moduleNameOrPath: string): string { + return path.sep !== MODULE_NAME_SEPARATOR + ? moduleNameOrPath.split(path.sep).join(MODULE_NAME_SEPARATOR) + : moduleNameOrPath; +} + +/** + * Singleton class for require-in-the-middle. + * + * Instead of creating a separate require patch for each module wrapper, + * this creates a single patch that uses a trie to efficiently look up + * registered hooks for each required module. + * + * WARNING: Multiple instances of this singleton (e.g., from multiple versions + * of the SDK) will result in multiple RITM hooks, which impacts performance. + */ +export class RequireInTheMiddleSingleton { + private _moduleNameTrie: ModuleNameTrie = new ModuleNameTrie(); + private static _instance?: RequireInTheMiddleSingleton; + + private constructor() { + this._initialize(); + } + + private _initialize(): void { + new Hook( + // Intercept all `require` calls; we filter matching ones in the callback + null, + { internals: true }, + (exports, name, basedir) => { + // For internal files on Windows, `name` will use backslash + const normalizedModuleName = normalizePathSeparators(name); + + const matches = this._moduleNameTrie.search(normalizedModuleName, { + maintainInsertionOrder: true, + // For core modules (e.g. `fs`), do not match on sub-paths (e.g. `fs/promises`). + // This matches the behavior of require-in-the-middle. + // `basedir` is always `undefined` for core modules. + fullOnly: basedir === undefined, + }); + + for (const { onRequire } of matches) { + exports = onRequire(exports, name, basedir) as typeof exports; + } + + return exports; + }, + ); + } + + /** + * Register a hook with require-in-the-middle. + * + * @param moduleName - Module name to intercept (e.g., 'express', 'pg') + * @param onRequire - Hook function called when the module is required + * @returns The registered hook information + */ + register(moduleName: string, onRequire: OnRequireFn): ModuleHook { + const hooked = { moduleName, onRequire }; + this._moduleNameTrie.insert(hooked); + return hooked; + } + + /** + * Get the RequireInTheMiddleSingleton singleton instance. + * + * @returns The singleton instance + */ + static getInstance(): RequireInTheMiddleSingleton { + return (this._instance = this._instance ?? new RequireInTheMiddleSingleton()); + } +} diff --git a/packages/node-core/src/module-wrapper/version.ts b/packages/node-core/src/module-wrapper/version.ts new file mode 100644 index 000000000000..8c9e361c477a --- /dev/null +++ b/packages/node-core/src/module-wrapper/version.ts @@ -0,0 +1,33 @@ +/** + * Utilities for extracting package version information. + * + * This provides a helper to read the version from a package's package.json + * given its base directory (as provided by require-in-the-middle hooks). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Extract the version from a package's package.json. + * + * @param basedir - The base directory of the package (from RITM/IITM hooks) + * @returns The package version, or undefined if not found + */ +export function extractPackageVersion(basedir: string | undefined): string | undefined { + if (!basedir) { + return undefined; + } + + try { + const packageJsonPath = path.join(basedir, 'package.json'); + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent) as { version?: string }; + return packageJson.version; + } catch (e) { + DEBUG_BUILD && debug.warn(`Failed to extract package version from ${basedir}:`, e); + return undefined; + } +} diff --git a/packages/node-core/test/module-wrapper/semver.test.ts b/packages/node-core/test/module-wrapper/semver.test.ts new file mode 100644 index 000000000000..a7ce16bded5a --- /dev/null +++ b/packages/node-core/test/module-wrapper/semver.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { satisfies } from '../../src/module-wrapper/semver'; + +describe('semver satisfies', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('exact versions', () => { + it('matches exact version', () => { + expect(satisfies('1.0.0', '1.0.0')).toBe(true); + expect(satisfies('2.3.4', '2.3.4')).toBe(true); + expect(satisfies('1.0.0', '=1.0.0')).toBe(true); + }); + + it('does not match different versions', () => { + expect(satisfies('1.0.0', '1.0.1')).toBe(false); + expect(satisfies('1.0.0', '2.0.0')).toBe(false); + }); + }); + + describe('comparison operators', () => { + it('handles greater than', () => { + expect(satisfies('2.0.0', '>1.0.0')).toBe(true); + expect(satisfies('1.0.1', '>1.0.0')).toBe(true); + expect(satisfies('1.0.0', '>1.0.0')).toBe(false); + expect(satisfies('0.9.0', '>1.0.0')).toBe(false); + }); + + it('handles greater than or equal', () => { + expect(satisfies('2.0.0', '>=1.0.0')).toBe(true); + expect(satisfies('1.0.0', '>=1.0.0')).toBe(true); + expect(satisfies('0.9.0', '>=1.0.0')).toBe(false); + }); + + it('handles less than', () => { + expect(satisfies('0.9.0', '<1.0.0')).toBe(true); + expect(satisfies('0.9.9', '<1.0.0')).toBe(true); + expect(satisfies('1.0.0', '<1.0.0')).toBe(false); + expect(satisfies('2.0.0', '<1.0.0')).toBe(false); + }); + + it('handles less than or equal', () => { + expect(satisfies('0.9.0', '<=1.0.0')).toBe(true); + expect(satisfies('1.0.0', '<=1.0.0')).toBe(true); + expect(satisfies('2.0.0', '<=1.0.0')).toBe(false); + }); + }); + + describe('range expressions', () => { + it('handles space-separated ranges (AND)', () => { + expect(satisfies('1.5.0', '>=1.0.0 <2.0.0')).toBe(true); + expect(satisfies('1.0.0', '>=1.0.0 <2.0.0')).toBe(true); + expect(satisfies('1.9.9', '>=1.0.0 <2.0.0')).toBe(true); + expect(satisfies('0.5.0', '>=1.0.0 <2.0.0')).toBe(false); + expect(satisfies('2.0.0', '>=1.0.0 <2.0.0')).toBe(false); + expect(satisfies('2.5.0', '>=1.0.0 <2.0.0')).toBe(false); + }); + + it('handles OR ranges (||)', () => { + expect(satisfies('1.0.0', '1.0.0 || 2.0.0')).toBe(true); + expect(satisfies('2.0.0', '1.0.0 || 2.0.0')).toBe(true); + expect(satisfies('3.0.0', '1.0.0 || 2.0.0')).toBe(false); + }); + + it('handles complex OR with AND ranges', () => { + expect(satisfies('1.5.0', '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0')).toBe(true); + expect(satisfies('3.5.0', '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0')).toBe(true); + expect(satisfies('2.5.0', '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0')).toBe(false); + }); + }); + + describe('pre-release versions', () => { + it('handles pre-release versions', () => { + expect(satisfies('1.0.0-alpha', '1.0.0-alpha')).toBe(true); + expect(satisfies('1.0.0-beta', '1.0.0-alpha')).toBe(false); + }); + + it('release version is greater than pre-release', () => { + expect(satisfies('1.0.0', '>1.0.0-alpha')).toBe(true); + expect(satisfies('1.0.0-alpha', '<1.0.0')).toBe(true); + }); + + it('compares pre-release identifiers correctly', () => { + expect(satisfies('1.0.0-alpha.2', '>1.0.0-alpha.1')).toBe(true); + expect(satisfies('1.0.0-beta', '>1.0.0-alpha')).toBe(true); + }); + }); + + describe('invalid versions', () => { + it('returns false for invalid versions', () => { + expect(satisfies('not-a-version', '>=1.0.0')).toBe(false); + expect(satisfies('1.0', '>=1.0.0')).toBe(false); + }); + + it('returns false for invalid comparators', () => { + expect(satisfies('1.0.0', 'invalid')).toBe(false); + }); + }); + + describe('empty range', () => { + it('matches any version for empty range', () => { + expect(satisfies('1.0.0', '')).toBe(true); + expect(satisfies('999.0.0', '')).toBe(true); + expect(satisfies('1.0.0', ' ')).toBe(true); + }); + }); + + describe('unsupported patterns warning', () => { + it('still attempts to match but warns for caret ranges', () => { + // Caret won't match because it's not a valid comparator in our simplified impl + expect(satisfies('1.5.0', '^1.0.0')).toBe(false); + }); + + it('still attempts to match but warns for tilde ranges', () => { + expect(satisfies('1.2.5', '~1.2.0')).toBe(false); + }); + + it('still attempts to match but warns for x-ranges', () => { + expect(satisfies('1.5.0', '1.x')).toBe(false); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c1a28d46e642..295e0bcaf58e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26447,6 +26447,15 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +require-in-the-middle@^7.5.0: + version "7.5.2" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz#dc25b148affad42e570cf0e41ba30dc00f1703ec" + integrity sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ== + dependencies: + debug "^4.3.5" + module-details-from-path "^1.0.3" + resolve "^1.22.8" + require-in-the-middle@^8.0.0: version "8.0.1" resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" @@ -28557,7 +28566,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 9192a07e3e59e55d3b18b29a0f39fb86aef976a3 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 10 Apr 2026 09:48:53 +0200 Subject: [PATCH 2/3] WIP express example --- .../core/src/integrations/express/index.ts | 13 +-- .../src/integrations/express/patch-layer.ts | 8 +- .../core/src/integrations/express/types.ts | 1 - .../lib/integrations/express/index.test.ts | 57 ++++++------- .../integrations/express/patch-layer.test.ts | 30 +++---- .../node/src/integrations/tracing/express.ts | 85 +++++++++---------- 6 files changed, 98 insertions(+), 96 deletions(-) diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index 4b2d5e2f0677..f1dd8a301c17 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -69,11 +69,12 @@ const getExpressExport = (express: ExpressModuleExport): ExpressExport => * import express from 'express'; * import * as Sentry from '@sentry/deno'; // or any SDK that extends core * - * Sentry.patchExpressModule({ express }) + * Sentry.patchExpressModule(express, () => ({})); + * ``` */ -export const patchExpressModule = (options: ExpressIntegrationOptions) => { +export const patchExpressModule = (moduleExports: ExpressModuleExport, getOptions: () => ExpressIntegrationOptions) => { // pass in the require() or import() result of express - const express = getExpressExport(options.express); + const express = getExpressExport(moduleExports); const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express) ? express.Router.prototype // Express v5 : isExpressWithoutRouterPrototype(express) @@ -93,7 +94,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { function routeTrace(this: ExpressRouter, ...args: Parameters[]) { const route = originalRouteMethod.apply(this, args); const layer = this.stack[this.stack.length - 1] as ExpressLayer; - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); return route; }, ); @@ -113,7 +114,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { if (!layer) { return route; } - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); return route; }, ); @@ -141,7 +142,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => { if (router) { const layer = router.stack[router.stack.length - 1]; if (layer) { - patchLayer(options, layer, getLayerPath(args)); + patchLayer(getOptions, layer, getLayerPath(args)); } } return route; diff --git a/packages/core/src/integrations/express/patch-layer.ts b/packages/core/src/integrations/express/patch-layer.ts index 5f537e403524..3114cbafb320 100644 --- a/packages/core/src/integrations/express/patch-layer.ts +++ b/packages/core/src/integrations/express/patch-layer.ts @@ -61,7 +61,11 @@ export type ExpressPatchLayerOptions = Pick< 'onRouteResolved' | 'ignoreLayers' | 'ignoreLayersType' >; -export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: ExpressLayer, layerPath?: string): void { +export function patchLayer( + getOptions: () => ExpressPatchLayerOptions, + maybeLayer?: ExpressLayer, + layerPath?: string, +): void { if (!maybeLayer?.handle) { return; } @@ -86,6 +90,8 @@ export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: Expre //oxlint-disable-next-line no-explicit-any ...otherArgs: any[] ) { + const options = getOptions(); + // Set normalizedRequest here because expressRequestHandler middleware // (registered via setupExpressErrorHandler) is added after routes and // therefore never runs for successful requests — route handlers typically diff --git a/packages/core/src/integrations/express/types.ts b/packages/core/src/integrations/express/types.ts index 66d6f1de3c9e..406b2d460e3f 100644 --- a/packages/core/src/integrations/express/types.ts +++ b/packages/core/src/integrations/express/types.ts @@ -136,7 +136,6 @@ export type ExpressRouter = { export type IgnoreMatcher = string | RegExp | ((name: string) => boolean); export type ExpressIntegrationOptions = { - express: ExpressModuleExport; //Express /** Ignore specific based on their name */ ignoreLayers?: IgnoreMatcher[]; /** Ignore specific layers based on their type */ diff --git a/packages/core/test/lib/integrations/express/index.test.ts b/packages/core/test/lib/integrations/express/index.test.ts index 55fe442efb22..9e464b1b0adf 100644 --- a/packages/core/test/lib/integrations/express/index.test.ts +++ b/packages/core/test/lib/integrations/express/index.test.ts @@ -61,12 +61,12 @@ vi.mock('../../../../src/utils/debug-logger', () => ({ })); beforeEach(() => (patchLayerCalls.length = 0)); -const patchLayerCalls: [options: ExpressIntegrationOptions, layer: ExpressLayer, layerPath?: string][] = []; +const patchLayerCalls: [getOptions: () => ExpressIntegrationOptions, layer: ExpressLayer, layerPath?: string][] = []; vi.mock('../../../../src/integrations/express/patch-layer', () => ({ - patchLayer: (options: ExpressIntegrationOptions, layer?: ExpressLayer, layerPath?: string) => { + patchLayer: (getOptions: () => ExpressIntegrationOptions, layer?: ExpressLayer, layerPath?: string) => { if (layer) { - patchLayerCalls.push([options, layer, layerPath]); + patchLayerCalls.push([getOptions, layer, layerPath]); } }, })); @@ -131,24 +131,22 @@ function getExpress5(): ExpressExportv5 & { spies: ExpressSpies } { describe('patchExpressModule', () => { it('throws trying to patch/unpatch the wrong thing', () => { expect(() => { - patchExpressModule({ - express: {} as unknown as ExpressModuleExport, - } as unknown as ExpressIntegrationOptions); + patchExpressModule({} as unknown as ExpressModuleExport, () => ({})); }).toThrowError('no valid Express route function to instrument'); }); it('can patch and restore expressv4 style module', () => { for (const useDefault of [false, true]) { const express = getExpress4(); - const module = useDefault ? { default: express } : express; + const moduleExports = useDefault ? { default: express } : express; const r = express.Router as ExpressRouterv4; const a = express.application; - const options = { express: module } as unknown as ExpressIntegrationOptions; + const getOptions = () => ({}); expect((r.use as WrappedFunction).__sentry_original__).toBe(undefined); expect((r.route as WrappedFunction).__sentry_original__).toBe(undefined); expect((a.use as WrappedFunction).__sentry_original__).toBe(undefined); - patchExpressModule(options); + patchExpressModule(moduleExports, getOptions); expect(typeof (r.use as WrappedFunction).__sentry_original__).toBe('function'); expect(typeof (r.route as WrappedFunction).__sentry_original__).toBe('function'); @@ -161,13 +159,13 @@ describe('patchExpressModule', () => { const express = getExpress5(); const r = express.Router as ExpressRouterv5; const a = express.application; - const module = useDefault ? { default: express } : express; - const options = { express: module } as unknown as ExpressIntegrationOptions; + const moduleExports = useDefault ? { default: express } : express; + const getOptions = () => ({}); expect((r.prototype.use as WrappedFunction).__sentry_original__).toBe(undefined); expect((r.prototype.route as WrappedFunction).__sentry_original__).toBe(undefined); expect((a.use as WrappedFunction).__sentry_original__).toBe(undefined); - patchExpressModule(options); + patchExpressModule(moduleExports, getOptions); expect(typeof (r.prototype.use as WrappedFunction).__sentry_original__).toBe('function'); expect(typeof (r.prototype.route as WrappedFunction).__sentry_original__).toBe('function'); @@ -178,8 +176,8 @@ describe('patchExpressModule', () => { it('calls patched and original Router.route', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.Router.route('a'); expect(spies.routerRoute).toHaveBeenCalledExactlyOnceWith('a'); }); @@ -187,18 +185,18 @@ describe('patchExpressModule', () => { it('calls patched and original Router.use', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.Router.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.routerUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('skips patchLayer call in Router.use if no layer in the stack', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); const { stack } = expressv4.Router; expressv4.Router.stack = []; expressv4.Router.use('a'); @@ -210,28 +208,28 @@ describe('patchExpressModule', () => { it('calls patched and original application.use', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); expressv4.application.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.appUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('calls patched and original application.use on express v5', () => { const expressv5 = getExpress5(); const { spies } = expressv5; - const options = { express: expressv5 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv5, getOptions); expressv5.application.use('a'); - expect(patchLayerCalls).toStrictEqual([[options, { name: 'layerFinal' }, 'a']]); + expect(patchLayerCalls).toStrictEqual([[getOptions, { name: 'layerFinal' }, 'a']]); expect(spies.appUse).toHaveBeenCalledExactlyOnceWith('a'); }); it('skips patchLayer on application.use if no router found', () => { const expressv4 = getExpress4(); const { spies } = expressv4; - const options = { express: expressv4 }; - patchExpressModule(options); + const getOptions = () => ({}); + patchExpressModule(expressv4, getOptions); const app = expressv4.application as { _router?: ExpressRoute; }; @@ -246,8 +244,9 @@ describe('patchExpressModule', () => { it('debug error when patching fails', () => { const expressv5 = getExpress5(); - patchExpressModule({ express: expressv5 }); - patchExpressModule({ express: expressv5 }); + const getOptions = () => ({}); + patchExpressModule(expressv5, getOptions); + patchExpressModule(expressv5, getOptions); expect(debugErrors).toStrictEqual([ ['Failed to patch express route method:', new Error('Attempting to wrap method route multiple times')], ['Failed to patch express use method:', new Error('Attempting to wrap method use multiple times')], diff --git a/packages/core/test/lib/integrations/express/patch-layer.test.ts b/packages/core/test/lib/integrations/express/patch-layer.test.ts index 254ffb79edde..8953955ee373 100644 --- a/packages/core/test/lib/integrations/express/patch-layer.test.ts +++ b/packages/core/test/lib/integrations/express/patch-layer.test.ts @@ -150,12 +150,12 @@ describe('patchLayer', () => { describe('no-ops', () => { it('if layer is missing', () => { // mostly for coverage, verifying it doesn't throw or anything - patchLayer({}); + patchLayer(() => ({})); }); it('if layer.handle is missing', () => { // mostly for coverage, verifying it doesn't throw or anything - patchLayer({}, { handle: null } as unknown as ExpressLayer); + patchLayer(() => ({}), { handle: null } as unknown as ExpressLayer); }); it('if layer already patched', () => { @@ -166,7 +166,7 @@ describe('patchLayer', () => { const layer = { handle: wrapped, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(layer.handle).toBe(wrapped); }); @@ -177,7 +177,7 @@ describe('patchLayer', () => { const layer = { handle: original, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(layer.handle).toBe(original); }); @@ -188,7 +188,7 @@ describe('patchLayer', () => { const layer = { handle: original, } as unknown as ExpressLayer; - patchLayer({}, layer); + patchLayer(() => ({}), layer); expect(getOriginalFunction(layer.handle)).toBe(original); }); }); @@ -212,7 +212,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer); + patchLayer(() => options, layer); layer.handle(req, res); expect(layerHandleOriginal).toHaveBeenCalledOnce(); @@ -244,7 +244,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer, '/layerPath'); + patchLayer(() => options, layer, '/layerPath'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith('/a/:boo/:car/layerPath'); expect(layerHandleOriginal).toHaveBeenCalledOnce(); @@ -290,7 +290,7 @@ describe('patchLayer', () => { // 'router' → router, 'bound dispatch' → request_handler, other → middleware const layerName = type === 'router' ? 'router' : 'bound dispatch'; const layer = { name: layerName, handle: layerHandleOriginal } as unknown as ExpressLayer; - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); // storeLayer('/c') happens inside the patched handle, before being popped // after handle returns, storedLayers should be back to ['/a', '/b'] @@ -327,7 +327,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer, '/layerPath'); + patchLayer(() => options, layer, '/layerPath'); expect(getOriginalFunction(layer.handle)).toBe(layerHandleOriginal); expect(layer.handle.x).toBe(true); layer.handle.x = false; @@ -382,7 +382,7 @@ describe('patchLayer', () => { storeLayer(req, '/:boo'); storeLayer(req, '/:car'); - patchLayer(options, layer); + patchLayer(() => options, layer); expect(getOriginalFunction(layer.handle)).toBe(layerHandleOriginal); warnings.length = 0; layer.handle(req, res); @@ -441,7 +441,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith('/a/b/c'); const span = mockSpans[0]; @@ -482,7 +482,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); layer.handle(req, res); expect(onRouteResolved).toHaveBeenCalledExactlyOnceWith(undefined); const span = mockSpans[0]; @@ -526,7 +526,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(getStoredLayers(req)).toStrictEqual(['/a', '/b']); const callback = vi.fn(() => { @@ -576,7 +576,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(getStoredLayers(req)).toStrictEqual(['/a', '/b']); const callback = vi.fn(() => { @@ -622,7 +622,7 @@ describe('patchLayer', () => { storeLayer(req, '/a'); storeLayer(req, '/b'); - patchLayer(options, layer, '/c'); + patchLayer(() => options, layer, '/c'); expect(() => { layer.handle(req, res); }).toThrowError('yur head asplode'); diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index eb396b81a6ee..3b29919764b8 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,16 +1,12 @@ -// Automatic istrumentation for Express using OTel -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { context } from '@opentelemetry/api'; import { getRPCMetadata, RPCType } from '@opentelemetry/core'; -import { ensureIsWrapped, generateInstrumentOnce } from '@sentry/node-core'; +import { ensureIsWrapped, registerModuleWrapper } from '@sentry/node-core'; import { type ExpressIntegrationOptions, type IntegrationFn, debug, patchExpressModule, - SDK_VERSION, defineIntegration, setupExpressErrorHandler as coreSetupExpressErrorHandler, type ExpressHandlerOptions, @@ -18,6 +14,8 @@ import { export { expressErrorHandler } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; +type ExpressModuleExport = Parameters[0]; + const INTEGRATION_NAME = 'Express'; const SUPPORTED_VERSIONS = ['>=4.0.0 <6']; @@ -30,47 +28,46 @@ export function setupExpressErrorHandler( ensureIsWrapped(app.use, 'express'); } -export type ExpressInstrumentationConfig = InstrumentationConfig & - Omit; - -export const instrumentExpress = generateInstrumentOnce( - INTEGRATION_NAME, - (options?: ExpressInstrumentationConfig) => new ExpressInstrumentation(options), -); +export type ExpressInstrumentationConfig = Omit; -export class ExpressInstrumentation extends InstrumentationBase { - public constructor(config: ExpressInstrumentationConfig = {}) { - super('sentry-express', SDK_VERSION, config); - } - public init(): InstrumentationNodeModuleDefinition { - const module = new InstrumentationNodeModuleDefinition( - 'express', - SUPPORTED_VERSIONS, - express => { - try { - patchExpressModule({ - ...this.getConfig(), - express, - onRouteResolved(route) { - const rpcMetadata = getRPCMetadata(context.active()); - if (route && rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = route; - } - }, - }); - } catch (e) { - DEBUG_BUILD && debug.error('Failed to patch express module:', e); - } - return express; - }, - // we do not ever actually unpatch in our SDKs - express => express, - ); - return module; - } +/** + * Instrument Express using registerModuleWrapper. + * This registers hooks for both CJS and ESM module loading. + * + * Calling this multiple times is safe: + * - Hooks are only registered once (first call) + * - Options are updated on each call + * - Use getOptions() in the patch to access current options at runtime + */ +export function instrumentExpress(options: ExpressInstrumentationConfig = {}): void { + registerModuleWrapper({ + moduleName: 'express', + supportedVersions: SUPPORTED_VERSIONS, + options, + patch: (moduleExports, getOptions) => { + const express = moduleExports as ExpressModuleExport; + try { + patchExpressModule(express, () => ({ + ...getOptions(), + onRouteResolved(route) { + const rpcMetadata = getRPCMetadata(context.active()); + if (route && rpcMetadata?.type === RPCType.HTTP) { + rpcMetadata.route = route; + } + }, + })); + } catch (e) { + DEBUG_BUILD && debug.error('Failed to patch express module:', e); + } + return moduleExports; + }, + }); } -const _expressInstrumentation = ((options?: ExpressInstrumentationConfig) => { +// Add id property for compatibility with preloadOpenTelemetry logging +instrumentExpress.id = INTEGRATION_NAME; + +const _expressIntegration = ((options?: ExpressInstrumentationConfig) => { return { name: INTEGRATION_NAME, setupOnce() { @@ -79,4 +76,4 @@ const _expressInstrumentation = ((options?: ExpressInstrumentationConfig) => { }; }) satisfies IntegrationFn; -export const expressIntegration = defineIntegration(_expressInstrumentation); +export const expressIntegration = defineIntegration(_expressIntegration); From 956e1d14f32209663f639d71161d965508467458 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 10 Apr 2026 11:55:34 +0200 Subject: [PATCH 3/3] fixes, tests and logs --- .../node-core/src/module-wrapper/index.ts | 19 ++++- .../node-core/src/module-wrapper/semver.ts | 43 ++++++---- .../test/module-wrapper/semver.test.ts | 80 +++++++++++++++++++ 3 files changed, 127 insertions(+), 15 deletions(-) diff --git a/packages/node-core/src/module-wrapper/index.ts b/packages/node-core/src/module-wrapper/index.ts index b880136b174b..510eb7b4aea9 100644 --- a/packages/node-core/src/module-wrapper/index.ts +++ b/packages/node-core/src/module-wrapper/index.ts @@ -9,7 +9,8 @@ import { Hook } from 'import-in-the-middle'; import { satisfies } from './semver'; import { RequireInTheMiddleSingleton, type OnRequireFn } from './singleton'; import { extractPackageVersion } from './version'; - +import { DEBUG_BUILD } from '../debug-build'; +import { debug } from '@sentry/core'; export type { OnRequireFn }; export { satisfies } from './semver'; export { extractPackageVersion } from './version'; @@ -93,6 +94,14 @@ export function registerModuleWrapper(wrapperOptions: Module // Main module - check version and patch const version = extractPackageVersion(basedir); if (isVersionSupported(version, supportedVersions)) { + DEBUG_BUILD && + debug.log( + '[ModuleWrapper]', + `registering module wrapper for ${moduleName} with version ${version}`, + `supportedVersions: ${supportedVersions}`, + `file hooks: ${files?.map(f => f.name).join(', ')}`, + ); + return patch(exports, getOptions, version); } } else if (files) { @@ -140,6 +149,14 @@ export function registerModuleWrapper(wrapperOptions: Module if (isMainModule) { const version = extractPackageVersion(baseDirectory); if (isVersionSupported(version, supportedVersions)) { + DEBUG_BUILD && + debug.log( + '[ModuleWrapper]', + `registering ESM module wrapper for ${moduleName} with version ${version}`, + `supportedVersions: ${supportedVersions}`, + `file hooks: ${files?.map(f => f.name).join(', ')}`, + ); + return patch(exports, getOptions, version); } } else if (files) { diff --git a/packages/node-core/src/module-wrapper/semver.ts b/packages/node-core/src/module-wrapper/semver.ts index a1b1b4c64045..f6fda16fdca2 100644 --- a/packages/node-core/src/module-wrapper/semver.ts +++ b/packages/node-core/src/module-wrapper/semver.ts @@ -2,8 +2,9 @@ * Lightweight semantic versioning utilities. * * This is a simplified semver implementation that only supports basic comparison - * operators (<, <=, >, >=, =). For module wrapper version checking, these operators - * combined with space-separated AND ranges and || OR ranges are sufficient. + * operators (<, <=, >, >=, =). Comparators may use a major-only bound (e.g. `<6` as + * `<6.0.0`). For module wrapper version checking, these operators combined with + * space-separated AND ranges and || OR ranges are sufficient. * * Unsupported patterns (caret ^, tilde ~, hyphen ranges, x-ranges) will log a warning. */ @@ -17,6 +18,9 @@ const VERSION_REGEXP = const COMPARATOR_REGEXP = /^(?<|>|<=|>=|=)?(?:v)?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/; +/** Major-only bound (e.g. `<6`, `>=2`) — interpreted as `<6.0.0`, `>=2.0.0`. */ +const MAJOR_ONLY_COMPARATOR_REGEXP = /^(?<|>|<=|>=|=)?(?:v)?(?0|[1-9]\d*)$/; + const UNSUPPORTED_PATTERN = /[~^*xX]| - /; interface ParsedVersion { @@ -147,22 +151,33 @@ function parseVersion(version: string): ParsedVersion | undefined { */ function parseComparator(comparator: string): ParsedComparator | undefined { const match = comparator.match(COMPARATOR_REGEXP); - if (!match?.groups) { - return undefined; + if (match?.groups) { + const { op, major, minor, patch, prerelease } = match.groups; + if (major !== undefined && minor !== undefined && patch !== undefined) { + return { + op: op || '=', + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch, 10), + prerelease: prerelease ? prerelease.split('.') : undefined, + }; + } } - const { op, major, minor, patch, prerelease } = match.groups; - if (major === undefined || minor === undefined || patch === undefined) { - return undefined; + const majorOnly = comparator.match(MAJOR_ONLY_COMPARATOR_REGEXP); + if (majorOnly?.groups) { + const { op, major } = majorOnly.groups; + if (major !== undefined) { + return { + op: op || '=', + major: parseInt(major, 10), + minor: 0, + patch: 0, + }; + } } - return { - op: op || '=', - major: parseInt(major, 10), - minor: parseInt(minor, 10), - patch: parseInt(patch, 10), - prerelease: prerelease ? prerelease.split('.') : undefined, - }; + return undefined; } /** diff --git a/packages/node-core/test/module-wrapper/semver.test.ts b/packages/node-core/test/module-wrapper/semver.test.ts index a7ce16bded5a..9ea5d433f447 100644 --- a/packages/node-core/test/module-wrapper/semver.test.ts +++ b/packages/node-core/test/module-wrapper/semver.test.ts @@ -68,6 +68,17 @@ describe('semver satisfies', () => { expect(satisfies('3.5.0', '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0')).toBe(true); expect(satisfies('2.5.0', '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0')).toBe(false); }); + + it('handles major-only bound ranges', () => { + const range = '>=4.0.0 <6'; + expect(satisfies('4.0.0', range)).toBe(true); + expect(satisfies('4.18.2', range)).toBe(true); + expect(satisfies('5.0.0', range)).toBe(true); + expect(satisfies('5.99.99', range)).toBe(true); + expect(satisfies('3.9.9', range)).toBe(false); + expect(satisfies('6.0.0', range)).toBe(false); + expect(satisfies('6.0.0-alpha', range)).toBe(true); + }); }); describe('pre-release versions', () => { @@ -119,5 +130,74 @@ describe('semver satisfies', () => { it('still attempts to match but warns for x-ranges', () => { expect(satisfies('1.5.0', '1.x')).toBe(false); }); + + it('warns for hyphen ranges (space-hyphen-space)', () => { + expect(satisfies('1.5.0', '1.0.0 - 2.0.0')).toBe(false); + }); + }); + + describe('version string formats', () => { + it('accepts an optional v prefix on the version', () => { + expect(satisfies('v1.2.3', '>=1.0.0')).toBe(true); + expect(satisfies('v0.0.1', '<1.0.0')).toBe(true); + expect(satisfies('v10.20.30', '>=10.0.0')).toBe(true); + }); + + it('parses build metadata on versions but not on comparators', () => { + expect(satisfies('1.0.0+build.1', '1.0.0')).toBe(true); + expect(satisfies('1.0.0+build', '>=1.0.0')).toBe(true); + expect(satisfies('2.0.0+meta', '>1.0.0')).toBe(true); + // Build metadata is not part of `COMPARATOR_REGEXP`; cannot express `1.0.0+foo` as a range bound. + expect(satisfies('1.0.0+githash', '1.0.0+other')).toBe(false); + }); + + it('parses prerelease plus build metadata together', () => { + expect(satisfies('1.0.0-rc.1+exp.sha512', '>=1.0.0-rc.0')).toBe(true); + expect(satisfies('1.0.0-rc.1+exp', '1.0.0-rc.1')).toBe(true); + expect(satisfies('1.0.0-alpha.beta+build', '<1.0.0')).toBe(true); + }); + + it('rejects versions that do not match strict semver numeric rules', () => { + expect(satisfies('01.2.3', '>=0.0.0')).toBe(false); + expect(satisfies('1.02.3', '>=0.0.0')).toBe(false); + expect(satisfies('1.2.03', '>=0.0.0')).toBe(false); + }); + + it('rejects missing segments or extra segments', () => { + expect(satisfies('1.0', '>=1.0.0')).toBe(false); + expect(satisfies('1', '>=1.0.0')).toBe(false); + expect(satisfies('1.0.0.1', '>=1.0.0')).toBe(false); + expect(satisfies('', '>=1.0.0')).toBe(false); + }); + }); + + describe('comparator string formats', () => { + it('accepts an optional v prefix on comparators', () => { + expect(satisfies('1.5.0', '>=v1.0.0')).toBe(true); + expect(satisfies('1.0.0', '=v1.0.0')).toBe(true); + expect(satisfies('2.0.0', '>v1.9.9')).toBe(true); + expect(satisfies('1.0.0-rc.1', '>=v1.0.0-alpha')).toBe(true); + }); + + it('does not support build metadata on comparators (invalid comparator)', () => { + expect(satisfies('1.0.0', '>=1.0.0+build')).toBe(false); + }); + + it('handles multi-part prerelease in comparators', () => { + expect(satisfies('1.0.0-rc.2', '>=1.0.0-rc.1')).toBe(true); + expect(satisfies('1.0.0', '>=1.0.0-rc.99')).toBe(true); + }); + }); + + describe('prerelease ordering (additional cases)', () => { + it('orders numeric vs non-numeric prerelease identifiers per semver rules', () => { + // Numeric identifiers have lower precedence than non-numeric (semver 2.0.0). + expect(satisfies('1.0.0-alpha', '>1.0.0-1')).toBe(true); + expect(satisfies('1.0.0-1', '>1.0.0-alpha')).toBe(false); + }); + + it('shorter identifier list has lower precedence when shared prefix matches', () => { + expect(satisfies('1.0.0-alpha.1', '>1.0.0-alpha')).toBe(true); + }); }); });