From 058437b7f7558d427507fc6e828475c28b48f8cd Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 19 Jun 2026 11:46:14 +0100 Subject: [PATCH] feat: Create Sentry spans from `fsIntegration` --- packages/node/src/integrations/fs/index.ts | 22 +- .../fs/vendored/instrumentation.ts | 333 ++++++++---------- .../src/integrations/fs/vendored/types.ts | 3 +- 3 files changed, 156 insertions(+), 202 deletions(-) diff --git a/packages/node/src/integrations/fs/index.ts b/packages/node/src/integrations/fs/index.ts index 444059161f83..c2573f5308ec 100644 --- a/packages/node/src/integrations/fs/index.ts +++ b/packages/node/src/integrations/fs/index.ts @@ -1,9 +1,10 @@ import { defineIntegration } from '@sentry/core'; -import { generateInstrumentOnce } from '@sentry/node-core'; -import { FsInstrumentation } from './vendored/instrumentation'; +import { enableFsInstrumentation } from './vendored/instrumentation'; const INTEGRATION_NAME = 'FileSystem'; +let _isEnabled = false; + /** * This integration will create spans for `fs` API operations, like reading and writing files. * @@ -16,6 +17,11 @@ const INTEGRATION_NAME = 'FileSystem'; export const fsIntegration = defineIntegration( ( options: { + /** + * Whether to enable the plugin. + * @default true + */ + enabled?: boolean; /** * Setting this option to `true` will include any filepath arguments from your `fs` API calls as span attributes. * @@ -34,14 +40,10 @@ export const fsIntegration = defineIntegration( return { name: INTEGRATION_NAME, setupOnce() { - generateInstrumentOnce( - INTEGRATION_NAME, - () => - new FsInstrumentation({ - recordFilePaths: options.recordFilePaths, - recordErrorMessagesAsSpanAttributes: options.recordErrorMessagesAsSpanAttributes, - }), - )(); + if (options.enabled === false) return; + if (_isEnabled) return; + _isEnabled = true; + enableFsInstrumentation(options); }, }; }, diff --git a/packages/node/src/integrations/fs/vendored/instrumentation.ts b/packages/node/src/integrations/fs/vendored/instrumentation.ts index f81c259f831d..5753f059f41f 100644 --- a/packages/node/src/integrations/fs/vendored/instrumentation.ts +++ b/packages/node/src/integrations/fs/vendored/instrumentation.ts @@ -9,31 +9,26 @@ * - The OpenTelemetry tracer APIs were replaced with Sentry's `startSpan`/`startInactiveSpan`/`suppressTracing` * and the configurable `createHook`/`endHook`/`requireParentSpan` options were removed in favor of inlined, * Sentry-specific span attributes. + * - Completely reworked to create Sentry spans rather than OTel spans. */ -import { context } from '@opentelemetry/api'; -import { InstrumentationBase, InstrumentationNodeModuleDefinition, isWrapped } from '@opentelemetry/instrumentation'; import type { Span, SpanAttributes } from '@sentry/core'; import { - SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, + getActiveSpan, startInactiveSpan, startSpan, suppressTracing, + withActiveSpan, } from '@sentry/core'; -import type * as fs from 'fs'; +import * as fs from 'fs'; import { promisify } from 'util'; import { CALLBACK_FUNCTIONS, PROMISE_FUNCTIONS, SYNC_FUNCTIONS } from './constants'; import type { FMember, FPMember, FsInstrumentationConfig, GenericFunction } from './types'; import { indexFs } from './utils'; -type FS = typeof fs; -type FSPromises = (typeof fs)['promises']; - -const PACKAGE_NAME = '@sentry/instrumentation-fs'; - const SPAN_ORIGIN = 'auto.file.fs'; const SPAN_OP = 'file'; @@ -141,213 +136,171 @@ function patchedFunctionWithOriginalProperties(patche return Object.assign(patchedFunction, original); } -export class FsInstrumentation extends InstrumentationBase { - public constructor(config: FsInstrumentationConfig = {}) { - super(PACKAGE_NAME, SDK_VERSION, config); - } - - public init(): InstrumentationNodeModuleDefinition[] { - return [ - new InstrumentationNodeModuleDefinition( - 'fs', - ['*'], - (fs: FS) => { - for (const fName of SYNC_FUNCTIONS) { - const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); +// Tracks patched methods to prevent double-patching if `enableFsInstrumentation` is called more than once. +const _patched = new WeakMap, Set>(); - if (isWrapped(objectToPatch[functionNameToPatch])) { - this._unwrap(objectToPatch, functionNameToPatch); - } - this._wrap(objectToPatch, functionNameToPatch, this._patchSyncFunction.bind(this, fName)); - } - for (const fName of CALLBACK_FUNCTIONS) { - const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); - if (isWrapped(objectToPatch[functionNameToPatch])) { - this._unwrap(objectToPatch, functionNameToPatch); - } - if (fName === 'exists') { - // handling separately because of the inconsistent cb style: - // `exists` doesn't have error as the first argument, but the result - this._wrap(objectToPatch, functionNameToPatch, this._patchExistsCallbackFunction.bind(this, fName)); - continue; - } - this._wrap(objectToPatch, functionNameToPatch, this._patchCallbackFunction.bind(this, fName)); - } - for (const fName of PROMISE_FUNCTIONS) { - if (isWrapped(fs.promises[fName])) { - this._unwrap(fs.promises, fName); - } - this._wrap(fs.promises, fName, this._patchPromiseFunction.bind(this, fName)); - } - return fs; - }, - (fs: FS) => { - if (fs === undefined) return; - for (const fName of SYNC_FUNCTIONS) { - const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); - if (isWrapped(objectToPatch[functionNameToPatch])) { - this._unwrap(objectToPatch, functionNameToPatch); - } - } - for (const fName of CALLBACK_FUNCTIONS) { - const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); - if (isWrapped(objectToPatch[functionNameToPatch])) { - this._unwrap(objectToPatch, functionNameToPatch); - } - } - for (const fName of PROMISE_FUNCTIONS) { - if (isWrapped(fs.promises[fName])) { - this._unwrap(fs.promises, fName); - } - } - }, - ), - new InstrumentationNodeModuleDefinition( - 'fs/promises', - ['*'], - (fsPromises: FSPromises) => { - for (const fName of PROMISE_FUNCTIONS) { - if (isWrapped(fsPromises[fName])) { - this._unwrap(fsPromises, fName); - } - this._wrap(fsPromises, fName, this._patchPromiseFunction.bind(this, fName)); - } - return fsPromises; - }, - (fsPromises: FSPromises) => { - if (fsPromises === undefined) return; - for (const fName of PROMISE_FUNCTIONS) { - if (isWrapped(fsPromises[fName])) { - this._unwrap(fsPromises, fName); - } - } - }, - ), - ]; +function _patchMethod( + obj: Record, + name: string, + wrapper: (original: GenericFunction) => GenericFunction, +): void { + const original = obj[name]; + if (typeof original !== 'function') return; + let patched = _patched.get(obj); + if (!patched) { + patched = new Set(); + _patched.set(obj, patched); } + if (patched.has(name)) return; + patched.add(name); + obj[name] = wrapper(original); +} - protected _patchSyncFunction(functionName: FMember, original: T): T { - // oxlint-disable-next-line typescript/no-this-alias - const instrumentation = this; - const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { - const config = instrumentation.getConfig(); - const attributes = getSpanAttributes(functionName, args, config); - - return startSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }, span => { - try { - // Suppress tracing for internal fs calls - return suppressTracing(() => original.apply(this, args)) as ReturnType; - } catch (error) { - recordError(span, error, config); - throw error; - } - }); - }; - return patchedFunctionWithOriginalProperties(patchedFunction as T, original); - } - - protected _patchCallbackFunction(functionName: FMember, original: T): T { - // oxlint-disable-next-line typescript/no-this-alias - const instrumentation = this; - const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { - const config = instrumentation.getConfig(); - - const lastIdx = args.length - 1; - const cb: unknown = args[lastIdx]; - if (typeof cb !== 'function') { - // TODO: what to do if we are pretty sure it's going to throw - return original.apply(this, args) as ReturnType; - } - - const attributes = getSpanAttributes(functionName, args, config); - const span = startInactiveSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }); - - // Return to the context active during the call in the callback - args[lastIdx] = context.bind(context.active(), function (this: unknown, ...cbArgs: unknown[]) { - const error = cbArgs[0]; - if (error) { - recordError(span, error, config); - } - span.end(); - return cb.apply(this, cbArgs); - }); - +function _patchSyncFunction( + functionName: FMember, + original: T, + config: FsInstrumentationConfig, +): T { + const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { + const attributes = getSpanAttributes(functionName, args, config); + return startSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }, span => { try { - // Suppress tracing for internal fs calls return suppressTracing(() => original.apply(this, args)) as ReturnType; } catch (error) { recordError(span, error, config); - span.end(); throw error; } + }); + }; + return patchedFunctionWithOriginalProperties(patchedFunction as T, original); +} + +function _patchCallbackFunction( + functionName: FMember, + original: T, + config: FsInstrumentationConfig, +): T { + const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { + const lastIdx = args.length - 1; + const cb: unknown = args[lastIdx]; + if (typeof cb !== 'function') { + return original.apply(this, args) as ReturnType; + } + + const attributes = getSpanAttributes(functionName, args, config); + const span = startInactiveSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }); + const parentSpan = getActiveSpan(); + + // Wrap the callback to end the span and restore the caller's active span context. + // fs callbacks fire from Node's I/O event loop where the AsyncLocalStorage context + // is lost, so any spans created inside the callback would otherwise have no parent. + args[lastIdx] = function (this: unknown, ...cbArgs: unknown[]) { + const error = cbArgs[0]; + if (error) { + recordError(span, error, config); + } + span.end(); + if (parentSpan) { + return withActiveSpan(parentSpan, () => (cb as GenericFunction).apply(this, cbArgs)); + } + return (cb as GenericFunction).apply(this, cbArgs); }; - return patchedFunctionWithOriginalProperties(patchedFunction as T, original); - } - protected _patchExistsCallbackFunction(functionName: 'exists', original: T): T { - // oxlint-disable-next-line typescript/no-this-alias - const instrumentation = this; - const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { - const config = instrumentation.getConfig(); + try { + return suppressTracing(() => original.apply(this, args)) as ReturnType; + } catch (error) { + recordError(span, error, config); + span.end(); + throw error; + } + }; + return patchedFunctionWithOriginalProperties(patchedFunction as T, original); +} + +function _patchExistsCallbackFunction(original: T, config: FsInstrumentationConfig): T { + const functionName = 'exists' as FMember; + const patchedFunction = function (this: unknown, ...args: Parameters): ReturnType { + const lastIdx = args.length - 1; + const cb: unknown = args[lastIdx]; + if (typeof cb !== 'function') { + return original.apply(this, args) as ReturnType; + } + + const attributes = getSpanAttributes(functionName, args, config); + const span = startInactiveSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }); + const parentSpan = getActiveSpan(); - const lastIdx = args.length - 1; - const cb: unknown = args[lastIdx]; - if (typeof cb !== 'function') { - return original.apply(this, args) as ReturnType; + // `exists` never calls the callback with an error + args[lastIdx] = function (this: unknown, ...cbArgs: unknown[]) { + span.end(); + if (parentSpan) { + return withActiveSpan(parentSpan, () => (cb as GenericFunction).apply(this, cbArgs)); } + return (cb as GenericFunction).apply(this, cbArgs); + }; + + try { + return suppressTracing(() => original.apply(this, args)) as ReturnType; + } catch (error) { + recordError(span, error, config); + span.end(); + throw error; + } + }; + const functionWithOriginalProperties = patchedFunctionWithOriginalProperties(patchedFunction as T, original); - const attributes = getSpanAttributes(functionName, args, config); - const span = startInactiveSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }); + // `exists` has a custom promisify function because of the inconsistent signature + // replicating that on the patched function + const promisified = function (path: unknown): Promise { + return new Promise(resolve => (functionWithOriginalProperties as GenericFunction)(path, resolve)); + }; + Object.defineProperty(promisified, 'name', { value: functionName }); + Object.defineProperty(functionWithOriginalProperties, promisify.custom, { + value: promisified, + }); - // Return to the context active during the call in the callback - args[lastIdx] = context.bind(context.active(), function (this: unknown, ...cbArgs: unknown[]) { - // `exists` never calls the callback with an error - span.end(); - return cb.apply(this, cbArgs); - }); + return functionWithOriginalProperties; +} +function _patchPromiseFunction( + functionName: FPMember, + original: T, + config: FsInstrumentationConfig, +): T { + const patchedFunction = async function (this: unknown, ...args: Parameters): Promise { + const attributes = getSpanAttributes(functionName, args, config); + return startSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }, async span => { try { - // Suppress tracing for internal fs calls - return suppressTracing(() => original.apply(this, args)) as ReturnType; + return await suppressTracing(() => original.apply(this, args) as Promise); } catch (error) { recordError(span, error, config); - span.end(); throw error; } - }; - const functionWithOriginalProperties = patchedFunctionWithOriginalProperties(patchedFunction as T, original); - - // `exists` has a custom promisify function because of the inconsistent signature - // replicating that on the patched function - const promisified = function (path: unknown): Promise { - return new Promise(resolve => (functionWithOriginalProperties as GenericFunction)(path, resolve)); - }; - Object.defineProperty(promisified, 'name', { value: functionName }); - Object.defineProperty(functionWithOriginalProperties, promisify.custom, { - value: promisified, }); + }; + return patchedFunctionWithOriginalProperties(patchedFunction as unknown as T, original); +} - return functionWithOriginalProperties; +export function enableFsInstrumentation(config: FsInstrumentationConfig = {}): void { + for (const fName of SYNC_FUNCTIONS) { + const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); + _patchMethod(objectToPatch, functionNameToPatch, original => _patchSyncFunction(fName, original, config)); } - protected _patchPromiseFunction(functionName: FPMember, original: T): T { - // oxlint-disable-next-line typescript/no-this-alias - const instrumentation = this; - const patchedFunction = async function (this: unknown, ...args: Parameters): Promise { - const config = instrumentation.getConfig(); - const attributes = getSpanAttributes(functionName, args, config); + for (const fName of CALLBACK_FUNCTIONS) { + const { objectToPatch, functionNameToPatch } = indexFs(fs, fName); + if (fName === 'exists') { + _patchMethod(objectToPatch, functionNameToPatch, original => _patchExistsCallbackFunction(original, config)); + } else { + _patchMethod(objectToPatch, functionNameToPatch, original => _patchCallbackFunction(fName, original, config)); + } + } - return startSpan({ name: `fs.${functionName}`, onlyIfParent: true, attributes }, async span => { - try { - // Suppress tracing for internal fs calls - return await suppressTracing(() => original.apply(this, args) as Promise); - } catch (error) { - recordError(span, error, config); - throw error; - } - }); - }; - return patchedFunctionWithOriginalProperties(patchedFunction as unknown as T, original); + // `fs.promises` and `import { readFile } from 'fs/promises'` share the same object in Node 14+, + // so patching one covers both. + const fsPromises = fs.promises as unknown as Record; + for (const fName of PROMISE_FUNCTIONS) { + _patchMethod(fsPromises, fName, original => _patchPromiseFunction(fName, original, config)); } } diff --git a/packages/node/src/integrations/fs/vendored/types.ts b/packages/node/src/integrations/fs/vendored/types.ts index 9d688843be80..7c6bbcd6f69b 100644 --- a/packages/node/src/integrations/fs/vendored/types.ts +++ b/packages/node/src/integrations/fs/vendored/types.ts @@ -8,7 +8,6 @@ * - The `createHook`/`endHook`/`requireParentSpan` config options were removed in favor of Sentry-specific options. */ -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import type * as fs from 'fs'; // oxlint-disable-next-line typescript/no-explicit-any @@ -45,7 +44,7 @@ export type FPMember = | FunctionPropertyNames<(typeof fs)['promises']> | FunctionPropertyNamesTwoLevels<(typeof fs)['promises']>; -export interface FsInstrumentationConfig extends InstrumentationConfig { +export interface FsInstrumentationConfig { /** * Setting this option to `true` will include any filepath arguments from your `fs` API calls as span attributes. */