diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fb2f17b6089..6468c312bbe1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,7 @@ export { makeSession, closeSession, updateSession } from './session'; export { SessionFlusher } from './sessionflusher'; export { Scope } from './scope'; export { + notifyEventProcessors, // eslint-disable-next-line deprecation/deprecation addGlobalEventProcessor, } from './eventProcessors'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 57adc3d33c36..b7782fcfa65c 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -46,7 +46,7 @@ function filterDuplicates(integrations: Integration[]): Integration[] { } /** Gets integrations to install */ -export function getIntegrationsToSetup(options: Options): Integration[] { +export function getIntegrationsToSetup(options: Pick): Integration[] { const defaultIntegrations = options.defaultIntegrations || []; const userIntegrations = options.integrations; diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index 5d976dcd4576..e45616b2e65a 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -13,7 +13,34 @@ export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanc export * as Handlers from './sdk/handlers'; export type { Span } from './types'; -export { startSpan, startInactiveSpan, getCurrentHub, getClient, getActiveSpan } from '@sentry/opentelemetry'; +export { startSpan, startInactiveSpan, getActiveSpan } from '@sentry/opentelemetry'; +export { + getClient, + addBreadcrumb, + captureException, + captureEvent, + captureMessage, + addGlobalEventProcessor, + addEventProcessor, + lastEventId, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + withScope, + withIsolationScope, + // eslint-disable-next-line deprecation/deprecation + configureScope, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setIsolationScope, + setCurrentScope, +} from './sdk/api'; +export { getCurrentHub, makeMain } from './sdk/hub'; +export { Scope } from './sdk/scope'; export { makeNodeTransport, @@ -24,36 +51,16 @@ export { extractRequestData, deepReadDirSync, getModuleFromFilename, - // eslint-disable-next-line deprecation/deprecation - addGlobalEventProcessor, - addEventProcessor, - addBreadcrumb, - captureException, - captureEvent, - captureMessage, close, - // eslint-disable-next-line deprecation/deprecation - configureScope, createTransport, // eslint-disable-next-line deprecation/deprecation extractTraceparentData, flush, - getActiveTransaction, Hub, - lastEventId, - makeMain, runWithAsyncContext, - Scope, SDK_VERSION, - setContext, - setExtra, - setExtras, - setTag, - setTags, - setUser, spanStatusfromHttpCode, trace, - withScope, captureCheckIn, withMonitor, hapiErrorPlugin, diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 860169c6a43e..4588d1b36b15 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -8,6 +8,8 @@ import { _INTERNAL, getClient, getCurrentHub, getSpanKind, setSpanMetadata } fro import type { EventProcessor, Hub, Integration } from '@sentry/types'; import { stringMatchesSomePattern } from '@sentry/utils'; +import { getIsolationScope, setIsolationScope } from '../sdk/api'; +import { Scope } from '../sdk/scope'; import type { NodeExperimentalClient } from '../types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { getRequestUrl } from '../utils/getRequestUrl'; @@ -127,6 +129,11 @@ export class Http implements Integration { requireParentforIncomingSpans: false, requestHook: (span, req) => { this._updateSpan(span, req); + + // Update the isolation scope, isolate this request + if (getSpanKind(span) === SpanKind.SERVER) { + setIsolationScope(getIsolationScope().clone()); + } }, responseHook: (span, res) => { this._addRequestBreadcrumb(span, res); diff --git a/packages/node-experimental/src/otel/asyncContextStrategy.ts b/packages/node-experimental/src/otel/asyncContextStrategy.ts new file mode 100644 index 000000000000..e0d976c71ff1 --- /dev/null +++ b/packages/node-experimental/src/otel/asyncContextStrategy.ts @@ -0,0 +1,29 @@ +import * as api from '@opentelemetry/api'; + +import { setAsyncContextStrategy } from './../sdk/globals'; +import { getCurrentHub } from './../sdk/hub'; +import type { CurrentScopes } from './../sdk/types'; +import { getScopesFromContext } from './../utils/contextData'; + +/** + * Sets the async context strategy to use follow the OTEL context under the hood. + * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) + */ +export function setOpenTelemetryContextAsyncContextStrategy(): void { + function getScopes(): CurrentScopes | undefined { + const ctx = api.context.active(); + return getScopesFromContext(ctx); + } + + /* This is more or less a NOOP - we rely on the OTEL context manager for this */ + function runWithAsyncContext(callback: () => T): T { + const ctx = api.context.active(); + + // We depend on the otelContextManager to handle the context/hub + return api.context.with(ctx, () => { + return callback(); + }); + } + + setAsyncContextStrategy({ getScopes, getCurrentHub, runWithAsyncContext }); +} diff --git a/packages/node-experimental/src/otel/contextManager.ts b/packages/node-experimental/src/otel/contextManager.ts new file mode 100644 index 000000000000..4ba4f0642b16 --- /dev/null +++ b/packages/node-experimental/src/otel/contextManager.ts @@ -0,0 +1,45 @@ +import type { Context } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { setHubOnContext } from '@sentry/opentelemetry'; +import { getCurrentHub } from '../sdk/hub'; + +import { getCurrentScope, getIsolationScope } from './../sdk/api'; +import { Scope } from './../sdk/scope'; +import type { CurrentScopes } from './../sdk/types'; +import { getScopesFromContext, setScopesOnContext } from './../utils/contextData'; + +/** + * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. + * It ensures that we create a new hub per context, so that the OTEL Context & the Sentry Hub are always in sync. + * + * Note that we currently only support AsyncHooks with this, + * but since this should work for Node 14+ anyhow that should be good enough. + */ +export class SentryContextManager extends AsyncLocalStorageContextManager { + /** + * Overwrite with() of the original AsyncLocalStorageContextManager + * to ensure we also create a new hub per context. + */ + public with ReturnType>( + context: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + const previousScopes = getScopesFromContext(context); + + const currentScope = previousScopes ? previousScopes.scope : getCurrentScope(); + const isolationScope = previousScopes ? previousScopes.isolationScope : getIsolationScope(); + + const newCurrentScope = currentScope.clone(); + const scopes: CurrentScopes = { scope: newCurrentScope, isolationScope }; + + // We also need to "mock" the hub on the context, as the original @sentry/opentelemetry uses that... + const mockHub = { ...getCurrentHub(), getScope: () => scopes.scope }; + + const ctx1 = setHubOnContext(context, mockHub); + const ctx2 = setScopesOnContext(ctx1, scopes); + + return super.with(ctx2, fn, thisArg, ...args); + } +} diff --git a/packages/node-experimental/src/sdk/api.ts b/packages/node-experimental/src/sdk/api.ts new file mode 100644 index 000000000000..1a7ddfd52ad5 --- /dev/null +++ b/packages/node-experimental/src/sdk/api.ts @@ -0,0 +1,225 @@ +// PUBLIC APIS + +import { context } from '@opentelemetry/api'; +import { DEFAULT_ENVIRONMENT, closeSession, makeSession, updateSession } from '@sentry/core'; +import type { + Breadcrumb, + BreadcrumbHint, + CaptureContext, + Client, + Event, + EventHint, + EventProcessor, + Extra, + Extras, + Primitive, + Session, + Severity, + SeverityLevel, + User, +} from '@sentry/types'; +import { GLOBAL_OBJ, consoleSandbox, dateTimestampInSeconds } from '@sentry/utils'; +import { getScopesFromContext, setScopesOnContext } from '../utils/contextData'; + +import type { ExclusiveEventHintOrCaptureContext } from '../utils/prepareEvent'; +import { parseEventHintOrCaptureContext } from '../utils/prepareEvent'; +import type { Scope } from './scope'; +import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from './scope'; + +export { getCurrentScope, getGlobalScope, getIsolationScope, getClient }; +export { setCurrentScope, setIsolationScope } from './scope'; + +/** + * Fork a scope from the current scope, and make it the current scope in the given callback + */ +export function withScope(callback: (scope: Scope) => T): T { + return context.with(context.active(), () => callback(getCurrentScope())); +} + +/** + * For a new isolation scope from the current isolation scope, + * and make it the current isolation scope in the given callback. + */ +export function withIsolationScope(callback: (isolationScope: Scope) => T): T { + const ctx = context.active(); + const currentScopes = getScopesFromContext(ctx); + const scopes = currentScopes + ? { ...currentScopes } + : { + scope: getCurrentScope(), + isolationScope: getIsolationScope(), + }; + + scopes.isolationScope = scopes.isolationScope.clone(); + + return context.with(setScopesOnContext(ctx, scopes), () => { + return callback(getIsolationScope()); + }); +} + +/** Get the ID of the last sent error event. */ +export function lastEventId(): string | undefined { + return getCurrentScope().lastEventId(); +} + +/** + * Configure the current scope. + * @deprecated Use `getCurrentScope()` instead. + */ +export function configureScope(callback: (scope: Scope) => void): void { + callback(getCurrentScope()); +} + +/** Record an exception and send it to Sentry. */ +export function captureException(exception: unknown, hint?: ExclusiveEventHintOrCaptureContext): string { + return getCurrentScope().captureException(exception, parseEventHintOrCaptureContext(hint)); +} + +/** Record a message and send it to Sentry. */ +export function captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + captureContext?: CaptureContext | Severity | SeverityLevel, +): string { + // This is necessary to provide explicit scopes upgrade, without changing the original + // arity of the `captureMessage(message, level)` method. + const level = typeof captureContext === 'string' ? captureContext : undefined; + const context = typeof captureContext !== 'string' ? { captureContext } : undefined; + + return getCurrentScope().captureMessage(message, level, context); +} + +/** Capture a generic event and send it to Sentry. */ +export function captureEvent(event: Event, hint?: EventHint): string { + return getCurrentScope().captureEvent(event, hint); +} + +/** + * Add a breadcrumb to the current isolation scope. + */ +export function addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void { + const client = getClient(); + + const { beforeBreadcrumb, maxBreadcrumbs } = client.getOptions(); + + if (maxBreadcrumbs && maxBreadcrumbs <= 0) return; + + const timestamp = dateTimestampInSeconds(); + const mergedBreadcrumb = { timestamp, ...breadcrumb }; + const finalBreadcrumb = beforeBreadcrumb + ? (consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)) as Breadcrumb | null) + : mergedBreadcrumb; + + if (finalBreadcrumb === null) return; + + if (client.emit) { + client.emit('beforeAddBreadcrumb', finalBreadcrumb, hint); + } + + getIsolationScope().addBreadcrumb(finalBreadcrumb, maxBreadcrumbs); +} + +/** + * Add a global event processor. + */ +export function addGlobalEventProcessor(eventProcessor: EventProcessor): void { + getGlobalScope().addEventProcessor(eventProcessor); +} + +/** + * Add an event processor to the current isolation scope. + */ +export function addEventProcessor(eventProcessor: EventProcessor): void { + getIsolationScope().addEventProcessor(eventProcessor); +} + +/** Set the user for the current isolation scope. */ +export function setUser(user: User | null): void { + getIsolationScope().setUser(user); +} + +/** Set tags for the current isolation scope. */ +export function setTags(tags: { [key: string]: Primitive }): void { + getIsolationScope().setTags(tags); +} + +/** Set a single tag user for the current isolation scope. */ +export function setTag(key: string, value: Primitive): void { + getIsolationScope().setTag(key, value); +} + +/** Set extra data for the current isolation scope. */ +export function setExtra(key: string, extra: Extra): void { + getIsolationScope().setExtra(key, extra); +} + +/** Set multiple extra data for the current isolation scope. */ +export function setExtras(extras: Extras): void { + getIsolationScope().setExtras(extras); +} + +/** Set context data for the current isolation scope. */ +export function setContext( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { [key: string]: any } | null, +): void { + getIsolationScope().setContext(name, context); +} + +/** Start a session on the current isolation scope. */ +export function startSession(context?: Session): Session { + const client = getClient(); + const isolationScope = getIsolationScope(); + + const { release, environment = DEFAULT_ENVIRONMENT } = client.getOptions(); + + // Will fetch userAgent if called from browser sdk + const { userAgent } = GLOBAL_OBJ.navigator || {}; + + const session = makeSession({ + release, + environment, + user: isolationScope.getUser(), + ...(userAgent && { userAgent }), + ...context, + }); + + // End existing session if there's one + const currentSession = isolationScope.getSession && isolationScope.getSession(); + if (currentSession && currentSession.status === 'ok') { + updateSession(currentSession, { status: 'exited' }); + } + endSession(); + + // Afterwards we set the new session on the scope + isolationScope.setSession(session); + + return session; +} + +/** End the session on the current isolation scope. */ +export function endSession(): void { + const isolationScope = getIsolationScope(); + const session = isolationScope.getSession(); + if (session) { + closeSession(session); + } + _sendSessionUpdate(); + + // the session is over; take it off of the scope + isolationScope.setSession(); +} + +/** + * Sends the current Session on the scope + */ +function _sendSessionUpdate(): void { + const scope = getCurrentScope(); + const client = getClient(); + + const session = scope.getSession(); + if (session && client.captureSession) { + client.captureSession(session); + } +} diff --git a/packages/node-experimental/src/sdk/client.ts b/packages/node-experimental/src/sdk/client.ts index 809d1fa49035..8a7626b4ff9c 100644 --- a/packages/node-experimental/src/sdk/client.ts +++ b/packages/node-experimental/src/sdk/client.ts @@ -1,7 +1,16 @@ import { NodeClient, SDK_VERSION } from '@sentry/node'; -import { wrapClientClass } from '@sentry/opentelemetry'; -class NodeExperimentalBaseClient extends NodeClient { +import type { Tracer } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { CaptureContext, Event, EventHint } from '@sentry/types'; +import { Scope } from './scope'; + +/** A client for using Sentry with Node & OpenTelemetry. */ +export class NodeExperimentalClient extends NodeClient { + public traceProvider: BasicTracerProvider | undefined; + private _tracer: Tracer | undefined; + public constructor(options: ConstructorParameters[0]) { options._metadata = options._metadata || {}; options._metadata.sdk = options._metadata.sdk || { @@ -17,6 +26,54 @@ class NodeExperimentalBaseClient extends NodeClient { super(options); } + + /** Get the OTEL tracer. */ + public get tracer(): Tracer { + if (this._tracer) { + return this._tracer; + } + + const name = '@sentry/node-experimental'; + const version = SDK_VERSION; + const tracer = trace.getTracer(name, version); + this._tracer = tracer; + + return tracer; + } + + /** + * @inheritDoc + */ + public async flush(timeout?: number): Promise { + const provider = this.traceProvider; + const spanProcessor = provider?.activeSpanProcessor; + + if (spanProcessor) { + await spanProcessor.forceFlush(); + } + + return super.flush(timeout); + } + + /** + * Extends the base `_prepareEvent` so that we can properly handle `captureContext`. + * This uses `new Scope()`, which we need to replace with our own Scope 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 = getScopeForEvent(scope, hint.captureContext); + delete hint.captureContext; + } + + return super._prepareEvent(event, hint, actualScope); + } } -export const NodeExperimentalClient = wrapClientClass(NodeExperimentalBaseClient); +function getScopeForEvent(scope: Scope | undefined, captureContext: CaptureContext): Scope | undefined { + const finalScope = scope ? scope.clone() : new Scope(); + finalScope.update(captureContext); + return finalScope; +} diff --git a/packages/node-experimental/src/sdk/globals.ts b/packages/node-experimental/src/sdk/globals.ts new file mode 100644 index 000000000000..a91f07cd206d --- /dev/null +++ b/packages/node-experimental/src/sdk/globals.ts @@ -0,0 +1,38 @@ +import type { Hub } from '@sentry/types'; +import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; + +import type { AsyncContextStrategy, SentryCarrier } from './types'; + +/** Update the async context strategy */ +export function setAsyncContextStrategy(strategy: AsyncContextStrategy | undefined): void { + const carrier = getGlobalCarrier(); + carrier.acs = strategy; +} + +/** + * Returns the global shim registry. + **/ +export function getGlobalCarrier(): SentryCarrier { + GLOBAL_OBJ.__SENTRY__ = GLOBAL_OBJ.__SENTRY__ || { + extensions: {}, + // For legacy reasons... + globalEventProcessors: [], + }; + + return GLOBAL_OBJ.__SENTRY__; +} + +/** + * Calls global extension method and binding current instance to the function call + */ +// @ts-expect-error Function lacks ending return statement and return type does not include 'undefined'. ts(2366) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function callExtensionMethod(hub: Hub, method: string, ...args: any[]): T { + const carrier = getGlobalCarrier(); + + if (carrier.extensions && typeof carrier.extensions[method] === 'function') { + return carrier.extensions[method].apply(hub, args); + } + DEBUG_BUILD && logger.warn(`Extension method ${method} couldn't be found, doing nothing.`); +} diff --git a/packages/node-experimental/src/sdk/hub.ts b/packages/node-experimental/src/sdk/hub.ts new file mode 100644 index 000000000000..21e1c83a34bb --- /dev/null +++ b/packages/node-experimental/src/sdk/hub.ts @@ -0,0 +1,170 @@ +import type { + Client, + CustomSamplingContext, + EventHint, + Hub, + Integration, + IntegrationClass, + Session, + Severity, + SeverityLevel, + TransactionContext, +} from '@sentry/types'; + +import { + addBreadcrumb, + captureEvent, + captureException, + captureMessage, + configureScope, + endSession, + getClient, + getCurrentScope, + lastEventId, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + startSession, + withScope, +} from './api'; +import { callExtensionMethod, getGlobalCarrier } from './globals'; +import type { Scope } from './scope'; +import type { SentryCarrier } from './types'; + +/** Ensure the global hub is our proxied hub. */ +export function setupGlobalHub(): void { + const carrier = getGlobalCarrier(); + carrier.hub = getCurrentHub(); +} + +/** + * This is for legacy reasons, and returns a proxy object instead of a hub to be used. + */ +export function getCurrentHub(): Hub { + return { + isOlderThan(_version: number): boolean { + return false; + }, + + bindClient(client: Client): void { + const scope = getCurrentScope(); + scope.setClient(client); + }, + + pushScope(): Scope { + // TODO: This does not work and is actually deprecated + return getCurrentScope(); + }, + + popScope(): boolean { + // TODO: This does not work and is actually deprecated + return false; + }, + + withScope, + getClient, + getScope: getCurrentScope, + captureException: (exception: unknown, hint?: EventHint) => { + return getCurrentScope().captureException(exception, hint); + }, + captureMessage: ( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ) => { + return getCurrentScope().captureMessage(message, level, hint); + }, + captureEvent, + lastEventId, + addBreadcrumb, + setUser, + setTags, + setTag, + setExtra, + setExtras, + setContext, + // eslint-disable-next-line deprecation/deprecation + configureScope: configureScope, + + run(callback: (hub: Hub) => void): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return withScope(() => callback(this as any)); + }, + + getIntegration(integration: IntegrationClass): T | null { + return getClient().getIntegration(integration); + }, + + traceHeaders(): { [key: string]: string } { + return callExtensionMethod<{ [key: string]: string }>(this, 'traceHeaders'); + }, + + startTransaction( + _context: TransactionContext, + _customSamplingContext?: CustomSamplingContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any { + // eslint-disable-next-line no-console + console.warn('startTransaction is a noop in @sentry/node-experimental. Use `startSpan` instead.'); + // We return an object here as hub.ts checks for the result of this + // and renders a different warning if this is empty + return {}; + }, + + startSession, + + endSession, + + captureSession(endSession?: boolean): void { + // both send the update and pull the session from the scope + if (endSession) { + return this.endSession(); + } + + // only send the update + _sendSessionUpdate(); + }, + + shouldSendDefaultPii(): boolean { + const client = getClient(); + const options = client.getOptions(); + return Boolean(options.sendDefaultPii); + }, + }; +} + +/** + * Replaces the current main hub with the passed one on the global object + * + * @returns The old replaced hub + */ +export function makeMain(hub: Hub): Hub { + // eslint-disable-next-line no-console + console.warn('makeMain is a noop in @sentry/node-experimental. Use `setCurrentScope` instead.'); + return hub; +} + +/** + * Sends the current Session on the scope + */ +function _sendSessionUpdate(): void { + const scope = getCurrentScope(); + const client = getClient(); + + const session = scope.getSession(); + if (session && client.captureSession) { + client.captureSession(session); + } +} + +/** + * Set a mocked hub on the current carrier. + */ +export function setLegacyHubOnCarrier(carrier: SentryCarrier): boolean { + carrier.hub = getCurrentHub(); + return true; +} diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index be4843a5d2f7..e7c6ebf72381 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -1,15 +1,32 @@ -import { hasTracingEnabled } from '@sentry/core'; -import type { NodeClient } from '@sentry/node'; -import { defaultIntegrations as defaultNodeIntegrations, init as initNode } from '@sentry/node'; -import { setOpenTelemetryContextAsyncContextStrategy, setupGlobalHub } from '@sentry/opentelemetry'; +import { getIntegrationsToSetup, hasTracingEnabled } from '@sentry/core'; +import { + Integrations, + defaultIntegrations as defaultNodeIntegrations, + defaultStackParser, + getSentryRelease, + isAnrChildProcess, + makeNodeTransport, +} from '@sentry/node'; import type { Integration } from '@sentry/types'; -import { parseSemver } from '@sentry/utils'; +import { + consoleSandbox, + dropUndefinedKeys, + logger, + parseSemver, + stackParserFromStackParserOptions, + tracingContextFromHeaders, +} from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations'; import { Http } from '../integrations/http'; import { NodeFetch } from '../integrations/node-fetch'; -import type { NodeExperimentalOptions } from '../types'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../otel/asyncContextStrategy'; +import type { NodeExperimentalClientOptions, NodeExperimentalOptions } from '../types'; +import { endSession, getClient, getCurrentScope, getGlobalScope, getIsolationScope, startSession } from './api'; import { NodeExperimentalClient } from './client'; +import { getGlobalCarrier } from './globals'; +import { setLegacyHubOnCarrier } from './hub'; import { initOtel } from './initOtel'; const NODE_VERSION: ReturnType = parseSemver(process.versions.node); @@ -29,24 +46,172 @@ if (NODE_VERSION.major && NODE_VERSION.major >= 16) { * Initialize Sentry for Node. */ export function init(options: NodeExperimentalOptions | undefined = {}): void { - setupGlobalHub(); + const clientOptions = getClientOptions(options); + + if (clientOptions.debug === true) { + if (DEBUG_BUILD) { + logger.enable(); + } else { + // use `console.warn` rather than `logger.warn` since by non-debug bundles have all `logger.x` statements stripped + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry] Cannot initialize SDK with `debug` option using a non-debug bundle.'); + }); + } + } + + const scope = getCurrentScope(); + scope.update(options.initialScope); + + const client = new NodeExperimentalClient(clientOptions); + // The client is on the global scope, from where it generally is inherited + // unless somebody specifically sets a different one on a scope/isolations cope + getGlobalScope().setClient(client); + + client.setupIntegrations(); + + if (options.autoSessionTracking) { + startSessionTracking(); + } + + updateScopeFromEnvVariables(); + + if (options.spotlight) { + const client = getClient(); + if (client.addIntegration) { + // force integrations to be setup even if no DSN was set + client.setupIntegrations(true); + client.addIntegration( + new Integrations.Spotlight({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + } + + // Always init Otel, even if tracing is disabled, because we need it for trace propagation & the HTTP integration + initOtel(); + setOpenTelemetryContextAsyncContextStrategy(); +} + +function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalClientOptions { + const carrier = getGlobalCarrier(); + setLegacyHubOnCarrier(carrier); const isTracingEnabled = hasTracingEnabled(options); - options.defaultIntegrations = + const autoloadedIntegrations = carrier.integrations || []; + + const fullDefaultIntegrations = options.defaultIntegrations === false ? [] : [ ...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : defaultIntegrations), ...(isTracingEnabled ? getAutoPerformanceIntegrations() : []), + ...autoloadedIntegrations, ]; - options.instrumenter = 'otel'; - options.clientClass = NodeExperimentalClient as unknown as typeof NodeClient; + const release = getRelease(options.release); - initNode(options); + // If there is no release, or we are in an ANR child process, we disable autoSessionTracking by default + const autoSessionTracking = + typeof release !== 'string' || isAnrChildProcess() + ? false + : options.autoSessionTracking === undefined + ? true + : options.autoSessionTracking; + // We enforce tracesSampleRate = 0 in ANR child processes + const tracesSampleRate = isAnrChildProcess() ? 0 : getTracesSampleRate(options.tracesSampleRate); - // Always init Otel, even if tracing is disabled, because we need it for trace propagation & the HTTP integration - initOtel(); - setOpenTelemetryContextAsyncContextStrategy(); + const baseOptions = dropUndefinedKeys({ + transport: makeNodeTransport, + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENVIRONMENT, + }); + + const overwriteOptions = dropUndefinedKeys({ + release, + autoSessionTracking, + tracesSampleRate, + }); + + const clientOptions: NodeExperimentalClientOptions = { + ...baseOptions, + ...options, + ...overwriteOptions, + instrumenter: 'otel', + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + integrations: getIntegrationsToSetup({ + defaultIntegrations: fullDefaultIntegrations, + integrations: options.integrations, + }), + }; + + return clientOptions; +} + +function getRelease(release: NodeExperimentalOptions['release']): string | undefined { + if (release !== undefined) { + return release; + } + + const detectedRelease = getSentryRelease(); + if (detectedRelease !== undefined) { + return detectedRelease; + } + + return undefined; +} + +function getTracesSampleRate(tracesSampleRate: NodeExperimentalOptions['tracesSampleRate']): number | undefined { + if (tracesSampleRate !== undefined) { + return tracesSampleRate; + } + + const sampleRateFromEnv = process.env.SENTRY_TRACES_SAMPLE_RATE; + if (!sampleRateFromEnv) { + return undefined; + } + + const parsed = parseFloat(sampleRateFromEnv); + return isFinite(parsed) ? parsed : undefined; +} + +/** + * Update scope and propagation context based on environmental variables. + * + * See https://github.com/getsentry/rfcs/blob/main/text/0071-continue-trace-over-process-boundaries.md + * for more details. + */ +function updateScopeFromEnvVariables(): void { + const sentryUseEnvironment = (process.env.SENTRY_USE_ENVIRONMENT || '').toLowerCase(); + if (!['false', 'n', 'no', 'off', '0'].includes(sentryUseEnvironment)) { + const sentryTraceEnv = process.env.SENTRY_TRACE; + const baggageEnv = process.env.SENTRY_BAGGAGE; + const { propagationContext } = tracingContextFromHeaders(sentryTraceEnv, baggageEnv); + getCurrentScope().setPropagationContext(propagationContext); + } +} + +/** + * Enable automatic Session Tracking for the node process. + */ +function startSessionTracking(): void { + startSession(); + + // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because + // The 'beforeExit' event is not emitted for conditions causing explicit termination, + // such as calling process.exit() or uncaught exceptions. + // Ref: https://nodejs.org/api/process.html#process_event_beforeexit + process.on('beforeExit', () => { + const session = getIsolationScope().getSession(); + + // Only call endSession, if the Session exists on Scope and SessionStatus is not a + // Terminal Status i.e. Exited or Crashed because + // "When a session is moved away from ok it must not be updated anymore." + // Ref: https://develop.sentry.dev/sdk/sessions/ + if (session && session.status !== 'ok') { + endSession(); + } + }); } diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 53f319d313b6..1a078a1c013a 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -1,20 +1,15 @@ import { DiagLogLevel, diag } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { Resource } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; import { SDK_VERSION } from '@sentry/core'; -import { - SentryPropagator, - SentrySampler, - getClient, - setupEventContextTrace, - wrapContextManagerClass, -} from '@sentry/opentelemetry'; +import { SentryPropagator, SentrySampler, setupEventContextTrace } from '@sentry/opentelemetry'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; +import { SentryContextManager } from '../otel/contextManager'; import type { NodeExperimentalClient } from '../types'; +import { getClient } from './api'; import { NodeExperimentalSentrySpanProcessor } from './spanProcessor'; /** @@ -62,8 +57,6 @@ export function setupOtel(client: NodeExperimentalClient): BasicTracerProvider { }); provider.addSpanProcessor(new NodeExperimentalSentrySpanProcessor()); - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - // Initialize the provider provider.register({ propagator: new SentryPropagator(), diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts new file mode 100644 index 000000000000..f3195c7141b6 --- /dev/null +++ b/packages/node-experimental/src/sdk/scope.ts @@ -0,0 +1,406 @@ +import { notifyEventProcessors } from '@sentry/core'; +import { OpenTelemetryScope } from '@sentry/opentelemetry'; +import type { + Attachment, + Breadcrumb, + Client, + Event, + EventHint, + EventProcessor, + Severity, + SeverityLevel, +} from '@sentry/types'; +import { uuid4 } from '@sentry/utils'; + +import { getGlobalCarrier } from './globals'; +import type { CurrentScopes, Scope as ScopeInterface, ScopeData, SentryCarrier } from './types'; + +/** Get the current scope. */ +export function getCurrentScope(): Scope { + return getScopes().scope as Scope; +} + +/** + * Set the current scope on the execution context. + * This should mostly only be called in Sentry.init() + */ +export function setCurrentScope(scope: Scope): void { + getScopes().scope = scope; +} + +/** Get the global scope. */ +export function getGlobalScope(): Scope { + const carrier = getGlobalCarrier(); + + if (!carrier.globalScope) { + carrier.globalScope = new Scope(); + } + + return carrier.globalScope as Scope; +} + +/** Get the currently active isolation scope. */ +export function getIsolationScope(): Scope { + return getScopes().isolationScope as Scope; +} + +/** + * Set the currently active isolation scope. + * Use this with caution! As it updates the isolation scope for the current execution context. + */ +export function setIsolationScope(isolationScope: Scope): void { + getScopes().isolationScope = isolationScope; +} + +/** Get the currently active client. */ +export function getClient(): C { + const currentScope = getCurrentScope(); + const isolationScope = getIsolationScope(); + const globalScope = getGlobalScope(); + + const client = currentScope.getClient() || isolationScope.getClient() || globalScope.getClient(); + if (client) { + return client as C; + } + + // TODO otherwise ensure we use a noop client + return {} as C; +} + +/** A fork of the classic scope with some otel specific stuff. */ +export class Scope extends OpenTelemetryScope implements ScopeInterface { + // Overwrite this if you want to use a specific isolation scope here + public isolationScope: Scope | undefined; + + protected _client: Client | undefined; + + protected _lastEventId: string | undefined; + + /** + * @inheritDoc + */ + public clone(): Scope { + const newScope = new Scope(); + newScope._breadcrumbs = [...this['_breadcrumbs']]; + newScope._tags = { ...this['_tags'] }; + newScope._extra = { ...this['_extra'] }; + newScope._contexts = { ...this['_contexts'] }; + newScope._user = { ...this['_user'] }; + newScope._level = this['_level']; + newScope._span = this['_span']; + newScope._session = this['_session']; + newScope._transactionName = this['_transactionName']; + newScope._fingerprint = this['_fingerprint']; + newScope._eventProcessors = [...this['_eventProcessors']]; + newScope._requestSession = this['_requestSession']; + newScope._attachments = [...this['_attachments']]; + newScope._sdkProcessingMetadata = { ...this['_sdkProcessingMetadata'] }; + newScope._propagationContext = { ...this['_propagationContext'] }; + + return newScope; + } + + /** Update the client on the scope. */ + public setClient(client: Client): void { + this._client = client; + } + + /** + * Get the client assigned to this scope. + * Should generally not be used by users - use top-level `Sentry.getClient()` instead! + * @internal + */ + public getClient(): Client | undefined { + return this._client; + } + + /** @inheritdoc */ + public getAttachments(): Attachment[] { + const data = getGlobalScope().getScopeData(); + const isolationScopeData = this._getIsolationScope().getScopeData(); + const scopeData = this.getScopeData(); + + // Merge data together, in order + mergeData(data, isolationScopeData); + mergeData(data, scopeData); + + return data.attachments; + } + + /** Capture an exception for this scope. */ + public captureException(exception: unknown, hint?: EventHint): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + const syntheticException = new Error('Sentry syntheticException'); + + getClient().captureException( + exception, + { + originalException: exception, + syntheticException, + ...hint, + event_id: eventId, + }, + this, + ); + + this._lastEventId = eventId; + + return eventId; + } + + /** Capture a message for this scope. */ + public captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + const syntheticException = new Error(message); + + getClient().captureMessage( + message, + level, + { + originalException: message, + syntheticException, + ...hint, + event_id: eventId, + }, + this, + ); + + this._lastEventId = eventId; + + return eventId; + } + + /** Capture a message for this scope. */ + public captureEvent(event: Event, hint?: EventHint): string { + const eventId = hint && hint.event_id ? hint.event_id : uuid4(); + if (!event.type) { + this._lastEventId = eventId; + } + + getClient().captureEvent(event, { ...hint, event_id: eventId }, this); + + return eventId; + } + + /** Get the ID of the last sent error event. */ + public lastEventId(): string | undefined { + return this._lastEventId; + } + + /** + * @inheritDoc + */ + public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { + return this._addBreadcrumb(breadcrumb, maxBreadcrumbs); + } + + /** Get all relevant data for this scope. */ + public getScopeData(): ScopeData { + const { + _breadcrumbs, + _attachments, + _contexts, + _tags, + _extra, + _user, + _level, + _fingerprint, + _eventProcessors, + _propagationContext, + _sdkProcessingMetadata, + } = this; + + return { + breadcrumbs: _breadcrumbs, + attachments: _attachments, + contexts: _contexts, + tags: _tags, + extra: _extra, + user: _user, + level: _level, + fingerprint: _fingerprint || [], + eventProcessors: _eventProcessors, + propagationContext: _propagationContext, + sdkProcessingMetadata: _sdkProcessingMetadata, + }; + } + + /** + * Applies data from the scope to the event and runs all event processors on it. + * + * @param event Event + * @param hint Object containing additional information about the original exception, for use by the event processors. + * @hidden + */ + public applyToEvent( + event: Event, + hint: EventHint = {}, + additionalEventProcessors: EventProcessor[] = [], + ): PromiseLike { + const data = getGlobalScope().getScopeData(); + const isolationScopeData = this._getIsolationScope().getScopeData(); + const scopeData = this.getScopeData(); + + // Merge data together, in order + mergeData(data, isolationScopeData); + mergeData(data, scopeData); + + // Apply the data + const { extra, tags, user, contexts, level, sdkProcessingMetadata, breadcrumbs, fingerprint, eventProcessors } = + data; + + mergePropKeep(event, 'extra', extra); + mergePropKeep(event, 'tags', tags); + mergePropKeep(event, 'user', user); + mergePropKeep(event, 'contexts', contexts); + mergePropKeep(event, 'sdkProcessingMetadata', sdkProcessingMetadata); + event.sdkProcessingMetadata = { + ...event.sdkProcessingMetadata, + propagationContext: this._propagationContext, + }; + + mergeArray(event, 'breadcrumbs', breadcrumbs); + mergeArray(event, 'fingerprint', fingerprint); + + if (level) { + event.level = level; + } + + const allEventProcessors = [...additionalEventProcessors, ...eventProcessors]; + + // Apply additional things to the event + if (this._transactionName) { + event.transaction = this._transactionName; + } + + return notifyEventProcessors(allEventProcessors, event, hint); + } + + /** + * Get all breadcrumbs attached to this scope. + * @internal + */ + public getBreadcrumbs(): Breadcrumb[] { + return this._breadcrumbs; + } + + /** Get the isolation scope for this scope. */ + protected _getIsolationScope(): Scope { + return this.isolationScope || getIsolationScope(); + } +} + +/** Exported only for tests */ +export function mergeData(data: ScopeData, mergeData: ScopeData): void { + const { + extra, + tags, + user, + contexts, + level, + sdkProcessingMetadata, + breadcrumbs, + fingerprint, + eventProcessors, + attachments, + } = mergeData; + + mergePropOverwrite(data, 'extra', extra); + mergePropOverwrite(data, 'tags', tags); + mergePropOverwrite(data, 'user', user); + mergePropOverwrite(data, 'contexts', contexts); + mergePropOverwrite(data, 'sdkProcessingMetadata', sdkProcessingMetadata); + + if (level) { + data.level = level; + } + + if (breadcrumbs.length) { + data.breadcrumbs = [...data.breadcrumbs, ...breadcrumbs]; + } + + if (fingerprint.length) { + data.fingerprint = [...data.fingerprint, ...fingerprint]; + } + + if (eventProcessors.length) { + data.eventProcessors = [...data.eventProcessors, ...eventProcessors]; + } + + if (attachments.length) { + data.attachments = [...data.attachments, ...attachments]; + } +} + +/** + * Merge properties, overwriting existing keys. + * Exported only for tests. + */ +export function mergePropOverwrite< + Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata', + Data extends ScopeData | Event, +>(data: Data, prop: Prop, mergeVal: Data[Prop]): void { + if (mergeVal && Object.keys(mergeVal).length) { + data[prop] = { ...data[prop], ...mergeVal }; + } +} + +/** + * Merge properties, keeping existing keys. + * Exported only for tests. + */ +export function mergePropKeep< + Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata', + Data extends ScopeData | Event, +>(data: Data, prop: Prop, mergeVal: Data[Prop]): void { + if (mergeVal && Object.keys(mergeVal).length) { + data[prop] = { ...mergeVal, ...data[prop] }; + } +} + +/** Exported only for tests */ +export function mergeArray( + event: Event, + prop: Prop, + mergeVal: ScopeData[Prop], +): void { + const prevVal = event[prop]; + // If we are not merging any new values, + // we only need to proceed if there was an empty array before (as we want to replace it with undefined) + if (!mergeVal.length && (!prevVal || prevVal.length)) { + return; + } + + const merged = [...(prevVal || []), ...mergeVal] as ScopeData[Prop]; + event[prop] = merged.length ? merged : undefined; +} + +function getScopes(): CurrentScopes { + const carrier = getGlobalCarrier(); + + if (carrier.acs && carrier.acs.getScopes) { + const scopes = carrier.acs.getScopes(); + + if (scopes) { + return scopes; + } + } + + return getGlobalCurrentScopes(carrier); +} + +function getGlobalCurrentScopes(carrier: SentryCarrier): CurrentScopes { + if (!carrier.scopes) { + carrier.scopes = { + scope: new Scope(), + isolationScope: new Scope(), + }; + } + + return carrier.scopes; +} diff --git a/packages/node-experimental/src/sdk/spanProcessor.ts b/packages/node-experimental/src/sdk/spanProcessor.ts index 067e1568e90f..175f3681479b 100644 --- a/packages/node-experimental/src/sdk/spanProcessor.ts +++ b/packages/node-experimental/src/sdk/spanProcessor.ts @@ -1,16 +1,35 @@ +import type { Context } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { SentrySpanProcessor, getClient } from '@sentry/opentelemetry'; +import { SentrySpanProcessor, getClient, getSpanFinishScope } from '@sentry/opentelemetry'; import { Http } from '../integrations/http'; import { NodeFetch } from '../integrations/node-fetch'; import type { NodeExperimentalClient } from '../types'; +import { getIsolationScope } from './api'; +import { Scope } from './scope'; /** * Implement custom code to avoid sending spans in certain cases. */ export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor { + public constructor() { + super({ scopeClass: Scope }); + } + + /** @inheritDoc */ + public onStart(span: Span, parentContext: Context): void { + super.onStart(span, parentContext); + + // We need to make sure that we use the correct isolation scope when finishing the span + // so we store it on the span finish scope for later use + const scope = getSpanFinishScope(span) as Scope | undefined; + if (scope) { + scope.isolationScope = getIsolationScope(); + } + } + /** @inheritDoc */ protected _shouldSendSpanToSentry(span: Span): boolean { const client = getClient(); diff --git a/packages/node-experimental/src/sdk/types.ts b/packages/node-experimental/src/sdk/types.ts new file mode 100644 index 000000000000..773c404d65ce --- /dev/null +++ b/packages/node-experimental/src/sdk/types.ts @@ -0,0 +1,91 @@ +import type { + Attachment, + Breadcrumb, + Client, + Contexts, + Event, + EventHint, + EventProcessor, + Extras, + Hub, + Integration, + Primitive, + PropagationContext, + Scope as BaseScope, + Severity, + SeverityLevel, + User, +} from '@sentry/types'; + +export interface ScopeData { + eventProcessors: EventProcessor[]; + breadcrumbs: Breadcrumb[]; + user: User; + tags: { [key: string]: Primitive }; + extra: Extras; + contexts: Contexts; + attachments: Attachment[]; + propagationContext: PropagationContext; + sdkProcessingMetadata: { [key: string]: unknown }; + fingerprint: string[]; + level?: SeverityLevel; +} + +export interface Scope extends BaseScope { + // @ts-expect-error typeof this is what we want here + isolationScope: typeof this | undefined; + // @ts-expect-error typeof this is what we want here + clone(scope?: Scope): typeof this; + setClient(client: Client): void; + getClient(): Client | undefined; + captureException(exception: unknown, hint?: EventHint): string; + captureMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + level?: Severity | SeverityLevel, + hint?: EventHint, + ): string; + captureEvent(event: Event, hint?: EventHint): string; + lastEventId(): string | undefined; + getScopeData(): ScopeData; +} + +export interface CurrentScopes { + scope: Scope; + isolationScope: Scope; +} + +/** + * Strategy used to track async context. + */ +export interface AsyncContextStrategy { + /** + * Gets the current async context. Returns undefined if there is no current async context. + */ + getScopes: () => CurrentScopes | undefined; + + /** This is here for legacy reasons. */ + getCurrentHub: () => Hub; + + /** + * Runs the supplied callback in its own async context. + */ + runWithAsyncContext(callback: () => T): T; +} + +export interface SentryCarrier { + globalScope?: Scope; + scopes?: CurrentScopes; + acs?: AsyncContextStrategy; + + // hub is here for legacy reasons + hub?: Hub; + + extensions?: { + /** Extension methods for the hub, which are bound to the current Hub instance */ + // eslint-disable-next-line @typescript-eslint/ban-types + [key: string]: Function; + }; + + integrations?: Integration[]; +} diff --git a/packages/node-experimental/src/utils/contextData.ts b/packages/node-experimental/src/utils/contextData.ts new file mode 100644 index 000000000000..5c69f186eb6d --- /dev/null +++ b/packages/node-experimental/src/utils/contextData.ts @@ -0,0 +1,22 @@ +import type { Context } from '@opentelemetry/api'; +import { createContextKey } from '@opentelemetry/api'; + +import type { CurrentScopes } from '../sdk/types'; + +export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); + +/** + * Try to get the current scopes from the given OTEL context. + * This requires a Context Manager that was wrapped with getWrappedContextManager. + */ +export function getScopesFromContext(context: Context): CurrentScopes | undefined { + return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; +} + +/** + * Set the current scopes on an OTEL context. + * This will return a forked context with the Propagation Context set. + */ +export function setScopesOnContext(context: Context, scopes: CurrentScopes): Context { + return context.setValue(SENTRY_SCOPES_CONTEXT_KEY, scopes); +} diff --git a/packages/node-experimental/src/utils/prepareEvent.ts b/packages/node-experimental/src/utils/prepareEvent.ts new file mode 100644 index 000000000000..db89c2b198c0 --- /dev/null +++ b/packages/node-experimental/src/utils/prepareEvent.ts @@ -0,0 +1,58 @@ +import { Scope } from '@sentry/core'; +import type { CaptureContext, EventHint, Scope as ScopeInterface, ScopeContext } from '@sentry/types'; + +/** + * This type makes sure that we get either a CaptureContext, OR an EventHint. + * It does not allow mixing them, which could lead to unexpected outcomes, e.g. this is disallowed: + * { user: { id: '123' }, mechanism: { handled: false } } + */ +export type ExclusiveEventHintOrCaptureContext = + | (CaptureContext & Partial<{ [key in keyof EventHint]: never }>) + | (EventHint & Partial<{ [key in keyof ScopeContext]: never }>); + +/** + * Parse either an `EventHint` directly, or convert a `CaptureContext` to an `EventHint`. + * This is used to allow to update method signatures that used to accept a `CaptureContext` but should now accept an `EventHint`. + */ +export function parseEventHintOrCaptureContext( + hint: ExclusiveEventHintOrCaptureContext | undefined, +): EventHint | undefined { + if (!hint) { + return undefined; + } + + // If you pass a Scope or `() => Scope` as CaptureContext, we just return this as captureContext + if (hintIsScopeOrFunction(hint)) { + return { captureContext: hint }; + } + + if (hintIsScopeContext(hint)) { + return { + captureContext: hint, + }; + } + + return hint; +} + +function hintIsScopeOrFunction( + hint: CaptureContext | EventHint, +): hint is ScopeInterface | ((scope: ScopeInterface) => ScopeInterface) { + return hint instanceof Scope || typeof hint === 'function'; +} + +type ScopeContextProperty = keyof ScopeContext; +const captureContextKeys: readonly ScopeContextProperty[] = [ + 'user', + 'level', + 'extra', + 'contexts', + 'tags', + 'fingerprint', + 'requestSession', + 'propagationContext', +] as const; + +function hintIsScopeContext(hint: Partial | EventHint): hint is Partial { + return Object.keys(hint).some(key => captureContextKeys.includes(key as ScopeContextProperty)); +} diff --git a/packages/node-experimental/test/helpers/mockSdkInit.ts b/packages/node-experimental/test/helpers/mockSdkInit.ts index 82752ab203d0..9cc7692463d5 100644 --- a/packages/node-experimental/test/helpers/mockSdkInit.ts +++ b/packages/node-experimental/test/helpers/mockSdkInit.ts @@ -7,14 +7,17 @@ import type { NodeExperimentalClientOptions } from '../../src/types'; const PUBLIC_DSN = 'https://username@domain/123'; -export function mockSdkInit(options?: Partial) { +export function resetGlobals(): void { GLOBAL_OBJ.__SENTRY__ = { extensions: {}, hub: undefined, globalEventProcessors: [], logger: undefined, }; +} +export function mockSdkInit(options?: Partial) { + resetGlobals(); init({ dsn: PUBLIC_DSN, defaultIntegrations: false, ...options }); } diff --git a/packages/node-experimental/test/integration/breadcrumbs.test.ts b/packages/node-experimental/test/integration/breadcrumbs.test.ts index 80842451c3bf..fea78a353011 100644 --- a/packages/node-experimental/test/integration/breadcrumbs.test.ts +++ b/packages/node-experimental/test/integration/breadcrumbs.test.ts @@ -1,5 +1,6 @@ -import { withScope } from '@sentry/core'; +import { captureException, withScope } from '@sentry/core'; import { getCurrentHub, startSpan } from '@sentry/opentelemetry'; +import { addBreadcrumb, getClient, withIsolationScope } from '../../src/sdk/api'; import type { NodeExperimentalClient } from '../../src/types'; import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; @@ -55,24 +56,23 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = getClient(); const error = new Error('test'); - hub.addBreadcrumb({ timestamp: 123456, message: 'test0' }); + addBreadcrumb({ timestamp: 123456, message: 'test0' }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test2' }); - hub.captureException(error); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test2' }); + captureException(error); }); - withScope(() => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test3' }); + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test3' }); }); await client.flush(); @@ -142,7 +142,7 @@ describe('Integration | breadcrumbs', () => { ); }); - it('correctly adds & retrieves breadcrumbs for the current root span only', async () => { + it('correctly adds & retrieves breadcrumbs for the current isolation span only', async () => { const beforeSend = jest.fn(() => null); const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); @@ -153,22 +153,26 @@ describe('Integration | breadcrumbs', () => { const error = new Error('test'); - startSpan({ name: 'test1' }, () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + withIsolationScope(() => { + startSpan({ name: 'test1' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); - startSpan({ name: 'inner1' }, () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); }); }); - startSpan({ name: 'test2' }, () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + withIsolationScope(() => { + startSpan({ name: 'test2' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); - startSpan({ name: 'inner2' }, () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); + startSpan({ name: 'inner2' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); - hub.captureException(error); + hub.captureException(error); + }); }); await client.flush(); @@ -303,31 +307,35 @@ describe('Integration | breadcrumbs', () => { const error = new Error('test'); - const promise1 = startSpan({ name: 'test' }, async () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + const promise1 = withIsolationScope(async () => { + await startSpan({ name: 'test' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); - await startSpan({ name: 'inner1' }, async () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); - }); + await startSpan({ name: 'inner1' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); - await startSpan({ name: 'inner2' }, async () => { - hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); - }); + await startSpan({ name: 'inner2' }, async () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 10)); - hub.captureException(error); + hub.captureException(error); + }); }); - const promise2 = startSpan({ name: 'test-b' }, async () => { - hub.addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + const promise2 = withIsolationScope(async () => { + await startSpan({ name: 'test-b' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); - await startSpan({ name: 'inner1b' }, async () => { - hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); + await startSpan({ name: 'inner1b' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); - await startSpan({ name: 'inner2b' }, async () => { - hub.addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + await startSpan({ name: 'inner2b' }, async () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + }); }); }); diff --git a/packages/node-experimental/test/integration/scope.test.ts b/packages/node-experimental/test/integration/scope.test.ts index 78579701e47e..57be6126bcae 100644 --- a/packages/node-experimental/test/integration/scope.test.ts +++ b/packages/node-experimental/test/integration/scope.test.ts @@ -2,7 +2,7 @@ import { getCurrentHub, getSpanScope } from '@sentry/opentelemetry'; import * as Sentry from '../../src/'; import type { NodeExperimentalClient } from '../../src/types'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; describe('Integration | Scope', () => { afterEach(() => { @@ -101,6 +101,7 @@ describe('Integration | Scope', () => { tag1: 'val1', tag2: 'val2', tag3: 'val3', + tag4: 'val4', }, timestamp: expect.any(Number), transaction: 'outer', @@ -226,4 +227,459 @@ describe('Integration | Scope', () => { } }); }); + + describe('global scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const globalScope = Sentry.getGlobalScope(); + expect(globalScope).toBeDefined(); + expect(globalScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(globalScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getGlobalScope()).toBe(globalScope); + + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the global scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + expect(globalScope.getClient()).toBeDefined(); + expect(Sentry.getGlobalScope()).toBe(globalScope); + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const globalScope = Sentry.getGlobalScope(); + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('isolation scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const isolationScope = Sentry.getIsolationScope(); + expect(isolationScope).toBeDefined(); + expect(isolationScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(isolationScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getIsolationScope()).toBe(isolationScope); + + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the isolation scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is only attached to global scope by default + expect(isolationScope.getClient()).toBeUndefined(); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withIsolationScope works', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + initialIsolationScope.setTag('tag2', 'val2'); + + const initialCurrentScope = Sentry.getCurrentScope(); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.withIsolationScope(newIsolationScope => { + expect(Sentry.getCurrentScope()).not.toBe(initialCurrentScope); + expect(Sentry.getIsolationScope()).toBe(newIsolationScope); + expect(newIsolationScope).not.toBe(initialIsolationScope); + + // Data is forked off original isolation scope + expect(newIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag2', 'val2'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('current scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const currentScope = Sentry.getCurrentScope(); + expect(currentScope).toBeDefined(); + expect(currentScope).toBeInstanceOf(Sentry.Scope); + // No client attached + expect(currentScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getCurrentScope()).toBe(currentScope); + + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the current scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is only attached to global scope by default + expect(currentScope.getClient()).toBeUndefined(); + // current scope remains intact + expect(Sentry.getCurrentScope()).toBe(currentScope); + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const currentScope = Sentry.getCurrentScope(); + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withScope works', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + initialCurrentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + + Sentry.withScope(newCurrentScope => { + newCurrentScope.setTag('tag4', 'val4'); + }); + + Sentry.withScope(newCurrentScope => { + expect(Sentry.getCurrentScope()).toBe(newCurrentScope); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(newCurrentScope).not.toBe(initialCurrentScope); + + // Data is forked off original isolation scope + expect(newCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newCurrentScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag2', 'val2'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag3', 'val3'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('automatically forks with OTEL context', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.startSpan({ name: 'outer' }, () => { + Sentry.getCurrentScope().setTag('tag2', 'val2'); + + Sentry.startSpan({ name: 'inner 1' }, () => { + Sentry.getCurrentScope().setTag('tag3', 'val3'); + + Sentry.startSpan({ name: 'inner 2' }, () => { + Sentry.getCurrentScope().setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('scope merging', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('merges data from global, isolation and current scope', async () => { + const beforeSend = jest.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + Sentry.getGlobalScope().setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(isolationScope => { + Sentry.getCurrentScope().setTag('tag2', 'val2a'); + isolationScope.setTag('tag2', 'val2b'); + isolationScope.setTag('tag3', 'val3'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + + Sentry.captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); }); diff --git a/packages/node-experimental/test/integration/transactions.test.ts b/packages/node-experimental/test/integration/transactions.test.ts index 107377c9a633..1a09b3234d92 100644 --- a/packages/node-experimental/test/integration/transactions.test.ts +++ b/packages/node-experimental/test/integration/transactions.test.ts @@ -8,6 +8,7 @@ import { logger } from '@sentry/utils'; import * as Sentry from '../../src'; import { startSpan } from '../../src'; import type { Http, NodeFetch } from '../../src/integrations'; +import { getIsolationScope } from '../../src/sdk/api'; import type { NodeExperimentalClient } from '../../src/types'; import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; @@ -22,8 +23,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); Sentry.setTag('outer.tag', 'test value'); @@ -128,6 +128,7 @@ describe('Integration | Transactions', () => { start_timestamp: expect.any(Number), tags: { 'outer.tag': 'test value', + 'test.tag': 'test value', }, timestamp: expect.any(Number), transaction: 'test name', @@ -176,49 +177,52 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const hub = getCurrentHub(); - const client = hub.getClient() as NodeExperimentalClient; + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); - Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + Sentry.withIsolationScope(() => { + Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); - span.setAttributes({ - 'test.outer': 'test value', - }); + span.setAttributes({ + 'test.outer': 'test value', + }); - const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - Sentry.setTag('test.tag', 'test value'); + Sentry.setTag('test.tag', 'test value'); - Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); - innerSpan.setAttributes({ - 'test.inner': 'test value', + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); }); }); }); - Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + Sentry.withIsolationScope(() => { + Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); - span.setAttributes({ - 'test.outer': 'test value b', - }); + span.setAttributes({ + 'test.outer': 'test value b', + }); - const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); - subSpan.end(); + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); - Sentry.setTag('test.tag', 'test value b'); + Sentry.setTag('test.tag', 'test value b'); - Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { - Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); - innerSpan.setAttributes({ - 'test.inner': 'test value b', + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); }); }); }); @@ -257,7 +261,7 @@ describe('Integration | Transactions', () => { }), ], start_timestamp: expect.any(Number), - tags: {}, + tags: { 'test.tag': 'test value' }, timestamp: expect.any(Number), transaction: 'test name', transaction_info: { source: 'task' }, @@ -299,7 +303,7 @@ describe('Integration | Transactions', () => { }), ], start_timestamp: expect.any(Number), - tags: {}, + tags: { 'test.tag': 'test value b' }, timestamp: expect.any(Number), transaction: 'test name b', transaction_info: { source: 'custom' }, diff --git a/packages/node-experimental/test/sdk/scope.test.ts b/packages/node-experimental/test/sdk/scope.test.ts new file mode 100644 index 000000000000..a0e179373626 --- /dev/null +++ b/packages/node-experimental/test/sdk/scope.test.ts @@ -0,0 +1,416 @@ +import type { Attachment, Breadcrumb, Client, EventProcessor } from '@sentry/types'; +import { Scope, getIsolationScope } from '../../src'; +import { getGlobalScope, mergeArray, mergeData, mergePropKeep, mergePropOverwrite } from '../../src/sdk/scope'; +import type { ScopeData } from '../../src/sdk/types'; +import { mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; + +describe('Unit | Scope', () => { + it('allows to create & update a scope', () => { + const scope = new Scope(); + + expect(scope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: {}, + extra: {}, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + + scope.update({ + tags: { foo: 'bar' }, + extra: { foo2: 'bar2' }, + }); + + expect(scope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: { + foo: 'bar', + }, + extra: { + foo2: 'bar2', + }, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + }); + + it('allows to clone a scope', () => { + const scope = new Scope(); + + scope.update({ + tags: { foo: 'bar' }, + extra: { foo2: 'bar2' }, + }); + + const newScope = scope.clone(); + expect(newScope).toBeInstanceOf(Scope); + expect(newScope).not.toBe(scope); + + expect(newScope.getScopeData()).toEqual({ + breadcrumbs: [], + attachments: [], + contexts: {}, + tags: { + foo: 'bar', + }, + extra: { + foo2: 'bar2', + }, + user: {}, + level: undefined, + fingerprint: [], + eventProcessors: [], + propagationContext: { + traceId: expect.any(String), + spanId: expect.any(String), + }, + sdkProcessingMetadata: {}, + }); + }); + + it('allows to set & get a client', () => { + const scope = new Scope(); + expect(scope.getClient()).toBeUndefined(); + const client = {} as Client; + scope.setClient(client); + expect(scope.getClient()).toBe(client); + }); + + it('gets the correct isolationScope in _getIsolationScope', () => { + resetGlobals(); + + const scope = new Scope(); + const globalIsolationScope = getIsolationScope(); + + expect(scope['_getIsolationScope']()).toBe(globalIsolationScope); + + const customIsolationScope = new Scope(); + scope.isolationScope = customIsolationScope; + + expect(scope['_getIsolationScope']()).toBe(customIsolationScope); + }); + + describe('mergeArray', () => { + it.each([ + [[], [], undefined], + [undefined, [], undefined], + [['a'], [], ['a']], + [['a'], ['b', 'c'], ['a', 'b', 'c']], + [[], ['b', 'c'], ['b', 'c']], + [undefined, ['b', 'c'], ['b', 'c']], + ])('works with %s and %s', (a, b, expected) => { + const data = { fingerprint: a }; + mergeArray(data, 'fingerprint', b); + expect(data.fingerprint).toEqual(expected); + }); + + it('does not mutate the original array if no changes are made', () => { + const fingerprint = ['a']; + const data = { fingerprint }; + mergeArray(data, 'fingerprint', []); + expect(data.fingerprint).toBe(fingerprint); + }); + }); + + describe('mergePropKeep', () => { + it.each([ + [{}, {}, {}], + [{ a: 'aa' }, {}, { a: 'aa' }], + [{ a: 'aa' }, { b: 'bb' }, { a: 'aa', b: 'bb' }], + // Does not overwrite existing keys + [{ a: 'aa' }, { b: 'bb', a: 'cc' }, { a: 'aa', b: 'bb' }], + ])('works with %s and %s', (a, b, expected) => { + const data = { tags: a } as unknown as ScopeData; + mergePropKeep(data, 'tags', b); + expect(data.tags).toEqual(expected); + }); + + it('does not deep merge', () => { + const data = { + contexts: { + app: { app_version: 'v1' }, + culture: { display_name: 'name1' }, + }, + } as unknown as ScopeData; + mergePropKeep(data, 'contexts', { + os: { name: 'os1' }, + app: { app_name: 'name1' }, + }); + expect(data.contexts).toEqual({ + os: { name: 'os1' }, + culture: { display_name: 'name1' }, + app: { app_version: 'v1' }, + }); + }); + + it('does not mutate the original object if no changes are made', () => { + const tags = { a: 'aa' }; + const data = { tags } as unknown as ScopeData; + mergePropKeep(data, 'tags', {}); + expect(data.tags).toBe(tags); + }); + }); + + describe('mergePropOverwrite', () => { + it.each([ + [{}, {}, {}], + [{ a: 'aa' }, {}, { a: 'aa' }], + [{ a: 'aa' }, { b: 'bb' }, { a: 'aa', b: 'bb' }], + // overwrites existing keys + [{ a: 'aa' }, { b: 'bb', a: 'cc' }, { a: 'cc', b: 'bb' }], + ])('works with %s and %s', (a, b, expected) => { + const data = { tags: a } as unknown as ScopeData; + mergePropOverwrite(data, 'tags', b); + expect(data.tags).toEqual(expected); + }); + + it('does not deep merge', () => { + const data = { + contexts: { + app: { app_version: 'v1' }, + culture: { display_name: 'name1' }, + }, + } as unknown as ScopeData; + mergePropOverwrite(data, 'contexts', { + os: { name: 'os1' }, + app: { app_name: 'name1' }, + }); + expect(data.contexts).toEqual({ + os: { name: 'os1' }, + culture: { display_name: 'name1' }, + app: { app_name: 'name1' }, + }); + }); + + it('does not mutate the original object if no changes are made', () => { + const tags = { a: 'aa' }; + const data = { tags } as unknown as ScopeData; + mergePropOverwrite(data, 'tags', {}); + expect(data.tags).toBe(tags); + }); + }); + + describe('mergeData', () => { + it('works with empty data', () => { + const data1: ScopeData = { + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }; + const data2: ScopeData = { + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }; + mergeData(data1, data2); + expect(data1).toEqual({ + eventProcessors: [], + breadcrumbs: [], + user: {}, + tags: {}, + extra: {}, + contexts: {}, + attachments: [], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: {}, + fingerprint: [], + }); + }); + + it('merges data correctly', () => { + const attachment1 = { filename: '1' } as Attachment; + const attachment2 = { filename: '2' } as Attachment; + const attachment3 = { filename: '3' } as Attachment; + + const breadcrumb1 = { message: '1' } as Breadcrumb; + const breadcrumb2 = { message: '2' } as Breadcrumb; + const breadcrumb3 = { message: '3' } as Breadcrumb; + + const eventProcessor1 = ((a: unknown) => null) as EventProcessor; + const eventProcessor2 = ((b: unknown) => null) as EventProcessor; + const eventProcessor3 = ((c: unknown) => null) as EventProcessor; + + const data1: ScopeData = { + eventProcessors: [eventProcessor1], + breadcrumbs: [breadcrumb1], + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + attachments: [attachment1], + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: { aa: 'aa', bb: 'aa' }, + fingerprint: ['aa', 'bb'], + }; + const data2: ScopeData = { + eventProcessors: [eventProcessor2, eventProcessor3], + breadcrumbs: [breadcrumb2, breadcrumb3], + user: { id: '2', name: 'foo' }, + tags: { tag2: 'bb', tag3: 'bb' }, + extra: { extra2: 'bb', extra3: 'bb' }, + contexts: { os: { name: 'os2' } }, + attachments: [attachment2, attachment3], + propagationContext: { spanId: '2', traceId: '2' }, + sdkProcessingMetadata: { bb: 'bb', cc: 'bb' }, + fingerprint: ['cc'], + }; + mergeData(data1, data2); + expect(data1).toEqual({ + eventProcessors: [eventProcessor1, eventProcessor2, eventProcessor3], + breadcrumbs: [breadcrumb1, breadcrumb2, breadcrumb3], + user: { id: '2', name: 'foo', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'bb', tag3: 'bb' }, + extra: { extra1: 'aa', extra2: 'bb', extra3: 'bb' }, + contexts: { os: { name: 'os2' }, culture: { display_name: 'name1' } }, + attachments: [attachment1, attachment2, attachment3], + // This is not merged, we always use the one from the scope here anyhow + propagationContext: { spanId: '1', traceId: '1' }, + sdkProcessingMetadata: { aa: 'aa', bb: 'bb', cc: 'bb' }, + fingerprint: ['aa', 'bb', 'cc'], + }); + }); + }); + + describe('applyToEvent', () => { + it('works without any data', async () => { + mockSdkInit(); + + const scope = new Scope(); + + const event = await scope.applyToEvent({ message: 'foo' }); + + expect(event).toEqual({ + message: 'foo', + sdkProcessingMetadata: { + propagationContext: { + spanId: expect.any(String), + traceId: expect.any(String), + }, + }, + }); + }); + + it('merges scope data', async () => { + mockSdkInit(); + + const breadcrumb1 = { message: '1', timestamp: 111 } as Breadcrumb; + const breadcrumb2 = { message: '2', timestamp: 222 } as Breadcrumb; + const breadcrumb3 = { message: '3', timestamp: 123 } as Breadcrumb; + const breadcrumb4 = { message: '4', timestamp: 333 } as Breadcrumb; + + const eventProcessor1 = jest.fn((a: unknown) => a) as EventProcessor; + const eventProcessor2 = jest.fn((b: unknown) => b) as EventProcessor; + const eventProcessor3 = jest.fn((c: unknown) => c) as EventProcessor; + + const scope = new Scope(); + scope.update({ + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + propagationContext: { spanId: '1', traceId: '1' }, + fingerprint: ['aa'], + }); + scope.addBreadcrumb(breadcrumb1); + scope.addEventProcessor(eventProcessor1); + + const globalScope = getGlobalScope(); + const isolationScope = getIsolationScope(); + + globalScope.addBreadcrumb(breadcrumb2); + globalScope.addEventProcessor(eventProcessor2); + globalScope.setSDKProcessingMetadata({ aa: 'aa' }); + + isolationScope.addBreadcrumb(breadcrumb3); + isolationScope.addEventProcessor(eventProcessor3); + globalScope.setSDKProcessingMetadata({ bb: 'bb' }); + + const event = await scope.applyToEvent({ + message: 'foo', + breadcrumbs: [breadcrumb4], + fingerprint: ['dd'], + }); + + expect(event).toEqual({ + message: 'foo', + user: { id: '1', email: 'test@example.com' }, + tags: { tag1: 'aa', tag2: 'aa' }, + extra: { extra1: 'aa', extra2: 'aa' }, + contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, + fingerprint: ['dd', 'aa'], + breadcrumbs: [breadcrumb4, breadcrumb2, breadcrumb3, breadcrumb1], + sdkProcessingMetadata: { + aa: 'aa', + bb: 'bb', + propagationContext: { + spanId: '1', + traceId: '1', + }, + }, + }); + }); + }); + + describe('getAttachments', () => { + it('works without any data', async () => { + mockSdkInit(); + + const scope = new Scope(); + + const actual = scope.getAttachments(); + expect(actual).toEqual([]); + }); + + it('merges attachments data', async () => { + mockSdkInit(); + + const attachment1 = { filename: '1' } as Attachment; + const attachment2 = { filename: '2' } as Attachment; + const attachment3 = { filename: '3' } as Attachment; + + const scope = new Scope(); + scope.addAttachment(attachment1); + + const globalScope = getGlobalScope(); + const isolationScope = getIsolationScope(); + + globalScope.addAttachment(attachment2); + isolationScope.addAttachment(attachment3); + + const actual = scope.getAttachments(); + expect(actual).toEqual([attachment2, attachment3, attachment1]); + }); + }); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 950fe7bac197..06524bcd0c0a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -79,7 +79,7 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; -export { enableAnrDetection } from './anr'; +export { enableAnrDetection, isAnrChildProcess } from './anr'; import { Integrations as CoreIntegrations } from '@sentry/core'; diff --git a/packages/opentelemetry/src/custom/scope.ts b/packages/opentelemetry/src/custom/scope.ts index e206ba8d8096..e08f8484d87d 100644 --- a/packages/opentelemetry/src/custom/scope.ts +++ b/packages/opentelemetry/src/custom/scope.ts @@ -87,6 +87,11 @@ export class OpenTelemetryScope extends Scope { return this; } + return this._addBreadcrumb(breadcrumb, maxBreadcrumbs); + } + + /** Add a breadcrumb to this scope. */ + protected _addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { return super.addBreadcrumb(breadcrumb, maxBreadcrumbs); } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 3ac617aada9d..f379b4216da5 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -6,9 +6,16 @@ export type { OpenTelemetryClient } from './types'; export { wrapClientClass } from './custom/client'; export { getSpanKind } from './utils/getSpanKind'; -export { getSpanHub, getSpanMetadata, getSpanParent, getSpanScope, setSpanMetadata } from './utils/spanData'; +export { + getSpanHub, + getSpanMetadata, + getSpanParent, + getSpanScope, + setSpanMetadata, + getSpanFinishScope, +} from './utils/spanData'; -export { getPropagationContextFromContext, setPropagationContextOnContext } from './utils/contextData'; +export { getPropagationContextFromContext, setPropagationContextOnContext, setHubOnContext } from './utils/contextData'; export { spanHasAttributes, @@ -25,6 +32,7 @@ export { getActiveSpan, getRootSpan } from './utils/getActiveSpan'; export { startSpan, startInactiveSpan } from './trace'; export { getCurrentHub, setupGlobalHub, getClient } from './custom/hub'; +export { OpenTelemetryScope } from './custom/scope'; export { addTracingExtensions } from './custom/hubextensions'; export { setupEventContextTrace } from './setupEventContextTrace'; diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 95ad13997fb9..c15bd4483a9b 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -20,7 +20,7 @@ import type { SpanNode } from './utils/groupSpansWithParents'; import { groupSpansWithParents } from './utils/groupSpansWithParents'; import { mapStatus } from './utils/mapStatus'; import { parseSpanDescription } from './utils/parseSpanDescription'; -import { getSpanHub, getSpanMetadata, getSpanScope } from './utils/spanData'; +import { getSpanFinishScope, getSpanHub, getSpanMetadata, getSpanScope } from './utils/spanData'; type SpanNodeCompleted = SpanNode & { span: ReadableSpan }; @@ -111,12 +111,9 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { }); // Now finish the transaction, which will send it together with all the spans - // We make sure to use the current span as the activeSpan for this transaction - const scope = getSpanScope(span) as OpenTelemetryScope | undefined; - const forkedScope = scope ? scope.clone() : new OpenTelemetryScope(); - forkedScope.activeSpan = span as unknown as Span; - - transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), forkedScope); + // We make sure to use the finish scope + const scope = getSpanFinishScope(span); + transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), scope); }); return Array.from(remaining) diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index a2d7de69fb00..dd5e6de53cbd 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -5,13 +5,14 @@ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { logger } from '@sentry/utils'; import { getCurrentHub } from './custom/hub'; +import { OpenTelemetryScope } from './custom/scope'; import { DEBUG_BUILD } from './debug-build'; import { SentrySpanExporter } from './spanExporter'; import { maybeCaptureExceptionForTimedEvent } from './utils/captureExceptionForTimedEvent'; import { getHubFromContext } from './utils/contextData'; -import { getSpanHub, setSpanHub, setSpanParent, setSpanScope } from './utils/spanData'; +import { getSpanHub, setSpanFinishScope, setSpanHub, setSpanParent, setSpanScope } from './utils/spanData'; -function onSpanStart(span: Span, parentContext: Context): void { +function onSpanStart(span: Span, parentContext: Context, ScopeClass: typeof OpenTelemetryScope): void { // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK const parentSpan = trace.getSpan(parentContext); const hub = getHubFromContext(parentContext); @@ -30,8 +31,14 @@ function onSpanStart(span: Span, parentContext: Context): void { // We need the scope at time of span creation in order to apply it to the event when the span is finished if (actualHub) { + const scope = actualHub.getScope(); setSpanScope(span, actualHub.getScope()); setSpanHub(span, actualHub); + + // Use this scope for finishing the span + const finishScope = (scope as OpenTelemetryScope).clone(); + finishScope.activeSpan = span; + setSpanFinishScope(span, finishScope); } } @@ -48,15 +55,19 @@ function onSpanEnd(span: Span): void { * the Sentry SDK. */ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProcessorInterface { - public constructor() { + private _scopeClass: typeof OpenTelemetryScope; + + public constructor(options: { scopeClass?: typeof OpenTelemetryScope } = {}) { super(new SentrySpanExporter()); + + this._scopeClass = options.scopeClass || OpenTelemetryScope; } /** * @inheritDoc */ public onStart(span: Span, parentContext: Context): void { - onSpanStart(span, parentContext); + onSpanStart(span, parentContext, this._scopeClass); DEBUG_BUILD && logger.log(`[Tracing] Starting span "${span.name}" (${span.spanContext().spanId})`); diff --git a/packages/opentelemetry/src/utils/spanData.ts b/packages/opentelemetry/src/utils/spanData.ts index e8fe58506866..18d9661a6488 100644 --- a/packages/opentelemetry/src/utils/spanData.ts +++ b/packages/opentelemetry/src/utils/spanData.ts @@ -7,6 +7,7 @@ import type { AbstractSpan } from '../types'; // This way we can enhance the data that an OTEL Span natively gives us // and since we are using weakmaps, we do not need to clean up after ourselves const SpanScope = new WeakMap(); +const SpanFinishScope = new WeakMap(); const SpanHub = new WeakMap(); const SpanParent = new WeakMap(); const SpanMetadata = new WeakMap>(); @@ -50,3 +51,13 @@ export function setSpanMetadata(span: AbstractSpan, metadata: Partial | undefined { return SpanMetadata.get(span); } + +/** Set the Sentry scope to be used for finishing a given OTEL span. */ +export function setSpanFinishScope(span: AbstractSpan, scope: Scope): void { + SpanFinishScope.set(span, scope); +} + +/** Get the Sentry scope to use for finishing an OTEL span. */ +export function getSpanFinishScope(span: AbstractSpan): Scope | undefined { + return SpanFinishScope.get(span); +}