diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84c26af7e9b1..909ab774a048 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -823,6 +823,7 @@ jobs: 'standard-frontend-react-tracing-import', 'sveltekit', 'generic-ts3.8', + 'node-experimental-fastify-app', ] build-command: - false diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/.gitignore b/packages/e2e-tests/test-applications/node-experimental-fastify-app/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/.npmrc b/packages/e2e-tests/test-applications/node-experimental-fastify-app/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/event-proxy-server.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/event-proxy-server.ts new file mode 100644 index 000000000000..67cf80b4dabf --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/event-proxy-server.ts @@ -0,0 +1,253 @@ +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/package.json b/packages/e2e-tests/test-applications/node-experimental-fastify-app/package.json new file mode 100644 index 000000000000..8ada1cb5d82e --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/package.json @@ -0,0 +1,32 @@ +{ + "name": "node-experimental-fastify-app", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node src/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node-experimental": "latest || *", + "@sentry/types": "latest || *", + "@sentry/core": "latest || *", + "@sentry/utils": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry-node": "latest || *", + "@sentry-internal/tracing": "latest || *", + "@types/node": "18.15.1", + "fastify": "4.23.2", + "fastify-plugin": "4.5.1", + "typescript": "4.9.5", + "ts-node": "10.9.1" + }, + "devDependencies": { + "@playwright/test": "^1.38.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts new file mode 100644 index 000000000000..f39997dc76e8 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts @@ -0,0 +1,62 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const fastifyPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${fastifyPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: 'pnpm start', + port: fastifyPort, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js b/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js new file mode 100644 index 000000000000..62e194170fa8 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js @@ -0,0 +1,62 @@ +require('./tracing'); + +const Sentry = require('@sentry/node-experimental'); +const { fastify } = require('fastify'); +const fastifyPlugin = require('fastify-plugin'); + +const FastifySentry = fastifyPlugin(async (fastify, options) => { + fastify.decorateRequest('_sentryContext', null); + + fastify.addHook('onError', async (_request, _reply, error) => { + Sentry.captureException(error); + }); +}); + +const app = fastify(); +const port = 3030; + +app.register(FastifySentry); + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', async function (req, res) { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + + await Sentry.flush(); + + res.send({ + transactionIds: global.transactionIds || [], + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.listen({ port: port }); + +Sentry.addGlobalEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; +}); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/tracing.js b/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/tracing.js new file mode 100644 index 000000000000..e571a4374a9e --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/src/tracing.js @@ -0,0 +1,10 @@ +const Sentry = require('@sentry/node-experimental'); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [], + debug: true, + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/start-event-proxy.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/start-event-proxy.ts new file mode 100644 index 000000000000..7ae352993f3c --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-experimental-fastify-app', +}); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/server.test.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/server.test.ts new file mode 100644 index 000000000000..9a9848eefa1a --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/server.test.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 30_000; + +test('Sends exception to Sentry', async ({ baseURL }) => { + const { data } = await axios.get(`${baseURL}/test-error`); + const { exceptionId } = data; + + const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`; + + console.log(`Polling for error eventId: ${exceptionId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get(url, { headers: { Authorization: `Bearer ${authToken}` } }); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { timeout: EVENT_POLLING_TIMEOUT }, + ) + .toBe(200); +}); + +test('Sends transactions to Sentry', async ({ baseURL }) => { + const { data } = await axios.get(`${baseURL}/test-transaction`); + const { transactionIds } = data; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(transactionIds)}`); + + expect(transactionIds.length).toBe(1); + + await Promise.all( + transactionIds.map(async (transactionId: string) => { + const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionId}/`; + + await expect + .poll( + async () => { + try { + const response = await axios.get(url, { headers: { Authorization: `Bearer ${authToken}` } }); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { timeout: EVENT_POLLING_TIMEOUT }, + ) + .toBe(200); + }), + ); +}); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts new file mode 100644 index 000000000000..00cc2b149e13 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts @@ -0,0 +1,124 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; +import axios, { AxiosError } from 'axios'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 30_000; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await axios.get(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + tags: { + 'http.status_code': 200, + }, + trace_id: expect.any(String), + }, + }), + + spans: [ + { + data: { + 'plugin.name': 'fastify -> app-auto-0', + 'fastify.type': 'request_handler', + 'http.route': '/test-transaction', + 'otel.kind': 'INTERNAL', + }, + description: 'request handler - anonymous', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.otel.fastify', + }, + { + data: { + 'otel.kind': 'INTERNAL', + }, + description: 'test-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + { + data: { + 'otel.kind': 'INTERNAL', + }, + description: 'child-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + ], + tags: { + 'http.status_code': 200, + }, + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tsconfig.json b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tsconfig.json new file mode 100644 index 000000000000..17bd2c1f4c00 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "outDir": "dist" + }, + "include": ["*.ts"] +} diff --git a/packages/e2e-tests/test-registry.npmrc b/packages/e2e-tests/test-registry.npmrc index c35d987cca9f..fd8ba6605a28 100644 --- a/packages/e2e-tests/test-registry.npmrc +++ b/packages/e2e-tests/test-registry.npmrc @@ -1,3 +1,6 @@ @sentry:registry=http://localhost:4873 @sentry-internal:registry=http://localhost:4873 //localhost:4873/:_authToken=some-token + +# Do not notify about npm updates +update-notifier=false diff --git a/packages/node-experimental/package.json b/packages/node-experimental/package.json index 9146e163a42d..98e8b6f5e73e 100644 --- a/packages/node-experimental/package.json +++ b/packages/node-experimental/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@opentelemetry/api": "~1.6.0", + "@opentelemetry/core": "~1.17.0", "@opentelemetry/context-async-hooks": "~1.17.0", "@opentelemetry/instrumentation": "~0.43.0", "@opentelemetry/instrumentation-express": "~0.33.1", diff --git a/packages/node-experimental/src/constants.ts b/packages/node-experimental/src/constants.ts index dc714590556a..930574157d73 100644 --- a/packages/node-experimental/src/constants.ts +++ b/packages/node-experimental/src/constants.ts @@ -1,3 +1,15 @@ import { createContextKey } from '@opentelemetry/api'; export const OTEL_CONTEXT_HUB_KEY = createContextKey('sentry_hub'); + +export const OTEL_ATTR_ORIGIN = 'sentry.origin'; +export const OTEL_ATTR_OP = 'sentry.op'; +export const OTEL_ATTR_SOURCE = 'sentry.source'; + +export const OTEL_ATTR_PARENT_SAMPLED = 'sentry.parentSampled'; + +export const OTEL_ATTR_BREADCRUMB_TYPE = 'sentry.breadcrumb.type'; +export const OTEL_ATTR_BREADCRUMB_LEVEL = 'sentry.breadcrumb.level'; +export const OTEL_ATTR_BREADCRUMB_EVENT_ID = 'sentry.breadcrumb.event_id'; +export const OTEL_ATTR_BREADCRUMB_CATEGORY = 'sentry.breadcrumb.category'; +export const OTEL_ATTR_BREADCRUMB_DATA = 'sentry.breadcrumb.data'; diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index 3c7fa347cf94..d1f04a48bd72 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -12,6 +12,7 @@ export { INTEGRATIONS as Integrations }; export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanceIntegrations'; export * as Handlers from './sdk/handlers'; export * from './sdk/trace'; +export { getActiveSpan } from './utils/getActiveSpan'; export { getCurrentHub, getHubFromCarrier } from './sdk/hub'; export { @@ -39,7 +40,6 @@ export { makeMain, runWithAsyncContext, Scope, - startTransaction, SDK_VERSION, setContext, setExtra, diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 6a4b8766a242..09092d680174 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -1,26 +1,19 @@ -import type { Attributes } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { hasTracingEnabled, isSentryRequestUrl, Transaction } from '@sentry/core'; -import { getCurrentHub } from '@sentry/node'; -import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; +import { hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; import type { EventProcessor, Hub, Integration } from '@sentry/types'; +import { stringMatchesSomePattern } from '@sentry/utils'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; -import type { NodeExperimentalClient, OtelSpan } from '../types'; +import { OTEL_ATTR_ORIGIN } from '../constants'; +import { setOtelSpanMetadata } from '../opentelemetry/spanData'; +import type { NodeExperimentalClient } from '../sdk/client'; +import { getCurrentHub } from '../sdk/hub'; +import type { OtelSpan } from '../types'; import { getRequestSpanData } from '../utils/getRequestSpanData'; import { getRequestUrl } from '../utils/getRequestUrl'; -interface TracingOptions { - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - interface HttpOptions { /** * Whether breadcrumbs should be recorded for requests @@ -32,7 +25,12 @@ interface HttpOptions { * Whether tracing spans should be created for requests * Defaults to false */ - tracing?: TracingOptions | boolean; + spans?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs matching the given patterns. + */ + ignoreOutgoingRequests?: (string | RegExp)[]; } /** @@ -54,12 +52,16 @@ export class Http implements Integration { */ public name: string; + /** + * If spans for HTTP requests should be captured. + */ + public shouldCreateSpansForRequests: boolean; + private _unload?: () => void; private readonly _breadcrumbs: boolean; - // undefined: default behavior based on tracing settings - private readonly _tracing: boolean | undefined; - private _shouldCreateSpans: boolean; - private _shouldCreateSpanForRequest?: (url: string) => boolean; + // If this is undefined, use default behavior based on client settings + private readonly _spans: boolean | undefined; + private _ignoreOutgoingRequests: (string | RegExp)[]; /** * @inheritDoc @@ -67,12 +69,12 @@ export class Http implements Integration { public constructor(options: HttpOptions = {}) { this.name = Http.id; this._breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; - this._tracing = typeof options.tracing === 'undefined' ? undefined : !!options.tracing; - this._shouldCreateSpans = false; + this._spans = typeof options.spans === 'undefined' ? undefined : options.spans; - if (options.tracing && typeof options.tracing === 'object') { - this._shouldCreateSpanForRequest = options.tracing.shouldCreateSpanForRequest; - } + this._ignoreOutgoingRequests = options.ignoreOutgoingRequests || []; + + // Properly set in setupOnce based on client settings + this.shouldCreateSpansForRequests = false; } /** @@ -80,14 +82,16 @@ export class Http implements Integration { */ public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { // No need to instrument if we don't want to track anything - if (!this._breadcrumbs && this._tracing === false) { + if (!this._breadcrumbs && this._spans === false) { return; } const client = getCurrentHub().getClient(); const clientOptions = client?.getOptions(); - this._shouldCreateSpans = typeof this._tracing === 'undefined' ? hasTracingEnabled(clientOptions) : this._tracing; + // This is used in the sampler function + this.shouldCreateSpansForRequests = + typeof this._spans === 'boolean' ? this._spans : hasTracingEnabled(clientOptions); // Register instrumentations we care about this._unload = registerInstrumentations({ @@ -95,7 +99,20 @@ export class Http implements Integration { new HttpInstrumentation({ ignoreOutgoingRequestHook: request => { const url = getRequestUrl(request); - return url ? isSentryRequestUrl(url, getCurrentHub()) : false; + + if (!url) { + return false; + } + + if (isSentryRequestUrl(url, getCurrentHub())) { + return true; + } + + if (this._ignoreOutgoingRequests.length && stringMatchesSomePattern(url, this._ignoreOutgoingRequests)) { + return true; + } + + return false; }, ignoreIncomingRequestHook: request => { @@ -111,20 +128,15 @@ export class Http implements Integration { requireParentforOutgoingSpans: true, requireParentforIncomingSpans: false, requestHook: (span, req) => { - this._updateSentrySpan(span as unknown as OtelSpan, req); + this._updateSpan(span as unknown as OtelSpan, req); }, responseHook: (span, res) => { + span.spanContext().traceFlags = 0x1; this._addRequestBreadcrumb(span as unknown as OtelSpan, res); }, }), ], }); - - this._shouldCreateSpanForRequest = - // eslint-disable-next-line deprecation/deprecation - this._shouldCreateSpanForRequest || clientOptions?.shouldCreateSpanForRequest; - - client?.on?.('otelSpanEnd', this._onSpanEnd); } /** @@ -134,64 +146,13 @@ export class Http implements Integration { this._unload?.(); } - private _onSpanEnd: (otelSpan: unknown, mutableOptions: { drop: boolean }) => void = ( - otelSpan: unknown, - mutableOptions: { drop: boolean }, - ) => { - if (!this._shouldCreateSpans) { - mutableOptions.drop = true; - return; - } - - if (this._shouldCreateSpanForRequest) { - const url = getHttpUrl((otelSpan as OtelSpan).attributes); - if (url && !this._shouldCreateSpanForRequest(url)) { - mutableOptions.drop = true; - return; - } - } - - return; - }; + /** Update the span with data we need. */ + private _updateSpan(span: OtelSpan, request: ClientRequest | IncomingMessage): void { + span.setAttribute(OTEL_ATTR_ORIGIN, 'auto.http.otel.http'); - /** Update the Sentry span data based on the OTEL span. */ - private _updateSentrySpan(span: OtelSpan, request: ClientRequest | IncomingMessage): void { - const data = getRequestSpanData(span); - const { attributes } = span; - - const sentrySpan = _INTERNAL_getSentrySpan(span.spanContext().spanId); - if (!sentrySpan) { - return; + if (span.kind === SpanKind.SERVER) { + setOtelSpanMetadata(span, { request }); } - - sentrySpan.origin = 'auto.http.otel.http'; - - const additionalData: Record = { - url: data.url, - }; - - if (sentrySpan instanceof Transaction && span.kind === SpanKind.SERVER) { - sentrySpan.setMetadata({ request }); - } - - if (attributes[SemanticAttributes.HTTP_STATUS_CODE]) { - const statusCode = attributes[SemanticAttributes.HTTP_STATUS_CODE] as string; - additionalData['http.response.status_code'] = statusCode; - - sentrySpan.setTag('http.status_code', statusCode); - } - - if (data['http.query']) { - additionalData['http.query'] = data['http.query'].slice(1); - } - if (data['http.fragment']) { - additionalData['http.fragment'] = data['http.fragment'].slice(1); - } - - Object.keys(additionalData).forEach(prop => { - const value = additionalData[prop]; - sentrySpan.setData(prop, value); - }); } /** Add a breadcrumb for outgoing requests. */ @@ -220,8 +181,3 @@ export class Http implements Integration { ); } } - -function getHttpUrl(attributes: Attributes): string | undefined { - const url = attributes[SemanticAttributes.HTTP_URL]; - return typeof url === 'string' ? url : undefined; -} diff --git a/packages/node-experimental/src/opentelemetry/spanData.ts b/packages/node-experimental/src/opentelemetry/spanData.ts new file mode 100644 index 000000000000..2a3c8a20f516 --- /dev/null +++ b/packages/node-experimental/src/opentelemetry/spanData.ts @@ -0,0 +1,50 @@ +import type { Span as OtelSpan } from '@opentelemetry/api'; +import type { Hub, Scope, TransactionMetadata } from '@sentry/types'; + +// We store the parent span, scope & metadata in separate weakmaps, so we can access them for a given span +// This way we can enhance the data that an OTEL Span natively gives us +// and since we are using weakmaps, we do not need to clean up after ourselves +const otelSpanScope = new WeakMap(); +const otelSpanHub = new WeakMap(); +const otelSpanParent = new WeakMap(); +const otelSpanMetadata = new WeakMap>(); + +/** Set the Sentry scope on an OTEL span. */ +export function setOtelSpanScope(span: OtelSpan, scope: Scope): void { + otelSpanScope.set(span, scope); +} + +/** Get the Sentry scope of an OTEL span. */ +export function getOtelSpanScope(span: OtelSpan): Scope | undefined { + return otelSpanScope.get(span); +} + +/** Set the Sentry hub on an OTEL span. */ +export function setOtelSpanHub(span: OtelSpan, hub: Hub): void { + otelSpanHub.set(span, hub); +} + +/** Get the Sentry hub of an OTEL span. */ +export function getOtelSpanHub(span: OtelSpan): Hub | undefined { + return otelSpanHub.get(span); +} + +/** Set the parent OTEL span on an OTEL span. */ +export function setOtelSpanParent(span: OtelSpan, parentSpan: OtelSpan): void { + otelSpanParent.set(span, parentSpan); +} + +/** Get the parent OTEL span of an OTEL span. */ +export function getOtelSpanParent(span: OtelSpan): OtelSpan | undefined { + return otelSpanParent.get(span); +} + +/** Set metadata for an OTEL span. */ +export function setOtelSpanMetadata(span: OtelSpan, metadata: Partial): void { + otelSpanMetadata.set(span, metadata); +} + +/** Get metadata for an OTEL span. */ +export function getOtelSpanMetadata(span: OtelSpan): Partial | undefined { + return otelSpanMetadata.get(span); +} diff --git a/packages/node-experimental/src/opentelemetry/spanExporter.ts b/packages/node-experimental/src/opentelemetry/spanExporter.ts new file mode 100644 index 000000000000..23c0f72e7991 --- /dev/null +++ b/packages/node-experimental/src/opentelemetry/spanExporter.ts @@ -0,0 +1,301 @@ +import { SpanKind } from '@opentelemetry/api'; +import type { ExportResult } from '@opentelemetry/core'; +import { ExportResultCode } from '@opentelemetry/core'; +import type { SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import { flush } from '@sentry/core'; +import { mapOtelStatus, parseOtelSpanDescription } from '@sentry/opentelemetry-node'; +import type { DynamicSamplingContext, Span, SpanOrigin, TransactionSource } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_PARENT_SAMPLED, OTEL_ATTR_SOURCE } from '../constants'; +import { getCurrentHub } from '../sdk/hub'; +import { NodeExperimentalScope } from '../sdk/scope'; +import type { NodeExperimentalTransaction } from '../sdk/transaction'; +import { startTransaction } from '../sdk/transaction'; +import type { OtelSpan } from '../types'; +import { convertOtelTimeToSeconds } from '../utils/convertOtelTimeToSeconds'; +import { getRequestSpanData } from '../utils/getRequestSpanData'; +import type { OtelSpanNode } from '../utils/groupOtelSpansWithParents'; +import { groupOtelSpansWithParents } from '../utils/groupOtelSpansWithParents'; +import { getOtelSpanHub, getOtelSpanMetadata, getOtelSpanScope } from './spanData'; + +/** + * A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. + */ +export class SentrySpanExporter implements SpanExporter { + private _finishedSpans: OtelSpan[]; + private _stopped: boolean; + + public constructor() { + this._stopped = false; + this._finishedSpans = []; + } + + /** @inheritDoc */ + public export(spans: OtelSpan[], resultCallback: (result: ExportResult) => void): void { + if (this._stopped) { + return resultCallback({ + code: ExportResultCode.FAILED, + error: new Error('Exporter has been stopped'), + }); + } + + const openSpanCount = this._finishedSpans.length; + const newSpanCount = spans.length; + + this._finishedSpans.push(...spans); + + const remainingSpans = maybeSend(this._finishedSpans); + + const remainingOpenSpanCount = remainingSpans.length; + const sentSpanCount = openSpanCount + newSpanCount - remainingOpenSpanCount; + + __DEBUG_BUILD__ && + logger.log(`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} unsent spans remaining`); + + this._finishedSpans = remainingSpans.filter(span => { + const shouldDrop = shouldCleanupSpan(span, 5 * 60); + __DEBUG_BUILD__ && + shouldDrop && + logger.log( + `SpanExporter dropping span ${span.name} (${ + span.spanContext().spanId + }) because it is pending for more than 5 minutes.`, + ); + return !shouldDrop; + }); + + setTimeout(() => resultCallback({ code: ExportResultCode.SUCCESS }), 0); + } + + /** @inheritDoc */ + public shutdown(): Promise { + this._stopped = true; + this._finishedSpans = []; + return this.forceFlush(); + } + + /** @inheritDoc */ + public async forceFlush(): Promise { + await flush(); + } +} + +/** Send the given spans, but only if they are part of a finished transaction. Returns the unsent spans. */ +function maybeSend(spans: OtelSpan[]): OtelSpan[] { + const grouped = groupOtelSpansWithParents(spans); + const remaining = new Set(grouped); + + const rootNodes = grouped.filter(node => node.span && !node.parentNode); + + rootNodes.forEach(root => { + remaining.delete(root); + const span = root.span as OtelSpan; + const transaction = createTransactionForOtelSpan(span); + + root.children.forEach(child => { + createAndFinishSpanForOtelSpan(child, transaction, remaining); + }); + + // Now finish the transaction, which will send it together with all the spans + // We make sure to use the current span as the activeSpan for this transaction + const scope = getOtelSpanScope(span); + const forkedScope = NodeExperimentalScope.clone( + scope as NodeExperimentalScope | undefined, + ) as NodeExperimentalScope; + forkedScope.activeSpan = span; + + transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), forkedScope); + }); + + return Array.from(remaining) + .map(node => node.span as OtelSpan) + .filter(Boolean); +} + +function shouldCleanupSpan(span: OtelSpan, maxStartTimeOffsetSeconds: number): boolean { + const cutoff = Date.now() / 1000 - maxStartTimeOffsetSeconds; + return convertOtelTimeToSeconds(span.startTime) < cutoff; +} + +function parseSpan(otelSpan: OtelSpan): { op?: string; origin?: SpanOrigin; source?: TransactionSource } { + const attributes = otelSpan.attributes; + + const origin = attributes[OTEL_ATTR_ORIGIN] as SpanOrigin | undefined; + const op = attributes[OTEL_ATTR_OP] as string | undefined; + const source = attributes[OTEL_ATTR_SOURCE] as TransactionSource | undefined; + + return { origin, op, source }; +} + +function createTransactionForOtelSpan(span: OtelSpan): NodeExperimentalTransaction { + const scope = getOtelSpanScope(span); + const hub = getOtelSpanHub(span) || getCurrentHub(); + const spanContext = span.spanContext(); + const spanId = spanContext.spanId; + const traceId = spanContext.traceId; + const parentSpanId = span.parentSpanId; + + const parentSampled = span.attributes[OTEL_ATTR_PARENT_SAMPLED] as boolean | undefined; + const dynamicSamplingContext: DynamicSamplingContext | undefined = scope + ? scope.getPropagationContext().dsc + : undefined; + + const { op, description, tags, data, origin, source } = getSpanData(span); + const metadata = getOtelSpanMetadata(span); + + const transaction = startTransaction(hub, { + spanId, + traceId, + parentSpanId, + parentSampled, + name: description, + op, + instrumenter: 'otel', + status: mapOtelStatus(span), + startTimestamp: convertOtelTimeToSeconds(span.startTime), + metadata: { + dynamicSamplingContext, + source, + ...metadata, + }, + data: removeSentryAttributes(data), + origin, + tags, + }) as NodeExperimentalTransaction; + + transaction.setContext('otel', { + attributes: removeSentryAttributes(span.attributes), + resource: span.resource.attributes, + }); + + return transaction; +} + +function createAndFinishSpanForOtelSpan( + node: OtelSpanNode, + sentryParentSpan: Span, + remaining: Set, +): void { + remaining.delete(node); + const otelSpan = node.span; + + const shouldDrop = !otelSpan; + + // If this span should be dropped, we still want to create spans for the children of this + if (shouldDrop) { + node.children.forEach(child => { + createAndFinishSpanForOtelSpan(child, sentryParentSpan, remaining); + }); + return; + } + + const otelSpanId = otelSpan.spanContext().spanId; + const { attributes } = otelSpan; + + const { op, description, tags, data, origin } = getSpanData(otelSpan); + const allData = { ...removeSentryAttributes(attributes), ...data }; + + const sentrySpan = sentryParentSpan.startChild({ + description, + op, + data: allData, + status: mapOtelStatus(otelSpan), + instrumenter: 'otel', + startTimestamp: convertOtelTimeToSeconds(otelSpan.startTime), + spanId: otelSpanId, + origin, + tags, + }); + + node.children.forEach(child => { + createAndFinishSpanForOtelSpan(child, sentrySpan, remaining); + }); + + sentrySpan.finish(convertOtelTimeToSeconds(otelSpan.endTime)); +} + +function getSpanData(span: OtelSpan): { + tags: Record; + data: Record; + op?: string; + description: string; + source?: TransactionSource; + origin?: SpanOrigin; +} { + const { op: definedOp, source: definedSource, origin } = parseSpan(span); + const { op: inferredOp, description, source: inferredSource, data: inferredData } = parseOtelSpanDescription(span); + + const op = definedOp || inferredOp; + const source = definedSource || inferredSource; + + const tags = getTags(span); + const data = { ...inferredData, ...getData(span) }; + + return { + op, + description, + source, + origin, + tags, + data, + }; +} + +/** + * Remove custom `sentry.` attribtues we do not need to send. + * These are more carrier attributes we use inside of the SDK, we do not need to send them to the API. + */ +function removeSentryAttributes(data: Record): Record { + const cleanedData = { ...data }; + + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + delete cleanedData[OTEL_ATTR_PARENT_SAMPLED]; + delete cleanedData[OTEL_ATTR_ORIGIN]; + delete cleanedData[OTEL_ATTR_OP]; + delete cleanedData[OTEL_ATTR_SOURCE]; + /* eslint-enable @typescript-eslint/no-dynamic-delete */ + + return cleanedData; +} + +function getTags(span: OtelSpan): Record { + const attributes = span.attributes; + const tags: Record = {}; + + if (attributes[SemanticAttributes.HTTP_STATUS_CODE]) { + const statusCode = attributes[SemanticAttributes.HTTP_STATUS_CODE] as string; + + tags['http.status_code'] = statusCode; + } + + return tags; +} + +function getData(span: OtelSpan): Record { + const attributes = span.attributes; + const data: Record = { + 'otel.kind': SpanKind[span.kind], + }; + + if (attributes[SemanticAttributes.HTTP_STATUS_CODE]) { + const statusCode = attributes[SemanticAttributes.HTTP_STATUS_CODE] as string; + data['http.response.status_code'] = statusCode; + } + + const requestData = getRequestSpanData(span); + + if (requestData.url) { + data.url = requestData.url; + } + + if (requestData['http.query']) { + data['http.query'] = requestData['http.query'].slice(1); + } + if (requestData['http.fragment']) { + data['http.fragment'] = requestData['http.fragment'].slice(1); + } + + return data; +} diff --git a/packages/node-experimental/src/opentelemetry/spanProcessor.ts b/packages/node-experimental/src/opentelemetry/spanProcessor.ts new file mode 100644 index 000000000000..ab64883ef5a1 --- /dev/null +++ b/packages/node-experimental/src/opentelemetry/spanProcessor.ts @@ -0,0 +1,112 @@ +import type { Context } from '@opentelemetry/api'; +import { ROOT_CONTEXT, SpanKind, trace } from '@opentelemetry/api'; +import type { SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import { + _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY, + maybeCaptureExceptionForTimedEvent, +} from '@sentry/opentelemetry-node'; +import type { Hub, TraceparentData } from '@sentry/types'; + +import { OTEL_ATTR_PARENT_SAMPLED, OTEL_CONTEXT_HUB_KEY } from '../constants'; +import { Http } from '../integrations'; +import type { NodeExperimentalClient } from '../sdk/client'; +import { getCurrentHub } from '../sdk/hub'; +import type { OtelSpan } from '../types'; +import { getOtelSpanHub, setOtelSpanHub, setOtelSpanParent, setOtelSpanScope } from './spanData'; +import { SentrySpanExporter } from './spanExporter'; + +/** + * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via + * the Sentry SDK. + */ +export class SentrySpanProcessor extends BatchSpanProcessor implements OtelSpanProcessor { + public constructor() { + super(new SentrySpanExporter()); + } + + /** + * @inheritDoc + */ + public onStart(span: OtelSpan, parentContext: Context): void { + // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK + const parentSpan = trace.getSpan(parentContext) as OtelSpan | undefined; + const hub = parentContext.getValue(OTEL_CONTEXT_HUB_KEY) as Hub | undefined; + + // We need access to the parent span in order to be able to move up the span tree for breadcrumbs + if (parentSpan) { + setOtelSpanParent(span, parentSpan); + } + + // The root context does not have a hub stored, so we check for this specifically + // We do this instead of just falling back to `getCurrentHub` to avoid attaching the wrong hub + let actualHub = hub; + if (parentContext === ROOT_CONTEXT) { + actualHub = getCurrentHub(); + } + + // We need the scope at time of span creation in order to apply it to the event when the span is finished + if (actualHub) { + setOtelSpanScope(span, actualHub.getScope()); + setOtelSpanHub(span, actualHub); + } + + // We need to set this here based on the parent context + const parentSampled = getParentSampled(span, parentContext); + if (typeof parentSampled === 'boolean') { + span.setAttribute(OTEL_ATTR_PARENT_SAMPLED, parentSampled); + } + + return super.onStart(span, parentContext); + } + + /** @inheritDoc */ + public onEnd(span: OtelSpan): void { + if (!shouldCaptureSentrySpan(span)) { + // Prevent this being called to super.onEnd(), which would pass this to the span exporter + return; + } + + // Capture exceptions as events + const hub = getOtelSpanHub(span) || getCurrentHub(); + span.events.forEach(event => { + maybeCaptureExceptionForTimedEvent(hub, event, span); + }); + + return super.onEnd(span); + } +} + +function getTraceParentData(parentContext: Context): TraceparentData | undefined { + return parentContext.getValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY) as TraceparentData | undefined; +} + +function getParentSampled(span: OtelSpan, parentContext: Context): boolean | undefined { + const spanContext = span.spanContext(); + const traceId = spanContext.traceId; + const traceparentData = getTraceParentData(parentContext); + + // Only inherit sample rate if `traceId` is the same + return traceparentData && traceId === traceparentData.traceId ? traceparentData.parentSampled : undefined; +} + +function shouldCaptureSentrySpan(span: OtelSpan): boolean { + const client = getCurrentHub().getClient(); + const httpIntegration = client ? client.getIntegration(Http) : undefined; + + // If we encounter a client or server span with url & method, we assume this comes from the http instrumentation + // In this case, if `shouldCreateSpansForRequests` is false, we want to _record_ the span but not _sample_ it, + // So we can generate a breadcrumb for it but no span will be sent + if ( + httpIntegration && + (span.kind === SpanKind.CLIENT || span.kind === SpanKind.SERVER) && + span.attributes[SemanticAttributes.HTTP_URL] && + span.attributes[SemanticAttributes.HTTP_METHOD] && + !httpIntegration.shouldCreateSpansForRequests + ) { + return false; + } + + return true; +} diff --git a/packages/node-experimental/src/sdk/client.ts b/packages/node-experimental/src/sdk/client.ts index 29f68980f008..a3145475e307 100644 --- a/packages/node-experimental/src/sdk/client.ts +++ b/packages/node-experimental/src/sdk/client.ts @@ -1,5 +1,6 @@ import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { EventHint, Scope } from '@sentry/node'; import { NodeClient, SDK_VERSION } from '@sentry/node'; import type { Event } from '@sentry/types'; @@ -8,12 +9,13 @@ import type { NodeExperimentalClient as NodeExperimentalClientInterface, NodeExperimentalClientOptions, } from '../types'; -import { OtelScope } from './scope'; +import { NodeExperimentalScope } from './scope'; /** * A client built on top of the NodeClient, which provides some otel-specific things on top. */ export class NodeExperimentalClient extends NodeClient implements NodeExperimentalClientInterface { + public traceProvider: BasicTracerProvider | undefined; private _tracer: Tracer | undefined; public constructor(options: ConstructorParameters[0]) { @@ -54,16 +56,30 @@ export class NodeExperimentalClient extends NodeClient implements NodeExperiment return super.getOptions(); } + /** + * @inheritDoc + */ + public async flush(timeout?: number): Promise { + const provider = this.traceProvider; + const spanProcessor = provider?.activeSpanProcessor; + + if (spanProcessor) { + await spanProcessor.forceFlush(); + } + + return super.flush(timeout); + } + /** * Extends the base `_prepareEvent` so that we can properly handle `captureContext`. - * This uses `Scope.clone()`, which we need to replace with `OtelScope.clone()` for this client. + * This uses `Scope.clone()`, which we need to replace with `NodeExperimentalScope.clone()` for this client. */ protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { let actualScope = scope; // Remove `captureContext` hint and instead clone already here if (hint && hint.captureContext) { - actualScope = OtelScope.clone(scope); + actualScope = NodeExperimentalScope.clone(scope); delete hint.captureContext; } diff --git a/packages/node-experimental/src/sdk/hub.ts b/packages/node-experimental/src/sdk/hub.ts index 8220265e600c..50958d13c84d 100644 --- a/packages/node-experimental/src/sdk/hub.ts +++ b/packages/node-experimental/src/sdk/hub.ts @@ -3,12 +3,14 @@ import { Hub } from '@sentry/core'; import type { Client } from '@sentry/types'; import { getGlobalSingleton, GLOBAL_OBJ } from '@sentry/utils'; -import { OtelScope } from './scope'; +import { NodeExperimentalScope } from './scope'; -/** A custom hub that ensures we always creat an OTEL scope. */ - -class OtelHub extends Hub { - public constructor(client?: Client, scope: Scope = new OtelScope()) { +/** + * A custom hub that ensures we always creat an OTEL scope. + * Exported only for testing + */ +export class NodeExperimentalHub extends Hub { + public constructor(client?: Client, scope: Scope = new NodeExperimentalScope()) { super(client, scope); } @@ -17,7 +19,7 @@ class OtelHub extends Hub { */ public pushScope(): Scope { // We want to clone the content of prev scope - const scope = OtelScope.clone(this.getScope()); + const scope = NodeExperimentalScope.clone(this.getScope()); this.getStack().push({ client: this.getClient(), scope, @@ -29,11 +31,11 @@ class OtelHub extends Hub { /** * ******************************************************************************* * Everything below here is a copy of the stuff from core's hub.ts, - * only that we make sure to create our custom OtelScope instead of the default Scope. + * only that we make sure to create our custom NodeExperimentalScope instead of the default Scope. * This is necessary to get the correct breadcrumbs behavior. * - * Basically, this overwrites all places that do `new Scope()` with `new OtelScope()`. - * Which in turn means overwriting all places that do `new Hub()` and make sure to pass in a OtelScope instead. + * Basically, this overwrites all places that do `new Scope()` with `new NodeExperimentalScope()`. + * Which in turn means overwriting all places that do `new Hub()` and make sure to pass in a NodeExperimentalScope instead. * ******************************************************************************* */ @@ -77,7 +79,7 @@ export function getCurrentHub(): Hub { * @hidden */ export function getHubFromCarrier(carrier: Carrier): Hub { - return getGlobalSingleton('hub', () => new OtelHub(), carrier); + return getGlobalSingleton('hub', () => new NodeExperimentalHub(), carrier); } /** @@ -89,14 +91,17 @@ export function ensureHubOnCarrier(carrier: Carrier, parent: Hub = getGlobalHub( // If there's no hub on current domain, or it's an old API, assign a new one if (!hasHubOnCarrier(carrier) || getHubFromCarrier(carrier).isOlderThan(API_VERSION)) { const globalHubTopStack = parent.getStackTop(); - setHubOnCarrier(carrier, new OtelHub(globalHubTopStack.client, OtelScope.clone(globalHubTopStack.scope))); + setHubOnCarrier( + carrier, + new NodeExperimentalHub(globalHubTopStack.client, NodeExperimentalScope.clone(globalHubTopStack.scope)), + ); } } function getGlobalHub(registry: Carrier = getMainCarrier()): Hub { // If there's no hub, or its an old API, assign a new one if (!hasHubOnCarrier(registry) || getHubFromCarrier(registry).isOlderThan(API_VERSION)) { - setHubOnCarrier(registry, new OtelHub()); + setHubOnCarrier(registry, new NodeExperimentalHub()); } // Return hub that lives on a global object diff --git a/packages/node-experimental/src/sdk/hubextensions.ts b/packages/node-experimental/src/sdk/hubextensions.ts index 4971226fee01..07ee08c1f7f9 100644 --- a/packages/node-experimental/src/sdk/hubextensions.ts +++ b/packages/node-experimental/src/sdk/hubextensions.ts @@ -1,11 +1,5 @@ -import type { startTransaction } from '@sentry/core'; import { addTracingExtensions as _addTracingExtensions, getMainCarrier } from '@sentry/core'; -import type { Breadcrumb, Hub, Transaction } from '@sentry/types'; -import { dateTimestampInSeconds } from '@sentry/utils'; - -import type { TransactionWithBreadcrumbs } from '../types'; - -const DEFAULT_MAX_BREADCRUMBS = 100; +import type { CustomSamplingContext, TransactionContext } from '@sentry/types'; /** * Add tracing extensions, ensuring a patched `startTransaction` to work with OTEL. @@ -19,62 +13,18 @@ export function addTracingExtensions(): void { } carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; - if (carrier.__SENTRY__.extensions.startTransaction) { - carrier.__SENTRY__.extensions.startTransaction = getPatchedStartTransaction( - carrier.__SENTRY__.extensions.startTransaction as typeof startTransaction, - ); - } -} - -/** - * We patch the `startTransaction` function to ensure we create a `TransactionWithBreadcrumbs` instead of a regular `Transaction`. - */ -function getPatchedStartTransaction(_startTransaction: typeof startTransaction): typeof startTransaction { - return function (this: Hub, ...args) { - const transaction = _startTransaction.apply(this, args); - - return patchTransaction(transaction); - }; -} - -function patchTransaction(transaction: Transaction): TransactionWithBreadcrumbs { - return new Proxy(transaction as TransactionWithBreadcrumbs, { - get(target, prop, receiver) { - if (prop === 'addBreadcrumb') { - return addBreadcrumb; - } - if (prop === 'getBreadcrumbs') { - return getBreadcrumbs; - } - if (prop === '_breadcrumbs') { - const breadcrumbs = Reflect.get(target, prop, receiver); - return breadcrumbs || []; - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -/** Add a breadcrumb to a transaction. */ -function addBreadcrumb(this: TransactionWithBreadcrumbs, breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void { - const maxCrumbs = typeof maxBreadcrumbs === 'number' ? maxBreadcrumbs : DEFAULT_MAX_BREADCRUMBS; - - // No data has been changed, so don't notify scope listeners - if (maxCrumbs <= 0) { - return; + if (carrier.__SENTRY__.extensions.startTransaction !== startTransactionNoop) { + carrier.__SENTRY__.extensions.startTransaction = startTransactionNoop; } - - const mergedBreadcrumb = { - timestamp: dateTimestampInSeconds(), - ...breadcrumb, - }; - - const breadcrumbs = this._breadcrumbs; - breadcrumbs.push(mergedBreadcrumb); - this._breadcrumbs = breadcrumbs.length > maxCrumbs ? breadcrumbs.slice(-maxCrumbs) : breadcrumbs; } -/** Get all breadcrumbs from a transaction. */ -function getBreadcrumbs(this: TransactionWithBreadcrumbs): Breadcrumb[] { - return this._breadcrumbs; +function startTransactionNoop( + _transactionContext: TransactionContext, + _customSamplingContext?: CustomSamplingContext, +): unknown { + // eslint-disable-next-line no-console + console.warn('startTransaction is a noop in @sentry/node-experimental. Use `startSpan` instead.'); + // We return an object here as hub.ts checks for the result of this + // and renders a different warning if this is empty + return {}; } diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index 070728367925..588b98cd1b43 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -5,6 +5,7 @@ import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerforman import { Http } from '../integrations/http'; import type { NodeExperimentalOptions } from '../types'; import { NodeExperimentalClient } from './client'; +import { getCurrentHub } from './hub'; import { initOtel } from './initOtel'; import { setOtelContextAsyncContextStrategy } from './otelAsyncContextStrategy'; @@ -19,6 +20,10 @@ export const defaultIntegrations = [ * Initialize Sentry for Node. */ export function init(options: NodeExperimentalOptions | undefined = {}): void { + // Ensure we register our own global hub before something else does + // This will register the NodeExperimentalHub as the global hub + getCurrentHub(); + const isTracingEnabled = hasTracingEnabled(options); options.defaultIntegrations = diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 3ed0e2ab2b2b..855a443889bb 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -2,18 +2,21 @@ import { diag, DiagLogLevel } from '@opentelemetry/api'; import { Resource } from '@opentelemetry/resources'; import { AlwaysOnSampler, BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; -import { getCurrentHub, SDK_VERSION } from '@sentry/core'; -import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node'; +import { SDK_VERSION } from '@sentry/core'; +import { SentryPropagator } from '@sentry/opentelemetry-node'; import { logger } from '@sentry/utils'; +import { SentrySpanProcessor } from '../opentelemetry/spanProcessor'; import type { NodeExperimentalClient } from '../types'; +import { setupEventContextTrace } from '../utils/setupEventContextTrace'; import { SentryContextManager } from './../opentelemetry/contextManager'; +import { getCurrentHub } from './hub'; /** * Initialize OpenTelemetry for Node. * We use the @sentry/opentelemetry-node package to communicate with OpenTelemetry. */ -export function initOtel(): () => void { +export function initOtel(): void { const client = getCurrentHub().getClient(); if (client?.getOptions().debug) { @@ -27,6 +30,18 @@ export function initOtel(): () => void { diag.setLogger(otelLogger, DiagLogLevel.DEBUG); } + if (client) { + setupEventContextTrace(client); + } + + const provider = setupOtel(); + if (client) { + client.traceProvider = provider; + } +} + +/** Just exported for tests. */ +export function setupOtel(): BasicTracerProvider { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new AlwaysOnSampler(), @@ -35,6 +50,7 @@ export function initOtel(): () => void { [SemanticResourceAttributes.SERVICE_NAMESPACE]: 'sentry', [SemanticResourceAttributes.SERVICE_VERSION]: SDK_VERSION, }), + forceFlushTimeoutMillis: 500, }); provider.addSpanProcessor(new SentrySpanProcessor()); @@ -47,9 +63,5 @@ export function initOtel(): () => void { contextManager, }); - // Cleanup function - return () => { - void provider.forceFlush(); - void provider.shutdown(); - }; + return provider; } diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts index 12fcc6862904..889af264cf15 100644 --- a/packages/node-experimental/src/sdk/scope.ts +++ b/packages/node-experimental/src/sdk/scope.ts @@ -1,16 +1,33 @@ +import type { TimedEvent } from '@opentelemetry/sdk-trace-base'; import { Scope } from '@sentry/core'; -import type { Breadcrumb } from '@sentry/types'; +import type { Breadcrumb, SeverityLevel, Span } from '@sentry/types'; +import { dateTimestampInSeconds, dropUndefinedKeys, logger } from '@sentry/utils'; -import type { TransactionWithBreadcrumbs } from '../types'; -import { getActiveSpan } from './trace'; +import { + OTEL_ATTR_BREADCRUMB_CATEGORY, + OTEL_ATTR_BREADCRUMB_DATA, + OTEL_ATTR_BREADCRUMB_EVENT_ID, + OTEL_ATTR_BREADCRUMB_LEVEL, + OTEL_ATTR_BREADCRUMB_TYPE, +} from '../constants'; +import { getOtelSpanParent } from '../opentelemetry/spanData'; +import type { OtelSpan } from '../types'; +import { convertOtelTimeToSeconds } from '../utils/convertOtelTimeToSeconds'; +import { getActiveSpan, getRootSpan } from '../utils/getActiveSpan'; /** A fork of the classic scope with some otel specific stuff. */ -export class OtelScope extends Scope { +export class NodeExperimentalScope extends Scope { + /** + * This can be set to ensure the scope uses _this_ span as the active one, + * instead of using getActiveSpan(). + */ + public activeSpan: OtelSpan | undefined; + /** * @inheritDoc */ public static clone(scope?: Scope): Scope { - const newScope = new OtelScope(); + const newScope = new NodeExperimentalScope(); if (scope) { newScope._breadcrumbs = [...scope['_breadcrumbs']]; newScope._tags = { ...scope['_tags'] }; @@ -31,14 +48,42 @@ export class OtelScope extends Scope { return newScope; } + /** + * In node-experimental, scope.getSpan() always returns undefined. + * Instead, use the global `getActiveSpan()`. + */ + public getSpan(): undefined { + __DEBUG_BUILD__ && + logger.warn('Calling getSpan() is a noop in @sentry/node-experimental. Use `getActiveSpan()` instead.'); + + return undefined; + } + + /** + * In node-experimental, scope.setSpan() is a noop. + * Instead, use the global `startSpan()` to define the active span. + */ + public setSpan(_span: Span): this { + __DEBUG_BUILD__ && + logger.warn('Calling setSpan() is a noop in @sentry/node-experimental. Use `startSpan()` instead.'); + + return this; + } + /** * @inheritDoc */ public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { - const transaction = getActiveTransaction(); + const activeSpan = this.activeSpan || getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + if (rootSpan) { + const mergedBreadcrumb = { + timestamp: dateTimestampInSeconds(), + ...breadcrumb, + }; - if (transaction && transaction.addBreadcrumb) { - transaction.addBreadcrumb(breadcrumb, maxBreadcrumbs); + rootSpan.addEvent(...breadcrumbToOtelEvent(mergedBreadcrumb)); return this; } @@ -49,18 +94,78 @@ export class OtelScope extends Scope { * @inheritDoc */ protected _getBreadcrumbs(): Breadcrumb[] { - const transaction = getActiveTransaction(); - const transactionBreadcrumbs = transaction && transaction.getBreadcrumbs ? transaction.getBreadcrumbs() : []; + const span = this.activeSpan || getActiveSpan(); - return this._breadcrumbs.concat(transactionBreadcrumbs); + const spanBreadcrumbs = span ? getBreadcrumbsForSpan(span) : []; + + return spanBreadcrumbs.length > 0 ? this._breadcrumbs.concat(spanBreadcrumbs) : this._breadcrumbs; } } /** - * This gets the currently active transaction, - * and ensures to wrap it so that we can store breadcrumbs on it. + * Get all breadcrumbs for the given span as well as it's parents. */ -function getActiveTransaction(): TransactionWithBreadcrumbs | undefined { - const activeSpan = getActiveSpan(); - return activeSpan && (activeSpan.transaction as TransactionWithBreadcrumbs | undefined); +function getBreadcrumbsForSpan(span: OtelSpan): Breadcrumb[] { + const events = span ? getOtelEvents(span) : []; + + return events.map(otelEventToBreadcrumb); +} + +function breadcrumbToOtelEvent(breadcrumb: Breadcrumb): Parameters { + const name = breadcrumb.message || ''; + + return [ + name, + dropUndefinedKeys({ + [OTEL_ATTR_BREADCRUMB_TYPE]: breadcrumb.type, + [OTEL_ATTR_BREADCRUMB_LEVEL]: breadcrumb.level, + [OTEL_ATTR_BREADCRUMB_EVENT_ID]: breadcrumb.event_id, + [OTEL_ATTR_BREADCRUMB_CATEGORY]: breadcrumb.category, + [OTEL_ATTR_BREADCRUMB_DATA]: + breadcrumb.data && Object.keys(breadcrumb.data).length > 0 ? JSON.stringify(breadcrumb.data) : undefined, + }), + breadcrumb.timestamp ? new Date(breadcrumb.timestamp * 1000) : undefined, + ]; +} + +function otelEventToBreadcrumb(event: TimedEvent): Breadcrumb { + const attributes = event.attributes || {}; + + const type = attributes[OTEL_ATTR_BREADCRUMB_TYPE] as string | undefined; + const level = attributes[OTEL_ATTR_BREADCRUMB_LEVEL] as SeverityLevel | undefined; + const eventId = attributes[OTEL_ATTR_BREADCRUMB_EVENT_ID] as string | undefined; + const category = attributes[OTEL_ATTR_BREADCRUMB_CATEGORY] as string | undefined; + const dataStr = attributes[OTEL_ATTR_BREADCRUMB_DATA] as string | undefined; + + const breadcrumb: Breadcrumb = dropUndefinedKeys({ + timestamp: convertOtelTimeToSeconds(event.time), + message: event.name, + type, + level, + event_id: eventId, + category, + }); + + if (typeof dataStr === 'string') { + try { + const data = JSON.parse(dataStr); + breadcrumb.data = data; + } catch (e) {} // eslint-disable-line no-empty + } + + return breadcrumb; +} + +function getOtelEvents(span: OtelSpan, events: TimedEvent[] = []): TimedEvent[] { + if (span.events) { + events.push(...span.events); + } + + // Go up parent chain and collect events + const parent = getOtelSpanParent(span) as OtelSpan | undefined; + if (parent) { + return getOtelEvents(parent, events); + } + + return events; } diff --git a/packages/node-experimental/src/sdk/trace.ts b/packages/node-experimental/src/sdk/trace.ts index 1faf780ec5c7..0fde392baf48 100644 --- a/packages/node-experimental/src/sdk/trace.ts +++ b/packages/node-experimental/src/sdk/trace.ts @@ -1,11 +1,12 @@ -import type { Span as OtelSpan, Tracer } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import { getCurrentHub, hasTracingEnabled, Transaction } from '@sentry/core'; -import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; -import type { Span, TransactionContext } from '@sentry/types'; +import type { Tracer } from '@opentelemetry/api'; +import { SpanStatusCode } from '@opentelemetry/api'; +import { hasTracingEnabled } from '@sentry/core'; import { isThenable } from '@sentry/utils'; -import type { NodeExperimentalClient } from '../types'; +import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_SOURCE } from '../constants'; +import { setOtelSpanMetadata } from '../opentelemetry/spanData'; +import type { NodeExperimentalClient, NodeExperimentalSpanContext, OtelSpan } from '../types'; +import { getCurrentHub } from './hub'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. @@ -18,32 +19,26 @@ import type { NodeExperimentalClient } from '../types'; * or you didn't set `tracesSampleRate`, this function will not generate spans * and the `span` returned from the callback will be undefined. */ -export function startSpan(context: TransactionContext, callback: (span: Span | undefined) => T): T { +export function startSpan(spanContext: NodeExperimentalSpanContext, callback: (span: OtelSpan | undefined) => T): T { const tracer = getTracer(); if (!tracer) { return callback(undefined); } - const name = context.name || context.description || context.op || ''; - - return tracer.startActiveSpan(name, (span: OtelSpan): T => { - const otelSpanId = span.spanContext().spanId; - - const sentrySpan = _INTERNAL_getSentrySpan(otelSpanId); - - if (sentrySpan && isTransaction(sentrySpan) && context.metadata) { - sentrySpan.setMetadata(context.metadata); - } + const { name } = spanContext; + return tracer.startActiveSpan(name, (span): T => { function finishSpan(): void { span.end(); } + _initSpan(span as OtelSpan, spanContext); + let maybePromiseResult: T; try { - maybePromiseResult = callback(sentrySpan); + maybePromiseResult = callback(span as OtelSpan); } catch (e) { - sentrySpan && sentrySpan.setStatus('internal_error'); + span.setStatus({ code: SpanStatusCode.ERROR }); finishSpan(); throw e; } @@ -54,7 +49,7 @@ export function startSpan(context: TransactionContext, callback: (span: Span finishSpan(); }, () => { - sentrySpan && sentrySpan.setStatus('internal_error'); + span.setStatus({ code: SpanStatusCode.ERROR }); finishSpan(); }, ); @@ -81,50 +76,19 @@ export const startActiveSpan = startSpan; * or you didn't set `tracesSampleRate` or `tracesSampler`, this function will not generate spans * and the `span` returned from the callback will be undefined. */ -export function startInactiveSpan(context: TransactionContext): Span | undefined { +export function startInactiveSpan(spanContext: NodeExperimentalSpanContext): OtelSpan | undefined { const tracer = getTracer(); if (!tracer) { return undefined; } - const name = context.name || context.description || context.op || ''; - const otelSpan = tracer.startSpan(name); - - const otelSpanId = otelSpan.spanContext().spanId; - - const sentrySpan = _INTERNAL_getSentrySpan(otelSpanId); - - if (!sentrySpan) { - return undefined; - } - - if (isTransaction(sentrySpan) && context.metadata) { - sentrySpan.setMetadata(context.metadata); - } + const { name } = spanContext; - // Monkey-patch `finish()` to finish the OTEL span instead - // This will also in turn finish the Sentry Span, so no need to call this ourselves - const wrappedSentrySpan = new Proxy(sentrySpan, { - get(target, prop, receiver) { - if (prop === 'finish') { - return () => { - otelSpan.end(); - }; - } - return Reflect.get(target, prop, receiver); - }, - }); + const span = tracer.startSpan(name) as OtelSpan; - return wrappedSentrySpan; -} + _initSpan(span, spanContext); -/** - * Returns the currently active span. - */ -export function getActiveSpan(): Span | undefined { - const otelSpan = trace.getActiveSpan(); - const spanId = otelSpan && otelSpan.spanContext().spanId; - return spanId ? _INTERNAL_getSentrySpan(spanId) : undefined; + return span; } function getTracer(): Tracer | undefined { @@ -136,6 +100,22 @@ function getTracer(): Tracer | undefined { return client && client.tracer; } -function isTransaction(span: Span): span is Transaction { - return span instanceof Transaction; +function _initSpan(span: OtelSpan, spanContext: NodeExperimentalSpanContext): void { + const { origin, op, source, metadata } = spanContext; + + if (origin) { + span.setAttribute(OTEL_ATTR_ORIGIN, origin); + } + + if (op) { + span.setAttribute(OTEL_ATTR_OP, op); + } + + if (source) { + span.setAttribute(OTEL_ATTR_SOURCE, source); + } + + if (metadata) { + setOtelSpanMetadata(span, metadata); + } } diff --git a/packages/node-experimental/src/sdk/transaction.ts b/packages/node-experimental/src/sdk/transaction.ts new file mode 100644 index 000000000000..c301dd6e9521 --- /dev/null +++ b/packages/node-experimental/src/sdk/transaction.ts @@ -0,0 +1,62 @@ +import type { Hub } from '@sentry/core'; +import { sampleTransaction, Transaction } from '@sentry/core'; +import type { + ClientOptions, + CustomSamplingContext, + Hub as HubInterface, + Scope, + TransactionContext, +} from '@sentry/types'; +import { uuid4 } from '@sentry/utils'; + +/** + * This is a fork of core's tracing/hubextensions.ts _startTransaction, + * with some OTEL specifics. + */ +export function startTransaction( + hub: HubInterface, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, +): Transaction { + const client = hub.getClient(); + const options: Partial = (client && client.getOptions()) || {}; + + let transaction = new NodeExperimentalTransaction(transactionContext, hub as Hub); + transaction = sampleTransaction(transaction, options, { + parentSampled: transactionContext.parentSampled, + transactionContext, + ...customSamplingContext, + }); + if (transaction.sampled) { + transaction.initSpanRecorder(options._experiments && (options._experiments.maxSpans as number)); + } + if (client && client.emit) { + client.emit('startTransaction', transaction); + } + return transaction; +} + +/** + * This is a fork of the base Transaction with OTEL specific stuff added. + */ +export class NodeExperimentalTransaction extends Transaction { + /** + * Finish the transaction, but apply the given scope instead of the current one. + */ + public finishWithScope(endTimestamp?: number, scope?: Scope): string | undefined { + const event = this._finishTransaction(endTimestamp); + + if (!event) { + return undefined; + } + + const client = this._hub.getClient(); + + if (!client) { + return undefined; + } + + const eventId = uuid4(); + return client.captureEvent(event, { event_id: eventId }, scope); + } +} diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 0fd9a6922a78..f64aa7893764 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,29 +1,23 @@ import type { Tracer } from '@opentelemetry/api'; -import type { Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; +import type { BasicTracerProvider, Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import type { Breadcrumb, Transaction } from '@sentry/types'; +import type { SpanOrigin, TransactionMetadata, TransactionSource } from '@sentry/types'; export type NodeExperimentalOptions = NodeOptions; export type NodeExperimentalClientOptions = ConstructorParameters[0]; export interface NodeExperimentalClient extends NodeClient { tracer: Tracer; + traceProvider: BasicTracerProvider | undefined; getOptions(): NodeExperimentalClientOptions; } -/** - * This is a fork of the base Transaction with OTEL specific stuff added. - * Note that we do not solve this via an actual subclass, but by wrapping this in a proxy when we need it - - * as we can't easily control all the places a transaction may be created. - */ -export interface TransactionWithBreadcrumbs extends Transaction { - _breadcrumbs: Breadcrumb[]; - - /** Get all breadcrumbs added to this transaction. */ - getBreadcrumbs(): Breadcrumb[]; - - /** Add a breadcrumb to this transaction. */ - addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void; +export interface NodeExperimentalSpanContext { + name: string; + op?: string; + metadata?: Partial; + origin?: SpanOrigin; + source?: TransactionSource; } export type { OtelSpan }; diff --git a/packages/node-experimental/src/utils/addOriginToSpan.ts b/packages/node-experimental/src/utils/addOriginToSpan.ts index 4320d31d7fce..19033b970157 100644 --- a/packages/node-experimental/src/utils/addOriginToSpan.ts +++ b/packages/node-experimental/src/utils/addOriginToSpan.ts @@ -1,14 +1,10 @@ // We are using the broader OtelSpan type from api here, as this is also what integrations etc. use import type { Span as OtelSpan } from '@opentelemetry/api'; -import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; import type { SpanOrigin } from '@sentry/types'; +import { OTEL_ATTR_ORIGIN } from '../constants'; + /** Adds an origin to an OTEL Span. */ export function addOriginToOtelSpan(otelSpan: OtelSpan, origin: SpanOrigin): void { - const sentrySpan = _INTERNAL_getSentrySpan(otelSpan.spanContext().spanId); - if (!sentrySpan) { - return; - } - - sentrySpan.origin = origin; + otelSpan.setAttribute(OTEL_ATTR_ORIGIN, origin); } diff --git a/packages/node-experimental/src/utils/convertOtelTimeToSeconds.ts b/packages/node-experimental/src/utils/convertOtelTimeToSeconds.ts new file mode 100644 index 000000000000..64087aeffc4d --- /dev/null +++ b/packages/node-experimental/src/utils/convertOtelTimeToSeconds.ts @@ -0,0 +1,4 @@ +/** Convert an OTEL time to seconds */ +export function convertOtelTimeToSeconds([seconds, nano]: [number, number]): number { + return seconds + nano / 1_000_000_000; +} diff --git a/packages/node-experimental/src/utils/getActiveSpan.ts b/packages/node-experimental/src/utils/getActiveSpan.ts new file mode 100644 index 000000000000..9fcfd6f0e508 --- /dev/null +++ b/packages/node-experimental/src/utils/getActiveSpan.ts @@ -0,0 +1,25 @@ +import { trace } from '@opentelemetry/api'; + +import { getOtelSpanParent } from '../opentelemetry/spanData'; +import type { OtelSpan } from '../types'; + +/** + * Returns the currently active span. + */ +export function getActiveSpan(): OtelSpan | undefined { + return trace.getActiveSpan() as OtelSpan | undefined; +} + +/** + * Get the root span for the given span. + * The given span may be the root span itself. + */ +export function getRootSpan(span: OtelSpan): OtelSpan { + let parent = span; + + while (getOtelSpanParent(parent)) { + parent = getOtelSpanParent(parent) as OtelSpan; + } + + return parent; +} diff --git a/packages/node-experimental/src/utils/getRequestSpanData.ts b/packages/node-experimental/src/utils/getRequestSpanData.ts index ca89f5a2b976..586401958b7c 100644 --- a/packages/node-experimental/src/utils/getRequestSpanData.ts +++ b/packages/node-experimental/src/utils/getRequestSpanData.ts @@ -7,12 +7,17 @@ import type { OtelSpan } from '../types'; /** * Get sanitizied request data from an OTEL span. */ -export function getRequestSpanData(span: OtelSpan): SanitizedRequestData { - const data: SanitizedRequestData = { - url: span.attributes[SemanticAttributes.HTTP_URL] as string, - 'http.method': (span.attributes[SemanticAttributes.HTTP_METHOD] as string) || 'GET', +export function getRequestSpanData(span: OtelSpan): Partial { + const data: Partial = { + url: span.attributes[SemanticAttributes.HTTP_URL] as string | undefined, + 'http.method': span.attributes[SemanticAttributes.HTTP_METHOD] as string | undefined, }; + // Default to GET if URL is set but method is not + if (!data['http.method'] && data.url) { + data['http.method'] = 'GET'; + } + try { const urlStr = span.attributes[SemanticAttributes.HTTP_URL]; if (typeof urlStr === 'string') { diff --git a/packages/node-experimental/src/utils/groupOtelSpansWithParents.ts b/packages/node-experimental/src/utils/groupOtelSpansWithParents.ts new file mode 100644 index 000000000000..c0a07293b703 --- /dev/null +++ b/packages/node-experimental/src/utils/groupOtelSpansWithParents.ts @@ -0,0 +1,79 @@ +import { getOtelSpanParent } from '../opentelemetry/spanData'; +import type { OtelSpan } from '../types'; + +export interface OtelSpanNode { + id: string; + span?: OtelSpan; + parentNode?: OtelSpanNode | undefined; + children: OtelSpanNode[]; +} + +type OtelSpanMap = Map; + +/** + * This function runs through a list of OTEL Spans, and wraps them in an `OtelSpanNode` + * where each node holds a reference to their parent node. + */ +export function groupOtelSpansWithParents(otelSpans: OtelSpan[]): OtelSpanNode[] { + const nodeMap: OtelSpanMap = new Map(); + + for (const span of otelSpans) { + createOrUpdateSpanNodeAndRefs(nodeMap, span); + } + + return Array.from(nodeMap, function ([_id, spanNode]) { + return spanNode; + }); +} + +function createOrUpdateSpanNodeAndRefs(nodeMap: OtelSpanMap, span: OtelSpan): void { + const parentSpan = getOtelSpanParent(span); + const parentIsRemote = parentSpan ? !!parentSpan.spanContext().isRemote : false; + + const id = span.spanContext().spanId; + + // If the parentId is the trace parent ID, we pretend it's undefined + // As this means the parent exists somewhere else + const parentId = !parentIsRemote ? span.parentSpanId : undefined; + + if (!parentId) { + createOrUpdateNode(nodeMap, { id, span, children: [] }); + return; + } + + // Else make sure to create parent node as well + // Note that the parent may not know it's parent _yet_, this may be updated in a later pass + const parentNode = createOrGetParentNode(nodeMap, parentId); + const node = createOrUpdateNode(nodeMap, { id, span, parentNode, children: [] }); + parentNode.children.push(node); +} + +function createOrGetParentNode(nodeMap: OtelSpanMap, id: string): OtelSpanNode { + const existing = nodeMap.get(id); + + if (existing) { + return existing; + } + + return createOrUpdateNode(nodeMap, { id, children: [] }); +} + +function createOrUpdateNode(nodeMap: OtelSpanMap, spanNode: OtelSpanNode): OtelSpanNode { + const existing = nodeMap.get(spanNode.id); + + // If span is already set, nothing to do here + if (existing && existing.span) { + return existing; + } + + // If it exists but span is not set yet, we update it + if (existing && !existing.span) { + existing.span = spanNode.span; + existing.parentNode = spanNode.parentNode; + return existing; + } + + // Else, we create a new one... + nodeMap.set(spanNode.id, spanNode); + return spanNode; +} diff --git a/packages/node-experimental/src/utils/setupEventContextTrace.ts b/packages/node-experimental/src/utils/setupEventContextTrace.ts new file mode 100644 index 000000000000..c3b40d1db654 --- /dev/null +++ b/packages/node-experimental/src/utils/setupEventContextTrace.ts @@ -0,0 +1,31 @@ +import type { Client } from '@sentry/types'; + +import { getActiveSpan } from './getActiveSpan'; + +/** Ensure the `trace` context is set on all events. */ +export function setupEventContextTrace(client: Client): void { + if (!client.addEventProcessor) { + return; + } + + client.addEventProcessor(event => { + const otelSpan = getActiveSpan(); + if (!otelSpan) { + return event; + } + + const otelSpanContext = otelSpan.spanContext(); + + // If event has already set `trace` context, use that one. + event.contexts = { + trace: { + trace_id: otelSpanContext.traceId, + span_id: otelSpanContext.spanId, + parent_span_id: otelSpan.parentSpanId, + }, + ...event.contexts, + }; + + return event; + }); +} diff --git a/packages/node-experimental/test/helpers/createSpan.ts b/packages/node-experimental/test/helpers/createSpan.ts new file mode 100644 index 000000000000..a92dda655552 --- /dev/null +++ b/packages/node-experimental/test/helpers/createSpan.ts @@ -0,0 +1,32 @@ +import type { Context, SpanContext } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; +import type { Tracer } from '@opentelemetry/sdk-trace-base'; +import { Span } from '@opentelemetry/sdk-trace-base'; +import { uuid4 } from '@sentry/utils'; + +import type { OtelSpan } from '../../src/types'; + +export function createSpan( + name?: string, + { spanId, parentSpanId }: { spanId?: string; parentSpanId?: string } = {}, +): OtelSpan { + const spanProcessor = { + onStart: () => {}, + onEnd: () => {}, + }; + const tracer = { + resource: 'test-resource', + instrumentationLibrary: 'test-instrumentation-library', + getSpanLimits: () => ({}), + getActiveSpanProcessor: () => spanProcessor, + } as unknown as Tracer; + + const spanContext: SpanContext = { + spanId: spanId || uuid4(), + traceId: uuid4(), + traceFlags: 0, + }; + + // eslint-disable-next-line deprecation/deprecation + return new Span(tracer, {} as Context, name || 'test', spanContext, SpanKind.INTERNAL, parentSpanId); +} diff --git a/packages/node-experimental/test/helpers/mockSdkInit.ts b/packages/node-experimental/test/helpers/mockSdkInit.ts index f7bfb68f6bf6..3443f0608806 100644 --- a/packages/node-experimental/test/helpers/mockSdkInit.ts +++ b/packages/node-experimental/test/helpers/mockSdkInit.ts @@ -1,13 +1,49 @@ +import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { GLOBAL_OBJ } from '@sentry/utils'; + import { init } from '../../src/sdk/init'; import type { NodeExperimentalClientOptions } from '../../src/types'; -// eslint-disable-next-line no-var -declare var global: any; - const PUBLIC_DSN = 'https://username@domain/123'; export function mockSdkInit(options?: Partial) { - global.__SENTRY__ = {}; + GLOBAL_OBJ.__SENTRY__ = { + extensions: {}, + hub: undefined, + globalEventProcessors: [], + logger: undefined, + }; init({ dsn: PUBLIC_DSN, defaultIntegrations: false, ...options }); } + +export function cleanupOtel(_provider?: BasicTracerProvider): void { + const provider = getProvider(_provider); + + if (!provider) { + return; + } + + void provider.forceFlush(); + void provider.shutdown(); + + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} + +export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { + let provider = _provider || trace.getTracerProvider(); + + if (provider instanceof ProxyTracerProvider) { + provider = provider.getDelegate(); + } + + if (!(provider instanceof BasicTracerProvider)) { + return undefined; + } + + return provider; +} diff --git a/packages/node-experimental/test/integration/breadcrumbs.test.ts b/packages/node-experimental/test/integration/breadcrumbs.test.ts new file mode 100644 index 000000000000..fbd46a6bd466 --- /dev/null +++ b/packages/node-experimental/test/integration/breadcrumbs.test.ts @@ -0,0 +1,362 @@ +import { withScope } from '../../src/'; +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub, NodeExperimentalHub } from '../../src/sdk/hub'; +import { startSpan } from '../../src/sdk/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | breadcrumbs', () => { + const beforeSendTransaction = jest.fn(() => null); + + afterEach(() => { + cleanupOtel(); + }); + + describe('without tracing', () => { + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + expect(hub).toBeInstanceOf(NodeExperimentalHub); + expect(client).toBeInstanceOf(NodeExperimentalClient); + + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + hub.addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); + + const error = new Error('test'); + hub.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 scopes', async () => { + const beforeSend = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + expect(hub).toBeInstanceOf(NodeExperimentalHub); + expect(client).toBeInstanceOf(NodeExperimentalClient); + + const error = new Error('test'); + + hub.addBreadcrumb({ timestamp: 123456, message: 'test0' }); + + withScope(() => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + + withScope(() => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test2' }); + hub.captureException(error); + }); + + withScope(() => { + hub.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 = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const error = new Error('test'); + + startSpan({ name: 'test' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + }); + + startSpan({ name: 'inner2' }, () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + hub.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 root span only', async () => { + const beforeSend = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); + }); + + startSpan({ name: 'test2' }, () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + + startSpan({ name: 'inner2' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + hub.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 = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + hub.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 = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); + + startSpan({ name: 'inner2' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test3' }); + + startSpan({ name: 'inner3' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test4' }); + + hub.captureException(error); + + startSpan({ name: 'inner4' }, () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test5' }); + }); + + hub.addBreadcrumb({ timestamp: 123457, message: 'test6' }); + }); + }); + }); + + hub.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 spans', async () => { + const beforeSend = jest.fn(() => null); + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const error = new Error('test'); + + const promise1 = startSpan({ name: 'test' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + await startSpan({ name: 'inner1' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + hub.addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + hub.captureException(error); + }); + + const promise2 = startSpan({ name: 'test-b' }, async () => { + hub.addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + + await startSpan({ name: 'inner1' }, async () => { + hub.addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + hub.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/packages/node-experimental/test/integration/otelTimedEvents.test.ts b/packages/node-experimental/test/integration/otelTimedEvents.test.ts new file mode 100644 index 000000000000..8bdaec750a15 --- /dev/null +++ b/packages/node-experimental/test/integration/otelTimedEvents.test.ts @@ -0,0 +1,57 @@ +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; + +import type { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub } from '../../src/sdk/hub'; +import { startSpan } from '../../src/sdk/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | OTEL TimedEvents', () => { + afterEach(() => { + cleanupOtel(); + }); + + it('captures TimedEvents with name `exception` as exceptions', async () => { + const beforeSend = jest.fn(() => null); + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ beforeSend, beforeSendTransaction, enableTracing: true }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + startSpan({ name: 'test' }, span => { + span?.addEvent('exception', { + [SemanticAttributes.EXCEPTION_MESSAGE]: 'test-message', + 'test-span-event-attr': 'test-span-event-attr-value', + }); + + span?.addEvent('other', { + [SemanticAttributes.EXCEPTION_MESSAGE]: 'test-message-2', + 'test-span-event-attr': 'test-span-event-attr-value', + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + exception: { + values: [ + { + mechanism: { handled: true, type: 'generic' }, + stacktrace: expect.any(Object), + type: 'Error', + value: 'test-message', + }, + ], + }, + }), + { + event_id: expect.any(String), + originalException: expect.any(Error), + syntheticException: expect.any(Error), + }, + ); + }); +}); diff --git a/packages/node-experimental/test/integration/scope.test.ts b/packages/node-experimental/test/integration/scope.test.ts new file mode 100644 index 000000000000..925047583f2e --- /dev/null +++ b/packages/node-experimental/test/integration/scope.test.ts @@ -0,0 +1,235 @@ +import * as Sentry from '../../src/'; +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub, NodeExperimentalHub } from '../../src/sdk/hub'; +import { NodeExperimentalScope } from '../../src/sdk/scope'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | Scope', () => { + afterEach(() => { + cleanupOtel(); + }); + + describe.each([ + ['with tracing', true], + ['without tracing', false], + ])('%s', (_name, enableTracing) => { + it('correctly syncs OTEL context & Sentry hub/scope', async () => { + const beforeSend = jest.fn(() => null); + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ enableTracing, beforeSend, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const rootScope = hub.getScope(); + + expect(hub).toBeInstanceOf(NodeExperimentalHub); + expect(rootScope).toBeInstanceOf(NodeExperimentalScope); + expect(client).toBeInstanceOf(NodeExperimentalClient); + + const error = new Error('test error'); + let spanId: string | undefined; + let traceId: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + Sentry.withScope(scope2b => { + scope2b.setTag('tag3-b', 'val3-b'); + }); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + Sentry.startSpan({ name: 'outer' }, span => { + spanId = span?.spanContext().spanId; + traceId = span?.spanContext().traceId; + + Sentry.setTag('tag4', 'val4'); + + Sentry.captureException(error); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId + ? { + span_id: spanId, + trace_id: traceId, + parent_span_id: undefined, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + + if (enableTracing) { + 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: { 'otel.kind': 'INTERNAL' }, + span_id: spanId, + status: 'ok', + trace_id: traceId, + }, + }), + + spans: [], + start_timestamp: expect.any(Number), + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + timestamp: expect.any(Number), + transaction: 'outer', + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + } + }); + + it('isolates parallel root scopes', async () => { + const beforeSend = jest.fn(() => null); + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ enableTracing, beforeSend, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + const rootScope = hub.getScope(); + + expect(hub).toBeInstanceOf(NodeExperimentalHub); + expect(rootScope).toBeInstanceOf(NodeExperimentalScope); + expect(client).toBeInstanceOf(NodeExperimentalClient); + + 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'); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + Sentry.startSpan({ name: 'outer' }, span => { + spanId1 = span?.spanContext().spanId; + traceId1 = span?.spanContext().traceId; + + Sentry.setTag('tag4', 'val4a'); + + Sentry.captureException(error1); + }); + }); + }); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + Sentry.startSpan({ name: 'outer' }, span => { + spanId2 = span?.spanContext().spanId; + traceId2 = span?.spanContext().traceId; + + Sentry.setTag('tag4', 'val4b'); + + Sentry.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, + parent_span_id: undefined, + } + : 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, + parent_span_id: undefined, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (enableTracing) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + }); +}); diff --git a/packages/node-experimental/test/integration/transactions.test.ts b/packages/node-experimental/test/integration/transactions.test.ts new file mode 100644 index 000000000000..00ec85700316 --- /dev/null +++ b/packages/node-experimental/test/integration/transactions.test.ts @@ -0,0 +1,604 @@ +import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import { _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY } from '@sentry/opentelemetry-node'; +import type { TransactionEvent } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import * as Sentry from '../../src'; +import { startSpan } from '../../src'; +import type { Http } from '../../src/integrations'; +import { SentrySpanProcessor } from '../../src/opentelemetry/spanProcessor'; +import type { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub } from '../../src/sdk/hub'; +import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | Transactions', () => { + afterEach(() => { + jest.restoreAllMocks(); + cleanupOtel(); + }); + + it('correctly creates transaction & spans', async () => { + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ enableTracing: true, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + Sentry.setTag('outer.tag', 'test value'); + + Sentry.startSpan( + { + op: 'test op', + name: 'test name', + source: 'task', + origin: 'auto.test', + metadata: { requestPath: 'test-path' }, + }, + span => { + if (!span) { + return; + } + + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan?.end(); + + Sentry.setTag('test.tag', 'test value'); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + if (!innerSpan) { + return; + } + + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenLastCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ], + contexts: { + otel: { + attributes: { + 'test.outer': 'test value', + }, + resource: { + 'service.name': 'node-experimental', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }, + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { 'otel.kind': 'INTERNAL' }, + op: 'test op', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }, + environment: 'production', + event_id: expect.any(String), + platform: 'node', + sdkProcessingMetadata: { + dynamicSamplingContext: expect.objectContaining({ + environment: 'production', + public_key: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.any(String), + transaction: 'test name', + }), + propagationContext: { + sampled: undefined, + spanId: expect.any(String), + traceId: expect.any(String), + }, + sampleRate: 1, + source: 'task', + spanMetadata: expect.any(Object), + requestPath: 'test-path', + }, + server_name: expect.any(String), + // 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.objectContaining({ + description: 'inner span 1', + }), + expect.objectContaining({ + description: 'inner span 2', + }), + ], + start_timestamp: expect.any(Number), + tags: { + 'outer.tag': 'test value', + }, + 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.map(span => span.toJSON())).toEqual([ + { + data: { 'otel.kind': 'INTERNAL' }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + { + data: { 'otel.kind': 'INTERNAL', 'test.inner': 'test value' }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ]); + }); + + it('correctly creates concurrent transaction & spans', async () => { + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ enableTracing: true, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + + Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { + if (!span) { + return; + } + + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan?.end(); + + Sentry.setTag('test.tag', 'test value'); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + if (!innerSpan) { + return; + } + + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }); + + Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { + if (!span) { + return; + } + + Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value b', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); + subSpan?.end(); + + Sentry.setTag('test.tag', 'test value b'); + + Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { + if (!innerSpan) { + return; + } + + Sentry.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({ + otel: expect.objectContaining({ + attributes: { + 'test.outer': 'test value', + }, + }), + trace: { + data: { 'otel.kind': 'INTERNAL' }, + op: 'test op', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }), + spans: [ + expect.objectContaining({ + description: 'inner span 1', + }), + expect.objectContaining({ + description: 'inner span 2', + }), + ], + start_timestamp: expect.any(Number), + tags: {}, + 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({ + otel: expect.objectContaining({ + attributes: { + 'test.outer': 'test value b', + }, + }), + trace: { + data: { 'otel.kind': 'INTERNAL' }, + op: 'test op b', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }), + spans: [ + expect.objectContaining({ + description: 'inner span 1b', + }), + expect.objectContaining({ + description: 'inner span 2b', + }), + ], + start_timestamp: expect.any(Number), + tags: {}, + 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 = jest.fn(() => null); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + const traceParentData = { + traceId, + parentSpanId, + parentSampled: true, + }; + + mockSdkInit({ enableTracing: true, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with( + trace.setSpanContext( + context.active().setValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY, traceParentData), + spanContext, + ), + () => { + Sentry.startSpan({ op: 'test op', name: 'test name', source: 'task', origin: 'auto.test' }, span => { + if (!span) { + return; + } + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan?.end(); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + if (!innerSpan) { + return; + } + }); + }); + }, + ); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenLastCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + otel: expect.objectContaining({ + attributes: {}, + }), + trace: { + data: { 'otel.kind': 'INTERNAL' }, + op: 'test op', + span_id: expect.any(String), + parent_span_id: parentSpanId, + status: 'ok', + trace_id: traceId, + }, + }), + // 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.objectContaining({ + description: 'inner span 1', + }), + expect.objectContaining({ + description: 'inner span 2', + }), + ], + 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.map(span => span.toJSON())).toEqual([ + { + data: { 'otel.kind': 'INTERNAL' }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + { + data: { 'otel.kind': 'INTERNAL' }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + 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 = jest.fn(() => null); + + const now = Date.now(); + jest.useFakeTimers(); + jest.setSystemTime(now); + + const logs: unknown[] = []; + jest.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + mockSdkInit({ enableTracing: true, beforeSendTransaction }); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + const provider = getProvider(); + const multiSpanProcessor = provider?.activeSpanProcessor as + | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) + | undefined; + const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( + spanProcessor => spanProcessor instanceof SentrySpanProcessor, + ) as SentrySpanProcessor | undefined; + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + let innerSpan1Id: string | undefined; + let innerSpan2Id: string | undefined; + + void Sentry.startSpan({ name: 'test name' }, async span => { + if (!span) { + return; + } + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + innerSpan1Id = subSpan?.spanContext().spanId; + subSpan?.end(); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + if (!innerSpan) { + return; + } + + innerSpan2Id = innerSpan.spanContext().spanId; + }); + + // Pretend this is pending for 10 minutes + await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000)); + }); + + // Nothing added to exporter yet + expect(exporter['_finishedSpans'].length).toBe(0); + + void client.flush(5_000); + jest.advanceTimersByTime(5_000); + + // Now the child-spans have been added to the exporter, but they are pending since they are waiting for their parant + expect(exporter['_finishedSpans'].length).toBe(2); + expect(beforeSendTransaction).toHaveBeenCalledTimes(0); + + // Now wait for 5 mins + jest.advanceTimersByTime(5 * 60 * 1_000); + + // Adding another span will trigger the cleanup + Sentry.startSpan({ name: 'other span' }, () => {}); + + void client.flush(5_000); + jest.advanceTimersByTime(5_000); + + // Old spans have been cleared away + expect(exporter['_finishedSpans'].length).toBe(0); + + // Called once for the 'other span' + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + + expect(logs).toEqual( + expect.arrayContaining([ + 'SpanExporter exported 0 spans, 2 unsent spans remaining', + 'SpanExporter exported 1 spans, 2 unsent spans remaining', + `SpanExporter dropping span inner span 1 (${innerSpan1Id}) because it is pending for more than 5 minutes.`, + `SpanExporter dropping span inner span 2 (${innerSpan2Id}) because it is pending for more than 5 minutes.`, + ]), + ); + }); + + it('does not creates spans for http requests if disabled in http integration xxx', async () => { + const beforeSendTransaction = jest.fn(() => null); + + mockSdkInit({ enableTracing: true, beforeSendTransaction }); + + jest.useFakeTimers(); + + const hub = getCurrentHub(); + const client = hub.getClient() as NodeExperimentalClient; + + jest.spyOn(client, 'getIntegration').mockImplementation(() => { + return { + shouldCreateSpansForRequests: false, + } as Http; + }); + + client.tracer.startActiveSpan( + 'test op', + { + kind: SpanKind.CLIENT, + attributes: { + [SemanticAttributes.HTTP_METHOD]: 'GET', + [SemanticAttributes.HTTP_URL]: 'https://example.com', + }, + }, + span => { + startSpan({ name: 'inner 1' }, () => { + startSpan({ name: 'inner 2' }, () => {}); + }); + + span.end(); + }, + ); + + void client.flush(); + jest.advanceTimersByTime(5_000); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(0); + + // Now try a non-HTTP span + client.tracer.startActiveSpan( + 'test op 2', + { + kind: SpanKind.CLIENT, + attributes: {}, + }, + span => { + startSpan({ name: 'inner 1' }, () => { + startSpan({ name: 'inner 2' }, () => {}); + }); + + span.end(); + }, + ); + + void client.flush(); + jest.advanceTimersByTime(5_000); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/node-experimental/test/sdk/client.test.ts b/packages/node-experimental/test/sdk/client.test.ts new file mode 100644 index 000000000000..03ee60ecbf0b --- /dev/null +++ b/packages/node-experimental/test/sdk/client.test.ts @@ -0,0 +1,47 @@ +import { ProxyTracer } from '@opentelemetry/api'; +import { SDK_VERSION } from '@sentry/core'; + +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { getDefaultNodeExperimentalClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; + +describe('NodeExperimentalClient', () => { + it('sets correct metadata', () => { + const options = getDefaultNodeExperimentalClientOptions(); + const client = new NodeExperimentalClient(options); + + expect(client.getOptions()).toEqual({ + integrations: [], + transport: options.transport, + stackParser: options.stackParser, + _metadata: { + sdk: { + name: 'sentry.javascript.node-experimental', + packages: [ + { + name: 'npm:@sentry/node-experimental', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }, + }, + transportOptions: { textEncoder: expect.any(Object) }, + platform: 'node', + runtime: { name: 'node', version: expect.any(String) }, + serverName: expect.any(String), + }); + }); + + it('exposes a tracer', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + + 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/packages/node-experimental/test/sdk/hub.test.ts b/packages/node-experimental/test/sdk/hub.test.ts new file mode 100644 index 000000000000..a25de1565ad8 --- /dev/null +++ b/packages/node-experimental/test/sdk/hub.test.ts @@ -0,0 +1,43 @@ +import { getCurrentHub, NodeExperimentalHub } from '../../src/sdk/hub'; +import { NodeExperimentalScope } from '../../src/sdk/scope'; + +describe('NodeExperimentalHub', () => { + it('getCurrentHub() returns the correct hub', () => { + const hub = getCurrentHub(); + expect(hub).toBeDefined(); + expect(hub).toBeInstanceOf(NodeExperimentalHub); + + const hub2 = getCurrentHub(); + expect(hub2).toBe(hub); + + const scope = hub.getScope(); + expect(scope).toBeDefined(); + expect(scope).toBeInstanceOf(NodeExperimentalScope); + }); + + it('hub gets correct scope on initialization', () => { + const hub = new NodeExperimentalHub(); + + const scope = hub.getScope(); + expect(scope).toBeDefined(); + expect(scope).toBeInstanceOf(NodeExperimentalScope); + }); + + it('pushScope() creates correct scope', () => { + const hub = new NodeExperimentalHub(); + + const scope = hub.pushScope(); + expect(scope).toBeInstanceOf(NodeExperimentalScope); + + const scope2 = hub.getScope(); + expect(scope2).toBe(scope); + }); + + it('withScope() creates correct scope', () => { + const hub = new NodeExperimentalHub(); + + hub.withScope(scope => { + expect(scope).toBeInstanceOf(NodeExperimentalScope); + }); + }); +}); diff --git a/packages/node-experimental/test/sdk/hubextensions.test.ts b/packages/node-experimental/test/sdk/hubextensions.test.ts new file mode 100644 index 000000000000..c2fee6baabde --- /dev/null +++ b/packages/node-experimental/test/sdk/hubextensions.test.ts @@ -0,0 +1,26 @@ +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub } from '../../src/sdk/hub'; +import { addTracingExtensions } from '../../src/sdk/hubextensions'; +import { getDefaultNodeExperimentalClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; + +describe('hubextensions', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('startTransaction is noop', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + getCurrentHub().bindClient(client); + addTracingExtensions(); + + const mockConsole = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const transaction = getCurrentHub().startTransaction({ name: 'test' }); + expect(transaction).toEqual({}); + + expect(mockConsole).toHaveBeenCalledTimes(1); + expect(mockConsole).toHaveBeenCalledWith( + 'startTransaction is a noop in @sentry/node-experimental. Use `startSpan` instead.', + ); + }); +}); diff --git a/packages/node-experimental/test/sdk/init.test.ts b/packages/node-experimental/test/sdk/init.test.ts index a150d61f3bf5..e220bf7e6ecd 100644 --- a/packages/node-experimental/test/sdk/init.test.ts +++ b/packages/node-experimental/test/sdk/init.test.ts @@ -3,6 +3,7 @@ import type { Integration } from '@sentry/types'; import * as auto from '../../src/integrations/getAutoPerformanceIntegrations'; import * as sdk from '../../src/sdk/init'; import { init } from '../../src/sdk/init'; +import { cleanupOtel } from '../helpers/mockSdkInit'; // eslint-disable-next-line no-var declare var global: any; @@ -31,6 +32,8 @@ describe('init()', () => { afterEach(() => { // @ts-expect-error - Reset the default integrations of node sdk to original sdk.defaultIntegrations = defaultIntegrationsBackup; + + cleanupOtel(); }); it("doesn't install default integrations if told not to", () => { diff --git a/packages/node-experimental/test/sdk/otelAsyncContextStrategy.test.ts b/packages/node-experimental/test/sdk/otelAsyncContextStrategy.test.ts new file mode 100644 index 000000000000..346683bf45f3 --- /dev/null +++ b/packages/node-experimental/test/sdk/otelAsyncContextStrategy.test.ts @@ -0,0 +1,140 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { Hub } from '@sentry/core'; +import { runWithAsyncContext, setAsyncContextStrategy } from '@sentry/core'; + +import { getCurrentHub } from '../../src/sdk/hub'; +import { setupOtel } from '../../src/sdk/initOtel'; +import { setOtelContextAsyncContextStrategy } from '../../src/sdk/otelAsyncContextStrategy'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +describe('otelAsyncContextStrategy', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + provider = setupOtel(); + setOtelContextAsyncContextStrategy(); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + afterAll(() => { + // clear the strategy + setAsyncContextStrategy(undefined); + }); + + test('hub scope inheritance', () => { + const globalHub = getCurrentHub(); + globalHub.setExtra('a', 'b'); + + runWithAsyncContext(() => { + const hub1 = getCurrentHub(); + expect(hub1).toEqual(globalHub); + + hub1.setExtra('c', 'd'); + expect(hub1).not.toEqual(globalHub); + + runWithAsyncContext(() => { + const hub2 = getCurrentHub(); + expect(hub2).toEqual(hub1); + expect(hub2).not.toEqual(globalHub); + + hub2.setExtra('e', 'f'); + expect(hub2).not.toEqual(hub1); + }); + }); + }); + + test('async hub scope inheritance', async () => { + async function addRandomExtra(hub: Hub, key: string): Promise { + return new Promise(resolve => { + setTimeout(() => { + hub.setExtra(key, Math.random()); + resolve(); + }, 100); + }); + } + + const globalHub = getCurrentHub(); + await addRandomExtra(globalHub, 'a'); + + await runWithAsyncContext(async () => { + const hub1 = getCurrentHub(); + expect(hub1).toEqual(globalHub); + + await addRandomExtra(hub1, 'b'); + expect(hub1).not.toEqual(globalHub); + + await runWithAsyncContext(async () => { + const hub2 = getCurrentHub(); + expect(hub2).toEqual(hub1); + expect(hub2).not.toEqual(globalHub); + + await addRandomExtra(hub1, 'c'); + expect(hub2).not.toEqual(hub1); + }); + }); + }); + + test('context single instance', () => { + const globalHub = getCurrentHub(); + runWithAsyncContext(() => { + expect(globalHub).not.toBe(getCurrentHub()); + }); + }); + + test('context within a context not reused', () => { + runWithAsyncContext(() => { + const hub1 = getCurrentHub(); + runWithAsyncContext(() => { + const hub2 = getCurrentHub(); + expect(hub1).not.toBe(hub2); + }); + }); + }); + + test('context within a context reused when requested', () => { + runWithAsyncContext(() => { + const hub1 = getCurrentHub(); + runWithAsyncContext( + () => { + const hub2 = getCurrentHub(); + expect(hub1).toBe(hub2); + }, + { reuseExisting: true }, + ); + }); + }); + + test('concurrent hub contexts', done => { + let d1done = false; + let d2done = false; + + runWithAsyncContext(() => { + const hub = getCurrentHub(); + hub.getStack().push({ client: 'process' } as any); + expect(hub.getStack()[1]).toEqual({ client: 'process' }); + // Just in case so we don't have to worry which one finishes first + // (although it always should be d2) + setTimeout(() => { + d1done = true; + if (d2done) { + done(); + } + }); + }); + + runWithAsyncContext(() => { + const hub = getCurrentHub(); + hub.getStack().push({ client: 'local' } as any); + expect(hub.getStack()[1]).toEqual({ client: 'local' }); + setTimeout(() => { + d2done = true; + if (d1done) { + done(); + } + }); + }); + }); +}); diff --git a/packages/node-experimental/test/sdk/scope.test.ts b/packages/node-experimental/test/sdk/scope.test.ts new file mode 100644 index 000000000000..51e87e51704c --- /dev/null +++ b/packages/node-experimental/test/sdk/scope.test.ts @@ -0,0 +1,438 @@ +import { makeSession } from '@sentry/core'; +import type { Breadcrumb } from '@sentry/types'; + +import { + OTEL_ATTR_BREADCRUMB_CATEGORY, + OTEL_ATTR_BREADCRUMB_DATA, + OTEL_ATTR_BREADCRUMB_EVENT_ID, + OTEL_ATTR_BREADCRUMB_LEVEL, + OTEL_ATTR_BREADCRUMB_TYPE, +} from '../../src/constants'; +import { setOtelSpanParent } from '../../src/opentelemetry/spanData'; +import { NodeExperimentalScope } from '../../src/sdk/scope'; +import { createSpan } from '../helpers/createSpan'; +import * as GetActiveSpan from './../../src/utils/getActiveSpan'; + +describe('NodeExperimentalScope', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('clone() correctly clones the scope', () => { + const scope = new NodeExperimentalScope(); + + scope['_breadcrumbs'] = [{ message: 'test' }]; + scope['_tags'] = { tag: 'bar' }; + scope['_extra'] = { extra: 'bar' }; + scope['_contexts'] = { os: { name: 'Linux' } }; + scope['_user'] = { id: '123' }; + scope['_level'] = 'warning'; + // we don't care about _span + scope['_session'] = makeSession({ sid: '123' }); + // we don't care about transactionName + scope['_fingerprint'] = ['foo']; + scope['_eventProcessors'] = [() => ({})]; + scope['_requestSession'] = { status: 'ok' }; + scope['_attachments'] = [{ data: '123', filename: 'test.txt' }]; + scope['_sdkProcessingMetadata'] = { sdk: 'bar' }; + + const scope2 = NodeExperimentalScope.clone(scope); + + expect(scope2).toBeInstanceOf(NodeExperimentalScope); + expect(scope2).not.toBe(scope); + + // Ensure everything is correctly cloned + expect(scope2['_breadcrumbs']).toEqual(scope['_breadcrumbs']); + expect(scope2['_tags']).toEqual(scope['_tags']); + expect(scope2['_extra']).toEqual(scope['_extra']); + expect(scope2['_contexts']).toEqual(scope['_contexts']); + expect(scope2['_user']).toEqual(scope['_user']); + expect(scope2['_level']).toEqual(scope['_level']); + expect(scope2['_session']).toEqual(scope['_session']); + expect(scope2['_fingerprint']).toEqual(scope['_fingerprint']); + expect(scope2['_eventProcessors']).toEqual(scope['_eventProcessors']); + expect(scope2['_requestSession']).toEqual(scope['_requestSession']); + expect(scope2['_attachments']).toEqual(scope['_attachments']); + expect(scope2['_sdkProcessingMetadata']).toEqual(scope['_sdkProcessingMetadata']); + expect(scope2['_propagationContext']).toEqual(scope['_propagationContext']); + + // Ensure things are not copied by reference + expect(scope2['_breadcrumbs']).not.toBe(scope['_breadcrumbs']); + expect(scope2['_tags']).not.toBe(scope['_tags']); + expect(scope2['_extra']).not.toBe(scope['_extra']); + expect(scope2['_contexts']).not.toBe(scope['_contexts']); + expect(scope2['_eventProcessors']).not.toBe(scope['_eventProcessors']); + expect(scope2['_attachments']).not.toBe(scope['_attachments']); + expect(scope2['_sdkProcessingMetadata']).not.toBe(scope['_sdkProcessingMetadata']); + expect(scope2['_propagationContext']).not.toBe(scope['_propagationContext']); + + // These are actually copied by reference + expect(scope2['_user']).toBe(scope['_user']); + expect(scope2['_session']).toBe(scope['_session']); + expect(scope2['_requestSession']).toBe(scope['_requestSession']); + expect(scope2['_fingerprint']).toBe(scope['_fingerprint']); + }); + + it('clone() works without existing scope', () => { + const scope = NodeExperimentalScope.clone(undefined); + + expect(scope).toBeInstanceOf(NodeExperimentalScope); + }); + + it('getSpan returns undefined', () => { + const scope = new NodeExperimentalScope(); + + // Pretend we have a _span set + scope['_span'] = {} as any; + + expect(scope.getSpan()).toBeUndefined(); + }); + + it('setSpan is a noop', () => { + const scope = new NodeExperimentalScope(); + + scope.setSpan({} as any); + + expect(scope['_span']).toBeUndefined(); + }); + + describe('addBreadcrumb', () => { + it('adds to scope if no root span is found', () => { + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(undefined); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { message: 'test' }; + + const now = Date.now(); + jest.useFakeTimers(); + jest.setSystemTime(now); + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([{ message: 'test', timestamp: now / 1000 }]); + }); + + it('adds to scope if no root span is found & uses given timestamp', () => { + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(undefined); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { message: 'test', timestamp: 1234 }; + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([breadcrumb]); + }); + + it('adds to root span if found', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { message: 'test' }; + + const now = Date.now(); + jest.useFakeTimers(); + jest.setSystemTime(now); + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: 'test', + time: [Math.floor(now / 1000), (now % 1000) * 1_000_000], + attributes: {}, + }), + ]); + }); + + it('adds to root span if found & uses given timestamp', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { timestamp: 12345, message: 'test' }; + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: 'test', + time: [12345, 0], + attributes: {}, + }), + ]); + }); + + it('adds many breadcrumbs to root span if found', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb1: Breadcrumb = { timestamp: 12345, message: 'test1' }; + const breadcrumb2: Breadcrumb = { timestamp: 5678, message: 'test2' }; + const breadcrumb3: Breadcrumb = { timestamp: 9101112, message: 'test3' }; + + scope.addBreadcrumb(breadcrumb1); + scope.addBreadcrumb(breadcrumb2); + scope.addBreadcrumb(breadcrumb3); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: 'test1', + time: [12345, 0], + attributes: {}, + }), + expect.objectContaining({ + name: 'test2', + time: [5678, 0], + attributes: {}, + }), + expect.objectContaining({ + name: 'test3', + time: [9101112, 0], + attributes: {}, + }), + ]); + }); + + it('adds to root span if found & no message is given', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { timestamp: 12345 }; + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: '', + time: [12345, 0], + attributes: {}, + }), + ]); + }); + + it('adds to root span with full attributes', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { + timestamp: 12345, + message: 'test', + data: { nested: { indeed: true } }, + level: 'info', + category: 'test-category', + type: 'test-type', + event_id: 'test-event-id', + }; + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: 'test', + time: [12345, 0], + attributes: { + [OTEL_ATTR_BREADCRUMB_DATA]: JSON.stringify({ nested: { indeed: true } }), + [OTEL_ATTR_BREADCRUMB_TYPE]: 'test-type', + [OTEL_ATTR_BREADCRUMB_LEVEL]: 'info', + [OTEL_ATTR_BREADCRUMB_EVENT_ID]: 'test-event-id', + [OTEL_ATTR_BREADCRUMB_CATEGORY]: 'test-category', + }, + }), + ]); + }); + + it('adds to root span with empty data', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + const breadcrumb: Breadcrumb = { timestamp: 12345, message: 'test', data: {} }; + + scope.addBreadcrumb(breadcrumb); + + expect(scope['_breadcrumbs']).toEqual([]); + expect(span.events).toEqual([ + expect.objectContaining({ + name: 'test', + time: [12345, 0], + attributes: {}, + }), + ]); + }); + }); + + describe('_getBreadcrumbs', () => { + it('gets from scope if no root span is found', () => { + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(undefined); + + const scope = new NodeExperimentalScope(); + const breadcrumbs: Breadcrumb[] = [ + { message: 'test1', timestamp: 1234 }, + { message: 'test2', timestamp: 12345 }, + { message: 'test3', timestamp: 12346 }, + ]; + scope['_breadcrumbs'] = breadcrumbs; + + expect(scope['_getBreadcrumbs']()).toEqual(breadcrumbs); + }); + + it('gets from root span if found', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + + const now = Date.now(); + + span.addEvent('basic event', now); + span.addEvent('breadcrumb event', {}, now + 1000); + span.addEvent( + 'breadcrumb event 2', + { + [OTEL_ATTR_BREADCRUMB_DATA]: JSON.stringify({ nested: { indeed: true } }), + [OTEL_ATTR_BREADCRUMB_TYPE]: 'test-type', + [OTEL_ATTR_BREADCRUMB_LEVEL]: 'info', + [OTEL_ATTR_BREADCRUMB_EVENT_ID]: 'test-event-id', + [OTEL_ATTR_BREADCRUMB_CATEGORY]: 'test-category', + }, + now + 3000, + ); + span.addEvent( + 'breadcrumb event invalid JSON data', + { + [OTEL_ATTR_BREADCRUMB_DATA]: 'this is not JSON...', + }, + now + 2000, + ); + + expect(scope['_getBreadcrumbs']()).toEqual([ + { message: 'basic event', timestamp: now / 1000 }, + { message: 'breadcrumb event', timestamp: now / 1000 + 1 }, + { + message: 'breadcrumb event 2', + timestamp: now / 1000 + 3, + data: { nested: { indeed: true } }, + level: 'info', + event_id: 'test-event-id', + category: 'test-category', + type: 'test-type', + }, + { message: 'breadcrumb event invalid JSON data', timestamp: now / 1000 + 2 }, + ]); + }); + + it('gets from spans up the parent chain if found', () => { + const span = createSpan(); + const parentSpan = createSpan(); + const rootSpan = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + setOtelSpanParent(span, parentSpan); + setOtelSpanParent(parentSpan, rootSpan); + + const scope = new NodeExperimentalScope(); + + const now = Date.now(); + + span.addEvent('basic event', now); + parentSpan.addEvent('parent breadcrumb event', {}, now + 1000); + span.addEvent( + 'breadcrumb event 2', + { + [OTEL_ATTR_BREADCRUMB_DATA]: JSON.stringify({ nested: true }), + }, + now + 3000, + ); + rootSpan.addEvent( + 'breadcrumb event invalid JSON data', + { + [OTEL_ATTR_BREADCRUMB_DATA]: 'this is not JSON...', + }, + now + 2000, + ); + + expect(scope['_getBreadcrumbs']()).toEqual([ + { message: 'basic event', timestamp: now / 1000 }, + { message: 'breadcrumb event 2', timestamp: now / 1000 + 3, data: { nested: true } }, + { message: 'parent breadcrumb event', timestamp: now / 1000 + 1 }, + { message: 'breadcrumb event invalid JSON data', timestamp: now / 1000 + 2 }, + ]); + }); + + it('combines scope & span breadcrumbs if both exist', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + + const breadcrumbs: Breadcrumb[] = [ + { message: 'test1', timestamp: 1234 }, + { message: 'test2', timestamp: 12345 }, + { message: 'test3', timestamp: 12346 }, + ]; + scope['_breadcrumbs'] = breadcrumbs; + + const now = Date.now(); + + span.addEvent('basic event', now); + span.addEvent('breadcrumb event', {}, now + 1000); + + expect(scope['_getBreadcrumbs']()).toEqual([ + { message: 'test1', timestamp: 1234 }, + { message: 'test2', timestamp: 12345 }, + { message: 'test3', timestamp: 12346 }, + { message: 'basic event', timestamp: now / 1000 }, + { message: 'breadcrumb event', timestamp: now / 1000 + 1 }, + ]); + }); + + it('gets from activeSpan if defined', () => { + const span = createSpan(); + jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); + + const scope = new NodeExperimentalScope(); + + const now = Date.now(); + + span.addEvent('basic event', now); + span.addEvent('breadcrumb event', {}, now + 1000); + span.addEvent( + 'breadcrumb event 2', + { + [OTEL_ATTR_BREADCRUMB_DATA]: JSON.stringify({ nested: { indeed: true } }), + [OTEL_ATTR_BREADCRUMB_TYPE]: 'test-type', + [OTEL_ATTR_BREADCRUMB_LEVEL]: 'info', + [OTEL_ATTR_BREADCRUMB_EVENT_ID]: 'test-event-id', + [OTEL_ATTR_BREADCRUMB_CATEGORY]: 'test-category', + }, + now + 3000, + ); + span.addEvent( + 'breadcrumb event invalid JSON data', + { + [OTEL_ATTR_BREADCRUMB_DATA]: 'this is not JSON...', + }, + now + 2000, + ); + + const activeSpan = createSpan(); + activeSpan.addEvent('event 1', now); + activeSpan.addEvent('event 2', {}, now + 1000); + scope.activeSpan = activeSpan; + + expect(scope['_getBreadcrumbs']()).toEqual([ + { message: 'event 1', timestamp: now / 1000 }, + { message: 'event 2', timestamp: now / 1000 + 1 }, + ]); + }); + }); +}); diff --git a/packages/node-experimental/test/sdk/trace.test.ts b/packages/node-experimental/test/sdk/trace.test.ts index c53606140fa1..413b75f25998 100644 --- a/packages/node-experimental/test/sdk/trace.test.ts +++ b/packages/node-experimental/test/sdk/trace.test.ts @@ -1,63 +1,69 @@ -import { Span, Transaction } from '@sentry/core'; - import * as Sentry from '../../src'; -import { mockSdkInit } from '../helpers/mockSdkInit'; +import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_SOURCE } from '../../src/constants'; +import { getOtelSpanMetadata } from '../../src/opentelemetry/spanData'; +import type { OtelSpan } from '../../src/types'; +import { getActiveSpan } from '../../src/utils/getActiveSpan'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; describe('trace', () => { beforeEach(() => { mockSdkInit({ enableTracing: true }); }); + afterEach(() => { + cleanupOtel(); + }); + describe('startSpan', () => { it('works with a sync callback', () => { - const spans: Span[] = []; + const spans: OtelSpan[] = []; - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(getActiveSpan()).toEqual(undefined); - Sentry.startSpan({ name: 'outer' }, outerSpan => { + const res = Sentry.startSpan({ name: 'outer' }, outerSpan => { expect(outerSpan).toBeDefined(); spans.push(outerSpan!); expect(outerSpan?.name).toEqual('outer'); - expect(outerSpan).toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(getActiveSpan()).toEqual(outerSpan); Sentry.startSpan({ name: 'inner' }, innerSpan => { expect(innerSpan).toBeDefined(); spans.push(innerSpan!); - expect(innerSpan?.description).toEqual('inner'); - expect(innerSpan).toBeInstanceOf(Span); - expect(innerSpan).not.toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(innerSpan); + expect(innerSpan?.name).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); }); + + return 'test value'; }); - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); expect(spans).toHaveLength(2); const [outerSpan, innerSpan] = spans; - expect((outerSpan as Transaction).name).toEqual('outer'); - expect(innerSpan.description).toEqual('inner'); + expect(outerSpan.name).toEqual('outer'); + expect(innerSpan.name).toEqual('inner'); - expect(outerSpan.endTimestamp).toEqual(expect.any(Number)); - expect(innerSpan.endTimestamp).toEqual(expect.any(Number)); + expect(outerSpan.endTime).not.toEqual([0, 0]); + expect(innerSpan.endTime).not.toEqual([0, 0]); }); it('works with an async callback', async () => { - const spans: Span[] = []; + const spans: OtelSpan[] = []; - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(getActiveSpan()).toEqual(undefined); - await Sentry.startSpan({ name: 'outer' }, async outerSpan => { + const res = await Sentry.startSpan({ name: 'outer' }, async outerSpan => { expect(outerSpan).toBeDefined(); spans.push(outerSpan!); await new Promise(resolve => setTimeout(resolve, 10)); expect(outerSpan?.name).toEqual('outer'); - expect(outerSpan).toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(getActiveSpan()).toEqual(outerSpan); await Sentry.startSpan({ name: 'inner' }, async innerSpan => { expect(innerSpan).toBeDefined(); @@ -65,46 +71,45 @@ describe('trace', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(innerSpan?.description).toEqual('inner'); - expect(innerSpan).toBeInstanceOf(Span); - expect(innerSpan).not.toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(innerSpan); + expect(innerSpan?.name).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); }); + + return 'test value'; }); - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); expect(spans).toHaveLength(2); const [outerSpan, innerSpan] = spans; - expect((outerSpan as Transaction).name).toEqual('outer'); - expect(innerSpan.description).toEqual('inner'); + expect(outerSpan.name).toEqual('outer'); + expect(innerSpan.name).toEqual('inner'); - expect(outerSpan.endTimestamp).toEqual(expect.any(Number)); - expect(innerSpan.endTimestamp).toEqual(expect.any(Number)); + expect(outerSpan.endTime).not.toEqual([0, 0]); + expect(innerSpan.endTime).not.toEqual([0, 0]); }); it('works with multiple parallel calls', () => { - const spans1: Span[] = []; - const spans2: Span[] = []; + const spans1: OtelSpan[] = []; + const spans2: OtelSpan[] = []; - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(getActiveSpan()).toEqual(undefined); Sentry.startSpan({ name: 'outer' }, outerSpan => { expect(outerSpan).toBeDefined(); spans1.push(outerSpan!); expect(outerSpan?.name).toEqual('outer'); - expect(outerSpan).toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(getActiveSpan()).toEqual(outerSpan); Sentry.startSpan({ name: 'inner' }, innerSpan => { expect(innerSpan).toBeDefined(); spans1.push(innerSpan!); - expect(innerSpan?.description).toEqual('inner'); - expect(innerSpan).toBeInstanceOf(Span); - expect(innerSpan).not.toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(innerSpan); + expect(innerSpan?.name).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); }); }); @@ -113,24 +118,55 @@ describe('trace', () => { spans2.push(outerSpan!); expect(outerSpan?.name).toEqual('outer2'); - expect(outerSpan).toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(getActiveSpan()).toEqual(outerSpan); Sentry.startSpan({ name: 'inner2' }, innerSpan => { expect(innerSpan).toBeDefined(); spans2.push(innerSpan!); - expect(innerSpan?.description).toEqual('inner2'); - expect(innerSpan).toBeInstanceOf(Span); - expect(innerSpan).not.toBeInstanceOf(Transaction); - expect(Sentry.getActiveSpan()).toEqual(innerSpan); + expect(innerSpan?.name).toEqual('inner2'); + expect(getActiveSpan()).toEqual(innerSpan); }); }); - expect(Sentry.getActiveSpan()).toEqual(undefined); + expect(getActiveSpan()).toEqual(undefined); expect(spans1).toHaveLength(2); expect(spans2).toHaveLength(2); }); + + it('allows to pass context arguments', () => { + Sentry.startSpan( + { + name: 'outer', + }, + span => { + expect(span).toBeDefined(); + expect(span?.attributes).toEqual({}); + + expect(getOtelSpanMetadata(span!)).toEqual(undefined); + }, + ); + + Sentry.startSpan( + { + name: 'outer', + op: 'my-op', + origin: 'auto.test.origin', + source: 'task', + metadata: { requestPath: 'test-path' }, + }, + span => { + expect(span).toBeDefined(); + expect(span?.attributes).toEqual({ + [OTEL_ATTR_SOURCE]: 'task', + [OTEL_ATTR_ORIGIN]: 'auto.test.origin', + [OTEL_ATTR_OP]: 'my-op', + }); + + expect(getOtelSpanMetadata(span!)).toEqual({ requestPath: 'test-path' }); + }, + ); + }); }); describe('startInactiveSpan', () => { @@ -138,36 +174,87 @@ describe('trace', () => { const span = Sentry.startInactiveSpan({ name: 'test' }); expect(span).toBeDefined(); - expect(span).toBeInstanceOf(Transaction); expect(span?.name).toEqual('test'); - expect(span?.endTimestamp).toBeUndefined(); - expect(Sentry.getActiveSpan()).toBeUndefined(); + expect(span?.endTime).toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); - span?.finish(); + span?.end(); - expect(span?.endTimestamp).toEqual(expect.any(Number)); - expect(Sentry.getActiveSpan()).toBeUndefined(); + expect(span?.endTime).not.toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); }); it('works as a child span', () => { Sentry.startSpan({ name: 'outer' }, outerSpan => { expect(outerSpan).toBeDefined(); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(getActiveSpan()).toEqual(outerSpan); const innerSpan = Sentry.startInactiveSpan({ name: 'test' }); expect(innerSpan).toBeDefined(); - expect(innerSpan).toBeInstanceOf(Span); - expect(innerSpan).not.toBeInstanceOf(Transaction); - expect(innerSpan?.description).toEqual('test'); - expect(innerSpan?.endTimestamp).toBeUndefined(); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(innerSpan?.name).toEqual('test'); + expect(innerSpan?.endTime).toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); - innerSpan?.finish(); + innerSpan?.end(); - expect(innerSpan?.endTimestamp).toEqual(expect.any(Number)); - expect(Sentry.getActiveSpan()).toEqual(outerSpan); + expect(innerSpan?.endTime).not.toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); }); }); + + it('allows to pass context arguments', () => { + const span = Sentry.startInactiveSpan({ + name: 'outer', + }); + + expect(span).toBeDefined(); + expect(span?.attributes).toEqual({}); + + expect(getOtelSpanMetadata(span!)).toEqual(undefined); + + const span2 = Sentry.startInactiveSpan({ + name: 'outer', + op: 'my-op', + origin: 'auto.test.origin', + source: 'task', + metadata: { requestPath: 'test-path' }, + }); + + expect(span2).toBeDefined(); + expect(span2?.attributes).toEqual({ + [OTEL_ATTR_SOURCE]: 'task', + [OTEL_ATTR_ORIGIN]: 'auto.test.origin', + [OTEL_ATTR_OP]: 'my-op', + }); + + expect(getOtelSpanMetadata(span2!)).toEqual({ requestPath: 'test-path' }); + }); + }); +}); + +describe('trace (tracing disabled)', () => { + beforeEach(() => { + mockSdkInit({ enableTracing: false }); + }); + + afterEach(() => { + cleanupOtel(); + }); + + it('startSpan calls callback without span', () => { + const val = Sentry.startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeUndefined(); + + return 'test value'; + }); + + expect(val).toEqual('test value'); + }); + + it('startInactiveSpan returns undefined', () => { + const span = Sentry.startInactiveSpan({ name: 'test' }); + + expect(span).toBeUndefined(); }); }); diff --git a/packages/node-experimental/test/sdk/transaction.test.ts b/packages/node-experimental/test/sdk/transaction.test.ts new file mode 100644 index 000000000000..bf27549c8017 --- /dev/null +++ b/packages/node-experimental/test/sdk/transaction.test.ts @@ -0,0 +1,245 @@ +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { getCurrentHub } from '../../src/sdk/hub'; +import { NodeExperimentalScope } from '../../src/sdk/scope'; +import { NodeExperimentalTransaction, startTransaction } from '../../src/sdk/transaction'; +import { getDefaultNodeExperimentalClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; + +describe('NodeExperimentalTransaction', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('works with finishWithScope without arguments', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + + const mockSend = jest.spyOn(client, 'captureEvent').mockImplementation(() => 'mocked'); + + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = new NodeExperimentalTransaction({ name: 'test' }, hub); + transaction.sampled = true; + + const res = transaction.finishWithScope(); + + expect(mockSend).toBeCalledTimes(1); + expect(mockSend).toBeCalledWith( + expect.objectContaining({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: [], + start_timestamp: expect.any(Number), + tags: {}, + timestamp: expect.any(Number), + transaction: 'test', + type: 'transaction', + sdkProcessingMetadata: { + source: 'custom', + spanMetadata: {}, + dynamicSamplingContext: { + environment: 'production', + trace_id: expect.any(String), + transaction: 'test', + sampled: 'true', + }, + }, + transaction_info: { source: 'custom' }, + }), + { event_id: expect.any(String) }, + undefined, + ); + expect(res).toBe('mocked'); + }); + + it('works with finishWithScope with endTime', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + + const mockSend = jest.spyOn(client, 'captureEvent').mockImplementation(() => 'mocked'); + + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = new NodeExperimentalTransaction({ name: 'test', startTimestamp: 123456 }, hub); + transaction.sampled = true; + + const res = transaction.finishWithScope(1234567); + + expect(mockSend).toBeCalledTimes(1); + expect(mockSend).toBeCalledWith( + expect.objectContaining({ + start_timestamp: 123456, + timestamp: 1234567, + }), + { event_id: expect.any(String) }, + undefined, + ); + expect(res).toBe('mocked'); + }); + + it('works with finishWithScope with endTime & scope', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + + const mockSend = jest.spyOn(client, 'captureEvent').mockImplementation(() => 'mocked'); + + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = new NodeExperimentalTransaction({ name: 'test', startTimestamp: 123456 }, hub); + transaction.sampled = true; + + const scope = new NodeExperimentalScope(); + scope.setTags({ + tag1: 'yes', + tag2: 'no', + }); + scope.setContext('os', { name: 'Custom OS' }); + + const res = transaction.finishWithScope(1234567, scope); + + expect(mockSend).toBeCalledTimes(1); + expect(mockSend).toBeCalledWith( + expect.objectContaining({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: [], + start_timestamp: 123456, + tags: {}, + timestamp: 1234567, + transaction: 'test', + type: 'transaction', + sdkProcessingMetadata: { + source: 'custom', + spanMetadata: {}, + dynamicSamplingContext: { + environment: 'production', + trace_id: expect.any(String), + transaction: 'test', + sampled: 'true', + }, + }, + transaction_info: { source: 'custom' }, + }), + { event_id: expect.any(String) }, + scope, + ); + expect(res).toBe('mocked'); + }); +}); + +describe('startTranscation', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates an unsampled NodeExperimentalTransaction by default', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + const mockEmit = jest.spyOn(client, 'emit').mockImplementation(() => {}); + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = startTransaction(hub, { name: 'test' }); + + expect(transaction).toBeInstanceOf(NodeExperimentalTransaction); + expect(mockEmit).toBeCalledTimes(1); + expect(mockEmit).toBeCalledWith('startTransaction', transaction); + + expect(transaction.sampled).toBe(false); + expect(transaction.spanRecorder).toBeUndefined(); + expect(transaction.metadata).toEqual({ + source: 'custom', + spanMetadata: {}, + }); + + expect(transaction.toJSON()).toEqual( + expect.objectContaining({ + origin: 'manual', + span_id: expect.any(String), + start_timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + ); + }); + + it('creates a sampled NodeExperimentalTransaction based on the tracesSampleRate', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions({ tracesSampleRate: 1 })); + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = startTransaction(hub, { name: 'test' }); + + expect(transaction).toBeInstanceOf(NodeExperimentalTransaction); + + expect(transaction.sampled).toBe(true); + expect(transaction.spanRecorder).toBeDefined(); + expect(transaction.spanRecorder?.spans).toHaveLength(1); + expect(transaction.metadata).toEqual({ + source: 'custom', + spanMetadata: {}, + sampleRate: 1, + }); + + expect(transaction.toJSON()).toEqual( + expect.objectContaining({ + origin: 'manual', + span_id: expect.any(String), + start_timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + ); + }); + + it('allows to pass data to transaction', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = startTransaction(hub, { + name: 'test', + startTimestamp: 1234, + spanId: 'span1', + traceId: 'trace1', + }); + + expect(transaction).toBeInstanceOf(NodeExperimentalTransaction); + + expect(transaction.sampled).toBe(false); + expect(transaction.spanRecorder).toBeUndefined(); + expect(transaction.metadata).toEqual({ + source: 'custom', + spanMetadata: {}, + }); + + expect(transaction.toJSON()).toEqual( + expect.objectContaining({ + origin: 'manual', + span_id: 'span1', + start_timestamp: 1234, + trace_id: 'trace1', + }), + ); + }); + + it('inherits sampled based on parentSampled', () => { + const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions({ tracesSampleRate: 0 })); + const hub = getCurrentHub(); + hub.bindClient(client); + + const transaction = startTransaction(hub, { + name: 'test', + startTimestamp: 1234, + spanId: 'span1', + traceId: 'trace1', + parentSampled: true, + }); + + expect(transaction.sampled).toBe(true); + }); +}); diff --git a/packages/node-experimental/test/utils/convertOtelTimeToSeconds.test.ts b/packages/node-experimental/test/utils/convertOtelTimeToSeconds.test.ts new file mode 100644 index 000000000000..4f4911cee0cb --- /dev/null +++ b/packages/node-experimental/test/utils/convertOtelTimeToSeconds.test.ts @@ -0,0 +1,9 @@ +import { convertOtelTimeToSeconds } from '../../src/utils/convertOtelTimeToSeconds'; + +describe('convertOtelTimeToSeconds', () => { + it('works', () => { + expect(convertOtelTimeToSeconds([0, 0])).toEqual(0); + expect(convertOtelTimeToSeconds([1000, 50])).toEqual(1000.00000005); + expect(convertOtelTimeToSeconds([1000, 505])).toEqual(1000.000000505); + }); +}); diff --git a/packages/node-experimental/test/utils/getActiveSpan.test.ts b/packages/node-experimental/test/utils/getActiveSpan.test.ts new file mode 100644 index 000000000000..2d041bb2ca5f --- /dev/null +++ b/packages/node-experimental/test/utils/getActiveSpan.test.ts @@ -0,0 +1,152 @@ +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; + +import { setupOtel } from '../../src/sdk/initOtel'; +import type { OtelSpan } from '../../src/types'; +import { getActiveSpan, getRootSpan } from '../../src/utils/getActiveSpan'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +describe('getActiveSpan', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + provider = setupOtel(); + }); + + 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(() => { + provider = setupOtel(); + }); + + 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 as OtelSpan)).toBe(span); + + const inner1 = tracer.startSpan('inner1'); + + expect(getRootSpan(inner1 as OtelSpan)).toBe(span); + + inner1.end(); + + tracer.startActiveSpan('inner2', inner2 => { + expect(getRootSpan(inner2 as OtelSpan)).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 as OtelSpan)).toBe(span); + + tracer.startActiveSpan('inner1', inner1 => { + expect(getRootSpan(inner1 as OtelSpan)).toBe(span); + inner1.end(); + }); + + span.end(); + }); + + tracer.startActiveSpan('test2', span => { + expect(getRootSpan(span as OtelSpan)).toBe(span); + + tracer.startActiveSpan('inner2', inner => { + expect(getRootSpan(inner as OtelSpan)).toBe(span); + inner.end(); + }); + + span.end(); + }); + }); +}); diff --git a/packages/node-experimental/test/utils/getRequestSpanData.test.ts b/packages/node-experimental/test/utils/getRequestSpanData.test.ts new file mode 100644 index 000000000000..0edd2befea6c --- /dev/null +++ b/packages/node-experimental/test/utils/getRequestSpanData.test.ts @@ -0,0 +1,59 @@ +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; + +import { getRequestSpanData } from '../../src/utils/getRequestSpanData'; +import { createSpan } from '../helpers/createSpan'; + +describe('getRequestSpanData', () => { + it('works with basic span', () => { + const span = createSpan(); + const data = getRequestSpanData(span); + + expect(data).toEqual({}); + }); + + it('works with http span', () => { + const span = createSpan(); + span.setAttributes({ + [SemanticAttributes.HTTP_URL]: 'http://example.com?foo=bar#baz', + [SemanticAttributes.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(); + span.setAttributes({ + [SemanticAttributes.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(); + span.setAttributes({ + [SemanticAttributes.HTTP_URL]: 'malformed-url-here', + [SemanticAttributes.HTTP_METHOD]: 'GET', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'malformed-url-here', + 'http.method': 'GET', + }); + }); +}); diff --git a/packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts b/packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts new file mode 100644 index 000000000000..ac839d59f95e --- /dev/null +++ b/packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts @@ -0,0 +1,123 @@ +import { groupOtelSpansWithParents } from '../../src/utils/groupOtelSpansWithParents'; +import { createSpan } from '../helpers/createSpan'; + +describe('groupOtelSpansWithParents', () => { + it('works with no spans', () => { + const actual = groupOtelSpansWithParents([]); + expect(actual).toEqual([]); + }); + + it('works with a single root span & in-order spans', () => { + const rootSpan = createSpan('root', { spanId: 'rootId' }); + const parentSpan1 = createSpan('parent1', { spanId: 'parent1Id', parentSpanId: 'rootId' }); + const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'rootId' }); + const child1 = createSpan('child1', { spanId: 'child1', parentSpanId: 'parent1Id' }); + + const actual = groupOtelSpansWithParents([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 parentSpan1 = createSpan('parent1', { spanId: 'parent1Id', parentSpanId: 'rootId' }); + const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'rootId' }); + const child1 = createSpan('child1', { spanId: 'child1', parentSpanId: 'parent1Id' }); + + const actual = groupOtelSpansWithParents([parentSpan1, parentSpan2, child1]); + expect(actual).toHaveLength(4); + + // Ensure parent & span is correctly set + const rootRef = actual.find(ref => ref.id === 'rootId'); + 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 rootSpan1 = createSpan('root1', { spanId: 'root1Id' }); + const rootSpan2 = createSpan('root2', { spanId: 'root2Id' }); + const parentSpan1 = createSpan('parent1', { spanId: 'parent1Id', parentSpanId: 'root1Id' }); + const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'root2Id' }); + const childSpan1 = createSpan('child1', { spanId: 'child1Id', parentSpanId: 'parent1Id' }); + + const actual = groupOtelSpansWithParents([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/packages/node-experimental/test/utils/setupEventContextTrace.test.ts b/packages/node-experimental/test/utils/setupEventContextTrace.test.ts new file mode 100644 index 000000000000..390fa255a146 --- /dev/null +++ b/packages/node-experimental/test/utils/setupEventContextTrace.test.ts @@ -0,0 +1,111 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { makeMain } from '@sentry/core'; + +import { NodeExperimentalClient } from '../../src/sdk/client'; +import { NodeExperimentalHub } from '../../src/sdk/hub'; +import { setupOtel } from '../../src/sdk/initOtel'; +import { startSpan } from '../../src/sdk/trace'; +import { setupEventContextTrace } from '../../src/utils/setupEventContextTrace'; +import { getDefaultNodeExperimentalClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('setupEventContextTrace', () => { + const beforeSend = jest.fn(() => null); + let client: NodeExperimentalClient; + let hub: NodeExperimentalHub; + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + client = new NodeExperimentalClient( + getDefaultNodeExperimentalClientOptions({ + sampleRate: 1, + enableTracing: true, + beforeSend, + debug: true, + dsn: PUBLIC_DSN, + }), + ); + + hub = new NodeExperimentalHub(client); + makeMain(hub); + + setupEventContextTrace(client); + provider = setupOtel(); + }); + + afterEach(() => { + beforeSend.mockReset(); + cleanupOtel(provider); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('works with no active span', async () => { + const error = new Error('test'); + hub.captureException(error); + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + }), + 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; + + startSpan({ name: 'outer' }, outerSpan => { + outerId = outerSpan?.spanContext().spanId; + traceId = outerSpan?.spanContext().traceId; + + startSpan({ name: 'inner' }, innerSpan => { + innerId = innerSpan?.spanContext().spanId; + hub.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/packages/opentelemetry-node/src/index.ts b/packages/opentelemetry-node/src/index.ts index 630acd960059..0d3c905eaf2c 100644 --- a/packages/opentelemetry-node/src/index.ts +++ b/packages/opentelemetry-node/src/index.ts @@ -1,7 +1,10 @@ -import { getSentrySpan } from './utils/spanMap'; +import { SENTRY_TRACE_PARENT_CONTEXT_KEY } from './constants'; export { SentrySpanProcessor } from './spanprocessor'; export { SentryPropagator } from './propagator'; +export { maybeCaptureExceptionForTimedEvent } from './utils/captureExceptionForTimedEvent'; +export { parseOtelSpanDescription } from './utils/parseOtelSpanDescription'; +export { mapOtelStatus } from './utils/mapOtelStatus'; /* eslint-disable deprecation/deprecation */ export { addOtelSpanData, getOtelSpanData, clearOtelSpanData } from './utils/spanData'; @@ -16,4 +19,4 @@ export type { AdditionalOtelSpanData } from './utils/spanData'; * * @private */ -export { getSentrySpan as _INTERNAL_getSentrySpan }; +export { SENTRY_TRACE_PARENT_CONTEXT_KEY as _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY }; diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 012ead8b9d3d..671cdbb7894a 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -10,7 +10,7 @@ import { SENTRY_DYNAMIC_SAMPLING_CONTEXT_KEY, SENTRY_TRACE_PARENT_CONTEXT_KEY } import { maybeCaptureExceptionForTimedEvent } from './utils/captureExceptionForTimedEvent'; import { isSentryRequestSpan } from './utils/isSentryRequest'; import { mapOtelStatus } from './utils/mapOtelStatus'; -import { parseSpanDescription } from './utils/parseOtelSpanDescription'; +import { parseOtelSpanDescription } from './utils/parseOtelSpanDescription'; import { clearSpan, getSentrySpan, setSentrySpan } from './utils/spanMap'; /** @@ -182,7 +182,7 @@ function getTraceData(otelSpan: OtelSpan, parentContext: Context): Partial