diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 19fc53365bb5..ec2c6394f040 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,6 +137,7 @@ export type { ExpressHandlerOptions, ExpressMiddleware, ExpressErrorMiddleware, + ExpressModuleExport, } from './integrations/express/types'; export { dedupeIntegration } from './integrations/dedupe'; export { extraErrorDataIntegration } from './integrations/extraerrordata'; diff --git a/packages/node-core/package.json b/packages/node-core/package.json index dfac5c3f7fd5..b327b0856f59 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -107,7 +107,8 @@ "dependencies": { "@sentry/core": "10.51.0", "@sentry/opentelemetry": "10.51.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..79954b77cf43 --- /dev/null +++ b/packages/node-core/src/module-wrapper/index.ts @@ -0,0 +1,201 @@ +/** + * 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'; +import { DEBUG_BUILD } from '../debug-build'; +import { debug } from '@sentry/core'; +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: TModuleExports, 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)) { + 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 as TModuleExports, 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)) { + 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 as TModuleExports, 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..f6fda16fdca2 --- /dev/null +++ b/packages/node-core/src/module-wrapper/semver.ts @@ -0,0 +1,261 @@ +/** + * Lightweight semantic versioning utilities. + * + * This is a simplified semver implementation that only supports basic comparison + * 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. + */ + +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-]*))*))?$/; + +/** 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 { + 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) { + 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 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 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..4d69b8877e57 --- /dev/null +++ b/packages/node-core/src/module-wrapper/singleton.ts @@ -0,0 +1,215 @@ +/** + * RequireInTheMiddle singleton for efficient CJS module patching. + * + * Provides a single `require-in-the-middle` hook with trie-based module name matching + * for better performance when using many module wrappers. + * + * This file is a derivative work based on OpenTelemetry's `RequireInTheMiddleSingleton` + * and `ModuleNameTrie` implementations. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* 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..9ea5d433f447 --- /dev/null +++ b/packages/node-core/test/module-wrapper/semver.test.ts @@ -0,0 +1,203 @@ +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); + }); + + 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', () => { + 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); + }); + + 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); + }); + }); +}); diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index c0f7cbc2414f..438f0dc05149 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,16 +1,13 @@ -// 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 ExpressModuleExport, type IntegrationFn, debug, patchExpressModule, - SDK_VERSION, defineIntegration, setupExpressErrorHandler as coreSetupExpressErrorHandler, type ExpressHandlerOptions, @@ -19,6 +16,7 @@ export { expressErrorHandler } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; const INTEGRATION_NAME = 'Express'; +const MODULE_NAME = 'express'; const SUPPORTED_VERSIONS = ['>=4.0.0 <6']; export function setupExpressErrorHandler( @@ -30,44 +28,44 @@ export function setupExpressErrorHandler( ensureIsWrapped(app.use, 'express'); } -export type ExpressInstrumentationConfig = InstrumentationConfig & - Omit; +export type ExpressInstrumentationConfig = Omit; -export const instrumentExpress = generateInstrumentOnce( - INTEGRATION_NAME, - (options?: ExpressInstrumentationConfig) => new ExpressInstrumentation(options), -); - -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(express, () => ({ - ...this.getConfig(), - 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: MODULE_NAME, + supportedVersions: SUPPORTED_VERSIONS, + options, + patch: (moduleExports, getOptions) => { + try { + patchExpressModule(moduleExports, () => ({ + ...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; + }, + }); } + +// Add id property for compatibility with preloadOpenTelemetry logging +instrumentExpress.id = INTEGRATION_NAME; + const _expressIntegration = ((options?: ExpressInstrumentationConfig) => { return { name: INTEGRATION_NAME, diff --git a/yarn.lock b/yarn.lock index 4be50683a09f..69e90cb8da1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5436,17 +5436,6 @@ dependencies: "@tybys/wasm-util" "^0.10.1" -"@nestjs/common@11.1.19", "@nestjs/common@^11": - version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" - integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== - dependencies: - uid "2.0.2" - file-type "21.3.4" - iterare "1.2.1" - load-esm "1.0.3" - tslib "2.8.1" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -5456,16 +5445,15 @@ iterare "1.2.1" tslib "2.8.1" -"@nestjs/core@11.1.19": +"@nestjs/common@^11": version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.19.tgz#d724f1afc0caac29e005464f0f659425fc80235b" - integrity sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw== + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.19.tgz#50ba93ae45ebaeda6163554b8e2ecec545a25c92" + integrity sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q== dependencies: uid "2.0.2" - "@nuxt/opencollective" "0.4.1" - fast-safe-stringify "2.1.1" + file-type "21.3.4" iterare "1.2.1" - path-to-regexp "8.4.2" + load-esm "1.0.3" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -5492,17 +5480,6 @@ path-to-regexp "8.4.2" tslib "2.8.1" -"@nestjs/platform-express@11.1.19": - version "11.1.19" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" - integrity sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg== - dependencies: - cors "2.8.6" - express "5.2.1" - multer "2.1.1" - path-to-regexp "8.4.2" - tslib "2.8.1" - "@nestjs/platform-express@^11": version "11.1.19" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz#e55f5078396b2285344f95f2b530b648e844cd4c" @@ -10001,7 +9978,7 @@ "@types/node" "*" "@types/webidl-conversions" "*" -"@types/ws@*", "@types/ws@^8.5.1", "@types/ws@^8.5.10": +"@types/ws@*", "@types/ws@^8.18.1", "@types/ws@^8.5.1", "@types/ws@^8.5.10": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== @@ -17418,16 +17395,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@21.3.2: - version "21.3.2" - resolved "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz" - integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== - dependencies: - "@tokenizer/inflate" "^0.4.1" - strtok3 "^10.3.4" - token-types "^6.1.1" - uint8array-extras "^1.4.0" - file-type@21.3.4: version "21.3.4" resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.4.tgz#e3f902faee8ec4aa152909fc902a7a77f9c06725" @@ -22540,19 +22507,6 @@ msgpackr@^1.11.9: optionalDependencies: msgpackr-extract "^3.0.2" -multer@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" - integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== - dependencies: - append-field "^1.0.0" - busboy "^1.6.0" - concat-stream "^2.0.0" - mkdirp "^0.5.6" - object-assign "^4.1.1" - type-is "^1.6.18" - xtend "^4.0.2" - multer@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/multer/-/multer-2.1.1.tgz#122d819244fbdfee1efddd9147426691014385b7" @@ -26426,6 +26380,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" @@ -31271,10 +31234,10 @@ ws@^7.3.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.4.2: - version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== +ws@^8.13.0, ws@^8.18.0, ws@^8.18.3, ws@^8.20.0, ws@^8.4.2: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8" + integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== ws@~8.17.1: version "8.17.1" @@ -31303,7 +31266,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0, xtend@^4.0.2: +xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==