Skip to content

Commit 789e849

Browse files
authored
feat(node-experimental): Keep breadcrumbs on transaction (#8967)
1 parent cfc2333 commit 789e849

File tree

9 files changed

+314
-8
lines changed

9 files changed

+314
-8
lines changed

packages/node-experimental/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { INTEGRATIONS as Integrations };
1212
export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanceIntegrations';
1313
export * as Handlers from './sdk/handlers';
1414
export * from './sdk/trace';
15+
export { getCurrentHub, getHubFromCarrier } from './sdk/hub';
1516

1617
export {
1718
makeNodeTransport,
@@ -33,8 +34,6 @@ export {
3334
extractTraceparentData,
3435
flush,
3536
getActiveTransaction,
36-
getHubFromCarrier,
37-
getCurrentHub,
3837
Hub,
3938
lastEventId,
4039
makeMain,

packages/node-experimental/src/integrations/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node';
1010
import type { EventProcessor, Hub, Integration } from '@sentry/types';
1111
import type { ClientRequest, IncomingMessage, ServerResponse } from 'http';
1212

13-
import type { NodeExperimentalClient } from '../sdk/client';
13+
import type { NodeExperimentalClient } from '../types';
1414
import { getRequestSpanData } from '../utils/getRequestSpanData';
1515

1616
interface TracingOptions {

packages/node-experimental/src/sdk/client.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { Tracer } from '@opentelemetry/api';
22
import { trace } from '@opentelemetry/api';
3+
import type { EventHint, Scope } from '@sentry/node';
34
import { NodeClient, SDK_VERSION } from '@sentry/node';
5+
import type { Event } from '@sentry/types';
46

5-
import type { NodeExperimentalClientOptions } from '../types';
7+
import type {
8+
NodeExperimentalClient as NodeExperimentalClientInterface,
9+
NodeExperimentalClientOptions,
10+
} from '../types';
11+
import { OtelScope } from './scope';
612

713
/**
814
* A client built on top of the NodeClient, which provides some otel-specific things on top.
915
*/
10-
export class NodeExperimentalClient extends NodeClient {
16+
export class NodeExperimentalClient extends NodeClient implements NodeExperimentalClientInterface {
1117
private _tracer: Tracer | undefined;
1218

1319
public constructor(options: ConstructorParameters<typeof NodeClient>[0]) {
@@ -47,4 +53,20 @@ export class NodeExperimentalClient extends NodeClient {
4753
// Just a type-cast, basically
4854
return super.getOptions();
4955
}
56+
57+
/**
58+
* Extends the base `_prepareEvent` so that we can properly handle `captureContext`.
59+
* This uses `Scope.clone()`, which we need to replace with `OtelScope.clone()` for this client.
60+
*/
61+
protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event | null> {
62+
let actualScope = scope;
63+
64+
// Remove `captureContext` hint and instead clone already here
65+
if (hint && hint.captureContext) {
66+
actualScope = OtelScope.clone(scope);
67+
delete hint.captureContext;
68+
}
69+
70+
return super._prepareEvent(event, hint, actualScope);
71+
}
5072
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { Carrier, Scope } from '@sentry/core';
2+
import { Hub } from '@sentry/core';
3+
import type { Client } from '@sentry/types';
4+
import { getGlobalSingleton, GLOBAL_OBJ } from '@sentry/utils';
5+
6+
import { OtelScope } from './scope';
7+
8+
/** A custom hub that ensures we always creat an OTEL scope. */
9+
10+
class OtelHub extends Hub {
11+
public constructor(client?: Client, scope: Scope = new OtelScope()) {
12+
super(client, scope);
13+
}
14+
15+
/**
16+
* @inheritDoc
17+
*/
18+
public pushScope(): Scope {
19+
// We want to clone the content of prev scope
20+
const scope = OtelScope.clone(this.getScope());
21+
this.getStack().push({
22+
client: this.getClient(),
23+
scope,
24+
});
25+
return scope;
26+
}
27+
}
28+
29+
/**
30+
* *******************************************************************************
31+
* Everything below here is a copy of the stuff from core's hub.ts,
32+
* only that we make sure to create our custom OtelScope instead of the default Scope.
33+
* This is necessary to get the correct breadcrumbs behavior.
34+
*
35+
* Basically, this overwrites all places that do `new Scope()` with `new OtelScope()`.
36+
* Which in turn means overwriting all places that do `new Hub()` and make sure to pass in a OtelScope instead.
37+
* *******************************************************************************
38+
*/
39+
40+
/**
41+
* API compatibility version of this hub.
42+
*
43+
* WARNING: This number should only be increased when the global interface
44+
* changes and new methods are introduced.
45+
*
46+
* @hidden
47+
*/
48+
const API_VERSION = 4;
49+
50+
/**
51+
* Returns the default hub instance.
52+
*
53+
* If a hub is already registered in the global carrier but this module
54+
* contains a more recent version, it replaces the registered version.
55+
* Otherwise, the currently registered hub will be returned.
56+
*/
57+
export function getCurrentHub(): Hub {
58+
// Get main carrier (global for every environment)
59+
const registry = getMainCarrier();
60+
61+
if (registry.__SENTRY__ && registry.__SENTRY__.acs) {
62+
const hub = registry.__SENTRY__.acs.getCurrentHub();
63+
64+
if (hub) {
65+
return hub;
66+
}
67+
}
68+
69+
// Return hub that lives on a global object
70+
return getGlobalHub(registry);
71+
}
72+
73+
/**
74+
* This will create a new {@link Hub} and add to the passed object on
75+
* __SENTRY__.hub.
76+
* @param carrier object
77+
* @hidden
78+
*/
79+
export function getHubFromCarrier(carrier: Carrier): Hub {
80+
return getGlobalSingleton<Hub>('hub', () => new OtelHub(), carrier);
81+
}
82+
83+
/**
84+
* @private Private API with no semver guarantees!
85+
*
86+
* If the carrier does not contain a hub, a new hub is created with the global hub client and scope.
87+
*/
88+
export function ensureHubOnCarrier(carrier: Carrier, parent: Hub = getGlobalHub()): void {
89+
// If there's no hub on current domain, or it's an old API, assign a new one
90+
if (!hasHubOnCarrier(carrier) || getHubFromCarrier(carrier).isOlderThan(API_VERSION)) {
91+
const globalHubTopStack = parent.getStackTop();
92+
setHubOnCarrier(carrier, new OtelHub(globalHubTopStack.client, OtelScope.clone(globalHubTopStack.scope)));
93+
}
94+
}
95+
96+
function getGlobalHub(registry: Carrier = getMainCarrier()): Hub {
97+
// If there's no hub, or its an old API, assign a new one
98+
if (!hasHubOnCarrier(registry) || getHubFromCarrier(registry).isOlderThan(API_VERSION)) {
99+
setHubOnCarrier(registry, new OtelHub());
100+
}
101+
102+
// Return hub that lives on a global object
103+
return getHubFromCarrier(registry);
104+
}
105+
106+
/**
107+
* This will tell whether a carrier has a hub on it or not
108+
* @param carrier object
109+
*/
110+
function hasHubOnCarrier(carrier: Carrier): boolean {
111+
return !!(carrier && carrier.__SENTRY__ && carrier.__SENTRY__.hub);
112+
}
113+
114+
/**
115+
* Returns the global shim registry.
116+
*
117+
* FIXME: This function is problematic, because despite always returning a valid Carrier,
118+
* it has an optional `__SENTRY__` property, which then in turn requires us to always perform an unnecessary check
119+
* at the call-site. We always access the carrier through this function, so we can guarantee that `__SENTRY__` is there.
120+
**/
121+
function getMainCarrier(): Carrier {
122+
GLOBAL_OBJ.__SENTRY__ = GLOBAL_OBJ.__SENTRY__ || {
123+
extensions: {},
124+
hub: undefined,
125+
};
126+
return GLOBAL_OBJ;
127+
}
128+
129+
/**
130+
* This will set passed {@link Hub} on the passed object's __SENTRY__.hub attribute
131+
* @param carrier object
132+
* @param hub Hub
133+
* @returns A boolean indicating success or failure
134+
*/
135+
function setHubOnCarrier(carrier: Carrier, hub: Hub): boolean {
136+
if (!carrier) return false;
137+
const __SENTRY__ = (carrier.__SENTRY__ = carrier.__SENTRY__ || {});
138+
__SENTRY__.hub = hub;
139+
return true;
140+
}

packages/node-experimental/src/sdk/initOtel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getCurrentHub } from '@sentry/core';
44
import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node';
55
import { logger } from '@sentry/utils';
66

7-
import type { NodeExperimentalClient } from './client';
7+
import type { NodeExperimentalClient } from '../types';
88
import { SentryContextManager } from './otelContextManager';
99

1010
/**

packages/node-experimental/src/sdk/otelContextManager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { Context } from '@opentelemetry/api';
22
import * as api from '@opentelemetry/api';
33
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
44
import type { Carrier, Hub } from '@sentry/core';
5-
import { ensureHubOnCarrier, getCurrentHub, getHubFromCarrier } from '@sentry/core';
5+
6+
import { ensureHubOnCarrier, getCurrentHub, getHubFromCarrier } from './hub';
67

78
export const OTEL_CONTEXT_HUB_KEY = api.createContextKey('sentry_hub');
89

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { Scope } from '@sentry/core';
2+
import type { Breadcrumb, Transaction } from '@sentry/types';
3+
import { dateTimestampInSeconds } from '@sentry/utils';
4+
5+
import { getActiveSpan } from './trace';
6+
7+
const DEFAULT_MAX_BREADCRUMBS = 100;
8+
9+
/**
10+
* This is a fork of the base Transaction with OTEL specific stuff added.
11+
* Note that we do not solve this via an actual subclass, but by wrapping this in a proxy when we need it -
12+
* as we can't easily control all the places a transaction may be created.
13+
*/
14+
interface TransactionWithBreadcrumbs extends Transaction {
15+
_breadcrumbs: Breadcrumb[];
16+
17+
/** Get all breadcrumbs added to this transaction. */
18+
getBreadcrumbs(): Breadcrumb[];
19+
20+
/** Add a breadcrumb to this transaction. */
21+
addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void;
22+
}
23+
24+
/** A fork of the classic scope with some otel specific stuff. */
25+
export class OtelScope extends Scope {
26+
/**
27+
* @inheritDoc
28+
*/
29+
public static clone(scope?: Scope): Scope {
30+
const newScope = new OtelScope();
31+
if (scope) {
32+
newScope._breadcrumbs = [...scope['_breadcrumbs']];
33+
newScope._tags = { ...scope['_tags'] };
34+
newScope._extra = { ...scope['_extra'] };
35+
newScope._contexts = { ...scope['_contexts'] };
36+
newScope._user = scope['_user'];
37+
newScope._level = scope['_level'];
38+
newScope._span = scope['_span'];
39+
newScope._session = scope['_session'];
40+
newScope._transactionName = scope['_transactionName'];
41+
newScope._fingerprint = scope['_fingerprint'];
42+
newScope._eventProcessors = [...scope['_eventProcessors']];
43+
newScope._requestSession = scope['_requestSession'];
44+
newScope._attachments = [...scope['_attachments']];
45+
newScope._sdkProcessingMetadata = { ...scope['_sdkProcessingMetadata'] };
46+
newScope._propagationContext = { ...scope['_propagationContext'] };
47+
}
48+
return newScope;
49+
}
50+
51+
/**
52+
* @inheritDoc
53+
*/
54+
public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this {
55+
const transaction = getActiveTransaction();
56+
57+
if (transaction) {
58+
transaction.addBreadcrumb(breadcrumb, maxBreadcrumbs);
59+
return this;
60+
}
61+
62+
return super.addBreadcrumb(breadcrumb, maxBreadcrumbs);
63+
}
64+
65+
/**
66+
* @inheritDoc
67+
*/
68+
protected _getBreadcrumbs(): Breadcrumb[] {
69+
const transaction = getActiveTransaction();
70+
const transactionBreadcrumbs = transaction ? transaction.getBreadcrumbs() : [];
71+
72+
return this._breadcrumbs.concat(transactionBreadcrumbs);
73+
}
74+
}
75+
76+
/**
77+
* This gets the currently active transaction,
78+
* and ensures to wrap it so that we can store breadcrumbs on it.
79+
*/
80+
function getActiveTransaction(): TransactionWithBreadcrumbs | undefined {
81+
const activeSpan = getActiveSpan();
82+
const transaction = activeSpan && activeSpan.transaction;
83+
84+
if (!transaction) {
85+
return undefined;
86+
}
87+
88+
if (transactionHasBreadcrumbs(transaction)) {
89+
return transaction;
90+
}
91+
92+
return new Proxy(transaction as TransactionWithBreadcrumbs, {
93+
get(target, prop, receiver) {
94+
if (prop === 'addBreadcrumb') {
95+
return addBreadcrumb;
96+
}
97+
if (prop === 'getBreadcrumbs') {
98+
return getBreadcrumbs;
99+
}
100+
if (prop === '_breadcrumbs') {
101+
const breadcrumbs = Reflect.get(target, prop, receiver);
102+
return breadcrumbs || [];
103+
}
104+
return Reflect.get(target, prop, receiver);
105+
},
106+
});
107+
}
108+
109+
function transactionHasBreadcrumbs(transaction: Transaction): transaction is TransactionWithBreadcrumbs {
110+
return (
111+
typeof (transaction as TransactionWithBreadcrumbs).getBreadcrumbs === 'function' &&
112+
typeof (transaction as TransactionWithBreadcrumbs).addBreadcrumb === 'function'
113+
);
114+
}
115+
116+
/** Add a breadcrumb to a transaction. */
117+
function addBreadcrumb(this: TransactionWithBreadcrumbs, breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void {
118+
const maxCrumbs = typeof maxBreadcrumbs === 'number' ? maxBreadcrumbs : DEFAULT_MAX_BREADCRUMBS;
119+
120+
// No data has been changed, so don't notify scope listeners
121+
if (maxCrumbs <= 0) {
122+
return;
123+
}
124+
125+
const mergedBreadcrumb = {
126+
timestamp: dateTimestampInSeconds(),
127+
...breadcrumb,
128+
};
129+
130+
const breadcrumbs = this._breadcrumbs;
131+
breadcrumbs.push(mergedBreadcrumb);
132+
this._breadcrumbs = breadcrumbs.length > maxCrumbs ? breadcrumbs.slice(-maxCrumbs) : breadcrumbs;
133+
}
134+
135+
/** Get all breadcrumbs from a transaction. */
136+
function getBreadcrumbs(this: TransactionWithBreadcrumbs): Breadcrumb[] {
137+
return this._breadcrumbs;
138+
}

packages/node-experimental/src/sdk/trace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node';
55
import type { Span, TransactionContext } from '@sentry/types';
66
import { isThenable } from '@sentry/utils';
77

8-
import type { NodeExperimentalClient } from './client';
8+
import type { NodeExperimentalClient } from '../types';
99

1010
/**
1111
* Wraps a function with a transaction/span and finishes the span after the function is done.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
import type { Tracer } from '@opentelemetry/api';
12
import type { NodeClient, NodeOptions } from '@sentry/node';
23

34
export type NodeExperimentalOptions = NodeOptions;
45
export type NodeExperimentalClientOptions = ConstructorParameters<typeof NodeClient>[0];
6+
7+
export interface NodeExperimentalClient extends NodeClient {
8+
tracer: Tracer;
9+
getOptions(): NodeExperimentalClientOptions;
10+
}

0 commit comments

Comments
 (0)