diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index c55a01641da7..3c7fa347cf94 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -12,6 +12,7 @@ export { INTEGRATIONS as Integrations }; export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanceIntegrations'; export * as Handlers from './sdk/handlers'; export * from './sdk/trace'; +export { getCurrentHub, getHubFromCarrier } from './sdk/hub'; export { makeNodeTransport, @@ -33,8 +34,6 @@ export { extractTraceparentData, flush, getActiveTransaction, - getHubFromCarrier, - getCurrentHub, Hub, lastEventId, makeMain, diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index d6e6a825d8a1..9a73ef98a970 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -10,7 +10,7 @@ import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; import type { EventProcessor, Hub, Integration } from '@sentry/types'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; -import type { NodeExperimentalClient } from '../sdk/client'; +import type { NodeExperimentalClient } from '../types'; import { getRequestSpanData } from '../utils/getRequestSpanData'; interface TracingOptions { diff --git a/packages/node-experimental/src/sdk/client.ts b/packages/node-experimental/src/sdk/client.ts index 8d7bddad07f6..29f68980f008 100644 --- a/packages/node-experimental/src/sdk/client.ts +++ b/packages/node-experimental/src/sdk/client.ts @@ -1,13 +1,19 @@ import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; +import type { EventHint, Scope } from '@sentry/node'; import { NodeClient, SDK_VERSION } from '@sentry/node'; +import type { Event } from '@sentry/types'; -import type { NodeExperimentalClientOptions } from '../types'; +import type { + NodeExperimentalClient as NodeExperimentalClientInterface, + NodeExperimentalClientOptions, +} from '../types'; +import { OtelScope } from './scope'; /** * A client built on top of the NodeClient, which provides some otel-specific things on top. */ -export class NodeExperimentalClient extends NodeClient { +export class NodeExperimentalClient extends NodeClient implements NodeExperimentalClientInterface { private _tracer: Tracer | undefined; public constructor(options: ConstructorParameters[0]) { @@ -47,4 +53,20 @@ export class NodeExperimentalClient extends NodeClient { // Just a type-cast, basically return super.getOptions(); } + + /** + * Extends the base `_prepareEvent` so that we can properly handle `captureContext`. + * This uses `Scope.clone()`, which we need to replace with `OtelScope.clone()` for this client. + */ + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { + let actualScope = scope; + + // Remove `captureContext` hint and instead clone already here + if (hint && hint.captureContext) { + actualScope = OtelScope.clone(scope); + delete hint.captureContext; + } + + return super._prepareEvent(event, hint, actualScope); + } } diff --git a/packages/node-experimental/src/sdk/hub.ts b/packages/node-experimental/src/sdk/hub.ts new file mode 100644 index 000000000000..8220265e600c --- /dev/null +++ b/packages/node-experimental/src/sdk/hub.ts @@ -0,0 +1,140 @@ +import type { Carrier, Scope } from '@sentry/core'; +import { Hub } from '@sentry/core'; +import type { Client } from '@sentry/types'; +import { getGlobalSingleton, GLOBAL_OBJ } from '@sentry/utils'; + +import { OtelScope } from './scope'; + +/** A custom hub that ensures we always creat an OTEL scope. */ + +class OtelHub extends Hub { + public constructor(client?: Client, scope: Scope = new OtelScope()) { + super(client, scope); + } + + /** + * @inheritDoc + */ + public pushScope(): Scope { + // We want to clone the content of prev scope + const scope = OtelScope.clone(this.getScope()); + this.getStack().push({ + client: this.getClient(), + scope, + }); + return scope; + } +} + +/** + * ******************************************************************************* + * Everything below here is a copy of the stuff from core's hub.ts, + * only that we make sure to create our custom OtelScope instead of the default Scope. + * This is necessary to get the correct breadcrumbs behavior. + * + * Basically, this overwrites all places that do `new Scope()` with `new OtelScope()`. + * Which in turn means overwriting all places that do `new Hub()` and make sure to pass in a OtelScope instead. + * ******************************************************************************* + */ + +/** + * API compatibility version of this hub. + * + * WARNING: This number should only be increased when the global interface + * changes and new methods are introduced. + * + * @hidden + */ +const API_VERSION = 4; + +/** + * Returns the default hub instance. + * + * If a hub is already registered in the global carrier but this module + * contains a more recent version, it replaces the registered version. + * Otherwise, the currently registered hub will be returned. + */ +export function getCurrentHub(): Hub { + // Get main carrier (global for every environment) + const registry = getMainCarrier(); + + if (registry.__SENTRY__ && registry.__SENTRY__.acs) { + const hub = registry.__SENTRY__.acs.getCurrentHub(); + + if (hub) { + return hub; + } + } + + // Return hub that lives on a global object + return getGlobalHub(registry); +} + +/** + * This will create a new {@link Hub} and add to the passed object on + * __SENTRY__.hub. + * @param carrier object + * @hidden + */ +export function getHubFromCarrier(carrier: Carrier): Hub { + return getGlobalSingleton('hub', () => new OtelHub(), carrier); +} + +/** + * @private Private API with no semver guarantees! + * + * If the carrier does not contain a hub, a new hub is created with the global hub client and scope. + */ +export function ensureHubOnCarrier(carrier: Carrier, parent: Hub = getGlobalHub()): void { + // If there's no hub on current domain, or it's an old API, assign a new one + if (!hasHubOnCarrier(carrier) || getHubFromCarrier(carrier).isOlderThan(API_VERSION)) { + const globalHubTopStack = parent.getStackTop(); + setHubOnCarrier(carrier, new OtelHub(globalHubTopStack.client, OtelScope.clone(globalHubTopStack.scope))); + } +} + +function getGlobalHub(registry: Carrier = getMainCarrier()): Hub { + // If there's no hub, or its an old API, assign a new one + if (!hasHubOnCarrier(registry) || getHubFromCarrier(registry).isOlderThan(API_VERSION)) { + setHubOnCarrier(registry, new OtelHub()); + } + + // Return hub that lives on a global object + return getHubFromCarrier(registry); +} + +/** + * This will tell whether a carrier has a hub on it or not + * @param carrier object + */ +function hasHubOnCarrier(carrier: Carrier): boolean { + return !!(carrier && carrier.__SENTRY__ && carrier.__SENTRY__.hub); +} + +/** + * Returns the global shim registry. + * + * FIXME: This function is problematic, because despite always returning a valid Carrier, + * it has an optional `__SENTRY__` property, which then in turn requires us to always perform an unnecessary check + * at the call-site. We always access the carrier through this function, so we can guarantee that `__SENTRY__` is there. + **/ +function getMainCarrier(): Carrier { + GLOBAL_OBJ.__SENTRY__ = GLOBAL_OBJ.__SENTRY__ || { + extensions: {}, + hub: undefined, + }; + return GLOBAL_OBJ; +} + +/** + * This will set passed {@link Hub} on the passed object's __SENTRY__.hub attribute + * @param carrier object + * @param hub Hub + * @returns A boolean indicating success or failure + */ +function setHubOnCarrier(carrier: Carrier, hub: Hub): boolean { + if (!carrier) return false; + const __SENTRY__ = (carrier.__SENTRY__ = carrier.__SENTRY__ || {}); + __SENTRY__.hub = hub; + return true; +} diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 318bba138837..44ae76329dd2 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -4,7 +4,7 @@ import { getCurrentHub } from '@sentry/core'; import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node'; import { logger } from '@sentry/utils'; -import type { NodeExperimentalClient } from './client'; +import type { NodeExperimentalClient } from '../types'; import { SentryContextManager } from './otelContextManager'; /** diff --git a/packages/node-experimental/src/sdk/otelContextManager.ts b/packages/node-experimental/src/sdk/otelContextManager.ts index 9110b9e62328..c8eadfb85f65 100644 --- a/packages/node-experimental/src/sdk/otelContextManager.ts +++ b/packages/node-experimental/src/sdk/otelContextManager.ts @@ -2,7 +2,8 @@ import type { Context } from '@opentelemetry/api'; import * as api from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import type { Carrier, Hub } from '@sentry/core'; -import { ensureHubOnCarrier, getCurrentHub, getHubFromCarrier } from '@sentry/core'; + +import { ensureHubOnCarrier, getCurrentHub, getHubFromCarrier } from './hub'; export const OTEL_CONTEXT_HUB_KEY = api.createContextKey('sentry_hub'); diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts new file mode 100644 index 000000000000..ab255f1d20bc --- /dev/null +++ b/packages/node-experimental/src/sdk/scope.ts @@ -0,0 +1,138 @@ +import { Scope } from '@sentry/core'; +import type { Breadcrumb, Transaction } from '@sentry/types'; +import { dateTimestampInSeconds } from '@sentry/utils'; + +import { getActiveSpan } from './trace'; + +const DEFAULT_MAX_BREADCRUMBS = 100; + +/** + * This is a fork of the base Transaction with OTEL specific stuff added. + * Note that we do not solve this via an actual subclass, but by wrapping this in a proxy when we need it - + * as we can't easily control all the places a transaction may be created. + */ +interface TransactionWithBreadcrumbs extends Transaction { + _breadcrumbs: Breadcrumb[]; + + /** Get all breadcrumbs added to this transaction. */ + getBreadcrumbs(): Breadcrumb[]; + + /** Add a breadcrumb to this transaction. */ + addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void; +} + +/** A fork of the classic scope with some otel specific stuff. */ +export class OtelScope extends Scope { + /** + * @inheritDoc + */ + public static clone(scope?: Scope): Scope { + const newScope = new OtelScope(); + if (scope) { + newScope._breadcrumbs = [...scope['_breadcrumbs']]; + newScope._tags = { ...scope['_tags'] }; + newScope._extra = { ...scope['_extra'] }; + newScope._contexts = { ...scope['_contexts'] }; + newScope._user = scope['_user']; + newScope._level = scope['_level']; + newScope._span = scope['_span']; + newScope._session = scope['_session']; + newScope._transactionName = scope['_transactionName']; + newScope._fingerprint = scope['_fingerprint']; + newScope._eventProcessors = [...scope['_eventProcessors']]; + newScope._requestSession = scope['_requestSession']; + newScope._attachments = [...scope['_attachments']]; + newScope._sdkProcessingMetadata = { ...scope['_sdkProcessingMetadata'] }; + newScope._propagationContext = { ...scope['_propagationContext'] }; + } + return newScope; + } + + /** + * @inheritDoc + */ + public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { + const transaction = getActiveTransaction(); + + if (transaction) { + transaction.addBreadcrumb(breadcrumb, maxBreadcrumbs); + return this; + } + + return super.addBreadcrumb(breadcrumb, maxBreadcrumbs); + } + + /** + * @inheritDoc + */ + protected _getBreadcrumbs(): Breadcrumb[] { + const transaction = getActiveTransaction(); + const transactionBreadcrumbs = transaction ? transaction.getBreadcrumbs() : []; + + return this._breadcrumbs.concat(transactionBreadcrumbs); + } +} + +/** + * This gets the currently active transaction, + * and ensures to wrap it so that we can store breadcrumbs on it. + */ +function getActiveTransaction(): TransactionWithBreadcrumbs | undefined { + const activeSpan = getActiveSpan(); + const transaction = activeSpan && activeSpan.transaction; + + if (!transaction) { + return undefined; + } + + if (transactionHasBreadcrumbs(transaction)) { + return transaction; + } + + return new Proxy(transaction as TransactionWithBreadcrumbs, { + get(target, prop, receiver) { + if (prop === 'addBreadcrumb') { + return addBreadcrumb; + } + if (prop === 'getBreadcrumbs') { + return getBreadcrumbs; + } + if (prop === '_breadcrumbs') { + const breadcrumbs = Reflect.get(target, prop, receiver); + return breadcrumbs || []; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +function transactionHasBreadcrumbs(transaction: Transaction): transaction is TransactionWithBreadcrumbs { + return ( + typeof (transaction as TransactionWithBreadcrumbs).getBreadcrumbs === 'function' && + typeof (transaction as TransactionWithBreadcrumbs).addBreadcrumb === 'function' + ); +} + +/** Add a breadcrumb to a transaction. */ +function addBreadcrumb(this: TransactionWithBreadcrumbs, breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void { + const maxCrumbs = typeof maxBreadcrumbs === 'number' ? maxBreadcrumbs : DEFAULT_MAX_BREADCRUMBS; + + // No data has been changed, so don't notify scope listeners + if (maxCrumbs <= 0) { + return; + } + + const mergedBreadcrumb = { + timestamp: dateTimestampInSeconds(), + ...breadcrumb, + }; + + const breadcrumbs = this._breadcrumbs; + breadcrumbs.push(mergedBreadcrumb); + this._breadcrumbs = breadcrumbs.length > maxCrumbs ? breadcrumbs.slice(-maxCrumbs) : breadcrumbs; +} + +/** Get all breadcrumbs from a transaction. */ +function getBreadcrumbs(this: TransactionWithBreadcrumbs): Breadcrumb[] { + return this._breadcrumbs; +} diff --git a/packages/node-experimental/src/sdk/trace.ts b/packages/node-experimental/src/sdk/trace.ts index e2fd14663e28..1faf780ec5c7 100644 --- a/packages/node-experimental/src/sdk/trace.ts +++ b/packages/node-experimental/src/sdk/trace.ts @@ -5,7 +5,7 @@ import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; import type { Span, TransactionContext } from '@sentry/types'; import { isThenable } from '@sentry/utils'; -import type { NodeExperimentalClient } from './client'; +import type { NodeExperimentalClient } from '../types'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 77b1a3ebee4c..107cfdb37266 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,4 +1,10 @@ +import type { Tracer } from '@opentelemetry/api'; import type { NodeClient, NodeOptions } from '@sentry/node'; export type NodeExperimentalOptions = NodeOptions; export type NodeExperimentalClientOptions = ConstructorParameters[0]; + +export interface NodeExperimentalClient extends NodeClient { + tracer: Tracer; + getOptions(): NodeExperimentalClientOptions; +}