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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const app = express();
app.get('/readFile-error', async (_, res) => {
try {
await fs.promises.readFile(path.join(__dirname, 'fixtures', 'some-file-that-doesnt-exist.txt'), 'utf-8');
} catch {
// noop
} catch (e) {
Sentry.captureException(e);
}
res.send('done');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ afterAll(() => {

test('should create spans for fs operations that take target argument', done => {
const runner = createRunner(__dirname, 'server.ts')
.ignore('event')
.expect({
transaction: {
transaction: 'GET /readFile-error',
Expand All @@ -30,6 +31,29 @@ test('should create spans for fs operations that take target argument', done =>
expect(runner.makeRequest('get', '/readFile-error')).resolves.toBe('done');
});

test('should create breadcrumbs for fs operations', done => {
const runner = createRunner(__dirname, 'server.ts')
.ignore('transaction')
.expect({
event: {
breadcrumbs: expect.arrayContaining([
expect.objectContaining({
timestamp: expect.any(Number),
message: 'fs.readFile',
level: 'error',
data: {
path_argument: expect.stringContaining('some-file-that-doesnt-exist.txt'),
fs_error: expect.stringContaining('ENOENT: no such file or directory'),
},
}),
]),
},
})
.start(done);

expect(runner.makeRequest('get', '/readFile-error')).resolves.toBe('done');
});

test('should create spans for fs operations that take one path', done => {
const runner = createRunner(__dirname, 'server.ts')
.expect({
Expand Down
184 changes: 106 additions & 78 deletions packages/node/src/integrations/fs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { FsInstrumentation } from '@opentelemetry/instrumentation-fs';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
addBreadcrumb,
defineIntegration,
} from '@sentry/core';
import type { SpanAttributes } from '@sentry/types';
import { generateInstrumentOnce } from '../otel/instrument';

const INTEGRATION_NAME = 'FileSystem';

interface Options {
/**
* Whether to capture breadcrumbs for `fs` operations.
*
* Defaults to `true`.
*/
breadcrumbs?: boolean;
/**
* Setting this option to `true` will include any filepath arguments from your `fs` API calls as span attributes.
*
* Defaults to `false`.
*/
recordFilePaths?: boolean;

/**
* Setting this option to `true` will include the error messages of failed `fs` API calls as a span attribute.
*
* Defaults to `false`.
*/
recordErrorMessagesAsSpanAttributes?: boolean;
}

/**
* This integration will create spans for `fs` API operations, like reading and writing files.
*
Expand All @@ -13,86 +41,38 @@ const INTEGRATION_NAME = 'FileSystem';
*
* @param options Configuration for this integration.
*/
export const fsIntegration = defineIntegration(
(
options: {
/**
* Setting this option to `true` will include any filepath arguments from your `fs` API calls as span attributes.
*
* Defaults to `false`.
*/
recordFilePaths?: boolean;
export const fsIntegration = defineIntegration((options: Options = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
generateInstrumentOnce(
INTEGRATION_NAME,
() =>
new FsInstrumentation({
requireParentSpan: true,
endHook(functionName, { args, span, error }) {
span.updateName(`fs.${functionName}`);

/**
* Setting this option to `true` will include the error messages of failed `fs` API calls as a span attribute.
*
* Defaults to `false`.
*/
recordErrorMessagesAsSpanAttributes?: boolean;
} = {},
) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
generateInstrumentOnce(
INTEGRATION_NAME,
() =>
new FsInstrumentation({
requireParentSpan: true,
endHook(functionName, { args, span, error }) {
span.updateName(`fs.${functionName}`);
const additionalAttributes = {
...(options.recordFilePaths && getFilePathAttributes(functionName, args)),
...(error && options.recordErrorMessagesAsSpanAttributes && { fs_error: error.message }),
};

span.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'file',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.file.fs',
});
span.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'file',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.file.fs',
...additionalAttributes,
});

if (options.recordErrorMessagesAsSpanAttributes) {
if (typeof args[0] === 'string' && FS_OPERATIONS_WITH_PATH_ARG.includes(functionName)) {
span.setAttribute('path_argument', args[0]);
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_TARGET_PATH.includes(functionName)
) {
span.setAttribute('target_argument', args[0]);
span.setAttribute('path_argument', args[1]);
} else if (typeof args[0] === 'string' && FS_OPERATIONS_WITH_PREFIX.includes(functionName)) {
span.setAttribute('prefix_argument', args[0]);
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_EXISTING_PATH_NEW_PATH.includes(functionName)
) {
span.setAttribute('existing_path_argument', args[0]);
span.setAttribute('new_path_argument', args[1]);
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_SRC_DEST.includes(functionName)
) {
span.setAttribute('src_argument', args[0]);
span.setAttribute('dest_argument', args[1]);
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_OLD_PATH_NEW_PATH.includes(functionName)
) {
span.setAttribute('old_path_argument', args[0]);
span.setAttribute('new_path_argument', args[1]);
}
}

if (error && options.recordErrorMessagesAsSpanAttributes) {
span.setAttribute('fs_error', error.message);
}
},
}),
)();
},
};
},
);
if (options.breadcrumbs !== false) {
captureBreadcrumb(functionName, additionalAttributes, !!error);
}
},
}),
)();
},
};
});

const FS_OPERATIONS_WITH_OLD_PATH_NEW_PATH = ['rename', 'renameSync'];
const FS_OPERATIONS_WITH_SRC_DEST = ['copyFile', 'cp', 'copyFileSync', 'cpSync'];
Expand Down Expand Up @@ -147,3 +127,51 @@ const FS_OPERATIONS_WITH_PATH_ARG = [
'utimesSync',
'writeFileSync',
];

function getFilePathAttributes(functionName: string, args: ArrayLike<unknown>): SpanAttributes {
const attributes: SpanAttributes = {};

if (typeof args[0] === 'string' && FS_OPERATIONS_WITH_PATH_ARG.includes(functionName)) {
attributes['path_argument'] = args[0];
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_TARGET_PATH.includes(functionName)
) {
attributes['target_argument'] = args[0];
attributes['path_argument'] = args[1];
} else if (typeof args[0] === 'string' && FS_OPERATIONS_WITH_PREFIX.includes(functionName)) {
attributes['prefix_argument'] = args[0];
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_EXISTING_PATH_NEW_PATH.includes(functionName)
) {
attributes['existing_path_argument'] = args[0];
attributes['new_path_argument'] = args[1];
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_SRC_DEST.includes(functionName)
) {
attributes['src_argument'] = args[0];
attributes['dest_argument'] = args[1];
} else if (
typeof args[0] === 'string' &&
typeof args[1] === 'string' &&
FS_OPERATIONS_WITH_OLD_PATH_NEW_PATH.includes(functionName)
) {
attributes['old_path_argument'] = args[0];
attributes['new_path_argument'] = args[1];
}

return attributes;
}

function captureBreadcrumb(functionName: string, attributes: SpanAttributes | undefined, error: boolean): void {
addBreadcrumb({
message: `fs.${functionName}`,
level: error ? 'error' : 'info',
data: attributes,
});
}
Loading