Skip to content

feat(node-experimental): Add NodeFetch integration #9226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 12, 2023
Merged
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 @@ -38,6 +38,13 @@ app.get('/test-outgoing-http', async function (req, res) {
res.send(data);
});

app.get('/test-outgoing-fetch', async function (req, res) {
const response = await fetch('http://localhost:3030/test-inbound-headers');
const data = await response.json();

res.send(data);
});

app.get('/test-transaction', async function (req, res) {
Sentry.startSpan({ name: 'test-span' }, () => {
Sentry.startSpan({ name: 'child-span' }, () => {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,94 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => {
}),
);
});

test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => {
const inboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /test-inbound-headers'
);
});

const outboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /test-outgoing-fetch'
);
});

const { data } = await axios.get(`${baseURL}/test-outgoing-fetch`);

const inboundTransaction = await inboundTransactionPromise;
const outboundTransaction = await outboundTransactionPromise;

const traceId = outboundTransaction?.contexts?.trace?.trace_id;
const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as
| ReturnType<Span['toJSON']>
| undefined;

expect(outgoingHttpSpan).toBeDefined();

const outgoingHttpSpanId = outgoingHttpSpan?.span_id;

expect(traceId).toEqual(expect.any(String));

// data is passed through from the inbound request, to verify we have the correct headers set
const inboundHeaderSentryTrace = data.headers?.['sentry-trace'];
const inboundHeaderBaggage = data.headers?.['baggage'];

expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`);
expect(inboundHeaderBaggage).toBeDefined();

const baggage = (inboundHeaderBaggage || '').split(',');
expect(baggage).toEqual(
expect.arrayContaining([
'sentry-environment=qa',
`sentry-trace_id=${traceId}`,
expect.stringMatching(/sentry-public_key=/),
]),
);

expect(outboundTransaction).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: {
data: {
url: 'http://localhost:3030/test-outgoing-fetch',
'otel.kind': 'SERVER',
'http.response.status_code': 200,
},
op: 'http.server',
span_id: expect.any(String),
status: 'ok',
tags: {
'http.status_code': 200,
},
trace_id: traceId,
},
}),
}),
);

expect(inboundTransaction).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: {
data: {
url: 'http://localhost:3030/test-inbound-headers',
'otel.kind': 'SERVER',
'http.response.status_code': 200,
},
op: 'http.server',
parent_span_id: outgoingHttpSpanId,
span_id: expect.any(String),
status: 'ok',
tags: {
'http.status_code': 200,
},
trace_id: traceId,
},
}),
}),
);
});
3 changes: 2 additions & 1 deletion packages/node-experimental/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"@sentry/node": "7.73.0",
"@sentry/opentelemetry-node": "7.73.0",
"@sentry/types": "7.73.0",
"@sentry/utils": "7.73.0"
"@sentry/utils": "7.73.0",
"opentelemetry-instrumentation-fetch-node": "1.1.0"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
1 change: 1 addition & 0 deletions packages/node-experimental/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {

export { Express } from './express';
export { Http } from './http';
export { NodeFetch } from './node-fetch';
export { Fastify } from './fastify';
export { GraphQL } from './graphql';
export { Mongo } from './mongo';
Expand Down
123 changes: 123 additions & 0 deletions packages/node-experimental/src/integrations/node-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Span } from '@opentelemetry/api';
import { SpanKind } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { hasTracingEnabled } from '@sentry/core';
import type { EventProcessor, Hub, Integration } from '@sentry/types';
import { FetchInstrumentation } from 'opentelemetry-instrumentation-fetch-node';

import { OTEL_ATTR_ORIGIN } from '../constants';
import type { NodeExperimentalClient } from '../sdk/client';
import { getCurrentHub } from '../sdk/hub';
import { getRequestSpanData } from '../utils/getRequestSpanData';
import { getSpanKind } from '../utils/getSpanKind';

interface NodeFetchOptions {
/**
* Whether breadcrumbs should be recorded for requests
* Defaults to true
*/
breadcrumbs?: boolean;

/**
* Whether tracing spans should be created for requests
* Defaults to false
*/
spans?: boolean;
}

/**
* Fetch instrumentation based on opentelemetry-instrumentation-fetch.
* This instrumentation does two things:
* * Create breadcrumbs for outgoing requests
* * Create spans for outgoing requests
*/
export class NodeFetch implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'NodeFetch';

/**
* @inheritDoc
*/
public name: string;

/**
* If spans for HTTP requests should be captured.
*/
public shouldCreateSpansForRequests: boolean;

private _unload?: () => void;
private readonly _breadcrumbs: boolean;
// If this is undefined, use default behavior based on client settings
private readonly _spans: boolean | undefined;

/**
* @inheritDoc
*/
public constructor(options: NodeFetchOptions = {}) {
this.name = NodeFetch.id;
this._breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs;
this._spans = typeof options.spans === 'undefined' ? undefined : options.spans;

// Properly set in setupOnce based on client settings
this.shouldCreateSpansForRequests = false;
}

/**
* @inheritDoc
*/
public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void {
// No need to instrument if we don't want to track anything
if (!this._breadcrumbs && this._spans === false) {
return;
}

const client = getCurrentHub().getClient<NodeExperimentalClient>();
const clientOptions = client?.getOptions();

// This is used in the sampler function
this.shouldCreateSpansForRequests =
typeof this._spans === 'boolean' ? this._spans : hasTracingEnabled(clientOptions);

// Register instrumentations we care about
this._unload = registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
onRequest: ({ span }: { span: Span }) => {
this._updateSpan(span);
this._addRequestBreadcrumb(span);
},
}),
],
});
}

/**
* Unregister this integration.
*/
public unregister(): void {
this._unload?.();
}

/** Update the span with data we need. */
private _updateSpan(span: Span): void {
span.setAttribute(OTEL_ATTR_ORIGIN, 'auto.http.otel.node_fetch');
}

/** Add a breadcrumb for outgoing requests. */
private _addRequestBreadcrumb(span: Span): void {
if (!this._breadcrumbs || getSpanKind(span) !== SpanKind.CLIENT) {
return;
}

const data = getRequestSpanData(span);
getCurrentHub().addBreadcrumb({
category: 'http',
data: {
...data,
},
type: 'http',
});
}
}
13 changes: 9 additions & 4 deletions packages/node-experimental/src/opentelemetry/spanProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logger } from '@sentry/utils';

import { OTEL_CONTEXT_HUB_KEY } from '../constants';
import { Http } from '../integrations';
import { NodeFetch } from '../integrations/node-fetch';
import type { NodeExperimentalClient } from '../sdk/client';
import { getCurrentHub } from '../sdk/hub';
import { getSpanHub, setSpanHub, setSpanParent, setSpanScope } from './spanData';
Expand Down Expand Up @@ -76,18 +77,22 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProce
function shouldCaptureSentrySpan(span: Span): boolean {
const client = getCurrentHub().getClient<NodeExperimentalClient>();
const httpIntegration = client ? client.getIntegration(Http) : undefined;
const fetchIntegration = client ? client.getIntegration(NodeFetch) : undefined;

// If we encounter a client or server span with url & method, we assume this comes from the http instrumentation
// In this case, if `shouldCreateSpansForRequests` is false, we want to _record_ the span but not _sample_ it,
// So we can generate a breadcrumb for it but no span will be sent
if (
httpIntegration &&
(span.kind === SpanKind.CLIENT || span.kind === SpanKind.SERVER) &&
span.attributes[SemanticAttributes.HTTP_URL] &&
span.attributes[SemanticAttributes.HTTP_METHOD] &&
!httpIntegration.shouldCreateSpansForRequests
span.attributes[SemanticAttributes.HTTP_METHOD]
) {
return false;
const shouldCreateSpansForRequests =
span.attributes['http.client'] === 'fetch'
? fetchIntegration?.shouldCreateSpansForRequests
: httpIntegration?.shouldCreateSpansForRequests;

return shouldCreateSpansForRequests !== false;
}

return true;
Expand Down
11 changes: 10 additions & 1 deletion packages/node-experimental/src/sdk/init.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { hasTracingEnabled } from '@sentry/core';
import { defaultIntegrations as defaultNodeIntegrations, init as initNode } from '@sentry/node';
import type { Integration } from '@sentry/types';
import { parseSemver } from '@sentry/utils';

import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations';
import { Http } from '../integrations/http';
import { NodeFetch } from '../integrations/node-fetch';
import type { NodeExperimentalOptions } from '../types';
import { NodeExperimentalClient } from './client';
import { getCurrentHub } from './hub';
import { initOtel } from './initOtel';
import { setOtelContextAsyncContextStrategy } from './otelAsyncContextStrategy';

const NODE_VERSION: ReturnType<typeof parseSemver> = parseSemver(process.versions.node);
const ignoredDefaultIntegrations = ['Http', 'Undici'];

export const defaultIntegrations = [
export const defaultIntegrations: Integration[] = [
...defaultNodeIntegrations.filter(i => !ignoredDefaultIntegrations.includes(i.name)),
new Http(),
];

// Only add NodeFetch if Node >= 16, as previous versions do not support it
if (NODE_VERSION.major && NODE_VERSION.major >= 16) {
defaultIntegrations.push(new NodeFetch());
}

/**
* Initialize Sentry for Node.
*/
Expand Down
Loading