From 613269d65fc20778c6e37a4266e14114a82c3b8a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 27 Sep 2023 15:22:59 +0200 Subject: [PATCH 1/3] feat(node-experimental): Use native OTEL Spans --- .github/workflows/build.yml | 1 + .../node-experimental-fastify-app/.gitignore | 1 + .../node-experimental-fastify-app/.npmrc | 2 + .../event-proxy-server.ts | 253 ++++++++ .../package.json | 32 + .../playwright.config.ts | 62 ++ .../node-experimental-fastify-app/src/app.js | 62 ++ .../src/tracing.js | 10 + .../start-event-proxy.ts | 6 + .../tests/server.test.ts | 77 +++ .../tests/transactions.test.ts | 124 ++++ .../tsconfig.json | 10 + packages/e2e-tests/test-registry.npmrc | 3 + packages/node-experimental/package.json | 1 + packages/node-experimental/src/constants.ts | 12 + packages/node-experimental/src/index.ts | 2 +- .../src/integrations/http.ts | 145 ++--- .../src/opentelemetry/spanData.ts | 50 ++ .../src/opentelemetry/spanExporter.ts | 315 +++++++++ .../src/opentelemetry/spanProcessor.ts | 112 ++++ packages/node-experimental/src/sdk/client.ts | 22 +- packages/node-experimental/src/sdk/hub.ts | 29 +- .../src/sdk/hubextensions.ts | 74 +-- packages/node-experimental/src/sdk/init.ts | 5 + .../node-experimental/src/sdk/initOtel.ts | 28 +- packages/node-experimental/src/sdk/scope.ts | 153 ++++- packages/node-experimental/src/sdk/trace.ts | 96 ++- .../node-experimental/src/sdk/transaction.ts | 62 ++ packages/node-experimental/src/types.ts | 24 +- .../src/utils/addOriginToSpan.ts | 10 +- .../src/utils/convertOtelTimeToSeconds.ts | 4 + .../src/utils/getActiveSpan.ts | 25 + .../src/utils/getRequestSpanData.ts | 13 +- .../src/utils/groupOtelSpansWithParents.ts | 79 +++ .../src/utils/setupEventContextTrace.ts | 31 + .../test/helpers/createSpan.ts | 32 + .../test/helpers/mockSdkInit.ts | 44 +- .../test/integration/breadcrumbs.test.ts | 362 +++++++++++ .../test/integration/otelTimedEvents.test.ts | 57 ++ .../test/integration/scope.test.ts | 235 +++++++ .../test/integration/transactions.test.ts | 604 ++++++++++++++++++ .../node-experimental/test/sdk/client.test.ts | 47 ++ .../node-experimental/test/sdk/hub.test.ts | 43 ++ .../test/sdk/hubextensions.test.ts | 26 + .../node-experimental/test/sdk/init.test.ts | 3 + .../test/sdk/otelAsyncContextStrategy.test.ts | 140 ++++ .../node-experimental/test/sdk/scope.test.ts | 438 +++++++++++++ .../node-experimental/test/sdk/trace.test.ts | 211 ++++-- .../test/sdk/transaction.test.ts | 245 +++++++ .../utils/convertOtelTimeToSeconds.test.ts | 9 + .../test/utils/getActiveSpan.test.ts | 152 +++++ .../test/utils/getRequestSpanData.test.ts | 59 ++ .../utils/groupOtelSpansWithParents.test.ts | 123 ++++ .../test/utils/setupEventContextTrace.test.ts | 111 ++++ packages/opentelemetry-node/src/index.ts | 7 +- .../opentelemetry-node/src/spanprocessor.ts | 6 +- .../src/utils/mapOtelStatus.ts | 3 +- .../src/utils/parseOtelSpanDescription.ts | 2 +- yarn.lock | 2 +- 59 files changed, 4541 insertions(+), 355 deletions(-) create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/.gitignore create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/.npmrc create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/event-proxy-server.ts create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/package.json create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/src/tracing.js create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/start-event-proxy.ts create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/server.test.ts create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts create mode 100644 packages/e2e-tests/test-applications/node-experimental-fastify-app/tsconfig.json create mode 100644 packages/node-experimental/src/opentelemetry/spanData.ts create mode 100644 packages/node-experimental/src/opentelemetry/spanExporter.ts create mode 100644 packages/node-experimental/src/opentelemetry/spanProcessor.ts create mode 100644 packages/node-experimental/src/sdk/transaction.ts create mode 100644 packages/node-experimental/src/utils/convertOtelTimeToSeconds.ts create mode 100644 packages/node-experimental/src/utils/getActiveSpan.ts create mode 100644 packages/node-experimental/src/utils/groupOtelSpansWithParents.ts create mode 100644 packages/node-experimental/src/utils/setupEventContextTrace.ts create mode 100644 packages/node-experimental/test/helpers/createSpan.ts create mode 100644 packages/node-experimental/test/integration/breadcrumbs.test.ts create mode 100644 packages/node-experimental/test/integration/otelTimedEvents.test.ts create mode 100644 packages/node-experimental/test/integration/scope.test.ts create mode 100644 packages/node-experimental/test/integration/transactions.test.ts create mode 100644 packages/node-experimental/test/sdk/client.test.ts create mode 100644 packages/node-experimental/test/sdk/hub.test.ts create mode 100644 packages/node-experimental/test/sdk/hubextensions.test.ts create mode 100644 packages/node-experimental/test/sdk/otelAsyncContextStrategy.test.ts create mode 100644 packages/node-experimental/test/sdk/scope.test.ts create mode 100644 packages/node-experimental/test/sdk/transaction.test.ts create mode 100644 packages/node-experimental/test/utils/convertOtelTimeToSeconds.test.ts create mode 100644 packages/node-experimental/test/utils/getActiveSpan.test.ts create mode 100644 packages/node-experimental/test/utils/getRequestSpanData.test.ts create mode 100644 packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts create mode 100644 packages/node-experimental/test/utils/setupEventContextTrace.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d6f3d2e9c2f..9f1440515c4f 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 75b177ade68d..e1e180b7faa2 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..25050f6399f0 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,7 +128,7 @@ 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) => { this._addRequestBreadcrumb(span as unknown as OtelSpan, res); @@ -119,12 +136,6 @@ export class Http implements Integration { }), ], }); - - this._shouldCreateSpanForRequest = - // eslint-disable-next-line deprecation/deprecation - this._shouldCreateSpanForRequest || clientOptions?.shouldCreateSpanForRequest; - - client?.on?.('otelSpanEnd', this._onSpanEnd); } /** @@ -134,64 +145,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 +180,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..23979e0d0840 --- /dev/null +++ b/packages/node-experimental/src/opentelemetry/spanExporter.ts @@ -0,0 +1,315 @@ +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'; + +type OtelSpanNodeCompleted = OtelSpanNode & { span: OtelSpan }; + +/** + * 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; + }); + + resultCallback({ code: ExportResultCode.SUCCESS }); + } + + /** @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. + * Spans remain unsent when their parent span is not yet finished. + * This will happen regularly, as child spans are generally finished before their parents. + * But it _could_ also happen because, for whatever reason, a parent span was lost. + * In this case, we'll eventually need to clean this up. + */ +function maybeSend(spans: OtelSpan[]): OtelSpan[] { + const grouped = groupOtelSpansWithParents(spans); + const remaining = new Set(grouped); + + const rootNodes = getCompletedRootNodes(grouped); + + rootNodes.forEach(root => { + remaining.delete(root); + const span = root.span; + 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 getCompletedRootNodes(nodes: OtelSpanNode[]): OtelSpanNodeCompleted[] { + return nodes.filter((node): node is OtelSpanNodeCompleted => !!node.span && !node.parentNode); +} + +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..3757a64acd08 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, normalize } 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 (transaction && transaction.addBreadcrumb) { - transaction.addBreadcrumb(breadcrumb, maxBreadcrumbs); + if (rootSpan) { + const mergedBreadcrumb = { + timestamp: dateTimestampInSeconds(), + ...breadcrumb, + }; + + rootSpan.addEvent(...breadcrumbToOtelEvent(mergedBreadcrumb)); return this; } @@ -49,18 +94,94 @@ export class OtelScope extends Scope { * @inheritDoc */ protected _getBreadcrumbs(): Breadcrumb[] { - const transaction = getActiveTransaction(); - const transactionBreadcrumbs = transaction && transaction.getBreadcrumbs ? transaction.getBreadcrumbs() : []; + const span = this.activeSpan || getActiveSpan(); + + const spanBreadcrumbs = span ? getBreadcrumbsForSpan(span) : []; - return this._breadcrumbs.concat(transactionBreadcrumbs); + 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 || ''; + + const dataAttrs = serializeBreadcrumbData(breadcrumb.data); + + 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, + ...dataAttrs, + }), + breadcrumb.timestamp ? new Date(breadcrumb.timestamp * 1000) : undefined, + ]; +} + +function serializeBreadcrumbData(data: Breadcrumb['data']): undefined | Record { + if (!data || Object.keys(data).length === 0) { + return undefined; + } + + try { + const normalizedData = normalize(data); + return { + [OTEL_ATTR_BREADCRUMB_DATA]: JSON.stringify(normalizedData), + }; + } catch (e) { + return 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..3909d80dbee9 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 { Span, 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 } 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: Span | 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(); } + _applySentryAttributesToSpan(span, spanContext); + let maybePromiseResult: T; try { - maybePromiseResult = callback(sentrySpan); + maybePromiseResult = callback(span); } 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): Span | 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); - return wrappedSentrySpan; -} + _applySentryAttributesToSpan(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 _applySentryAttributesToSpan(span: Span, 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 Date: Mon, 9 Oct 2023 10:30:15 +0200 Subject: [PATCH 2/3] align span & function names Use regular `Span` type from `@opentelemetry/api` wherever possible. --- packages/node-experimental/src/index.ts | 3 +- .../src/integrations/express.ts | 4 +- .../src/integrations/fastify.ts | 4 +- .../src/integrations/graphql.ts | 4 +- .../src/integrations/http.ts | 19 ++-- .../src/integrations/mongo.ts | 4 +- .../src/integrations/mongoose.ts | 4 +- .../src/integrations/mysql2.ts | 4 +- .../src/integrations/postgres.ts | 4 +- .../src/opentelemetry/spanData.ts | 47 +++++----- .../src/opentelemetry/spanExporter.ts | 89 ++++++++++--------- .../src/opentelemetry/spanProcessor.ts | 25 +++--- packages/node-experimental/src/sdk/scope.ts | 23 ++--- packages/node-experimental/src/sdk/trace.ts | 22 ++++- packages/node-experimental/src/types.ts | 4 +- .../src/utils/addOriginToSpan.ts | 7 +- .../src/utils/getActiveSpan.ts | 16 ++-- .../src/utils/getRequestSpanData.ts | 11 ++- .../src/utils/getSpanKind.ts | 18 ++++ ...ithParents.ts => groupSpansWithParents.ts} | 31 +++---- .../src/utils/setupEventContextTrace.ts | 13 +-- .../src/utils/spanIsSdkTraceBaseSpan.ts | 10 +++ .../test/helpers/createSpan.ts | 4 +- .../node-experimental/test/sdk/scope.test.ts | 6 +- .../node-experimental/test/sdk/trace.test.ts | 21 ++--- .../test/utils/getActiveSpan.test.ts | 15 ++-- ....test.ts => groupSpansWithParents.test.ts} | 12 +-- 27 files changed, 240 insertions(+), 184 deletions(-) create mode 100644 packages/node-experimental/src/utils/getSpanKind.ts rename packages/node-experimental/src/utils/{groupOtelSpansWithParents.ts => groupSpansWithParents.ts} (67%) create mode 100644 packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts rename packages/node-experimental/test/utils/{groupOtelSpansWithParents.test.ts => groupSpansWithParents.test.ts} (90%) diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index d1f04a48bd72..db1abe96495a 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -14,6 +14,7 @@ export * as Handlers from './sdk/handlers'; export * from './sdk/trace'; export { getActiveSpan } from './utils/getActiveSpan'; export { getCurrentHub, getHubFromCarrier } from './sdk/hub'; +export type { Span } from './types'; export { makeNodeTransport, @@ -67,10 +68,8 @@ export type { Exception, Session, SeverityLevel, - Span, StackFrame, Stacktrace, Thread, - Transaction, User, } from '@sentry/node'; diff --git a/packages/node-experimental/src/integrations/express.ts b/packages/node-experimental/src/integrations/express.ts index 95b9527c8498..0bbe3a19a11d 100644 --- a/packages/node-experimental/src/integrations/express.ts +++ b/packages/node-experimental/src/integrations/express.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -31,7 +31,7 @@ export class Express extends NodePerformanceIntegration implements Integra return [ new ExpressInstrumentation({ requestHook(span) { - addOriginToOtelSpan(span, 'auto.http.otel.express'); + addOriginToSpan(span, 'auto.http.otel.express'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/fastify.ts b/packages/node-experimental/src/integrations/fastify.ts index b84301967616..4d32037887b1 100644 --- a/packages/node-experimental/src/integrations/fastify.ts +++ b/packages/node-experimental/src/integrations/fastify.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { FastifyInstrumentation } from '@opentelemetry/instrumentation-fastify'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -31,7 +31,7 @@ export class Fastify extends NodePerformanceIntegration implements Integra return [ new FastifyInstrumentation({ requestHook(span) { - addOriginToOtelSpan(span, 'auto.http.otel.fastify'); + addOriginToSpan(span, 'auto.http.otel.fastify'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/graphql.ts b/packages/node-experimental/src/integrations/graphql.ts index 87749a0f54a2..b4a529df713e 100644 --- a/packages/node-experimental/src/integrations/graphql.ts +++ b/packages/node-experimental/src/integrations/graphql.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -32,7 +32,7 @@ export class GraphQL extends NodePerformanceIntegration implements Integra new GraphQLInstrumentation({ ignoreTrivialResolveSpans: true, responseHook(span) { - addOriginToOtelSpan(span, 'auto.graphql.otel.graphql'); + addOriginToSpan(span, 'auto.graphql.otel.graphql'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 25050f6399f0..5b939e2ead20 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -1,3 +1,4 @@ +import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; @@ -7,12 +8,12 @@ import { stringMatchesSomePattern } from '@sentry/utils'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; import { OTEL_ATTR_ORIGIN } from '../constants'; -import { setOtelSpanMetadata } from '../opentelemetry/spanData'; +import { setSpanMetadata } 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'; +import { getSpanKind } from '../utils/getSpanKind'; interface HttpOptions { /** @@ -128,10 +129,10 @@ export class Http implements Integration { requireParentforOutgoingSpans: true, requireParentforIncomingSpans: false, requestHook: (span, req) => { - this._updateSpan(span as unknown as OtelSpan, req); + this._updateSpan(span, req); }, responseHook: (span, res) => { - this._addRequestBreadcrumb(span as unknown as OtelSpan, res); + this._addRequestBreadcrumb(span, res); }, }), ], @@ -146,17 +147,17 @@ export class Http implements Integration { } /** Update the span with data we need. */ - private _updateSpan(span: OtelSpan, request: ClientRequest | IncomingMessage): void { + private _updateSpan(span: Span, request: ClientRequest | IncomingMessage): void { span.setAttribute(OTEL_ATTR_ORIGIN, 'auto.http.otel.http'); - if (span.kind === SpanKind.SERVER) { - setOtelSpanMetadata(span, { request }); + if (getSpanKind(span) === SpanKind.SERVER) { + setSpanMetadata(span, { request }); } } /** Add a breadcrumb for outgoing requests. */ - private _addRequestBreadcrumb(span: OtelSpan, response: IncomingMessage | ServerResponse): void { - if (!this._breadcrumbs || span.kind !== SpanKind.CLIENT) { + private _addRequestBreadcrumb(span: Span, response: IncomingMessage | ServerResponse): void { + if (!this._breadcrumbs || getSpanKind(span) !== SpanKind.CLIENT) { return; } diff --git a/packages/node-experimental/src/integrations/mongo.ts b/packages/node-experimental/src/integrations/mongo.ts index aea5d0a7d3fb..f8be482be946 100644 --- a/packages/node-experimental/src/integrations/mongo.ts +++ b/packages/node-experimental/src/integrations/mongo.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -31,7 +31,7 @@ export class Mongo extends NodePerformanceIntegration implements Integrati return [ new MongoDBInstrumentation({ responseHook(span) { - addOriginToOtelSpan(span, 'auto.db.otel.mongo'); + addOriginToSpan(span, 'auto.db.otel.mongo'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/mongoose.ts b/packages/node-experimental/src/integrations/mongoose.ts index 8f6eb65adb8b..a5361a620bc2 100644 --- a/packages/node-experimental/src/integrations/mongoose.ts +++ b/packages/node-experimental/src/integrations/mongoose.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -31,7 +31,7 @@ export class Mongoose extends NodePerformanceIntegration implements Integr return [ new MongooseInstrumentation({ responseHook(span) { - addOriginToOtelSpan(span, 'auto.db.otel.mongoose'); + addOriginToSpan(span, 'auto.db.otel.mongoose'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/mysql2.ts b/packages/node-experimental/src/integrations/mysql2.ts index b78b56bdd0ab..9a87de98fd66 100644 --- a/packages/node-experimental/src/integrations/mysql2.ts +++ b/packages/node-experimental/src/integrations/mysql2.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { MySQL2Instrumentation } from '@opentelemetry/instrumentation-mysql2'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -31,7 +31,7 @@ export class Mysql2 extends NodePerformanceIntegration implements Integrat return [ new MySQL2Instrumentation({ responseHook(span) { - addOriginToOtelSpan(span, 'auto.db.otel.mysql2'); + addOriginToSpan(span, 'auto.db.otel.mysql2'); }, }), ]; diff --git a/packages/node-experimental/src/integrations/postgres.ts b/packages/node-experimental/src/integrations/postgres.ts index 4ecab8d685f2..85584f8a6507 100644 --- a/packages/node-experimental/src/integrations/postgres.ts +++ b/packages/node-experimental/src/integrations/postgres.ts @@ -2,7 +2,7 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; import type { Integration } from '@sentry/types'; -import { addOriginToOtelSpan } from '../utils/addOriginToSpan'; +import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; /** @@ -32,7 +32,7 @@ export class Postgres extends NodePerformanceIntegration implements Integr new PgInstrumentation({ requireParentSpan: true, requestHook(span) { - addOriginToOtelSpan(span, 'auto.db.otel.postgres'); + addOriginToSpan(span, 'auto.db.otel.postgres'); }, }), ]; diff --git a/packages/node-experimental/src/opentelemetry/spanData.ts b/packages/node-experimental/src/opentelemetry/spanData.ts index 2a3c8a20f516..a19ec64d00df 100644 --- a/packages/node-experimental/src/opentelemetry/spanData.ts +++ b/packages/node-experimental/src/opentelemetry/spanData.ts @@ -1,50 +1,55 @@ -import type { Span as OtelSpan } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import type { Hub, Scope, TransactionMetadata } from '@sentry/types'; +// We allow passing either a Span (=which is bascially a WriteableSpan), or a ReadableSpan +// As we check by identity anyhow we don't really care +type AbstractSpan = Span | ReadableSpan; + // 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>(); +const SpanScope = new WeakMap(); +const SpanHub = new WeakMap(); +const SpanParent = new WeakMap(); +const SpanMetadata = new WeakMap>(); /** Set the Sentry scope on an OTEL span. */ -export function setOtelSpanScope(span: OtelSpan, scope: Scope): void { - otelSpanScope.set(span, scope); +export function setSpanScope(span: AbstractSpan, scope: Scope): void { + SpanScope.set(span, scope); } /** Get the Sentry scope of an OTEL span. */ -export function getOtelSpanScope(span: OtelSpan): Scope | undefined { - return otelSpanScope.get(span); +export function getSpanScope(span: AbstractSpan): Scope | undefined { + return SpanScope.get(span); } /** Set the Sentry hub on an OTEL span. */ -export function setOtelSpanHub(span: OtelSpan, hub: Hub): void { - otelSpanHub.set(span, hub); +export function setSpanHub(span: AbstractSpan, hub: Hub): void { + SpanHub.set(span, hub); } /** Get the Sentry hub of an OTEL span. */ -export function getOtelSpanHub(span: OtelSpan): Hub | undefined { - return otelSpanHub.get(span); +export function getSpanHub(span: AbstractSpan): Hub | undefined { + return SpanHub.get(span); } /** Set the parent OTEL span on an OTEL span. */ -export function setOtelSpanParent(span: OtelSpan, parentSpan: OtelSpan): void { - otelSpanParent.set(span, parentSpan); +export function setSpanParent(span: AbstractSpan, parentSpan: Span): void { + SpanParent.set(span, parentSpan); } /** Get the parent OTEL span of an OTEL span. */ -export function getOtelSpanParent(span: OtelSpan): OtelSpan | undefined { - return otelSpanParent.get(span); +export function getSpanParent(span: AbstractSpan): Span | undefined { + return SpanParent.get(span); } /** Set metadata for an OTEL span. */ -export function setOtelSpanMetadata(span: OtelSpan, metadata: Partial): void { - otelSpanMetadata.set(span, metadata); +export function setSpanMetadata(span: AbstractSpan, metadata: Partial): void { + SpanMetadata.set(span, metadata); } /** Get metadata for an OTEL span. */ -export function getOtelSpanMetadata(span: OtelSpan): Partial | undefined { - return otelSpanMetadata.get(span); +export function getSpanMetadata(span: AbstractSpan): Partial | undefined { + return SpanMetadata.get(span); } diff --git a/packages/node-experimental/src/opentelemetry/spanExporter.ts b/packages/node-experimental/src/opentelemetry/spanExporter.ts index 23979e0d0840..90af46e8672d 100644 --- a/packages/node-experimental/src/opentelemetry/spanExporter.ts +++ b/packages/node-experimental/src/opentelemetry/spanExporter.ts @@ -1,11 +1,12 @@ +import type { Span } from '@opentelemetry/api'; 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 type { ReadableSpan, Span as SdkTraceBaseSpan, 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 type { DynamicSamplingContext, Span as SentrySpan, 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'; @@ -13,20 +14,19 @@ 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'; +import type { SpanNode } from '../utils/groupSpansWithParents'; +import { groupSpansWithParents } from '../utils/groupSpansWithParents'; +import { getSpanHub, getSpanMetadata, getSpanScope } from './spanData'; -type OtelSpanNodeCompleted = OtelSpanNode & { span: OtelSpan }; +type SpanNodeCompleted = SpanNode & { span: ReadableSpan }; /** * A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. */ export class SentrySpanExporter implements SpanExporter { - private _finishedSpans: OtelSpan[]; + private _finishedSpans: ReadableSpan[]; private _stopped: boolean; public constructor() { @@ -35,7 +35,7 @@ export class SentrySpanExporter implements SpanExporter { } /** @inheritDoc */ - public export(spans: OtelSpan[], resultCallback: (result: ExportResult) => void): void { + public export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { if (this._stopped) { return resultCallback({ code: ExportResultCode.FAILED, @@ -93,8 +93,8 @@ export class SentrySpanExporter implements SpanExporter { * But it _could_ also happen because, for whatever reason, a parent span was lost. * In this case, we'll eventually need to clean this up. */ -function maybeSend(spans: OtelSpan[]): OtelSpan[] { - const grouped = groupOtelSpansWithParents(spans); +function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { + const grouped = groupSpansWithParents(spans); const remaining = new Set(grouped); const rootNodes = getCompletedRootNodes(grouped); @@ -110,31 +110,31 @@ function maybeSend(spans: OtelSpan[]): OtelSpan[] { // 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 scope = getSpanScope(span); const forkedScope = NodeExperimentalScope.clone( scope as NodeExperimentalScope | undefined, ) as NodeExperimentalScope; - forkedScope.activeSpan = span; + forkedScope.activeSpan = span as unknown as Span; transaction.finishWithScope(convertOtelTimeToSeconds(span.endTime), forkedScope); }); return Array.from(remaining) - .map(node => node.span as OtelSpan) - .filter(Boolean); + .map(node => node.span) + .filter((span): span is ReadableSpan => !!span); } -function getCompletedRootNodes(nodes: OtelSpanNode[]): OtelSpanNodeCompleted[] { - return nodes.filter((node): node is OtelSpanNodeCompleted => !!node.span && !node.parentNode); +function getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] { + return nodes.filter((node): node is SpanNodeCompleted => !!node.span && !node.parentNode); } -function shouldCleanupSpan(span: OtelSpan, maxStartTimeOffsetSeconds: number): boolean { +function shouldCleanupSpan(span: ReadableSpan, 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; +function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; source?: TransactionSource } { + const attributes = span.attributes; const origin = attributes[OTEL_ATTR_ORIGIN] as SpanOrigin | undefined; const op = attributes[OTEL_ATTR_OP] as string | undefined; @@ -143,9 +143,9 @@ function parseSpan(otelSpan: OtelSpan): { op?: string; origin?: SpanOrigin; sour return { origin, op, source }; } -function createTransactionForOtelSpan(span: OtelSpan): NodeExperimentalTransaction { - const scope = getOtelSpanScope(span); - const hub = getOtelSpanHub(span) || getCurrentHub(); +function createTransactionForOtelSpan(span: ReadableSpan): NodeExperimentalTransaction { + const scope = getSpanScope(span); + const hub = getSpanHub(span) || getCurrentHub(); const spanContext = span.spanContext(); const spanId = spanContext.spanId; const traceId = spanContext.traceId; @@ -156,8 +156,8 @@ function createTransactionForOtelSpan(span: OtelSpan): NodeExperimentalTransacti ? scope.getPropagationContext().dsc : undefined; - const { op, description, tags, data, origin, source } = getSpanData(span); - const metadata = getOtelSpanMetadata(span); + const { op, description, tags, data, origin, source } = getSpanData(span as SdkTraceBaseSpan); + const metadata = getSpanMetadata(span); const transaction = startTransaction(hub, { spanId, @@ -167,7 +167,7 @@ function createTransactionForOtelSpan(span: OtelSpan): NodeExperimentalTransacti name: description, op, instrumenter: 'otel', - status: mapOtelStatus(span), + status: mapOtelStatus(span as SdkTraceBaseSpan), startTimestamp: convertOtelTimeToSeconds(span.startTime), metadata: { dynamicSamplingContext, @@ -187,15 +187,11 @@ function createTransactionForOtelSpan(span: OtelSpan): NodeExperimentalTransacti return transaction; } -function createAndFinishSpanForOtelSpan( - node: OtelSpanNode, - sentryParentSpan: Span, - remaining: Set, -): void { +function createAndFinishSpanForOtelSpan(node: SpanNode, sentryParentSpan: SentrySpan, remaining: Set): void { remaining.delete(node); - const otelSpan = node.span; + const span = node.span; - const shouldDrop = !otelSpan; + const shouldDrop = !span; // If this span should be dropped, we still want to create spans for the children of this if (shouldDrop) { @@ -205,20 +201,20 @@ function createAndFinishSpanForOtelSpan( return; } - const otelSpanId = otelSpan.spanContext().spanId; - const { attributes } = otelSpan; + const spanId = span.spanContext().spanId; + const { attributes } = span; - const { op, description, tags, data, origin } = getSpanData(otelSpan); + const { op, description, tags, data, origin } = getSpanData(span as SdkTraceBaseSpan); const allData = { ...removeSentryAttributes(attributes), ...data }; const sentrySpan = sentryParentSpan.startChild({ description, op, data: allData, - status: mapOtelStatus(otelSpan), + status: mapOtelStatus(span as SdkTraceBaseSpan), instrumenter: 'otel', - startTimestamp: convertOtelTimeToSeconds(otelSpan.startTime), - spanId: otelSpanId, + startTimestamp: convertOtelTimeToSeconds(span.startTime), + spanId, origin, tags, }); @@ -227,10 +223,10 @@ function createAndFinishSpanForOtelSpan( createAndFinishSpanForOtelSpan(child, sentrySpan, remaining); }); - sentrySpan.finish(convertOtelTimeToSeconds(otelSpan.endTime)); + sentrySpan.finish(convertOtelTimeToSeconds(span.endTime)); } -function getSpanData(span: OtelSpan): { +function getSpanData(span: ReadableSpan): { tags: Record; data: Record; op?: string; @@ -239,7 +235,12 @@ function getSpanData(span: OtelSpan): { origin?: SpanOrigin; } { const { op: definedOp, source: definedSource, origin } = parseSpan(span); - const { op: inferredOp, description, source: inferredSource, data: inferredData } = parseOtelSpanDescription(span); + const { + op: inferredOp, + description, + source: inferredSource, + data: inferredData, + } = parseOtelSpanDescription(span as SdkTraceBaseSpan); const op = definedOp || inferredOp; const source = definedSource || inferredSource; @@ -274,7 +275,7 @@ function removeSentryAttributes(data: Record): Record { +function getTags(span: ReadableSpan): Record { const attributes = span.attributes; const tags: Record = {}; @@ -287,7 +288,7 @@ function getTags(span: OtelSpan): Record { return tags; } -function getData(span: OtelSpan): Record { +function getData(span: ReadableSpan): Record { const attributes = span.attributes; const data: Record = { 'otel.kind': SpanKind[span.kind], diff --git a/packages/node-experimental/src/opentelemetry/spanProcessor.ts b/packages/node-experimental/src/opentelemetry/spanProcessor.ts index ab64883ef5a1..2a202c0f7df9 100644 --- a/packages/node-experimental/src/opentelemetry/spanProcessor.ts +++ b/packages/node-experimental/src/opentelemetry/spanProcessor.ts @@ -1,6 +1,6 @@ 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 type { Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { @@ -13,15 +13,14 @@ 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 { getSpanHub, setSpanHub, setSpanParent, setSpanScope } 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 { +export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProcessorInterface { public constructor() { super(new SentrySpanExporter()); } @@ -29,14 +28,14 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements OtelSpanP /** * @inheritDoc */ - public onStart(span: OtelSpan, parentContext: Context): void { + public onStart(span: Span, 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 parentSpan = trace.getSpan(parentContext); 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); + setSpanParent(span, parentSpan); } // The root context does not have a hub stored, so we check for this specifically @@ -48,8 +47,8 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements OtelSpanP // 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); + setSpanScope(span, actualHub.getScope()); + setSpanHub(span, actualHub); } // We need to set this here based on the parent context @@ -62,14 +61,14 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements OtelSpanP } /** @inheritDoc */ - public onEnd(span: OtelSpan): void { + public onEnd(span: Span): 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(); + const hub = getSpanHub(span) || getCurrentHub(); span.events.forEach(event => { maybeCaptureExceptionForTimedEvent(hub, event, span); }); @@ -82,7 +81,7 @@ 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 { +function getParentSampled(span: Span, parentContext: Context): boolean | undefined { const spanContext = span.spanContext(); const traceId = spanContext.traceId; const traceparentData = getTraceParentData(parentContext); @@ -91,7 +90,7 @@ function getParentSampled(span: OtelSpan, parentContext: Context): boolean | und return traceparentData && traceId === traceparentData.traceId ? traceparentData.parentSampled : undefined; } -function shouldCaptureSentrySpan(span: OtelSpan): boolean { +function shouldCaptureSentrySpan(span: Span): boolean { const client = getCurrentHub().getClient(); const httpIntegration = client ? client.getIntegration(Http) : undefined; diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts index 3757a64acd08..2c38d2f84a2e 100644 --- a/packages/node-experimental/src/sdk/scope.ts +++ b/packages/node-experimental/src/sdk/scope.ts @@ -1,6 +1,7 @@ +import type { Span } from '@opentelemetry/api'; import type { TimedEvent } from '@opentelemetry/sdk-trace-base'; import { Scope } from '@sentry/core'; -import type { Breadcrumb, SeverityLevel, Span } from '@sentry/types'; +import type { Breadcrumb, SeverityLevel, Span as SentrySpan } from '@sentry/types'; import { dateTimestampInSeconds, dropUndefinedKeys, logger, normalize } from '@sentry/utils'; import { @@ -10,10 +11,10 @@ import { OTEL_ATTR_BREADCRUMB_LEVEL, OTEL_ATTR_BREADCRUMB_TYPE, } from '../constants'; -import { getOtelSpanParent } from '../opentelemetry/spanData'; -import type { OtelSpan } from '../types'; +import { getSpanParent } from '../opentelemetry/spanData'; import { convertOtelTimeToSeconds } from '../utils/convertOtelTimeToSeconds'; import { getActiveSpan, getRootSpan } from '../utils/getActiveSpan'; +import { spanIsSdkTraceBaseSpan } from '../utils/spanIsSdkTraceBaseSpan'; /** A fork of the classic scope with some otel specific stuff. */ export class NodeExperimentalScope extends Scope { @@ -21,7 +22,7 @@ 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; + public activeSpan: Span | undefined; /** * @inheritDoc @@ -63,7 +64,7 @@ export class NodeExperimentalScope extends Scope { * In node-experimental, scope.setSpan() is a noop. * Instead, use the global `startSpan()` to define the active span. */ - public setSpan(_span: Span): this { + public setSpan(_span: SentrySpan): this { __DEBUG_BUILD__ && logger.warn('Calling setSpan() is a noop in @sentry/node-experimental. Use `startSpan()` instead.'); @@ -77,7 +78,7 @@ export class NodeExperimentalScope extends Scope { const activeSpan = this.activeSpan || getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - if (rootSpan) { + if (rootSpan && spanIsSdkTraceBaseSpan(rootSpan)) { const mergedBreadcrumb = { timestamp: dateTimestampInSeconds(), ...breadcrumb, @@ -105,13 +106,13 @@ export class NodeExperimentalScope extends Scope { /** * Get all breadcrumbs for the given span as well as it's parents. */ -function getBreadcrumbsForSpan(span: OtelSpan): Breadcrumb[] { +function getBreadcrumbsForSpan(span: Span): Breadcrumb[] { const events = span ? getOtelEvents(span) : []; return events.map(otelEventToBreadcrumb); } -function breadcrumbToOtelEvent(breadcrumb: Breadcrumb): Parameters { +function breadcrumbToOtelEvent(breadcrumb: Breadcrumb): Parameters { const name = breadcrumb.message || ''; const dataAttrs = serializeBreadcrumbData(breadcrumb.data); @@ -172,13 +173,13 @@ function otelEventToBreadcrumb(event: TimedEvent): Breadcrumb { return breadcrumb; } -function getOtelEvents(span: OtelSpan, events: TimedEvent[] = []): TimedEvent[] { - if (span.events) { +function getOtelEvents(span: Span, events: TimedEvent[] = []): TimedEvent[] { + if (spanIsSdkTraceBaseSpan(span) && span.events) { events.push(...span.events); } // Go up parent chain and collect events - const parent = getOtelSpanParent(span) as OtelSpan | undefined; + const parent = getSpanParent(span); if (parent) { return getOtelEvents(parent, events); } diff --git a/packages/node-experimental/src/sdk/trace.ts b/packages/node-experimental/src/sdk/trace.ts index 3909d80dbee9..1c524cd993d3 100644 --- a/packages/node-experimental/src/sdk/trace.ts +++ b/packages/node-experimental/src/sdk/trace.ts @@ -1,11 +1,13 @@ -import type { Span, Tracer } from '@opentelemetry/api'; +import type { Tracer } from '@opentelemetry/api'; import { SpanStatusCode } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/sdk-trace-base'; import { hasTracingEnabled } from '@sentry/core'; import { isThenable } from '@sentry/utils'; import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_SOURCE } from '../constants'; -import { setOtelSpanMetadata } from '../opentelemetry/spanData'; +import { setSpanMetadata } from '../opentelemetry/spanData'; import type { NodeExperimentalClient, NodeExperimentalSpanContext } from '../types'; +import { spanIsSdkTraceBaseSpan } from '../utils/spanIsSdkTraceBaseSpan'; import { getCurrentHub } from './hub'; /** @@ -32,6 +34,13 @@ export function startSpan(spanContext: NodeExperimentalSpanContext, callback: span.end(); } + // This is just a sanity check - in reality, this should not happen as we control the tracer, + // but to ensure type saftey we rather bail out here than to pass an invalid type out + if (!spanIsSdkTraceBaseSpan(span)) { + span.end(); + return callback(undefined); + } + _applySentryAttributesToSpan(span, spanContext); let maybePromiseResult: T; @@ -86,6 +95,13 @@ export function startInactiveSpan(spanContext: NodeExperimentalSpanContext): Spa const span = tracer.startSpan(name); + // This is just a sanity check - in reality, this should not happen as we control the tracer, + // but to ensure type saftey we rather bail out here than to pass an invalid type out + if (!spanIsSdkTraceBaseSpan(span)) { + span.end(); + return undefined; + } + _applySentryAttributesToSpan(span, spanContext); return span; @@ -116,6 +132,6 @@ function _applySentryAttributesToSpan(span: Span, spanContext: NodeExperimentalS } if (metadata) { - setOtelSpanMetadata(span, metadata); + setSpanMetadata(span, metadata); } } diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index f64aa7893764..29bfad62dc6c 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,5 +1,5 @@ import type { Tracer } from '@opentelemetry/api'; -import type { BasicTracerProvider, Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; +import type { BasicTracerProvider, Span } from '@opentelemetry/sdk-trace-base'; import type { NodeClient, NodeOptions } from '@sentry/node'; import type { SpanOrigin, TransactionMetadata, TransactionSource } from '@sentry/types'; @@ -20,4 +20,4 @@ export interface NodeExperimentalSpanContext { source?: TransactionSource; } -export type { OtelSpan }; +export type { Span }; diff --git a/packages/node-experimental/src/utils/addOriginToSpan.ts b/packages/node-experimental/src/utils/addOriginToSpan.ts index 19033b970157..007f55bb1e05 100644 --- a/packages/node-experimental/src/utils/addOriginToSpan.ts +++ b/packages/node-experimental/src/utils/addOriginToSpan.ts @@ -1,10 +1,9 @@ -// 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 type { Span } from '@opentelemetry/api'; 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 { - otelSpan.setAttribute(OTEL_ATTR_ORIGIN, origin); +export function addOriginToSpan(span: Span, origin: SpanOrigin): void { + span.setAttribute(OTEL_ATTR_ORIGIN, origin); } diff --git a/packages/node-experimental/src/utils/getActiveSpan.ts b/packages/node-experimental/src/utils/getActiveSpan.ts index 9fcfd6f0e508..240842770a68 100644 --- a/packages/node-experimental/src/utils/getActiveSpan.ts +++ b/packages/node-experimental/src/utils/getActiveSpan.ts @@ -1,24 +1,24 @@ +import type { Span } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; -import { getOtelSpanParent } from '../opentelemetry/spanData'; -import type { OtelSpan } from '../types'; +import { getSpanParent } from '../opentelemetry/spanData'; /** * Returns the currently active span. */ -export function getActiveSpan(): OtelSpan | undefined { - return trace.getActiveSpan() as OtelSpan | undefined; +export function getActiveSpan(): Span | undefined { + return trace.getActiveSpan(); } /** * 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; +export function getRootSpan(span: Span): Span { + let parent: Span = span; - while (getOtelSpanParent(parent)) { - parent = getOtelSpanParent(parent) as OtelSpan; + while (getSpanParent(parent)) { + parent = getSpanParent(parent) as Span; } return parent; diff --git a/packages/node-experimental/src/utils/getRequestSpanData.ts b/packages/node-experimental/src/utils/getRequestSpanData.ts index 586401958b7c..860b48bff814 100644 --- a/packages/node-experimental/src/utils/getRequestSpanData.ts +++ b/packages/node-experimental/src/utils/getRequestSpanData.ts @@ -1,13 +1,20 @@ +import type { Span } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import type { SanitizedRequestData } from '@sentry/types'; import { getSanitizedUrlString, parseUrl } from '@sentry/utils'; -import type { OtelSpan } from '../types'; +import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; /** * Get sanitizied request data from an OTEL span. */ -export function getRequestSpanData(span: OtelSpan): Partial { +export function getRequestSpanData(span: Span | ReadableSpan): Partial { + // The base `Span` type has no `attributes`, so we need to guard here against that + if (!spanIsSdkTraceBaseSpan(span)) { + return {}; + } + const data: Partial = { url: span.attributes[SemanticAttributes.HTTP_URL] as string | undefined, 'http.method': span.attributes[SemanticAttributes.HTTP_METHOD] as string | undefined, diff --git a/packages/node-experimental/src/utils/getSpanKind.ts b/packages/node-experimental/src/utils/getSpanKind.ts new file mode 100644 index 000000000000..fe3807e1882e --- /dev/null +++ b/packages/node-experimental/src/utils/getSpanKind.ts @@ -0,0 +1,18 @@ +import type { Span } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; + +import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; + +/** + * Get the span kind from a span. + * For whatever reason, this is not public API on the generic "Span" type, + * so we need to check if we actually have a `SDKTraceBaseSpan` where we can fetch this from. + * Otherwise, we fall back to `SpanKind.INTERNAL`. + */ +export function getSpanKind(span: Span): SpanKind { + if (spanIsSdkTraceBaseSpan(span)) { + return span.kind; + } + + return SpanKind.INTERNAL; +} diff --git a/packages/node-experimental/src/utils/groupOtelSpansWithParents.ts b/packages/node-experimental/src/utils/groupSpansWithParents.ts similarity index 67% rename from packages/node-experimental/src/utils/groupOtelSpansWithParents.ts rename to packages/node-experimental/src/utils/groupSpansWithParents.ts index c0a07293b703..2af278d0bce2 100644 --- a/packages/node-experimental/src/utils/groupOtelSpansWithParents.ts +++ b/packages/node-experimental/src/utils/groupSpansWithParents.ts @@ -1,23 +1,24 @@ -import { getOtelSpanParent } from '../opentelemetry/spanData'; -import type { OtelSpan } from '../types'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -export interface OtelSpanNode { +import { getSpanParent } from '../opentelemetry/spanData'; + +export interface SpanNode { id: string; - span?: OtelSpan; - parentNode?: OtelSpanNode | undefined; - children: OtelSpanNode[]; + span?: ReadableSpan; + parentNode?: SpanNode | undefined; + children: SpanNode[]; } -type OtelSpanMap = Map; +type SpanMap = Map; /** - * This function runs through a list of OTEL Spans, and wraps them in an `OtelSpanNode` + * This function runs through a list of OTEL Spans, and wraps them in an `SpanNode` * where each node holds a reference to their parent node. */ -export function groupOtelSpansWithParents(otelSpans: OtelSpan[]): OtelSpanNode[] { - const nodeMap: OtelSpanMap = new Map(); +export function groupSpansWithParents(spans: ReadableSpan[]): SpanNode[] { + const nodeMap: SpanMap = new Map(); - for (const span of otelSpans) { + for (const span of spans) { createOrUpdateSpanNodeAndRefs(nodeMap, span); } @@ -26,8 +27,8 @@ export function groupOtelSpansWithParents(otelSpans: OtelSpan[]): OtelSpanNode[] }); } -function createOrUpdateSpanNodeAndRefs(nodeMap: OtelSpanMap, span: OtelSpan): void { - const parentSpan = getOtelSpanParent(span); +function createOrUpdateSpanNodeAndRefs(nodeMap: SpanMap, span: ReadableSpan): void { + const parentSpan = getSpanParent(span); const parentIsRemote = parentSpan ? !!parentSpan.spanContext().isRemote : false; const id = span.spanContext().spanId; @@ -48,7 +49,7 @@ function createOrUpdateSpanNodeAndRefs(nodeMap: OtelSpanMap, span: OtelSpan): vo parentNode.children.push(node); } -function createOrGetParentNode(nodeMap: OtelSpanMap, id: string): OtelSpanNode { +function createOrGetParentNode(nodeMap: SpanMap, id: string): SpanNode { const existing = nodeMap.get(id); if (existing) { @@ -58,7 +59,7 @@ function createOrGetParentNode(nodeMap: OtelSpanMap, id: string): OtelSpanNode { return createOrUpdateNode(nodeMap, { id, children: [] }); } -function createOrUpdateNode(nodeMap: OtelSpanMap, spanNode: OtelSpanNode): OtelSpanNode { +function createOrUpdateNode(nodeMap: SpanMap, spanNode: SpanNode): SpanNode { const existing = nodeMap.get(spanNode.id); // If span is already set, nothing to do here diff --git a/packages/node-experimental/src/utils/setupEventContextTrace.ts b/packages/node-experimental/src/utils/setupEventContextTrace.ts index c3b40d1db654..e28eed9d6661 100644 --- a/packages/node-experimental/src/utils/setupEventContextTrace.ts +++ b/packages/node-experimental/src/utils/setupEventContextTrace.ts @@ -1,6 +1,7 @@ import type { Client } from '@sentry/types'; import { getActiveSpan } from './getActiveSpan'; +import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; /** Ensure the `trace` context is set on all events. */ export function setupEventContextTrace(client: Client): void { @@ -9,19 +10,19 @@ export function setupEventContextTrace(client: Client): void { } client.addEventProcessor(event => { - const otelSpan = getActiveSpan(); - if (!otelSpan) { + const span = getActiveSpan(); + if (!span) { return event; } - const otelSpanContext = otelSpan.spanContext(); + const spanContext = span.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, + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + parent_span_id: spanIsSdkTraceBaseSpan(span) ? span.parentSpanId : undefined, }, ...event.contexts, }; diff --git a/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts b/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts new file mode 100644 index 000000000000..41d6f21d5965 --- /dev/null +++ b/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts @@ -0,0 +1,10 @@ +import type { Span } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { Span as SdkTraceBaseSpan } from '@opentelemetry/sdk-trace-base'; + +/** + * If the span is a SDK trace base span, which has some additional fields. + */ +export function spanIsSdkTraceBaseSpan(span: Span | ReadableSpan): span is SdkTraceBaseSpan { + return span instanceof SdkTraceBaseSpan; +} diff --git a/packages/node-experimental/test/helpers/createSpan.ts b/packages/node-experimental/test/helpers/createSpan.ts index a92dda655552..38c4ed96f3a8 100644 --- a/packages/node-experimental/test/helpers/createSpan.ts +++ b/packages/node-experimental/test/helpers/createSpan.ts @@ -4,12 +4,10 @@ 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 { +): Span { const spanProcessor = { onStart: () => {}, onEnd: () => {}, diff --git a/packages/node-experimental/test/sdk/scope.test.ts b/packages/node-experimental/test/sdk/scope.test.ts index 51e87e51704c..7d8d772abd8c 100644 --- a/packages/node-experimental/test/sdk/scope.test.ts +++ b/packages/node-experimental/test/sdk/scope.test.ts @@ -8,7 +8,7 @@ import { OTEL_ATTR_BREADCRUMB_LEVEL, OTEL_ATTR_BREADCRUMB_TYPE, } from '../../src/constants'; -import { setOtelSpanParent } from '../../src/opentelemetry/spanData'; +import { setSpanParent } from '../../src/opentelemetry/spanData'; import { NodeExperimentalScope } from '../../src/sdk/scope'; import { createSpan } from '../helpers/createSpan'; import * as GetActiveSpan from './../../src/utils/getActiveSpan'; @@ -336,8 +336,8 @@ describe('NodeExperimentalScope', () => { const rootSpan = createSpan(); jest.spyOn(GetActiveSpan, 'getActiveSpan').mockReturnValue(span); - setOtelSpanParent(span, parentSpan); - setOtelSpanParent(parentSpan, rootSpan); + setSpanParent(span, parentSpan); + setSpanParent(parentSpan, rootSpan); const scope = new NodeExperimentalScope(); diff --git a/packages/node-experimental/test/sdk/trace.test.ts b/packages/node-experimental/test/sdk/trace.test.ts index 413b75f25998..76ecb28a5f9d 100644 --- a/packages/node-experimental/test/sdk/trace.test.ts +++ b/packages/node-experimental/test/sdk/trace.test.ts @@ -1,7 +1,8 @@ +import type { Span } from '@opentelemetry/sdk-trace-base'; + import * as Sentry from '../../src'; 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 { getSpanMetadata } from '../../src/opentelemetry/spanData'; import { getActiveSpan } from '../../src/utils/getActiveSpan'; import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; @@ -16,7 +17,7 @@ describe('trace', () => { describe('startSpan', () => { it('works with a sync callback', () => { - const spans: OtelSpan[] = []; + const spans: Span[] = []; expect(getActiveSpan()).toEqual(undefined); @@ -52,7 +53,7 @@ describe('trace', () => { }); it('works with an async callback', async () => { - const spans: OtelSpan[] = []; + const spans: Span[] = []; expect(getActiveSpan()).toEqual(undefined); @@ -92,8 +93,8 @@ describe('trace', () => { }); it('works with multiple parallel calls', () => { - const spans1: OtelSpan[] = []; - const spans2: OtelSpan[] = []; + const spans1: Span[] = []; + const spans2: Span[] = []; expect(getActiveSpan()).toEqual(undefined); @@ -143,7 +144,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(span?.attributes).toEqual({}); - expect(getOtelSpanMetadata(span!)).toEqual(undefined); + expect(getSpanMetadata(span!)).toEqual(undefined); }, ); @@ -163,7 +164,7 @@ describe('trace', () => { [OTEL_ATTR_OP]: 'my-op', }); - expect(getOtelSpanMetadata(span!)).toEqual({ requestPath: 'test-path' }); + expect(getSpanMetadata(span!)).toEqual({ requestPath: 'test-path' }); }, ); }); @@ -211,7 +212,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(span?.attributes).toEqual({}); - expect(getOtelSpanMetadata(span!)).toEqual(undefined); + expect(getSpanMetadata(span!)).toEqual(undefined); const span2 = Sentry.startInactiveSpan({ name: 'outer', @@ -228,7 +229,7 @@ describe('trace', () => { [OTEL_ATTR_OP]: 'my-op', }); - expect(getOtelSpanMetadata(span2!)).toEqual({ requestPath: 'test-path' }); + expect(getSpanMetadata(span2!)).toEqual({ requestPath: 'test-path' }); }); }); }); diff --git a/packages/node-experimental/test/utils/getActiveSpan.test.ts b/packages/node-experimental/test/utils/getActiveSpan.test.ts index 2d041bb2ca5f..61b7d4f5d6c5 100644 --- a/packages/node-experimental/test/utils/getActiveSpan.test.ts +++ b/packages/node-experimental/test/utils/getActiveSpan.test.ts @@ -2,7 +2,6 @@ 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'; @@ -106,16 +105,16 @@ describe('getRootSpan', () => { const tracer = trace.getTracer('test'); tracer.startActiveSpan('test', span => { - expect(getRootSpan(span as OtelSpan)).toBe(span); + expect(getRootSpan(span)).toBe(span); const inner1 = tracer.startSpan('inner1'); - expect(getRootSpan(inner1 as OtelSpan)).toBe(span); + expect(getRootSpan(inner1)).toBe(span); inner1.end(); tracer.startActiveSpan('inner2', inner2 => { - expect(getRootSpan(inner2 as OtelSpan)).toBe(span); + expect(getRootSpan(inner2)).toBe(span); inner2.end(); }); @@ -128,10 +127,10 @@ describe('getRootSpan', () => { const tracer = trace.getTracer('test'); tracer.startActiveSpan('test1', span => { - expect(getRootSpan(span as OtelSpan)).toBe(span); + expect(getRootSpan(span)).toBe(span); tracer.startActiveSpan('inner1', inner1 => { - expect(getRootSpan(inner1 as OtelSpan)).toBe(span); + expect(getRootSpan(inner1)).toBe(span); inner1.end(); }); @@ -139,10 +138,10 @@ describe('getRootSpan', () => { }); tracer.startActiveSpan('test2', span => { - expect(getRootSpan(span as OtelSpan)).toBe(span); + expect(getRootSpan(span)).toBe(span); tracer.startActiveSpan('inner2', inner => { - expect(getRootSpan(inner as OtelSpan)).toBe(span); + expect(getRootSpan(inner)).toBe(span); inner.end(); }); diff --git a/packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts b/packages/node-experimental/test/utils/groupSpansWithParents.test.ts similarity index 90% rename from packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts rename to packages/node-experimental/test/utils/groupSpansWithParents.test.ts index ac839d59f95e..d9a3fa60cb97 100644 --- a/packages/node-experimental/test/utils/groupOtelSpansWithParents.test.ts +++ b/packages/node-experimental/test/utils/groupSpansWithParents.test.ts @@ -1,9 +1,9 @@ -import { groupOtelSpansWithParents } from '../../src/utils/groupOtelSpansWithParents'; +import { groupSpansWithParents } from '../../src/utils/groupSpansWithParents'; import { createSpan } from '../helpers/createSpan'; -describe('groupOtelSpansWithParents', () => { +describe('groupSpansWithParents', () => { it('works with no spans', () => { - const actual = groupOtelSpansWithParents([]); + const actual = groupSpansWithParents([]); expect(actual).toEqual([]); }); @@ -13,7 +13,7 @@ describe('groupOtelSpansWithParents', () => { const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'rootId' }); const child1 = createSpan('child1', { spanId: 'child1', parentSpanId: 'parent1Id' }); - const actual = groupOtelSpansWithParents([rootSpan, parentSpan1, parentSpan2, child1]); + const actual = groupSpansWithParents([rootSpan, parentSpan1, parentSpan2, child1]); expect(actual).toHaveLength(4); // Ensure parent & span is correctly set @@ -48,7 +48,7 @@ describe('groupOtelSpansWithParents', () => { const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'rootId' }); const child1 = createSpan('child1', { spanId: 'child1', parentSpanId: 'parent1Id' }); - const actual = groupOtelSpansWithParents([parentSpan1, parentSpan2, child1]); + const actual = groupSpansWithParents([parentSpan1, parentSpan2, child1]); expect(actual).toHaveLength(4); // Ensure parent & span is correctly set @@ -86,7 +86,7 @@ describe('groupOtelSpansWithParents', () => { const parentSpan2 = createSpan('parent2', { spanId: 'parent2Id', parentSpanId: 'root2Id' }); const childSpan1 = createSpan('child1', { spanId: 'child1Id', parentSpanId: 'parent1Id' }); - const actual = groupOtelSpansWithParents([childSpan1, parentSpan1, parentSpan2, rootSpan2, rootSpan1]); + const actual = groupSpansWithParents([childSpan1, parentSpan1, parentSpan2, rootSpan2, rootSpan1]); expect(actual).toHaveLength(5); // Ensure parent & span is correctly set From 9286ec855b9b078e340fef63fe1bc165b2f1139e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 9 Oct 2023 11:36:18 +0200 Subject: [PATCH 3/3] ref: More lenient checks for span types --- .../src/opentelemetry/spanData.ts | 5 +- packages/node-experimental/src/sdk/scope.ts | 6 +- packages/node-experimental/src/sdk/trace.ts | 2 +- packages/node-experimental/src/types.ts | 15 ++- .../src/utils/getRequestSpanData.ts | 4 +- .../src/utils/getSpanKind.ts | 4 +- .../src/utils/setupEventContextTrace.ts | 4 +- .../src/utils/spanIsSdkTraceBaseSpan.ts | 10 -- .../node-experimental/src/utils/spanTypes.ts | 58 +++++++++++ .../test/utils/spanTypes.test.ts | 98 +++++++++++++++++++ 10 files changed, 180 insertions(+), 26 deletions(-) delete mode 100644 packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts create mode 100644 packages/node-experimental/src/utils/spanTypes.ts create mode 100644 packages/node-experimental/test/utils/spanTypes.test.ts diff --git a/packages/node-experimental/src/opentelemetry/spanData.ts b/packages/node-experimental/src/opentelemetry/spanData.ts index a19ec64d00df..e8fe58506866 100644 --- a/packages/node-experimental/src/opentelemetry/spanData.ts +++ b/packages/node-experimental/src/opentelemetry/spanData.ts @@ -1,10 +1,7 @@ import type { Span } from '@opentelemetry/api'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import type { Hub, Scope, TransactionMetadata } from '@sentry/types'; -// We allow passing either a Span (=which is bascially a WriteableSpan), or a ReadableSpan -// As we check by identity anyhow we don't really care -type AbstractSpan = Span | ReadableSpan; +import type { AbstractSpan } from '../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 diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts index 2c38d2f84a2e..39f931936ccf 100644 --- a/packages/node-experimental/src/sdk/scope.ts +++ b/packages/node-experimental/src/sdk/scope.ts @@ -14,7 +14,7 @@ import { import { getSpanParent } from '../opentelemetry/spanData'; import { convertOtelTimeToSeconds } from '../utils/convertOtelTimeToSeconds'; import { getActiveSpan, getRootSpan } from '../utils/getActiveSpan'; -import { spanIsSdkTraceBaseSpan } from '../utils/spanIsSdkTraceBaseSpan'; +import { spanHasEvents } from '../utils/spanTypes'; /** A fork of the classic scope with some otel specific stuff. */ export class NodeExperimentalScope extends Scope { @@ -78,7 +78,7 @@ export class NodeExperimentalScope extends Scope { const activeSpan = this.activeSpan || getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - if (rootSpan && spanIsSdkTraceBaseSpan(rootSpan)) { + if (rootSpan) { const mergedBreadcrumb = { timestamp: dateTimestampInSeconds(), ...breadcrumb, @@ -174,7 +174,7 @@ function otelEventToBreadcrumb(event: TimedEvent): Breadcrumb { } function getOtelEvents(span: Span, events: TimedEvent[] = []): TimedEvent[] { - if (spanIsSdkTraceBaseSpan(span) && span.events) { + if (spanHasEvents(span)) { events.push(...span.events); } diff --git a/packages/node-experimental/src/sdk/trace.ts b/packages/node-experimental/src/sdk/trace.ts index 1c524cd993d3..72047f4478a3 100644 --- a/packages/node-experimental/src/sdk/trace.ts +++ b/packages/node-experimental/src/sdk/trace.ts @@ -7,7 +7,7 @@ import { isThenable } from '@sentry/utils'; import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_SOURCE } from '../constants'; import { setSpanMetadata } from '../opentelemetry/spanData'; import type { NodeExperimentalClient, NodeExperimentalSpanContext } from '../types'; -import { spanIsSdkTraceBaseSpan } from '../utils/spanIsSdkTraceBaseSpan'; +import { spanIsSdkTraceBaseSpan } from '../utils/spanTypes'; import { getCurrentHub } from './hub'; /** diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 29bfad62dc6c..8878a5fd2a8c 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,5 +1,5 @@ -import type { Tracer } from '@opentelemetry/api'; -import type { BasicTracerProvider, Span } from '@opentelemetry/sdk-trace-base'; +import type { Span as WriteableSpan, Tracer } from '@opentelemetry/api'; +import type { BasicTracerProvider, ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; import type { NodeClient, NodeOptions } from '@sentry/node'; import type { SpanOrigin, TransactionMetadata, TransactionSource } from '@sentry/types'; @@ -20,4 +20,15 @@ export interface NodeExperimentalSpanContext { source?: TransactionSource; } +/** + * The base `Span` type is basically a `WriteableSpan`. + * There are places where we basically want to allow passing _any_ span, + * so in these cases we type this as `AbstractSpan` which could be either a regular `Span` or a `ReadableSpan`. + * You'll have to make sur to check revelant fields before accessing them. + * + * Note that technically, the `Span` exported from `@opentelemwetry/sdk-trace-base` matches this, + * but we cannot be 100% sure that we are actually getting such a span, so this type is more defensive. + */ +export type AbstractSpan = WriteableSpan | ReadableSpan; + export type { Span }; diff --git a/packages/node-experimental/src/utils/getRequestSpanData.ts b/packages/node-experimental/src/utils/getRequestSpanData.ts index 860b48bff814..0154f8e4cd3e 100644 --- a/packages/node-experimental/src/utils/getRequestSpanData.ts +++ b/packages/node-experimental/src/utils/getRequestSpanData.ts @@ -4,14 +4,14 @@ import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import type { SanitizedRequestData } from '@sentry/types'; import { getSanitizedUrlString, parseUrl } from '@sentry/utils'; -import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; +import { spanHasAttributes } from './spanTypes'; /** * Get sanitizied request data from an OTEL span. */ export function getRequestSpanData(span: Span | ReadableSpan): Partial { // The base `Span` type has no `attributes`, so we need to guard here against that - if (!spanIsSdkTraceBaseSpan(span)) { + if (!spanHasAttributes(span)) { return {}; } diff --git a/packages/node-experimental/src/utils/getSpanKind.ts b/packages/node-experimental/src/utils/getSpanKind.ts index fe3807e1882e..7769a1cd3290 100644 --- a/packages/node-experimental/src/utils/getSpanKind.ts +++ b/packages/node-experimental/src/utils/getSpanKind.ts @@ -1,7 +1,7 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; -import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; +import { spanHasKind } from './spanTypes'; /** * Get the span kind from a span. @@ -10,7 +10,7 @@ import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; * Otherwise, we fall back to `SpanKind.INTERNAL`. */ export function getSpanKind(span: Span): SpanKind { - if (spanIsSdkTraceBaseSpan(span)) { + if (spanHasKind(span)) { return span.kind; } diff --git a/packages/node-experimental/src/utils/setupEventContextTrace.ts b/packages/node-experimental/src/utils/setupEventContextTrace.ts index e28eed9d6661..0e8dc7c23d7b 100644 --- a/packages/node-experimental/src/utils/setupEventContextTrace.ts +++ b/packages/node-experimental/src/utils/setupEventContextTrace.ts @@ -1,7 +1,7 @@ import type { Client } from '@sentry/types'; import { getActiveSpan } from './getActiveSpan'; -import { spanIsSdkTraceBaseSpan } from './spanIsSdkTraceBaseSpan'; +import { spanHasParentId } from './spanTypes'; /** Ensure the `trace` context is set on all events. */ export function setupEventContextTrace(client: Client): void { @@ -22,7 +22,7 @@ export function setupEventContextTrace(client: Client): void { trace: { trace_id: spanContext.traceId, span_id: spanContext.spanId, - parent_span_id: spanIsSdkTraceBaseSpan(span) ? span.parentSpanId : undefined, + parent_span_id: spanHasParentId(span) ? span.parentSpanId : undefined, }, ...event.contexts, }; diff --git a/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts b/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts deleted file mode 100644 index 41d6f21d5965..000000000000 --- a/packages/node-experimental/src/utils/spanIsSdkTraceBaseSpan.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Span } from '@opentelemetry/api'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { Span as SdkTraceBaseSpan } from '@opentelemetry/sdk-trace-base'; - -/** - * If the span is a SDK trace base span, which has some additional fields. - */ -export function spanIsSdkTraceBaseSpan(span: Span | ReadableSpan): span is SdkTraceBaseSpan { - return span instanceof SdkTraceBaseSpan; -} diff --git a/packages/node-experimental/src/utils/spanTypes.ts b/packages/node-experimental/src/utils/spanTypes.ts new file mode 100644 index 000000000000..3883a97f8004 --- /dev/null +++ b/packages/node-experimental/src/utils/spanTypes.ts @@ -0,0 +1,58 @@ +import type { SpanKind } from '@opentelemetry/api'; +import type { ReadableSpan, TimedEvent } from '@opentelemetry/sdk-trace-base'; +import { Span as SdkTraceBaseSpan } from '@opentelemetry/sdk-trace-base'; + +import type { AbstractSpan } from '../types'; + +/** + * Check if a given span has attributes. + * This is necessary because the base `Span` type does not have attributes, + * so in places where we are passed a generic span, we need to check if we want to access them. + */ +export function spanHasAttributes( + span: SpanType, +): span is SpanType & { attributes: ReadableSpan['attributes'] } { + const castSpan = span as ReadableSpan; + return !!castSpan.attributes && typeof castSpan.attributes === 'object'; +} + +/** + * Check if a given span has a kind. + * This is necessary because the base `Span` type does not have a kind, + * so in places where we are passed a generic span, we need to check if we want to access it. + */ +export function spanHasKind(span: SpanType): span is SpanType & { kind: SpanKind } { + const castSpan = span as ReadableSpan; + return !!castSpan.kind; +} + +/** + * Check if a given span has a kind. + * This is necessary because the base `Span` type does not have a kind, + * so in places where we are passed a generic span, we need to check if we want to access it. + */ +export function spanHasParentId( + span: SpanType, +): span is SpanType & { parentSpanId: string } { + const castSpan = span as ReadableSpan; + return !!castSpan.parentSpanId; +} + +/** + * Check if a given span has events. + * This is necessary because the base `Span` type does not have events, + * so in places where we are passed a generic span, we need to check if we want to access it. + */ +export function spanHasEvents( + span: SpanType, +): span is SpanType & { events: TimedEvent[] } { + const castSpan = span as ReadableSpan; + return Array.isArray(castSpan.events); +} + +/** + * If the span is a SDK trace base span, which has some additional fields. + */ +export function spanIsSdkTraceBaseSpan(span: AbstractSpan): span is SdkTraceBaseSpan { + return span instanceof SdkTraceBaseSpan; +} diff --git a/packages/node-experimental/test/utils/spanTypes.test.ts b/packages/node-experimental/test/utils/spanTypes.test.ts new file mode 100644 index 000000000000..e4c2ca907ce9 --- /dev/null +++ b/packages/node-experimental/test/utils/spanTypes.test.ts @@ -0,0 +1,98 @@ +import type { Span } from '@opentelemetry/api'; + +import { + spanHasAttributes, + spanHasEvents, + spanHasKind, + spanHasParentId, + spanIsSdkTraceBaseSpan, +} from '../../src/utils/spanTypes'; +import { createSpan } from '../helpers/createSpan'; + +describe('spanTypes', () => { + describe('spanHasAttributes', () => { + it.each([ + [{}, false], + [{ attributes: null }, false], + [{ attributes: {} }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasAttributes(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.attributes).toBeDefined(); + } + }); + }); + + describe('spanHasKind', () => { + it.each([ + [{}, false], + [{ kind: null }, false], + [{ kind: 'xxx' }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasKind(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.kind).toBeDefined(); + } + }); + }); + + describe('spanHasParentId', () => { + it.each([ + [{}, false], + [{ parentSpanId: null }, false], + [{ parentSpanId: 'xxx' }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasParentId(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.parentSpanId).toBeDefined(); + } + }); + }); + + describe('spanHasEvents', () => { + it.each([ + [{}, false], + [{ events: null }, false], + [{ events: [] }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasEvents(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.events).toBeDefined(); + } + }); + }); + + describe('spanIsSdkTraceBaseSpan', () => { + it.each([ + [{}, false], + [createSpan(), true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanIsSdkTraceBaseSpan(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.events).toBeDefined(); + expect(castSpan.attributes).toBeDefined(); + expect(castSpan.kind).toBeDefined(); + } + }); + }); +});