Skip to content

Commit

Permalink
feat(node): Extract Sentry-specific node-fetch instrumentation
Browse files Browse the repository at this point in the history
To allow users to opt-out of using the otel instrumentation.
  • Loading branch information
mydea committed Jan 30, 2025
1 parent d76db31 commit 4c6e295
Show file tree
Hide file tree
Showing 7 changed files with 463 additions and 114 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0',
tracePropagationTargets: [/\/v0/, 'v1'],
integrations: [Sentry.nativeNodeFetchIntegration({ spans: false })],
transport: loggingTransport,
});

async function run(): Promise<void> {
// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
await new Promise(resolve => setTimeout(resolve, 100));
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());
await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text());

Sentry.captureException(new Error('foo'));
}

// eslint-disable-next-line @typescript-eslint/no-floating-promises
run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createRunner } from '../../../../utils/runner';
import { createTestServer } from '../../../../utils/server';

describe('outgoing fetch', () => {
test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', done => {
expect.assertions(11);

createTestServer(done)
.get('/api/v0', headers => {
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
expect(headers['baggage']).toEqual(expect.any(String));
})
.get('/api/v1', headers => {
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
expect(headers['baggage']).toEqual(expect.any(String));
})
.get('/api/v2', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.get('/api/v3', headers => {
expect(headers['baggage']).toBeUndefined();
expect(headers['sentry-trace']).toBeUndefined();
})
.start()
.then(([SERVER_URL, closeTestServer]) => {
createRunner(__dirname, 'scenario.ts')
.withEnv({ SERVER_URL })
.ensureNoErrorOutput()
.expect({
event: {
exception: {
values: [
{
type: 'Error',
value: 'foo',
},
],
},
},
})
.start(closeTestServer);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { VERSION } from '@opentelemetry/core';
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
import { InstrumentationBase } from '@opentelemetry/instrumentation';
import type { SanitizedRequestData } from '@sentry/core';
import { LRUMap, getClient, getTraceData } from '@sentry/core';
import { addBreadcrumb, getBreadcrumbLogLevelFromHttpStatusCode, getSanitizedUrlString, parseUrl } from '@sentry/core';
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
import * as diagch from 'diagnostics_channel';
import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion';
import type { UndiciRequest, UndiciResponse } from './types';

export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & {
/**
* Whether breadcrumbs should be recorded for requests.
*
* @default `true`
*/
breadcrumbs?: boolean;

/**
* Do not capture breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`.
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
* The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and
* span creation.
*
* @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request.
*/
ignoreOutgoingRequests?: (url: string) => boolean;
};

interface ListenerRecord {
name: string;
unsubscribe: () => void;
}

/**
* This custom node-fetch instrumentation is used to instrument outgoing fetch requests.
* It does not emit any spans.
*
* The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this,
* which would lead to Sentry not working as expected.
*
* This is heavily inspired & adapted from:
* https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/undici.ts
*/
export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNodeFetchInstrumentationOptions> {
// Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for
// unsubscribing.
private _channelSubs: Array<ListenerRecord>;
private _propagationDecisionMap: LRUMap<string, boolean>;

public constructor(config: SentryNodeFetchInstrumentationOptions = {}) {
super('@sentry/instrumentation-node-fetch', VERSION, config);
this._channelSubs = [];
this._propagationDecisionMap = new LRUMap<string, boolean>(100);
}

/** No need to instrument files/modules. */
public init(): void {
return undefined;
}

/** Disable the instrumentation. */
public disable(): void {
super.disable();
this._channelSubs.forEach(sub => sub.unsubscribe());
this._channelSubs = [];
}

/** Enable the instrumentation. */
public enable(): void {
// "enabled" handling is currently a bit messy with InstrumentationBase.
// If constructed with `{enabled: false}`, this `.enable()` is still called,
// and `this.getConfig().enabled !== this.isEnabled()`, creating confusion.
//
// For now, this class will setup for instrumenting if `.enable()` is
// called, but use `this.getConfig().enabled` to determine if
// instrumentation should be generated. This covers the more likely common
// case of config being given a construction time, rather than later via
// `instance.enable()`, `.disable()`, or `.setConfig()` calls.
super.enable();

// This method is called by the super-class constructor before ours is
// called. So we need to ensure the property is initalized.
this._channelSubs = this._channelSubs || [];

// Avoid to duplicate subscriptions
if (this._channelSubs.length > 0) {
return;
}

this._subscribeToChannel('undici:request:create', this._onRequestCreated.bind(this));
this._subscribeToChannel('undici:request:headers', this._onResponseHeaders.bind(this));
}

/**
* This method is called when a request is created.
* You can still mutate the request here before it is sent.
*/
private _onRequestCreated({ request }: { request: UndiciRequest }): void {
const config = this.getConfig();
const enabled = config.enabled !== false;

if (!enabled) {
return;
}

// Add trace propagation headers
const url = getAbsoluteUrl(request.origin, request.path);
const _ignoreOutgoingRequests = config.ignoreOutgoingRequests;
const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url);

if (shouldIgnore) {
return;
}

// Manually add the trace headers, if it applies
// Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
// Which we do not have in this case
// The propagator _may_ overwrite this, but this should be fine as it is the same data
const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets;
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap)
? getTraceData()
: {};

// We do not want to overwrite existing headers here
// If the core UndiciInstrumentation is registered, it will already have set the headers
// We do not want to add any then
if (Array.isArray(request.headers)) {
const requestHeaders = request.headers;
Object.entries(addedHeaders)
.filter(([k]) => {
// If the header already exists, we do not want to set it again
return !requestHeaders.includes(`${k}:`);
})
.forEach(headers => requestHeaders.push(...headers));
} else {
const requestHeaders = request.headers;
request.headers += Object.entries(addedHeaders)
.filter(([k]) => {
// If the header already exists, we do not want to set it again
return !requestHeaders.includes(`${k}:`);
})
.map(([k, v]) => `${k}: ${v}\r\n`)
.join('');
}
}

/**
* This method is called when a response is received.
*/
private _onResponseHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void {
const config = this.getConfig();
const enabled = config.enabled !== false;

if (!enabled) {
return;
}

const _breadcrumbs = config.breadcrumbs;
const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs;

const _ignoreOutgoingRequests = config.ignoreOutgoingRequests;
const shouldCreateBreadcrumb =
typeof _ignoreOutgoingRequests === 'function'
? !_ignoreOutgoingRequests(getAbsoluteUrl(request.origin, request.path))
: true;

if (breadCrumbsEnabled && shouldCreateBreadcrumb) {
addRequestBreadcrumb(request, response);
}
}

/** Subscribe to a diagnostics channel. */
private _subscribeToChannel(
diagnosticChannel: string,
onMessage: (message: unknown, name: string | symbol) => void,
): void {
// `diagnostics_channel` had a ref counting bug until v18.19.0.
// https://github.com/nodejs/node/pull/47520
const useNewSubscribe = NODE_MAJOR > 18 || (NODE_MAJOR === 18 && NODE_MINOR >= 19);

let unsubscribe: () => void;
if (useNewSubscribe) {
diagch.subscribe?.(diagnosticChannel, onMessage);
unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage);
} else {
const channel = diagch.channel(diagnosticChannel);
channel.subscribe(onMessage);
unsubscribe = () => channel.unsubscribe(onMessage);
}

this._channelSubs.push({
name: diagnosticChannel,
unsubscribe,
});
}
}

/** Add a breadcrumb for outgoing requests. */
function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
const data = getBreadcrumbData(request);

const statusCode = response.statusCode;
const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode);

addBreadcrumb(
{
category: 'http',
data: {
status_code: statusCode,
...data,
},
type: 'http',
level,
},
{
event: 'response',
request,
response,
},
);
}

function getBreadcrumbData(request: UndiciRequest): Partial<SanitizedRequestData> {
try {
const url = getAbsoluteUrl(request.origin, request.path);
const parsedUrl = parseUrl(url);

const data: Partial<SanitizedRequestData> = {
url: getSanitizedUrlString(parsedUrl),
'http.method': request.method || 'GET',
};

if (parsedUrl.search) {
data['http.query'] = parsedUrl.search;
}
if (parsedUrl.hash) {
data['http.fragment'] = parsedUrl.hash;
}

return data;
} catch {
return {};
}
}

function getAbsoluteUrl(origin: string, path: string = '/'): string {
try {
const url = new URL(path, origin);
return url.toString();
} catch {
// fallback: Construct it on our own
const url = `${origin}`;

if (url.endsWith('/') && path.startsWith('/')) {
return `${url}${path.slice(1)}`;
}

if (!url.endsWith('/') && !path.startsWith('/')) {
return `${url}/${path.slice(1)}`;
}

return `${url}${path}`;
}
}
Loading

0 comments on commit 4c6e295

Please sign in to comment.