diff --git a/.size-limit.js b/.size-limit.js index 6128fee06b3d..bbe29ceded7c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -54,7 +54,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '70 KB', + limit: '70.1 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); diff --git a/dev-packages/opentelemetry-v2-tests/.eslintrc.js b/dev-packages/opentelemetry-v2-tests/.eslintrc.js new file mode 100644 index 000000000000..fdb9952bae52 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], +}; diff --git a/dev-packages/opentelemetry-v2-tests/README.md b/dev-packages/opentelemetry-v2-tests/README.md new file mode 100644 index 000000000000..e5ae255c830c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/README.md @@ -0,0 +1,19 @@ +# OpenTelemetry v2 Tests + +This package contains tests for `@sentry/opentelemetry` when using OpenTelemetry v2. It is used to ensure compatibility with OpenTelemetry v2 APIs. + +## Running Tests + +To run the tests: + +```bash +yarn test +``` + +## Structure + +The tests are copied from `packages/opentelemetry/test` with adjusted imports to work with OpenTelemetry v2 dependencies. The main differences are: + +1. Uses OpenTelemetry v2 as devDependencies +2. Imports from `@sentry/opentelemetry` instead of relative paths +3. Tests the same functionality but with v2 APIs diff --git a/dev-packages/opentelemetry-v2-tests/package.json b/dev-packages/opentelemetry-v2-tests/package.json new file mode 100644 index 000000000000..494c85fd666e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sentry-internal/opentelemetry-v2-tests", + "version": "1.0.0", + "private": true, + "description": "Tests for @sentry/opentelemetry with OpenTelemetry v2", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest --watch" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.200.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts b/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts new file mode 100644 index 000000000000..0df183362633 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts @@ -0,0 +1,442 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { Scope } from '@sentry/core'; +import { + getCurrentScope, + getIsolationScope, + Scope as ScopeClass, + setAsyncContextStrategy, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterAll, afterEach, beforeEach, describe, expect, it, test } from 'vitest'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../../../packages/opentelemetry/src/asyncContextStrategy'; +import { setupOtel } from './helpers/initOtel'; +import { cleanupOtel } from './helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; + +describe('asyncContextStrategy', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + + const options = getDefaultTestClientOptions(); + const client = new TestClient(options); + [provider] = setupOtel(client); + setOpenTelemetryContextAsyncContextStrategy(); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + afterAll(() => { + // clear the strategy + setAsyncContextStrategy(undefined); + }); + + test('scope inheritance', () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b', 'b'); + isolationScope1.setExtra('bb', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + }); + + test('async scope inheritance', async () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + scope.setExtra(key, value); + } + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + await asyncSetExtra(scope1, 'b', 'b'); + await asyncSetExtra(isolationScope1, 'bb', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + await asyncSetExtra(scope2, 'c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + }); + + test('concurrent scope contexts', () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b', 'b'); + isolationScope1.setExtra('bb', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b2', 'b'); + isolationScope1.setExtra('bb2', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c2', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b2: 'b', + c2: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb2: 'bb', + }); + }); + }); + }); + + test('concurrent async scope contexts', async () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + scope.setExtra(key, value); + } + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + await asyncSetExtra(scope1, 'b', 'b'); + await asyncSetExtra(isolationScope1, 'bb', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + await asyncSetExtra(scope2, 'c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b2', 'b'); + isolationScope1.setExtra('bb2', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c2', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b2: 'b', + c2: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb2: 'bb', + }); + }); + }); + }); + + describe('withScope()', () => { + it('will make the passed scope the active scope within the callback', () => + new Promise(done => { + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + done(); + }); + })); + + it('will pass a scope that is different from the current active isolation scope', () => + new Promise(done => { + withScope(scope => { + expect(getIsolationScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the scope when not passing any scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the scope when passing undefined', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(undefined, scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in scope as active scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new ScopeClass(); + + withScope(customScope, scope => { + expect(getCurrentScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); + }); + + describe('withIsolationScope()', () => { + it('will make the passed isolation scope the active isolation scope within the callback', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + done(); + }); + })); + + it('will pass an isolation scope that is different from the current active scope', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getCurrentScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the isolation scope when not passing any isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the isolation scope when passing undefined', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(undefined, scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in isolation scope as active isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new ScopeClass(); + + withIsolationScope(customScope, scope => { + expect(getIsolationScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts b/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts new file mode 100644 index 000000000000..b39f45d4919e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts @@ -0,0 +1,19 @@ +import { ProxyTracer } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('OpenTelemetryClient', () => { + it('exposes a tracer', () => { + const options = getDefaultTestClientOptions(); + const client = new TestClient(options); + + const tracer = client.tracer; + expect(tracer).toBeDefined(); + expect(tracer).toBeInstanceOf(ProxyTracer); + + // Ensure we always get the same tracer instance + const tracer2 = client.tracer; + + expect(tracer2).toBe(tracer); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts new file mode 100644 index 000000000000..f67cc361d73e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts @@ -0,0 +1,48 @@ +import type { ClientOptions, Event, Options, SeverityLevel } from '@sentry/core'; +import { Client, createTransport, getCurrentScope, resolvedSyncPromise } from '@sentry/core'; +import { wrapClientClass } from '../../../../packages/opentelemetry/src/custom/client'; +import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; + +class BaseTestClient extends Client { + public constructor(options: ClientOptions) { + super(options); + } + + public eventFromException(exception: any): PromiseLike { + return resolvedSyncPromise({ + exception: { + values: [ + { + type: exception.name, + value: exception.message, + }, + ], + }, + }); + } + + public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { + return resolvedSyncPromise({ message, level }); + } +} + +export const TestClient = wrapClientClass(BaseTestClient); + +export type TestClientInterface = Client & OpenTelemetryClient; + +export function init(options: Partial = {}): void { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ...options })); + + // The client is on the current scope, from where it generally is inherited + getCurrentScope().setClient(client); + client.init(); +} + +export function getDefaultTestClientOptions(options: Partial = {}): ClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + ...options, + } as ClientOptions; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts new file mode 100644 index 000000000000..50d35295ba60 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts @@ -0,0 +1,79 @@ +import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE, +} from '@opentelemetry/semantic-conventions'; +import { getClient, logger, SDK_VERSION } from '@sentry/core'; +import { wrapContextManagerClass } from '../../../../packages/opentelemetry/src/contextManager'; +import { DEBUG_BUILD } from '../../../../packages/opentelemetry/src/debug-build'; +import { SentryPropagator } from '../../../../packages/opentelemetry/src/propagator'; +import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; +import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { enhanceDscWithOpenTelemetryRootSpanName } from '../../../../packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName'; +import type { TestClientInterface } from './TestClient'; + +/** + * Initialize OpenTelemetry for Node. + */ +export function initOtel(): void { + const client = getClient(); + + if (!client) { + DEBUG_BUILD && + logger.warn( + 'No client available, skipping OpenTelemetry setup. This probably means that `Sentry.init()` was not called before `initOtel()`.', + ); + return; + } + + if (client.getOptions().debug) { + const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { + get(target, prop, receiver) { + const actualProp = prop === 'verbose' ? 'debug' : prop; + return Reflect.get(target, actualProp, receiver); + }, + }); + + diag.setLogger(otelLogger, DiagLogLevel.DEBUG); + } + + setupEventContextTrace(client); + enhanceDscWithOpenTelemetryRootSpanName(client); + + const [provider, spanProcessor] = setupOtel(client); + client.traceProvider = provider; + client.spanProcessor = spanProcessor; +} + +/** Just exported for tests. */ +export function setupOtel(client: TestClientInterface): [BasicTracerProvider, SentrySpanProcessor] { + const spanProcessor = new SentrySpanProcessor(); + // Create and configure NodeTracerProvider + const provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'opentelemetry-test', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), + forceFlushTimeoutMillis: 500, + spanProcessors: [spanProcessor], + }); + + // We use a custom context manager to keep context in sync with sentry scope + const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); + + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + context.setGlobalContextManager(new SentryContextManager()); + + return [provider, spanProcessor]; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts new file mode 100644 index 000000000000..3146551e3da7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts @@ -0,0 +1,12 @@ +import type { Span } from '@opentelemetry/api'; +import { INVALID_TRACEID, INVALID_SPANID, type SpanContext } from '@opentelemetry/api'; + +export const isSpan = (value: unknown): value is Span => { + return ( + typeof value === 'object' && + value !== null && + 'spanContext' in value && + (value.spanContext as () => SpanContext)().traceId !== INVALID_TRACEID && + (value.spanContext as () => SpanContext)().spanId !== INVALID_SPANID + ); +}; diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts new file mode 100644 index 000000000000..eb112d017a1c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts @@ -0,0 +1,81 @@ +import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { ClientOptions, Options } from '@sentry/core'; +import { flush, getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../../../../packages/opentelemetry/src/asyncContextStrategy'; +import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; +import { clearOpenTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; +import { initOtel } from './initOtel'; +import { init as initTestClient } from './TestClient'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +/** + * Initialize Sentry for Node. + */ +function init(options: Partial | undefined = {}): void { + setOpenTelemetryContextAsyncContextStrategy(); + initTestClient(options); + initOtel(); +} + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + delete (global as any).__SENTRY__; +} + +export function mockSdkInit(options?: Partial) { + resetGlobals(); + + init({ dsn: PUBLIC_DSN, ...options }); +} + +export async function cleanupOtel(_provider?: BasicTracerProvider): Promise { + clearOpenTelemetrySetupCheck(); + + const provider = getProvider(_provider); + + if (provider) { + await provider.forceFlush(); + await provider.shutdown(); + } + + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); + + await flush(); +} + +export function getSpanProcessor(): SentrySpanProcessor | undefined { + const client = getClient(); + if (!client) { + return undefined; + } + + const spanProcessor = client.spanProcessor; + if (spanProcessor instanceof SentrySpanProcessor) { + return spanProcessor; + } + + return undefined; +} + +export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { + let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); + + if (provider instanceof ProxyTracerProvider) { + provider = provider.getDelegate(); + } + + if (!(provider instanceof BasicTracerProvider)) { + return undefined; + } + + return provider; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts new file mode 100644 index 000000000000..800c2dbbeba1 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts @@ -0,0 +1,357 @@ +import { addBreadcrumb, captureException, getClient, withIsolationScope, withScope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | breadcrumbs', () => { + const beforeSendTransaction = vi.fn(() => null); + + afterEach(async () => { + await cleanupOtel(); + }); + + describe('without tracing', () => { + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + + const error = new Error('test'); + captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles parallel isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + addBreadcrumb({ timestamp: 123456, message: 'test0' }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test2' }); + captureException(error); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test3' }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test0', timestamp: 123456 }, + { message: 'test2', timestamp: 123456 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs for the current isolation scope only', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + withIsolationScope(() => { + startSpan({ name: 'test1' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); + }); + }); + + withIsolationScope(() => { + startSpan({ name: 'test2' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test2-a', timestamp: 123456 }, + { message: 'test2-b', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('ignores scopes inside of root span', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(2); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles deep nesting of scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test3' }); + + startSpan({ name: 'inner3' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test4' }); + + captureException(error); + + startSpan({ name: 'inner4' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test5' }); + }); + + addBreadcrumb({ timestamp: 123457, message: 'test6' }); + }); + }); + }); + + addBreadcrumb({ timestamp: 123456, message: 'test99' }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123457 }, + { message: 'test4', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs in async isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + const promise1 = withIsolationScope(() => { + return startSpan({ name: 'test' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + await startSpan({ name: 'inner1' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + captureException(error); + }); + }); + + const promise2 = withIsolationScope(() => { + return startSpan({ name: 'test-b' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + + await startSpan({ name: 'inner1' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + }); + }); + }); + + await Promise.all([promise1, promise2]); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(6); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts new file mode 100644 index 000000000000..3e237b749d5e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts @@ -0,0 +1,387 @@ +import { + captureException, + getCapturedScopesOnSpan, + getClient, + getCurrentScope, + getIsolationScope, + setTag, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | Scope', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + describe.each([ + ['with tracing', true], + ['without tracing', false], + ])('%s', (_name, tracingEnabled) => { + it('correctly syncs OTEL context & Sentry hub/scope', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ + tracesSampleRate: tracingEnabled ? 1 : 0, + beforeSend, + beforeSendTransaction, + }); + + const client = getClient() as TestClientInterface; + + const rootScope = getCurrentScope(); + + const error = new Error('test error'); + let spanId: string | undefined; + let traceId: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + withScope(scope2b => { + scope2b.setTag('tag3-b', 'val3-b'); + }); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + startSpan({ name: 'outer' }, span => { + expect(getCapturedScopesOnSpan(span).scope).toBe(tracingEnabled ? scope2 : undefined); + + spanId = span.spanContext().spanId; + traceId = span.spanContext().traceId; + + setTag('tag4', 'val4'); + + captureException(error); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + if (spanId) { + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: { + trace: { + span_id: spanId, + trace_id: traceId, + }, + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + } + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + // Note: Scope for transaction is taken at `start` time, not `finish` time + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + }, + span_id: spanId, + status: 'ok', + trace_id: traceId, + origin: 'manual', + }, + }), + spans: [], + start_timestamp: expect.any(Number), + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + timestamp: expect.any(Number), + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + } + }); + + it('isolates parallel scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + const rootScope = getCurrentScope(); + + const error1 = new Error('test error 1'); + const error2 = new Error('test error 2'); + let spanId1: string | undefined; + let spanId2: string | undefined; + let traceId1: string | undefined; + let traceId2: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + const initialIsolationScope = getIsolationScope(); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + expect(getIsolationScope()).toBe(initialIsolationScope); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).toBe(initialIsolationScope); + + spanId1 = span.spanContext().spanId; + traceId1 = span.spanContext().traceId; + + setTag('tag4', 'val4a'); + + captureException(error1); + }); + }); + }); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + expect(getIsolationScope()).toBe(initialIsolationScope); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).toBe(initialIsolationScope); + + spanId2 = span.spanContext().spanId; + traceId2 = span.spanContext().traceId; + + setTag('tag4', 'val4b'); + + captureException(error2); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(2); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId1 + ? { + span_id: spanId1, + trace_id: traceId1, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3a', + tag4: 'val4a', + }, + }), + { + event_id: expect.any(String), + originalException: error1, + syntheticException: expect.any(Error), + }, + ); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId2 + ? { + span_id: spanId2, + trace_id: traceId2, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + + it('isolates parallel isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + const rootScope = getCurrentScope(); + + const error1 = new Error('test error 1'); + const error2 = new Error('test error 2'); + let spanId1: string | undefined; + let spanId2: string | undefined; + let traceId1: string | undefined; + let traceId2: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('isolationTag1', 'val1'); + + withIsolationScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + expect(getIsolationScope()).not.toBe(initialIsolationScope); + getIsolationScope().setTag('isolationTag2', 'val2'); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).not.toBe(initialIsolationScope); + + spanId1 = span.spanContext().spanId; + traceId1 = span.spanContext().traceId; + + setTag('tag4', 'val4a'); + + captureException(error1); + }); + }); + }); + + withIsolationScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + expect(getIsolationScope()).not.toBe(initialIsolationScope); + getIsolationScope().setTag('isolationTag2', 'val2b'); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).not.toBe(initialIsolationScope); + + spanId2 = span.spanContext().spanId; + traceId2 = span.spanContext().traceId; + + setTag('tag4', 'val4b'); + + captureException(error2); + }); + }); + }); + + await client.flush(); + + expect(spanId1).toBeDefined(); + expect(spanId2).toBeDefined(); + expect(traceId1).toBeDefined(); + expect(traceId2).toBeDefined(); + + expect(beforeSend).toHaveBeenCalledTimes(2); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: spanId1, + trace_id: traceId1, + }, + }), + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3a', + tag4: 'val4a', + isolationTag1: 'val1', + isolationTag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error1, + syntheticException: expect.any(Error), + }, + ); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: spanId2, + trace_id: traceId2, + }, + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + isolationTag1: 'val1', + isolationTag2: 'val2b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts new file mode 100644 index 000000000000..fc2702b4e390 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -0,0 +1,676 @@ +import type { SpanContext } from '@opentelemetry/api'; +import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { TraceState } from '@opentelemetry/core'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Event, TransactionEvent } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + logger, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setTag, + startSpanManual, + withIsolationScope, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SENTRY_TRACE_STATE_DSC } from '../../../../packages/opentelemetry/src/constants'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { startInactiveSpan, startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, getProvider, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | Transactions', () => { + afterEach(async () => { + vi.restoreAllMocks(); + vi.useRealTimers(); + await cleanupOtel(); + }); + + it('correctly creates transaction & spans', async () => { + const transactions: TransactionEvent[] = []; + const beforeSendTransaction = vi.fn(event => { + transactions.push(event); + return null; + }); + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction, + release: '8.0.0', + }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + setTag('outer.tag', 'test value'); + + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + }, + }, + span => { + addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + setTag('test.tag', 'test value'); + + startSpan({ name: 'inner span 2' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + + await client.flush(); + + expect(transactions).toHaveLength(1); + const transaction = transactions[0]!; + + expect(transaction.breadcrumbs).toEqual([ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ]); + + expect(transaction.contexts?.otel).toEqual({ + resource: { + 'service.name': 'opentelemetry-test', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }); + + expect(transaction.contexts?.trace).toEqual({ + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'sentry.sample_rate': 1, + 'test.outer': 'test value', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }); + + expect(transaction.sdkProcessingMetadata?.sampleRate).toEqual(1); + expect(transaction.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ + environment: 'production', + public_key: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'test name', + release: '8.0.0', + sample_rand: expect.any(String), + }); + + expect(transaction.environment).toEqual('production'); + expect(transaction.event_id).toEqual(expect.any(String)); + expect(transaction.start_timestamp).toEqual(expect.any(Number)); + expect(transaction.timestamp).toEqual(expect.any(Number)); + expect(transaction.transaction).toEqual('test name'); + + expect(transaction.tags).toEqual({ + 'outer.tag': 'test value', + 'test.tag': 'test value', + }); + expect(transaction.transaction_info).toEqual({ source: 'task' }); + expect(transaction.type).toEqual('transaction'); + + expect(transaction.spans).toHaveLength(2); + const spans = transaction.spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + { + data: { + 'test.inner': 'test value', + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + }); + + it('correctly creates concurrent transaction & spans', async () => { + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + + withIsolationScope(() => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + span => { + addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + setTag('test.tag', 'test value'); + + startSpan({ name: 'inner span 2' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + }); + + withIsolationScope(() => { + startSpan({ op: 'test op b', name: 'test name b' }, span => { + addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value b', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); + + setTag('test.tag', 'test value b'); + + startSpan({ name: 'inner span 2b' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); + }); + }); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'test.outer': 'test value', + 'sentry.sample_rate': 1, + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value', + }, + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2b', timestamp: 123456 }, + { message: 'test breadcrumb 3b', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op b', + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'test.outer': 'test value b', + 'sentry.sample_rate': 1, + }, + op: 'test op b', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value b', + }, + timestamp: expect.any(Number), + transaction: 'test name b', + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + }); + + it('correctly creates transaction & spans with a trace header data', async () => { + const beforeSendTransaction = vi.fn(() => null); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const traceState = makeTraceState({ + dsc: undefined, + sampled: true, + }); + + const spanContext: SpanContext = { + traceId, + spanId: parentSpanId, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState, + }; + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + () => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + startSpan({ name: 'inner span 2' }, () => {}); + }, + ); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenLastCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: parentSpanId, + status: 'ok', + trace_id: traceId, + origin: 'auto.test', + }, + }), + // spans are circular (they have a reference to the transaction), which leads to jest choking on this + // instead we compare them in detail below + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + // Checking the spans here, as they are circular to the transaction... + const runArgs = beforeSendTransaction.mock.calls[0] as unknown as [TransactionEvent, unknown]; + const spans = runArgs[0].spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + ]); + }); + + it('cleans up spans that are not flushed for over 5 mins', async () => { + const beforeSendTransaction = vi.fn(() => null); + + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + void startSpan({ name: 'test name' }, async () => { + startInactiveSpan({ name: 'inner span 1' }).end(); + startInactiveSpan({ name: 'inner span 2' }).end(); + + // Pretend this is pending for 10 minutes + await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000)); + }); + + // Child-spans have been added to the exporter, but they are pending since they are waiting for their parent + const finishedSpans1 = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans1.push(...bucket.spans); + } + }); + expect(finishedSpans1.length).toBe(2); + expect(beforeSendTransaction).toHaveBeenCalledTimes(0); + + // Now wait for 5 mins + vi.advanceTimersByTime(5 * 60 * 1_000 + 1); + + // Adding another span will trigger the cleanup + startSpan({ name: 'other span' }, () => {}); + + vi.advanceTimersByTime(1); + + // Old spans have been cleared away + const finishedSpans2 = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans2.push(...bucket.spans); + } + }); + expect(finishedSpans2.length).toBe(0); + + // Called once for the 'other span' + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + + expect(logs).toEqual( + expect.arrayContaining([ + 'SpanExporter dropped 2 spans because they were pending for more than 300 seconds.', + 'SpanExporter exported 1 spans, 0 spans are waiting for their parent spans to finish', + ]), + ); + }); + + it('includes child spans that are finished in the same tick but after their parent span', async () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + const transactions: Event[] = []; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + + span.end(); + subSpan2.end(); + }); + + vi.advanceTimersByTime(1); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.spans).toHaveLength(2); + + // No spans are pending + const finishedSpans = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans.push(...bucket.spans); + } + }); + expect(finishedSpans.length).toBe(0); + }); + + it('discards child spans that are finished after their parent span', async () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + const transactions: Event[] = []; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + + span.end(); + + setTimeout(() => { + subSpan2.end(); + }, 1); + }); + + vi.advanceTimersByTime(2); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.spans).toHaveLength(1); + + // subSpan2 is pending (and will eventually be cleaned up) + const finishedSpans: any = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans.push(...bucket.spans); + } + }); + expect(finishedSpans.length).toBe(1); + expect(finishedSpans[0]?.name).toBe('inner span 2'); + }); + + it('uses & inherits DSC on span trace state', async () => { + const transactionEvents: Event[] = []; + const beforeSendTransaction = vi.fn(event => { + transactionEvents.push(event); + return null; + }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const dscString = `sentry-transaction=other-transaction,sentry-environment=other,sentry-release=8.0.0,sentry-public_key=public,sentry-trace_id=${traceId},sentry-sampled=true`; + + const spanContext: SpanContext = { + traceId, + spanId: parentSpanId, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState: new TraceState().set(SENTRY_TRACE_STATE_DSC, dscString), + }; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction, + release: '7.0.0', + }); + + const client = getClient() as TestClientInterface; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + span => { + expect(span.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + + expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + + subSpan.end(); + + startSpan({ name: 'inner span 2' }, subSpan => { + expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + }); + }, + ); + }); + + await client.flush(); + + expect(transactionEvents).toHaveLength(1); + expect(transactionEvents[0]?.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ + environment: 'other', + public_key: 'public', + release: '8.0.0', + sampled: 'true', + trace_id: traceId, + transaction: 'other-transaction', + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts b/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts new file mode 100644 index 000000000000..8e3f85b38250 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts @@ -0,0 +1,670 @@ +import { + context, + defaultTextMapGetter, + defaultTextMapSetter, + propagation, + ROOT_CONTEXT, + trace, + TraceFlags, +} from '@opentelemetry/api'; +import { suppressTracing } from '@opentelemetry/core'; +import { getCurrentScope, withScope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SENTRY_BAGGAGE_HEADER, + SENTRY_SCOPES_CONTEXT_KEY, + SENTRY_TRACE_HEADER, +} from '../../../packages/opentelemetry/src/constants'; +import { SentryPropagator } from '../../../packages/opentelemetry/src/propagator'; +import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; +import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('SentryPropagator', () => { + const propagator = new SentryPropagator(); + let carrier: { [key: string]: unknown }; + + beforeEach(() => { + carrier = {}; + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('returns fields set', () => { + expect(propagator.fields()).toEqual([SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER]); + }); + + describe('inject', () => { + describe('without active local span', () => { + it('uses scope propagation context without DSC if no span is found', () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + parentSpanId: '6e0c63257de34c93', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + }); + }); + + it('uses scope propagation context with DSC if no span is found', () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + parentSpanId: '6e0c63257de34c93', + sampled: true, + sampleRand: Math.random(), + dsc: { + transaction: 'sampled-transaction', + sampled: 'false', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=false', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ].sort(), + ); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + }); + }); + + it('uses propagation data from current scope if no scope & span is found', () => { + const scope = getCurrentScope(); + const traceId = scope.getPropagationContext().traceId; + + const ctx = trace.deleteSpan(ROOT_CONTEXT).deleteValue(SENTRY_SCOPES_CONTEXT_KEY); + propagator.inject(ctx, carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual([ + 'sentry-environment=production', + 'sentry-public_key=abc', + 'sentry-release=1.0.0', + `sentry-trace_id=${traceId}`, + ]); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(traceId); + }); + }); + + describe('with active span', () => { + it.each([ + [ + 'continues a remote trace without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + [ + 'continues a remote trace with dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + traceState: makeTraceState({ + dsc: { + transaction: 'sampled-transaction', + sampled: 'true', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=true', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + [ + 'continues an unsampled remote trace without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + undefined, + ], + [ + 'continues an unsampled remote trace with sampled trace state & without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + sampled: false, + }), + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-sampled=false', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'continues an unsampled remote trace with dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + dsc: { + transaction: 'sampled-transaction', + sampled: 'false', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=false', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'continues an unsampled remote trace with dsc & sampled trace state', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + sampled: false, + dsc: { + transaction: 'sampled-transaction', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'starts a new trace without existing dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + ])('%s', (_name, spanContext, baggage, sentryTrace, samplingDecision) => { + expect(getSamplingDecision(spanContext)).toBe(samplingDecision); + + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + trace.getTracer('test').startActiveSpan('test', span => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); + baggage.forEach(baggageItem => { + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(baggageItem); + }); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(sentryTrace.replace('{{spanId}}', span.spanContext().spanId)); + }); + }); + }); + + it('uses local span over propagation context', () => { + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }), + () => { + trace.getTracer('test').startActiveSpan('test', span => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ].forEach(item => { + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(item); + }); + expect(carrier[SENTRY_TRACE_HEADER]).toBe( + `d4cda95b652f4a1592b449d5929fda1b-${span.spanContext().spanId}-1`, + ); + }); + }); + }, + ); + }); + + it('uses remote span with deferred sampling decision over propagation context', () => { + const carrier: Record = {}; + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + }), + () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + // Used spanId is a random ID, not from the remote span + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}/); + expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'); + }); + }, + ); + }); + + it('uses remote span over propagation context', () => { + const carrier: Record = {}; + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ sampled: false }), + }), + () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=false', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + // Used spanId is a random ID, not from the remote span + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-0/); + expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'); + }); + }, + ); + }); + }); + + it('should include existing baggage', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should include existing baggage header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + other: 'header', + baggage: 'foo=bar,other=yes', + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should include existing baggage array header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + other: 'header', + baggage: ['foo=bar,other=yes', 'other2=no'], + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'other2=no', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should overwrite existing sentry baggage header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-other=yes', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should create baggage without propagation context', () => { + const scope = getCurrentScope(); + const traceId = scope.getPropagationContext().traceId; + + const context = ROOT_CONTEXT; + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( + `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, + ); + }); + + it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); + propagator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); + }); + }); + + describe('extract', () => { + it('sets data from sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('sets data from negative sampled sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({ sampled: false }), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(false); + }); + + it('sets data from not sampled sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(undefined); + }); + + it('handles undefined sentry trace header', () => { + const sentryTraceHeader = undefined; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual(undefined); + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: expect.stringMatching(/[a-f0-9]{32}/), + sampleRand: expect.any(Number), + }); + }); + + it('sets data from baggage header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + const baggage = + 'sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-transaction=dsc-transaction,sentry-sample_rand=0.123'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({ + dsc: { + environment: 'production', + release: '1.0.0', + public_key: 'abc', + trace_id: 'd4cda95b652f4a1592b449d5929fda1b', + transaction: 'dsc-transaction', + sample_rand: '0.123', + }, + }), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('handles empty dsc baggage header', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + const baggage = ''; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('handles when sentry-trace is an empty array', () => { + carrier[SENTRY_TRACE_HEADER] = []; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual(undefined); + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: expect.stringMatching(/[a-f0-9]{32}/), + sampleRand: expect.any(Number), + }); + }); + }); +}); + +function baggageToArray(baggage: unknown): string[] { + return typeof baggage === 'string' ? baggage.split(',').sort() : []; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts b/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts new file mode 100644 index 000000000000..86cf7b135f97 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts @@ -0,0 +1,141 @@ +import { context, SpanKind, trace } from '@opentelemetry/api'; +import { TraceState } from '@opentelemetry/core'; +import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; +import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; +import { generateSpanId, generateTraceId } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../../../packages/opentelemetry/src/constants'; +import { SentrySampler } from '../../../packages/opentelemetry/src/sampler'; +import { cleanupOtel } from './helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; + +describe('SentrySampler', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + it('works with tracesSampleRate=0', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual( + expect.objectContaining({ + decision: SamplingDecision.NOT_RECORD, + attributes: { 'sentry.sample_rate': 0 }, + }), + ); + expect(actual.traceState?.get('sentry.sampled_not_recording')).toBe('1'); + expect(actual.traceState?.get('sentry.sample_rand')).toEqual(expect.any(String)); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with tracesSampleRate=0 & for a child span', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with tracesSampleRate=1', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual( + expect.objectContaining({ + decision: SamplingDecision.RECORD_AND_SAMPLED, + attributes: { 'sentry.sample_rate': 1 }, + }), + ); + expect(actual.traceState?.constructor.name).toBe('TraceState'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with traceSampleRate=undefined', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: undefined })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState(), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('ignores local http client root spans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.CLIENT; + const spanAttributes = { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + }; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState(), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts b/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts new file mode 100644 index 000000000000..5a1782c89e7b --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts @@ -0,0 +1,169 @@ +import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTransactionForOtelSpan } from '../../../packages/opentelemetry/src/spanExporter'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('createTransactionForOtelSpan', () => { + beforeEach(() => { + mockSdkInit({ + tracesSampleRate: 1, + }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works with a basic span', () => { + const span = startInactiveSpan({ name: 'test', startTime: 1733821670000 }); + span.end(1733821672000); + + const event = createTransactionForOtelSpan(span as any); + // we do not care about this here + delete event.sdkProcessingMetadata; + + expect(event).toEqual({ + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + origin: 'manual', + status: 'ok', + }, + otel: { + resource: { + 'service.name': 'opentelemetry-test', + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + 'service.namespace': 'sentry', + 'service.version': SDK_VERSION, + }, + }, + }, + spans: [], + start_timestamp: 1733821670, + timestamp: 1733821672, + transaction: 'test', + type: 'transaction', + transaction_info: { source: 'custom' }, + }); + }); + + it('works with a http.server span', () => { + const span = startInactiveSpan({ + name: 'test', + startTime: 1733821670000, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + }, + }); + span.end(1733821672000); + + const event = createTransactionForOtelSpan(span as any); + // we do not care about this here + delete event.sdkProcessingMetadata; + + expect(event).toEqual({ + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'http.response.status_code': 200, + }, + origin: 'manual', + status: 'ok', + op: 'http.server', + }, + otel: { + resource: { + 'service.name': 'opentelemetry-test', + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + 'service.namespace': 'sentry', + 'service.version': SDK_VERSION, + }, + }, + response: { + status_code: 200, + }, + }, + spans: [], + start_timestamp: 1733821670, + timestamp: 1733821672, + transaction: 'test', + type: 'transaction', + transaction_info: { source: 'custom' }, + }); + }); + + it('adds span link to the trace context when adding with addLink()', () => { + const span = startInactiveSpan({ name: 'parent1' }); + span.end(); + + startSpanManual({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }); + rootSpan.end(); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + const event = createTransactionForOtelSpan(rootSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); + }); + + it('adds span link to the trace context when linked in span options', () => { + const span = startInactiveSpan({ name: 'parent1' }); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + + const linkedSpan = startInactiveSpan({ + name: 'parent2', + links: [{ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], + }); + + span.end(); + linkedSpan.end(); + + const event = createTransactionForOtelSpan(linkedSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts new file mode 100644 index 000000000000..84be427a1fb3 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts @@ -0,0 +1,1935 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span, TimeInput } from '@opentelemetry/api'; +import { context, ROOT_CONTEXT, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions'; +import type { Event, Scope } from '@sentry/core'; +import { + getClient, + getCurrentScope, + getDynamicSamplingContextFromClient, + getDynamicSamplingContextFromSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanIsSampled, + spanToJSON, + suppressTracing, + withScope, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + continueTrace, + startInactiveSpan, + startSpan, + startSpanManual, +} from '../../../packages/opentelemetry/src/trace'; +import type { AbstractSpan } from '../../../packages/opentelemetry/src/types'; +import { getActiveSpan } from '../../../packages/opentelemetry/src/utils/getActiveSpan'; +import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; +import { getSpanKind } from '../../../packages/opentelemetry/src/utils/getSpanKind'; +import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; +import { spanHasAttributes, spanHasName } from '../../../packages/opentelemetry/src/utils/spanTypes'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; +import { isSpan } from './helpers/isSpan'; +import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; + +describe('trace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + describe('startSpan', () => { + it('works with a sync callback', () => { + const spans: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const res = startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + return 'test value'; + }); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans).toHaveLength(2); + const [outerSpan, innerSpan] = spans as [Span, Span]; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getSpanName(innerSpan)).toEqual('inner'); + + expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + }); + + it('works with an async callback', async () => { + const spans: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const res = await startSpan({ name: 'outer' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans.push(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + await startSpan({ name: 'inner' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans.push(innerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + return 'test value'; + }); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans).toHaveLength(2); + const [outerSpan, innerSpan] = spans as [Span, Span]; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getSpanName(innerSpan)).toEqual('inner'); + + expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + }); + + it('works with multiple parallel calls', () => { + const spans1: Span[] = []; + const spans2: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans1.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans1.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + }); + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans2.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer2'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans2.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner2'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + }); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans1).toHaveLength(2); + expect(spans2).toHaveLength(2); + }); + + it('works with multiple parallel async calls', async () => { + const spans1: Span[] = []; + const spans2: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const promise1 = startSpan({ name: 'outer' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans1.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + expect(getRootSpan(outerSpan)).toEqual(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await startSpan({ name: 'inner' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans1.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + expect(getRootSpan(innerSpan)).toEqual(outerSpan); + }); + }); + + const promise2 = startSpan({ name: 'outer2' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans2.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer2'); + expect(getActiveSpan()).toEqual(outerSpan); + expect(getRootSpan(outerSpan)).toEqual(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await startSpan({ name: 'inner2' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans2.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner2'); + expect(getActiveSpan()).toEqual(innerSpan); + expect(getRootSpan(innerSpan)).toEqual(outerSpan); + }); + }); + + await Promise.all([promise1, promise2]); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans1).toHaveLength(2); + expect(spans2).toHaveLength(2); + }); + + it('allows to pass context arguments', () => { + startSpan( + { + name: 'outer', + }, + span => { + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + }, + ); + + startSpan( + { + name: 'outer', + op: 'my-op', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + }, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + }, + ); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + startSpan( + { + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }, + ); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const start = startSpan({ name: 'outer', startTime: startTime }, span => { + return getSpanStartTime(span); + }); + + expect(start).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + let parentSpan: Span; + + // "hack" to create a manual scope with a parent span + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + }); + + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag' }); + expect(getCurrentScope()).not.toBe(manualScope!); + + getCurrentScope().setTag('outer', 'tag'); + + startSpan({ name: 'GET users/[id]', scope: manualScope! }, span => { + // the current scope in the callback is a fork of the manual scope + expect(getCurrentScope()).not.toBe(initialScope); + expect(getCurrentScope()).not.toBe(manualScope); + expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag' }); + + // getActiveSpan returns the correct span + expect(getActiveSpan()).toBe(span); + + // span hierarchy is correct + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + // scope data modifications are isolated between original and forked manual scope + getCurrentScope().setTag('inner', 'tag'); + manualScope!.setTag('manual-scope-inner', 'tag'); + + expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag', inner: 'tag' }); + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); + }); + + // manualScope modifications remain set outside the callback + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); + + // current scope is reset back to initial scope + expect(getCurrentScope()).toBe(initialScope); + expect(getCurrentScope().getScopeData().tags).toEqual({ outer: 'tag' }); + + // although the manual span is still running, it's no longer active due to being outside of the callback + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + startSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { + expect(getActiveSpan()).toBe(span); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); + }); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'GET users/[id' }, () => { + startSpan({ name: 'child', parentSpan: null }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + }); + }); + }); + + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpan({ name: 'outer transaction' }, () => { + startSpan({ name: 'inner span' }, () => { + startSpan({ name: 'inner transaction', forceTransaction: true }, () => { + startSpan({ name: 'inner span 2' }, () => { + // all good + }); + }); + }); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + // TODO: propagation scope is not picked up by spans... + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + }); + + describe('startInactiveSpan', () => { + it('works at the root', () => { + const span = startInactiveSpan({ name: 'test' }); + + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('test'); + expect(getSpanEndTime(span)).toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); + + span.end(); + + expect(getSpanEndTime(span)).not.toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('works as a child span', () => { + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(getActiveSpan()).toEqual(outerSpan); + + const innerSpan = startInactiveSpan({ name: 'test' }); + + expect(innerSpan).toBeDefined(); + expect(getSpanName(innerSpan)).toEqual('test'); + expect(getSpanEndTime(innerSpan)).toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); + + innerSpan.end(); + + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); + }); + }); + + it('allows to pass context arguments', () => { + const span = startInactiveSpan({ + name: 'outer', + }); + + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + + const span2 = startInactiveSpan({ + name: 'outer', + op: 'my-op', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + }, + }); + + expect(span2).toBeDefined(); + expect(getSpanAttributes(span2)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', + }); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + const span = startInactiveSpan({ + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }); + + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const span = startInactiveSpan({ name: 'outer', startTime: startTime }); + + expect(getSpanStartTime(span)).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + + const parentSpan = startSpanManual({ name: 'detached' }, span => { + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + return span; + }); + + getCurrentScope().setTag('outer', 'tag'); + + const span = startInactiveSpan({ name: 'GET users/[id]', scope: manualScope! }); + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + expect(getCurrentScope()).toBe(initialScope); + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + const span = startInactiveSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }); + + expect(getActiveSpan()).toBe(undefined); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan!.spanContext().spanId); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'outer' }, () => { + const span = startInactiveSpan({ name: 'test span', parentSpan: null }); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + span.end(); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const rawSpan2 = startInactiveSpan({ + name: 'GET users/[id]', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }); + + const span1JSON = spanToJSON(rawSpan1); + const span2JSON = spanToJSON(rawSpan2); + const span2LinkJSON = span2JSON.links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + + // sampling decision is inherited + expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate'])); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpan({ name: 'outer transaction' }, () => { + startSpan({ name: 'inner span' }, () => { + const innerTransaction = startInactiveSpan({ name: 'inner transaction', forceTransaction: true }); + innerTransaction.end(); + }); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + + it('includes the scope at the time the span was started when finished', async () => { + const beforeSendTransaction = vi.fn(event => event); + + const client = getClient()!; + + client.getOptions().beforeSendTransaction = beforeSendTransaction; + + let span: Span; + + const scope = getCurrentScope(); + scope.setTag('outer', 'foo'); + + withScope(scope => { + scope.setTag('scope', 1); + span = startInactiveSpan({ name: 'my-span' }); + scope.setTag('scope_after_span', 2); + }); + + withScope(scope => { + scope.setTag('scope', 2); + span.end(); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + outer: 'foo', + scope: 1, + scope_after_span: 2, + }), + }), + expect.anything(), + ); + }); + }); + + describe('startSpanManual', () => { + it('does not automatically finish the span', () => { + expect(getActiveSpan()).toEqual(undefined); + + let _outerSpan: Span | undefined; + let _innerSpan: Span | undefined; + + const res = startSpanManual({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + _outerSpan = outerSpan; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpanManual({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + _innerSpan = innerSpan; + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + expect(getSpanEndTime(_innerSpan!)).toEqual([0, 0]); + + _innerSpan!.end(); + + expect(getSpanEndTime(_innerSpan!)).not.toEqual([0, 0]); + + return 'test value'; + }); + + expect(getSpanEndTime(_outerSpan!)).toEqual([0, 0]); + + _outerSpan!.end(); + + expect(getSpanEndTime(_outerSpan!)).not.toEqual([0, 0]); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + startSpanManual( + { + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }, + ); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const start = startSpanManual({ name: 'outer', startTime: startTime }, span => { + const start = getSpanStartTime(span); + span.end(); + return start; + }); + + expect(start).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + }); + + getCurrentScope().setTag('outer', 'tag'); + + startSpanManual({ name: 'GET users/[id]', scope: manualScope! }, span => { + expect(getCurrentScope()).not.toBe(initialScope); + + expect(getCurrentScope()).toEqual(manualScope); + expect(getActiveSpan()).toBe(span); + + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + span.end(); + }); + + expect(getCurrentScope()).toBe(initialScope); + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + startSpanManual({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { + expect(getActiveSpan()).toBe(span); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); + + span.end(); + }); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'outer' }, () => { + startSpanManual({ name: 'GET users/[id]', parentSpan: null }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + span.end(); + }); + }); + }); + + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpanManual({ name: 'outer transaction' }, span => { + startSpanManual({ name: 'inner span' }, span => { + startSpanManual({ name: 'inner transaction', forceTransaction: true }, span => { + startSpanManual({ name: 'inner span 2' }, span => { + // all good + span.end(); + }); + span.end(); + }); + span.end(); + }); + span.end(); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + }); + + describe('propagation', () => { + it('starts new trace, if there is no parent', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + const traceId = spanToJSON(span).trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + trace_id: traceId, + environment: 'production', + public_key: 'username', + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }); + }); + }); + + // Note: This _should_ never happen, when we have an incoming trace, we should always have a parent span + it('starts new trace, ignoring parentSpanId, if there is no parent', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + propagationContext.parentSpanId = '1121201211212012'; + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + const traceId = spanToJSON(span).trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + environment: 'production', + public_key: 'username', + trace_id: traceId, + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }); + }); + }); + + it('picks up the trace context from the parent without DSC', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + startSpan({ name: 'parent span' }, parentSpan => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual(parentSpan.spanContext().traceId); + expect(spanToJSON(span).parent_span_id).toEqual(parentSpan.spanContext().spanId); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + ...getDynamicSamplingContextFromClient(propagationContext.traceId, getClient()!), + trace_id: parentSpan.spanContext().traceId, + transaction: 'parent span', + sampled: 'true', + sample_rate: '1', + sample_rand: expect.any(String), + }); + }); + }); + }); + + it('picks up the trace context from the parent with DSC', () => { + withScope(() => { + const ctx = trace.setSpanContext(ROOT_CONTEXT, { + traceId: '12312012123120121231201212312012', + spanId: '1121201211212012', + isRemote: false, + traceFlags: TraceFlags.SAMPLED, + traceState: makeTraceState({ + dsc: { + release: '1.0', + environment: 'production', + }, + }), + }); + + context.with(ctx, () => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + release: '1.0', + environment: 'production', + }); + }); + }); + }); + + it('picks up the trace context from a remote parent', () => { + withScope(() => { + const ctx = trace.setSpanContext(ROOT_CONTEXT, { + traceId: '12312012123120121231201212312012', + spanId: '1121201211212012', + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState: makeTraceState({ + dsc: { + release: '1.0', + environment: 'production', + }, + }), + }); + + context.with(ctx, () => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + release: '1.0', + environment: 'production', + }); + }); + }); + }); + }); +}); + +describe('trace (tracing disabled)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 0 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('startSpan calls callback without span', () => { + const val = startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + return 'test value'; + }); + + expect(val).toEqual('test value'); + }); + + it('startInactiveSpan returns a NonRecordinSpan', () => { + const span = startInactiveSpan({ name: 'test' }); + + expect(span).toBeDefined(); + expect(span.isRecording()).toBe(false); + }); +}); + +describe('trace (sampling)', () => { + afterEach(async () => { + await cleanupOtel(); + vi.clearAllMocks(); + }); + + it('samples with a tracesSampleRate, when Math.random() > tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('samples with a tracesSampleRate, when Math.random() < tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.4); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + // All fields are empty for NonRecordingSpan + expect(getSpanName(outerSpan)).toBe('outer'); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(true); + expect(getSpanName(innerSpan)).toBe('inner'); + }); + }); + }); + + it('positive parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 1 }); + + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + expect(getSpanName(outerSpan)).toBe('outer'); + + // Now let's mutate the tracesSampleRate so that the next entry _should_ not be sampled + // but it will because of parent sampling + const client = getClient(); + client!.getOptions().tracesSampleRate = 0.5; + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(true); + expect(getSpanName(innerSpan)).toBe('inner'); + }); + }); + }); + + it('negative parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + // This will def. be unsampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + // Now let's mutate the tracesSampleRate so that the next entry _should_ be sampled + // but it will remain unsampled because of parent sampling + const client = getClient(); + client!.getOptions().tracesSampleRate = 1; + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('positive remote parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + expect(getSpanName(outerSpan)).toBe('outer'); + }); + }); + }); + + it('negative remote parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: false, + isRemote: true, + traceFlags: TraceFlags.NONE, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('samples with a tracesSampler returning a boolean', () => { + let tracesSamplerResponse: boolean = true; + + const tracesSampler = vi.fn(() => { + return tracesSamplerResponse; + }); + + mockSdkInit({ tracesSampler }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + }); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + + // Now return `false`, it should not sample + tracesSamplerResponse = false; + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(2); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer', + attributes: {}, + }), + ); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer2', + attributes: {}, + }), + ); + + // Only root spans should go through the sampler + expect(tracesSampler).not.toHaveBeenLastCalledWith({ + name: 'inner2', + }); + }); + + it('samples with a tracesSampler returning a number', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + let tracesSamplerResponse: number = 1; + + const tracesSampler = vi.fn(() => { + return tracesSamplerResponse; + }); + + mockSdkInit({ tracesSampler }); + + startSpan( + { + name: 'outer', + op: 'test.op', + attributes: { attr1: 'yes', attr2: 1 }, + }, + outerSpan => { + expect(outerSpan).toBeDefined(); + }, + ); + + expect(tracesSampler).toHaveBeenCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: { + attr1: 'yes', + attr2: 1, + 'sentry.op': 'test.op', + }, + inheritOrSampleWith: expect.any(Function), + }); + + // Now return `0`, it should not sample + tracesSamplerResponse = 0; + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(2); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer2', + attributes: {}, + }), + ); + + // Only root spans should be passed to tracesSampler + expect(tracesSampler).not.toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'inner2', + }), + ); + + // Now return `0.4`, it should not sample + tracesSamplerResponse = 0.4; + + startSpan({ name: 'outer3' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(3); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer3', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + }); + + it('samples with a tracesSampler even if parent is remotely sampled', () => { + const tracesSampler = vi.fn(() => { + return false; + }); + + mockSdkInit({ tracesSampler }); + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: true, + name: 'outer', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + }); + + it('ignores parent span context if it is invalid', () => { + mockSdkInit({ tracesSampleRate: 1 }); + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + + const spanContext = { + traceId, + spanId: 'INVALID', + traceFlags: TraceFlags.SAMPLED, + }; + + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(span.spanContext().spanId).not.toBe('INVALID'); + expect(span.spanContext().spanId).toMatch(/[a-f0-9]{16}/); + expect(span.spanContext().traceId).not.toBe(traceId); + expect(span.spanContext().traceId).toMatch(/[a-f0-9]{32}/); + }); + }); + }); +}); + +describe('HTTP methods (sampling)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('does sample when HTTP method is other than OPTIONS or HEAD', () => { + const spanGET = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'GET' } }, span => { + return span; + }); + expect(spanIsSampled(spanGET)).toBe(true); + expect(getSamplingDecision(spanGET.spanContext())).toBe(true); + + const spanPOST = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'POST' } }, span => { + return span; + }); + expect(spanIsSampled(spanPOST)).toBe(true); + expect(getSamplingDecision(spanPOST.spanContext())).toBe(true); + + const spanPUT = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'PUT' } }, span => { + return span; + }); + expect(spanIsSampled(spanPUT)).toBe(true); + expect(getSamplingDecision(spanPUT.spanContext())).toBe(true); + + const spanDELETE = startSpanManual( + { name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'DELETE' } }, + span => { + return span; + }, + ); + expect(spanIsSampled(spanDELETE)).toBe(true); + expect(getSamplingDecision(spanDELETE.spanContext())).toBe(true); + }); + + it('does not sample when HTTP method is OPTIONS', () => { + const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'OPTIONS' } }, span => { + return span; + }); + expect(spanIsSampled(span)).toBe(false); + expect(getSamplingDecision(span.spanContext())).toBe(false); + }); + + it('does not sample when HTTP method is HEAD', () => { + const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'HEAD' } }, span => { + return span; + }); + expect(spanIsSampled(span)).toBe(false); + expect(getSamplingDecision(span.spanContext())).toBe(false); + }); +}); + +describe('continueTrace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works without trace & baggage data', () => { + const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { + const span = getActiveSpan()!; + expect(span).toBeUndefined(); + return getCurrentScope(); + }); + + expect(scope.getPropagationContext()).toEqual({ + traceId: expect.any(String), + sampleRand: expect.any(Number), + }); + + expect(scope.getScopeData().sdkProcessingMetadata).toEqual({}); + }); + + it('works with trace data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }, + ); + }); + + it('works with trace & baggage data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-version=1.0,sentry-environment=production', + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(true); + expect(spanIsSampled(span)).toBe(true); + }, + ); + }); + + it('works with trace & 3rd party baggage data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-version=1.0,sentry-environment=production,dogs=great,cats=boring', + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(true); + expect(spanIsSampled(span)).toBe(true); + }, + ); + }); + + it('returns response of callback', () => { + const result = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }, + () => { + return 'aha'; + }, + ); + + expect(result).toEqual('aha'); + }); +}); + +describe('suppressTracing', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works for a root span', () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(span.isRecording()).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }); + + it('works for a child span', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child1 = startInactiveSpan({ name: 'inner1' }); + + expect(child1.isRecording()).toBe(true); + expect(spanIsSampled(child1)).toBe(true); + + const child2 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(child2.isRecording()).toBe(false); + expect(spanIsSampled(child2)).toBe(false); + }); + }); + + it('works for a child span with forceTransaction=true', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child = suppressTracing(() => { + return startInactiveSpan({ name: 'span', forceTransaction: true }); + }); + + expect(child.isRecording()).toBe(false); + expect(spanIsSampled(child)).toBe(false); + }); + }); +}); + +function getSpanName(span: AbstractSpan): string | undefined { + return spanHasName(span) ? span.name : undefined; +} + +function getSpanEndTime(span: AbstractSpan): [number, number] | undefined { + return (span as ReadableSpan).endTime; +} + +function getSpanStartTime(span: AbstractSpan): [number, number] | undefined { + return (span as ReadableSpan).startTime; +} + +function getSpanAttributes(span: AbstractSpan): Record | undefined { + return spanHasAttributes(span) ? span.attributes : undefined; +} + +function getSpanParentSpanId(span: AbstractSpan): string | undefined { + return getParentSpanId(span as ReadableSpan); +} diff --git a/dev-packages/opentelemetry-v2-tests/test/tsconfig.json b/dev-packages/opentelemetry-v2-tests/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts new file mode 100644 index 000000000000..c91e49ea5f84 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts @@ -0,0 +1,155 @@ +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { getRootSpan } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getActiveSpan } from '../../../../packages/opentelemetry/src/utils/getActiveSpan'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('getActiveSpan', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('returns undefined if no span is active', () => { + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + it('returns undefined if no provider is active', async () => { + await provider?.forceFlush(); + await provider?.shutdown(); + provider = undefined; + + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + it('returns currently active span', () => { + const tracer = trace.getTracer('test'); + + expect(getActiveSpan()).toBeUndefined(); + + tracer.startActiveSpan('test', span => { + expect(getActiveSpan()).toBe(span); + + const inner1 = tracer.startSpan('inner1'); + + expect(getActiveSpan()).toBe(span); + + inner1.end(); + + tracer.startActiveSpan('inner2', inner2 => { + expect(getActiveSpan()).toBe(inner2); + + inner2.end(); + }); + + expect(getActiveSpan()).toBe(span); + + span.end(); + }); + + expect(getActiveSpan()).toBeUndefined(); + }); + + it('returns currently active span in concurrent spans', () => { + const tracer = trace.getTracer('test'); + + expect(getActiveSpan()).toBeUndefined(); + + tracer.startActiveSpan('test1', span => { + expect(getActiveSpan()).toBe(span); + + tracer.startActiveSpan('inner1', inner1 => { + expect(getActiveSpan()).toBe(inner1); + inner1.end(); + }); + + span.end(); + }); + + tracer.startActiveSpan('test2', span => { + expect(getActiveSpan()).toBe(span); + + tracer.startActiveSpan('inner2', inner => { + expect(getActiveSpan()).toBe(inner); + inner.end(); + }); + + span.end(); + }); + + expect(getActiveSpan()).toBeUndefined(); + }); +}); + +describe('getRootSpan', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(async () => { + await provider?.forceFlush(); + await provider?.shutdown(); + }); + + it('returns currently active root span', () => { + const tracer = trace.getTracer('test'); + + tracer.startActiveSpan('test', span => { + expect(getRootSpan(span)).toBe(span); + + const inner1 = tracer.startSpan('inner1'); + + expect(getRootSpan(inner1)).toBe(span); + + inner1.end(); + + tracer.startActiveSpan('inner2', inner2 => { + expect(getRootSpan(inner2)).toBe(span); + + inner2.end(); + }); + + span.end(); + }); + }); + + it('returns currently active root span in concurrent spans', () => { + const tracer = trace.getTracer('test'); + + tracer.startActiveSpan('test1', span => { + expect(getRootSpan(span)).toBe(span); + + tracer.startActiveSpan('inner1', inner1 => { + expect(getRootSpan(inner1)).toBe(span); + inner1.end(); + }); + + span.end(); + }); + + tracer.startActiveSpan('test2', span => { + expect(getRootSpan(span)).toBe(span); + + tracer.startActiveSpan('inner2', inner => { + expect(getRootSpan(inner)).toBe(span); + inner.end(); + }); + + span.end(); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts new file mode 100644 index 000000000000..3f0914b6afb7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts @@ -0,0 +1,80 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getRequestSpanData } from '../../../../packages/opentelemetry/src/utils/getRequestSpanData'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('getRequestSpanData', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string): Span { + return trace.getTracer('test').startSpan(name); + } + + it('works with basic span', () => { + const span = createSpan('test-span'); + const data = getRequestSpanData(span); + + expect(data).toEqual({}); + }); + + it('works with http span', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'http://example.com?foo=bar#baz', + [SEMATTRS_HTTP_METHOD]: 'GET', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'http://example.com', + 'http.method': 'GET', + 'http.query': '?foo=bar', + 'http.fragment': '#baz', + }); + }); + + it('works without method', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'http://example.com', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'http://example.com', + 'http.method': 'GET', + }); + }); + + it('works with incorrect URL', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'malformed-url-here', + [SEMATTRS_HTTP_METHOD]: 'GET', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'malformed-url-here', + 'http.method': 'GET', + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts new file mode 100644 index 000000000000..16dacdafe8ee --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts @@ -0,0 +1,11 @@ +import type { Span } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { getSpanKind } from '../../../../packages/opentelemetry/src/utils/getSpanKind'; + +describe('getSpanKind', () => { + it('works', () => { + expect(getSpanKind({} as Span)).toBe(SpanKind.INTERNAL); + expect(getSpanKind({ kind: SpanKind.CLIENT } as unknown as Span)).toBe(SpanKind.CLIENT); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts new file mode 100644 index 000000000000..136b6251523d --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts @@ -0,0 +1,94 @@ +import { context, trace } from '@opentelemetry/api'; +import { getCurrentScope, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTraceData } from '../../../../packages/opentelemetry/src/utils/getTraceData'; +import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('getTraceData', () => { + beforeEach(() => { + setAsyncContextStrategy(undefined); + mockSdkInit(); + }); + + afterEach(async () => { + await cleanupOtel(); + vi.clearAllMocks(); + }); + + it('returns the tracing data from the span, if a span is available', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + }); + + context.with(ctx, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + }); + + it('allows to pass a span directly', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + }); + + const span = trace.getSpan(ctx)!; + + const data = getTraceData({ span }); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + + it('returns propagationContext DSC data if no span is available', () => { + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), + sampled: true, + dsc: { + environment: 'staging', + public_key: 'key', + trace_id: '12345678901234567890123456789012', + }, + }); + + const traceData = getTraceData(); + + expect(traceData['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(traceData.baggage).toEqual( + 'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012', + ); + }); + + it('works with an span with frozen DSC in traceState', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + traceState: makeTraceState({ + dsc: { environment: 'test-dev', public_key: '456', trace_id: '12345678901234567890123456789088' }, + }), + }); + + context.with(ctx, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=test-dev,sentry-public_key=456,sentry-trace_id=12345678901234567890123456789088', + }); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts new file mode 100644 index 000000000000..87d7daa4a43a --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts @@ -0,0 +1,174 @@ +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { withActiveSpan } from '../../../../packages/opentelemetry/src/trace'; +import { groupSpansWithParents } from '../../../../packages/opentelemetry/src/utils/groupSpansWithParents'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('groupSpansWithParents', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('works with no spans', () => { + const actual = groupSpansWithParents([]); + expect(actual).toEqual([]); + }); + + it('works with a single root span & in-order spans', () => { + const tracer = trace.getTracer('test'); + const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const child1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([rootSpan, parentSpan1, parentSpan2, child1]); + expect(actual).toHaveLength(4); + + // Ensure parent & span is correctly set + const rootRef = actual.find(ref => ref.span === rootSpan); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === child1); + + expect(rootRef).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(rootRef?.parentNode).toBeUndefined(); + expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(rootRef); + expect(parent2Ref?.parentNode).toBe(rootRef); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); + + it('works with a spans with missing root span', () => { + const tracer = trace.getTracer('test'); + + // We create this root span here, but we do not pass it to `groupSpansWithParents` below + const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const child1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([parentSpan1, parentSpan2, child1]); + expect(actual).toHaveLength(4); + + // Ensure parent & span is correctly set + const rootRef = actual.find(ref => ref.id === rootSpan.spanContext().spanId); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === child1); + + expect(rootRef).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(rootRef?.parentNode).toBeUndefined(); + expect(rootRef?.span).toBeUndefined(); + expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(rootRef); + expect(parent2Ref?.parentNode).toBe(rootRef); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); + + it('works with multiple root spans & out-of-order spans', () => { + const tracer = trace.getTracer('test'); + const rootSpan1 = tracer.startSpan('root1') as unknown as ReadableSpan; + const rootSpan2 = tracer.startSpan('root2') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan1 as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan2 as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const childSpan1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([childSpan1, parentSpan1, parentSpan2, rootSpan2, rootSpan1]); + expect(actual).toHaveLength(5); + + // Ensure parent & span is correctly set + const root1Ref = actual.find(ref => ref.span === rootSpan1); + const root2Ref = actual.find(ref => ref.span === rootSpan2); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === childSpan1); + + expect(root1Ref).toBeDefined(); + expect(root2Ref).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(root1Ref?.parentNode).toBeUndefined(); + expect(root1Ref?.children).toEqual([parent1Ref]); + + expect(root2Ref?.parentNode).toBeUndefined(); + expect(root2Ref?.children).toEqual([parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(root1Ref); + expect(parent2Ref?.parentNode).toBe(root2Ref); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts new file mode 100644 index 000000000000..b479da0d61ad --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_RPC_GRPC_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import type { SpanStatus } from '@sentry/core'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mapStatus } from '../../../../packages/opentelemetry/src/utils/mapStatus'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('mapStatus', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string): Span { + return trace.getTracer('test').startSpan(name); + } + + const statusTestTable: [undefined | number | string, undefined | string, SpanStatus][] = [ + // http codes + [400, undefined, { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + [401, undefined, { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], + [403, undefined, { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], + [404, undefined, { code: SPAN_STATUS_ERROR, message: 'not_found' }], + [409, undefined, { code: SPAN_STATUS_ERROR, message: 'already_exists' }], + [429, undefined, { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], + [499, undefined, { code: SPAN_STATUS_ERROR, message: 'cancelled' }], + [500, undefined, { code: SPAN_STATUS_ERROR, message: 'internal_error' }], + [501, undefined, { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], + [503, undefined, { code: SPAN_STATUS_ERROR, message: 'unavailable' }], + [504, undefined, { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], + [999, undefined, { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + + // grpc codes + [undefined, '1', { code: SPAN_STATUS_ERROR, message: 'cancelled' }], + [undefined, '2', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + [undefined, '3', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + [undefined, '4', { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], + [undefined, '5', { code: SPAN_STATUS_ERROR, message: 'not_found' }], + [undefined, '6', { code: SPAN_STATUS_ERROR, message: 'already_exists' }], + [undefined, '7', { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], + [undefined, '8', { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], + [undefined, '9', { code: SPAN_STATUS_ERROR, message: 'failed_precondition' }], + [undefined, '10', { code: SPAN_STATUS_ERROR, message: 'aborted' }], + [undefined, '11', { code: SPAN_STATUS_ERROR, message: 'out_of_range' }], + [undefined, '12', { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], + [undefined, '13', { code: SPAN_STATUS_ERROR, message: 'internal_error' }], + [undefined, '14', { code: SPAN_STATUS_ERROR, message: 'unavailable' }], + [undefined, '15', { code: SPAN_STATUS_ERROR, message: 'data_loss' }], + [undefined, '16', { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], + [undefined, '999', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + + // http takes precedence over grpc + [400, '2', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + ]; + + it.each(statusTestTable)('works with httpCode=%s, grpcCode=%s', (httpCode, grpcCode, expected) => { + const span = createSpan('test-span'); + span.setStatus({ code: 0 }); // UNSET + + if (httpCode) { + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, httpCode); + } + + if (grpcCode) { + span.setAttribute(SEMATTRS_RPC_GRPC_STATUS_CODE, grpcCode); + } + + const actual = mapStatus(span); + expect(actual).toEqual(expected); + }); + + it('works with string SEMATTRS_HTTP_STATUS_CODE', () => { + const span = createSpan('test-span'); + + span.setStatus({ code: 0 }); // UNSET + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, '400'); + + const actual = mapStatus(span); + expect(actual).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + }); + + it('returns ok span status when is UNSET present on span', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 0 }); // UNSET + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); + }); + + it('returns ok span status when already present on span', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 1 }); // OK + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); + }); + + it('returns error status when span already has error status', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 2, message: 'invalid_argument' }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + }); + + it('returns error status when span already has error status without message', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 2 }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); + }); + + it('infers error status form attributes when span already has error status without message', () => { + const span = createSpan('test-span'); + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, 500); + span.setStatus({ code: 2 }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + }); + + it('returns unknown error status when code is unknown', () => { + const span = createSpan('test-span'); + span.setStatus({ code: -1 as 0 }); + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts new file mode 100644 index 000000000000..56d50a3b2fbc --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts @@ -0,0 +1,690 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; +import { + ATTR_HTTP_ROUTE, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_SYSTEM, + SEMATTRS_FAAS_TRIGGER, + SEMATTRS_HTTP_HOST, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_HTTP_TARGET, + SEMATTRS_HTTP_URL, + SEMATTRS_MESSAGING_SYSTEM, + SEMATTRS_RPC_SERVICE, +} from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { + descriptionForHttpMethod, + getSanitizedUrl, + getUserUpdatedNameAndSource, + parseSpanDescription, +} from '../../../../packages/opentelemetry/src/utils/parseSpanDescription'; + +describe('parseSpanDescription', () => { + it.each([ + [ + 'works without attributes & name', + undefined, + undefined, + undefined, + { + description: '', + op: undefined, + source: 'custom', + }, + ], + [ + 'works with empty attributes', + {}, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: undefined, + source: 'custom', + }, + ], + [ + 'works with deprecated http method', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'http.client', + source: 'custom', + }, + ], + [ + 'works with http method', + { + 'http.request.method': 'GET', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'http.client', + source: 'custom', + }, + ], + [ + 'works with db system', + { + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'SELECT * from users', + op: 'db', + source: 'task', + }, + ], + [ + 'works with db system and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'db', + source: 'custom', + }, + ], + [ + 'works with db system and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'custom name', + op: 'db', + source: 'custom', + }, + ], + [ + 'works with db system and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'custom name', + op: 'db', + source: 'component', + }, + ], + [ + 'works with db system without statement', + { + [SEMATTRS_DB_SYSTEM]: 'mysql', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'db', + source: 'task', + }, + ], + [ + 'works with rpc service', + { + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'rpc', + source: 'route', + }, + ], + [ + 'works with rpc service and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'rpc', + source: 'custom', + }, + ], + [ + 'works with rpc service and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'rpc', + source: 'custom', + }, + ], + [ + 'works with rpc service and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'rpc', + source: 'component', + }, + ], + [ + 'works with messaging system', + { + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'message', + source: 'route', + }, + ], + [ + 'works with messaging system and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'message', + source: 'custom', + }, + ], + [ + 'works with messaging system and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'message', + source: 'custom', + }, + ], + [ + 'works with messaging system and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'message', + source: 'component', + }, + ], + [ + 'works with faas trigger', + { + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'test-faas-trigger', + source: 'route', + }, + ], + [ + 'works with faas trigger and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'test-faas-trigger', + source: 'custom', + }, + ], + [ + 'works with faas trigger and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'test-faas-trigger', + source: 'custom', + }, + ], + [ + 'works with faas trigger and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'test-faas-trigger', + source: 'component', + }, + ], + ])('%s', (_, attributes, name, kind, expected) => { + const actual = parseSpanDescription({ attributes, kind, name } as unknown as Span); + expect(actual).toEqual(expected); + }); +}); + +describe('descriptionForHttpMethod', () => { + it.each([ + [ + 'works without attributes', + 'GET', + {}, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'test name', + source: 'custom', + }, + ], + [ + 'works with basic client GET', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'GET https://www.example.com/my-path', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'url', + }, + ], + [ + 'works with prefetch request', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + 'sentry.http.prefetch': true, + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client.prefetch', + description: 'GET https://www.example.com/my-path', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'url', + }, + ], + [ + 'works with basic server POST', + 'POST', + { + [SEMATTRS_HTTP_METHOD]: 'POST', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.SERVER, + { + op: 'http.server', + description: 'POST /my-path', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'url', + }, + ], + [ + 'works with client GET with route', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'GET /my-path/:id', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'route', + }, + ], + [ + 'works with basic client GET with SpanKind.INTERNAL', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.INTERNAL, + { + op: 'http', + description: 'test name', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'custom', + }, + ], + [ + "doesn't overwrite span name with source custom", + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'test name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'custom', + }, + ], + [ + 'takes user-passed span name (with source custom)', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'custom name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'custom', + }, + ], + [ + 'takes user-passed span name (with source component)', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'custom name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'component', + }, + ], + ])('%s', (_, httpMethod, attributes, name, kind, expected) => { + const actual = descriptionForHttpMethod({ attributes, kind, name }, httpMethod); + expect(actual).toEqual(expected); + }); +}); + +describe('getSanitizedUrl', () => { + it.each([ + [ + 'works without attributes', + {}, + SpanKind.CLIENT, + { + urlPath: undefined, + url: undefined, + fragment: undefined, + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses url without query for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: 'http://example.com/', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses url without hash for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/sub#hash', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/sub#hash', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: 'http://example.com/sub', + url: 'http://example.com/sub', + fragment: '#hash', + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses route if available for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [ATTR_HTTP_ROUTE]: '/my-route', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: '/my-route', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: true, + }, + ], + [ + 'falls back to target for client request if url not available', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: '/', + url: undefined, + fragment: undefined, + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses target without query for server request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses target without hash for server request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/sub#hash', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/sub', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses route for server request if available', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [ATTR_HTTP_ROUTE]: '/my-route', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/my-route', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: true, + }, + ], + ])('%s', (_, attributes, kind, expected) => { + const actual = getSanitizedUrl(attributes, kind); + + expect(actual).toEqual(expected); + }); +}); + +describe('getUserUpdatedNameAndSource', () => { + it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { + expect(getUserUpdatedNameAndSource('base name', {})).toEqual({ description: 'base name', source: 'custom' }); + }); + + it('returns param name with custom fallback source if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { + expect(getUserUpdatedNameAndSource('base name', {}, 'route')).toEqual({ + description: 'base name', + source: 'route', + }); + }); + + it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not a string', () => { + expect(getUserUpdatedNameAndSource('base name', { [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 123 })).toEqual({ + description: 'base name', + source: 'custom', + }); + }); + + it.each(['custom', 'task', 'url', 'route'])( + 'returns `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute if is a string and source is %s', + source => { + expect( + getUserUpdatedNameAndSource('base name', { + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + }), + ).toEqual({ + description: 'custom name', + source, + }); + }, + ); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts new file mode 100644 index 000000000000..8f453bb9792c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts @@ -0,0 +1,44 @@ +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { openTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('openTelemetrySetupCheck', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + cleanupOtel(provider); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('returns empty array by default', () => { + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual([]); + }); + + it('returns all setup parts', () => { + const client = new TestClient(getDefaultTestClientOptions()); + [provider] = setupOtel(client); + + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual(['SentrySpanProcessor', 'SentrySampler', 'SentryPropagator', 'SentryContextManager']); + }); + + it('returns partial setup parts', () => { + const client = new TestClient(getDefaultTestClientOptions()); + provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + spanProcessors: [new SentrySpanProcessor()], + }); + + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor']); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts new file mode 100644 index 000000000000..fbf6e1b69991 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts @@ -0,0 +1,108 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { captureException, setCurrentClient } from '@sentry/core'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('setupEventContextTrace', () => { + const beforeSend = vi.fn(() => null); + let client: TestClientInterface; + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + sampleRate: 1, + tracesSampleRate: 1, + beforeSend, + debug: true, + dsn: PUBLIC_DSN, + }), + ); + + setCurrentClient(client); + client.init(); + + setupEventContextTrace(client); + [provider] = setupOtel(client); + }); + + afterEach(() => { + beforeSend.mockReset(); + cleanupOtel(provider); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it('works with no active span', async () => { + const error = new Error('test'); + captureException(error); + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }), + }), + expect.objectContaining({ + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }), + ); + }); + + it('works with active span', async () => { + const error = new Error('test'); + + let outerId: string | undefined; + let innerId: string | undefined; + let traceId: string | undefined; + + client.tracer.startActiveSpan('outer', outerSpan => { + outerId = outerSpan.spanContext().spanId; + traceId = outerSpan.spanContext().traceId; + + client.tracer.startActiveSpan('inner', innerSpan => { + innerId = innerSpan.spanContext().spanId; + captureException(error); + }); + }); + + await client.flush(); + + expect(outerId).toBeDefined(); + expect(innerId).toBeDefined(); + expect(traceId).toBeDefined(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: innerId, + parent_span_id: outerId, + trace_id: traceId, + }, + }), + }), + expect.objectContaining({ + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }), + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts new file mode 100644 index 000000000000..c1f9fe2a18c7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts @@ -0,0 +1,78 @@ +import type { Span, SpanOptions } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + spanToJSON, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('spanToJSON', () => { + describe('OpenTelemetry Span', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string, params?: SpanOptions): Span { + return trace.getTracer('test').startSpan(name, params); + } + + it('works with a simple span', () => { + const span = createSpan('test span', { startTime: [123, 0] }); + + expect(spanToJSON(span)).toEqual({ + span_id: span.spanContext().spanId, + trace_id: span.spanContext().traceId, + start_timestamp: 123, + description: 'test span', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }); + }); + + it('works with a full span', () => { + const span = createSpan('test span', { startTime: [123, 0] }); + + span.setAttributes({ + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }); + + span.setStatus({ code: 2, message: 'unknown_error' }); + span.end([456, 0]); + + expect(spanToJSON(span)).toEqual({ + span_id: span.spanContext().spanId, + trace_id: span.spanContext().traceId, + start_timestamp: 123, + timestamp: 456, + description: 'test span', + op: 'test op', + origin: 'auto', + data: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + status: 'unknown_error', + }); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts new file mode 100644 index 000000000000..00c9eccdf98e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts @@ -0,0 +1,80 @@ +import type { Span } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { + spanHasAttributes, + spanHasEvents, + spanHasKind, + spanHasParentId, +} from '../../../../packages/opentelemetry/src/utils/spanTypes'; + +describe('spanTypes', () => { + describe('spanHasAttributes', () => { + it.each([ + [{}, false], + [{ attributes: null }, false], + [{ attributes: {} }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasAttributes(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.attributes).toBeDefined(); + } + }); + }); + + describe('spanHasKind', () => { + it.each([ + [{}, false], + [{ kind: null }, false], + [{ kind: 0 }, true], + [{ kind: 5 }, true], + [{ kind: 'TEST_KIND' }, false], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasKind(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.kind).toBeDefined(); + } + }); + }); + + describe('spanHasParentId', () => { + it.each([ + [{}, false], + [{ parentSpanId: null }, false], + [{ parentSpanId: 'TEST_PARENT_ID' }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasParentId(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.parentSpanId).toBeDefined(); + } + }); + }); + + describe('spanHasEvents', () => { + it.each([ + [{}, false], + [{ events: null }, false], + [{ events: [] }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasEvents(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.events).toBeDefined(); + } + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.json b/dev-packages/opentelemetry-v2-tests/tsconfig.json new file mode 100644 index 000000000000..b9f9b425c7df --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "types": ["node", "vitest/globals"] + }, + "include": ["test/**/*", "vite.config.ts"] +} diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.test.json b/dev-packages/opentelemetry-v2-tests/tsconfig.test.json new file mode 100644 index 000000000000..ca7dbeb3be94 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + + // other package-specific, test-specific options + } +} diff --git a/dev-packages/opentelemetry-v2-tests/vite.config.ts b/dev-packages/opentelemetry-v2-tests/vite.config.ts new file mode 100644 index 000000000000..d7ea407dfac7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/vite.config.ts @@ -0,0 +1,11 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + coverage: { + enabled: false, + }, + }, +}; diff --git a/package.json b/package.json index 6bdca4b79365..13e1a600e83d 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "dev-packages/size-limit-gh-action", "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", - "dev-packages/rollup-utils" + "dev-packages/rollup-utils", + "dev-packages/opentelemetry-v2-tests" ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index ffdce534792e..8018a62a20d4 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -139,7 +139,18 @@ export function spanToJSON(span: Span): SpanJSON { // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { - const { attributes, startTime, name, endTime, parentSpanId, status, links } = span; + const { attributes, startTime, name, endTime, status, links } = span; + + // In preparation for the next major of OpenTelemetry, we want to support + // looking up the parent span id according to the new API + // In OTel v1, the parent span id is accessed as `parentSpanId` + // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + const parentSpanId = + 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; return { span_id, diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index ee1387b8bf82..3ad6aac7e027 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -43,11 +43,11 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.28.0" + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/instrumentation": "^0.57.1 || ^0.200.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/opentelemetry/src/custom/client.ts b/packages/opentelemetry/src/custom/client.ts index 70afb6f10752..a1f0e4792048 100644 --- a/packages/opentelemetry/src/custom/client.ts +++ b/packages/opentelemetry/src/custom/client.ts @@ -49,12 +49,7 @@ export function wrapClientClass< */ public async flush(timeout?: number): Promise { const provider = this.traceProvider; - const spanProcessor = provider?.activeSpanProcessor; - - if (spanProcessor) { - await spanProcessor.forceFlush(); - } - + await provider?.forceFlush(); return super.flush(timeout); } } diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ca7d2823feee..f9c403a47dfc 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -28,6 +28,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes'; +import { getParentSpanId } from './utils/getParentSpanId'; import { getRequestSpanData } from './utils/getRequestSpanData'; import type { SpanNode } from './utils/groupSpansWithParents'; import { getLocalParentId, groupSpansWithParents } from './utils/groupSpansWithParents'; @@ -255,7 +256,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve // even if `span.parentSpanId` is set // this is the case when we are starting a new trace, where we have a virtual span based on the propagationContext // We only want to continue the traceId in this case, but ignore the parent span - const parent_span_id = span.parentSpanId; + const parent_span_id = getParentSpanId(span); const status = mapStatus(span); @@ -321,8 +322,9 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS const span_id = span.spanContext().spanId; const trace_id = span.spanContext().traceId; + const parentSpanId = getParentSpanId(span); - const { attributes, startTime, endTime, parentSpanId, links } = span; + const { attributes, startTime, endTime, links } = span; const { op, description, data, origin = 'manual' } = getSpanData(span); const allData = { diff --git a/packages/opentelemetry/src/utils/getParentSpanId.ts b/packages/opentelemetry/src/utils/getParentSpanId.ts new file mode 100644 index 000000000000..63f4ab0b80f4 --- /dev/null +++ b/packages/opentelemetry/src/utils/getParentSpanId.ts @@ -0,0 +1,16 @@ +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; + +/** + * Get the parent span id from a span. + * In OTel v1, the parent span id is accessed as `parentSpanId` + * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + */ +export function getParentSpanId(span: ReadableSpan): string | undefined { + if ('parentSpanId' in span) { + return span.parentSpanId as string | undefined; + } else if ('parentSpanContext' in span) { + return (span.parentSpanContext as { spanId?: string } | undefined)?.spanId; + } + + return undefined; +} diff --git a/packages/opentelemetry/src/utils/groupSpansWithParents.ts b/packages/opentelemetry/src/utils/groupSpansWithParents.ts index ddc779e9f760..fcbb635d4b2b 100644 --- a/packages/opentelemetry/src/utils/groupSpansWithParents.ts +++ b/packages/opentelemetry/src/utils/groupSpansWithParents.ts @@ -1,5 +1,6 @@ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from '../semanticAttributes'; +import { getParentSpanId } from './getParentSpanId'; export interface SpanNode { id: string; @@ -33,7 +34,7 @@ export function getLocalParentId(span: ReadableSpan): string | undefined { const parentIsRemote = span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE] === true; // If the parentId is the trace parent ID, we pretend it's undefined // As this means the parent exists somewhere else - return !parentIsRemote ? span.parentSpanId : undefined; + return !parentIsRemote ? getParentSpanId(span) : undefined; } function createOrUpdateSpanNodeAndRefs(nodeMap: SpanMap, span: ReadableSpan): void { diff --git a/packages/opentelemetry/src/utils/spanTypes.ts b/packages/opentelemetry/src/utils/spanTypes.ts index 2009692177ac..15b7a5a987ad 100644 --- a/packages/opentelemetry/src/utils/spanTypes.ts +++ b/packages/opentelemetry/src/utils/spanTypes.ts @@ -1,6 +1,7 @@ import type { SpanKind, SpanStatus } from '@opentelemetry/api'; import type { ReadableSpan, TimedEvent } from '@opentelemetry/sdk-trace-base'; import type { AbstractSpan } from '../types'; +import { getParentSpanId } from './getParentSpanId'; /** * Check if a given span has attributes. @@ -55,7 +56,7 @@ export function spanHasParentId( span: SpanType, ): span is SpanType & { parentSpanId: string } { const castSpan = span as ReadableSpan; - return !!castSpan.parentSpanId; + return !!getParentSpanId(castSpan); } /** diff --git a/yarn.lock b/yarn.lock index 65c36ff15a0d..d42e0e48ea6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,14 +3912,14 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@git+https://github.com/getsentry/fastify-otel.git#otel-v1": +"@fastify/otel@getsentry/fastify-otel#otel-v1": version "0.8.0" - resolved "git+https://github.com/getsentry/fastify-otel.git#39826f0b6bb23e82fc83819d96c5440a504ab5bc" + resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/d6bb1756c3db3d00d4d82c39c93ee3316e06d305" dependencies: "@opentelemetry/core" "^1.30.1" "@opentelemetry/instrumentation" "^0.57.2" "@opentelemetry/semantic-conventions" "^1.28.0" - minimatch "^10.0.1" + minimatch "^9" "@gar/promisify@^1.1.3": version "1.1.3" @@ -5420,6 +5420,13 @@ dependencies: "@octokit/openapi-types" "^18.0.0" +"@opentelemetry/api-logs@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz#f9015fd844920c13968715b3cdccf5a4d4ff907e" + integrity sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q== + dependencies: + "@opentelemetry/api" "^1.3.0" + "@opentelemetry/api-logs@0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc" @@ -5444,6 +5451,11 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz#4f76280691a742597fd0bf682982126857622948" integrity sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA== +"@opentelemetry/context-async-hooks@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.0.tgz#c98a727238ca199cda943780acf6124af8d8cd80" + integrity sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA== + "@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.26.0", "@opentelemetry/core@^1.30.1", "@opentelemetry/core@^1.8.0": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.30.1.tgz#a0b468bb396358df801881709ea38299fc30ab27" @@ -5451,6 +5463,13 @@ dependencies: "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/core@2.0.0", "@opentelemetry/core@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.0.0.tgz#37e9f0e9ddec4479b267aca6f32d88757c941b3a" + integrity sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/instrumentation-amqplib@^0.46.1": version "0.46.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz#7101678488d0e942162ca85c9ac6e93e1f3e0008" @@ -5681,6 +5700,17 @@ semver "^7.5.2" shimmer "^1.2.1" +"@opentelemetry/instrumentation@^0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.200.0.tgz#29d1d4f70cbf0cb1ca9f2f78966379b0be96bddc" + integrity sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@types/shimmer" "^1.2.0" + import-in-the-middle "^1.8.1" + require-in-the-middle "^7.1.1" + shimmer "^1.2.1" + "@opentelemetry/instrumentation@^0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48" @@ -5711,6 +5741,14 @@ "@opentelemetry/core" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/resources@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.0.0.tgz#15c04794c32b7d0b3c7589225ece6ae9bba25989" + integrity sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/sdk-trace-base@^1.30.1": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz#41a42234096dc98e8f454d24551fc80b816feb34" @@ -5720,15 +5758,24 @@ "@opentelemetry/resources" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/sdk-trace-base@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.0.tgz#ebc06ea7537dea62f3882f8236c1234f4faf6b23" + integrity sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/semantic-conventions@1.28.0": version "1.28.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== -"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.30.0": - version "1.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz#a15e8f78f32388a7e4655e7f539570e40958ca3f" - integrity sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ== +"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0": + version "1.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.33.0.tgz#ec8ebd2ac768ab366aff94e0e7f27e8ae24fa49f" + integrity sha512-TIpZvE8fiEILFfTlfPnltpBaD3d9/+uQHVCyC3vfdh6WfCXKhNFzoP5RyDDIndfvZC5GrA4pyEDNyjPloJud+w== "@opentelemetry/sql-common@^0.40.1": version "0.40.1" @@ -20807,7 +20854,7 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.0.0, minimatch@^10.0.1: +minimatch@^10.0.0: version "10.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== @@ -20828,7 +20875,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0, minimatch@^9.0.4: +minimatch@^9, minimatch@^9.0.0, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==