diff --git a/.size-limit.js b/.size-limit.js index aeff0bd5d723..66e200a4eb3f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -394,6 +394,23 @@ module.exports = [ limit: '136 KB', disablePlugins: ['@size-limit/esbuild'], }, + { + name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', + path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'], + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '100 KB', + disablePlugins: ['@size-limit/esbuild'], + }, + { + name: '@sentry/node/light', + path: 'packages/node-core/build/esm/light/index.js', + import: createImport('init'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '100 KB', + disablePlugins: ['@size-limit/esbuild'], + }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 6ae689b80da3..1436e35fcc6b 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -21,6 +21,10 @@ const NODE_EXPORTS_IGNORE = [ 'SentryContextManager', 'validateOpenTelemetrySetup', 'preloadOpenTelemetry', + // Experimental, Node-runtime-only opt-in (diagnostics-channel injection); it + // registers Node module hooks and is not surfaced through the framework / + // serverless SDKs. + 'experimentalUseDiagnosticsChannelInjection', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json new file mode 100644 index 000000000000..e3f3bbf2efe7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-express-orchestrion-cjs-app", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node --import @sentry/node/import ./src/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^5.1.0", + "mysql": "2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js new file mode 100644 index 000000000000..867a932b23bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js @@ -0,0 +1,73 @@ +const Sentry = require('@sentry/node'); + +// The channels are injected by `node --import @sentry/node/import` (see the +// `start` script); opting in via this method makes the SDK subscribe to +// them instead of using the OTel instrumentation. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +const express = require('express'); +const mysql = require('mysql'); + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +const app = express(); +const port = 3030; + +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-mysql', function (req, res) { + connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + res.send({ status: 'ok' }); + }); + }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +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.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs new file mode 100644 index 000000000000..d035fa533f1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-orchestrion-cjs', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts new file mode 100644 index 000000000000..c5b3bc45e109 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-orchestrion-cjs', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts new file mode 100644 index 000000000000..9dbce2a05ac9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); + +test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; + }); + + await fetch(`${baseURL}/test-mysql`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-mysql'); + expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json new file mode 100644 index 000000000000..0257fa5acd6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-express-orchestrion-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --import ./src/instrument.mjs ./src/app.mjs", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^5.1.0", + "mysql": "2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs new file mode 100644 index 000000000000..f34260393bb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +const app = express(); +const port = 3030; + +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-mysql', function (req, res) { + connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + res.send({ status: 'ok' }); + }); + }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +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.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs new file mode 100644 index 000000000000..2f47402bb02a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; + +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. Because this file runs via `node --import` before +// `app.mjs` imports `mysql`, `Sentry.init()` synchronously installs the +// channel-injection hooks. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..52c720f60ea5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts new file mode 100644 index 000000000000..9dd4257ae02d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-orchestrion', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..fb11235943b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); + +test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; + }); + + await fetch(`${baseURL}/test-mysql`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-mysql'); + expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs new file mode 100644 index 000000000000..e2ddb56b9ffa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs @@ -0,0 +1,48 @@ +/** + * Asserts the orchestrion subtree is tree-shaken out of the bundle unless the + * app opted in via `experimentalUseDiagnosticsChannelInjection()`. + * + * @module + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// `orchestrion:mysql:query` lives only in @sentry/server-utils' orchestrion +// subtree (channels.ts), never in @sentry/node — so finding it in a bundle +// means the orchestrion code path was pulled in. +const MARKER = 'orchestrion:mysql:query'; + +function bundleText(name) { + const dir = join(__dirname, 'dist', name); + return readdirSync(dir) + .map(f => readFileSync(join(dir, f), 'utf8')) + .join('\n'); +} + +let failed = false; +function check(condition, message) { + // eslint-disable-next-line no-console + console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); + if (!condition) failed = true; +} + +const noOrchestrion = bundleText('no-orchestrion'); +const withOrchestrion = bundleText('with-orchestrion'); + +check( + !noOrchestrion.includes(MARKER), + 'orchestrion is EXCLUDED when experimentalUseDiagnosticsChannelInjection() is NOT called', +); +check( + withOrchestrion.includes(MARKER), + 'orchestrion is INCLUDED when experimentalUseDiagnosticsChannelInjection() IS called', +); + +if (failed) { + process.exit(1); +} +// eslint-disable-next-line no-console +console.log('All bundle tree-shaking assertions passed.'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs new file mode 100644 index 000000000000..81f1e661d9e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs @@ -0,0 +1,43 @@ +// Bundles both entrypoints with webpack (the pinned version in package.json +// kept current, since webpack's `createRequire` following has changed across +// releases). Outputs go to ./dist// for assert.mjs to inspect. +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import webpack from 'webpack'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function build(name) { + return new Promise((resolve, reject) => { + webpack( + { + entry: join(__dirname, 'src', `${name}.mjs`), + mode: 'production', + target: 'node', + experiments: { topLevelAwait: true, outputModule: true }, + output: { + path: join(__dirname, 'dist', name), + filename: 'main.mjs', + module: true, + library: { type: 'module' }, + chunkFormat: 'module', + }, + // Keep output readable; tree-shaking (module elimination via + // `sideEffects: false`) happens regardless of minification, and + // it's important to be able to debug when it messes up. + optimization: { minimize: false }, + }, + (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) { + return reject(new Error(`webpack build of ${name} failed:\n${stats.toString({ errors: true })}`)); + } + // eslint-disable-next-line no-console + console.log(`built ${name} (webpack ${webpack.version})`); + resolve(); + }, + ); + }); +} + +await Promise.all([build('no-orchestrion'), build('with-orchestrion')]); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json new file mode 100644 index 000000000000..69dd20caf346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json @@ -0,0 +1,22 @@ +{ + "name": "node-orchestrion-webpack", + "description": "ensure that orchestrion is not bundled inappropriately", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && node ./build.mjs", + "test:assert": "node ./assert.mjs" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" + }, + "devDependencies": { + "webpack": "5.107.2" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs new file mode 100644 index 000000000000..e66db6685328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.log('this is the application'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs new file mode 100644 index 000000000000..104d9144f5f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs @@ -0,0 +1,10 @@ +// Does NOT call `experimentalUseDiagnosticsChannelInjection()`, so a bundler +// must be able to drop the entire orchestrion subtree from the output. +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs new file mode 100644 index 000000000000..29c9ab3d5de8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs @@ -0,0 +1,12 @@ +// Calls `experimentalUseDiagnosticsChannelInjection()`, so the orchestrion +// subtree MUST be reachable and end up in the bundle. +import * as Sentry from '@sentry/node'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d3a61f5814f0..fa5396cc7eae 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -101,6 +101,7 @@ "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", + "esbuild": "0.28.0", "eslint-plugin-regexp": "^3.1.0", "globby": "11", "react": "^18.3.1", diff --git a/dev-packages/node-integration-tests/suites/esbuild/app.ts b/dev-packages/node-integration-tests/suites/esbuild/app.ts new file mode 100644 index 000000000000..6b11a4ce7fda --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/app.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// `@sentry/node` is `sideEffects: false`, so esbuild only evaluates the +// module if we reference an export. +// a module-scope `createRequire(import.meta.url)` throws +// in a CJS bundle, because esbuild rewrites `import.meta.url` to `{}`, +// so it becomes `createRequire(undefined)`, which would break apps that +// do not opt into orchestrion. +// eslint-disable-next-line no-console +console.log(`SENTRY_NODE_LOADED typeof_init=${typeof Sentry.init}`); diff --git a/dev-packages/node-integration-tests/suites/esbuild/test.ts b/dev-packages/node-integration-tests/suites/esbuild/test.ts new file mode 100644 index 000000000000..1d1806f0b843 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { build } from 'esbuild'; +import { describe, expect, test } from 'vitest'; + +describe('esbuild bundling', () => { + test('@sentry/node loads when bundled to CommonJS with esbuild', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'sentry-esbuild-cjs-')); + const outfile = join(outDir, 'bundle.cjs'); + + try { + await build({ + entryPoints: [join(__dirname, 'app.ts')], + outfile, + platform: 'node', + format: 'cjs', + bundle: true, + logLevel: 'silent', + }); + + const result = spawnSync('node', [outfile], { encoding: 'utf-8' }); + + // The specific failure signature this guards against. + expect(result.stderr).not.toContain('ERR_INVALID_ARG_VALUE'); + expect(result.stderr).not.toContain('createRequire'); + // The bundle loaded and ran to completion. + expect(result.status).toBe(0); + expect(result.stdout).toContain('SENTRY_NODE_LOADED'); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs new file mode 100644 index 000000000000..032187efe33b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -0,0 +1,17 @@ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. +// +// `Sentry.init()` swaps the OTel `mysql` instrumentation +// for the diagnostics-channel one and synchronously +// installs the module hooks that inject the channels. +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 2cb58e662a6b..6d5ba767cea1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -6,71 +6,92 @@ describe('mysql auto instrumentation', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ + // Builds the expected transaction. When `origin` is given, the spans must also + // carry that `sentry.origin`, which is how we assert that the + // diagnostics-channel instrumentation (not the OTel one) produced them. + function expectedTransaction(origin?: string): Record { + const span = (description: string): ReturnType => expect.objectContaining({ - description: 'SELECT 1 + 1 AS solution', + description, op: 'db', + ...(origin ? { origin } : {}), data: expect.objectContaining({ 'db.system': 'mysql', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', }), - }), - expect.objectContaining({ - description: 'SELECT NOW()', - op: 'db', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'net.peer.name': 'localhost', - 'net.peer.port': 3306, - 'db.user': 'root', - }), - }), - ]), - }; + }); - describe('with connection.connect()', () => { - createEsmAndCjsTests( - __dirname, - 'scenario-withConnect.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - }, - { failsOnEsm: true }, - ); - }); + return { + transaction: 'Test Transaction', + spans: expect.arrayContaining([span('SELECT 1 + 1 AS solution'), span('SELECT NOW()')]), + }; + } - describe('query without callback', () => { - createEsmAndCjsTests( - __dirname, - 'scenario-withoutCallback.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should auto-instrument `mysql` package when using query without callback', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - }, - { failsOnEsm: true }, - ); - }); + const CHANNEL_ORIGIN = 'auto.db.orchestrion.mysql'; - describe('without connection.connect()', () => { - createEsmAndCjsTests( - __dirname, - 'scenario-withoutConnect.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - }, - { failsOnEsm: true }, - ); - }); + // Each case maps to one of the two documented use cases, in opt-in and + // non-opt-in form. `flags` are extra Node CLI flags; the instrument file is + // always loaded via `--import` (esm) / `--require` (cjs) by the runner. + const CASES = [ + // OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.) + { label: 'opentelemetry (default)', instrument: 'instrument.mjs', flags: [], origin: undefined, failsOnEsm: true }, + // Opt-in via init only. `Sentry.init()` injects the channels synchronously. + { + label: 'diagnostics-channel (init opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: [], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Opt-in and rely on `node --import @sentry/node/import`. + { + label: 'diagnostics-channel (--import @sentry/node/import opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: ['--import', '@sentry/node/import'], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Without opt-in: channels are injected unconditionally but not subscribed + // to, so the OTel instrumentation records the spans — proves injecting the + // channels has no downside. (OTel does not support ESM.) + { + label: 'opentelemetry (channels injected, no opt-in)', + instrument: 'instrument.mjs', + flags: ['--import', '@sentry/node/import'], + origin: undefined, + failsOnEsm: true, + }, + ] as const; + + const SCENARIOS = [ + ['scenario-withConnect.mjs', 'using connection.connect()'], + ['scenario-withoutCallback.mjs', 'using query without callback'], + ['scenario-withoutConnect.mjs', 'without connection.connect()'], + ] as const; + + for (const { label, instrument, flags, origin, failsOnEsm } of CASES) { + describe(label, () => { + const expected = expectedTransaction(origin); + + for (const [scenario, description] of SCENARIOS) { + createEsmAndCjsTests( + __dirname, + scenario, + instrument, + (createRunner, test) => { + test(`should auto-instrument \`mysql\` package when ${description}`, async () => { + await createRunner() + .withFlags(...flags) + .expect({ transaction: expected }) + .start() + .completed(); + }); + }, + { failsOnEsm }, + ); + } + }); + } }); diff --git a/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js new file mode 100644 index 000000000000..e221b22cbd4a --- /dev/null +++ b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js @@ -0,0 +1,10 @@ +// Like otelEsmImportHookTemplate.js, but also registers the diagnostics-channel +// injection so that `node --import @sentry/node/import app.js` injects the +// channels unconditionally (they are only *subscribed* to when the app opts in +// via `experimentalUseDiagnosticsChannelInjection()`). +import '@sentry/server-utils/orchestrion/import-hook'; +import { register } from 'module'; + +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url); + +globalThis._sentryEsmLoaderHookRegistered = true; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 4a184d1ea4e5..10d3132cb84f 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -179,12 +179,25 @@ export function makeNPMConfigVariants(baseConfig, options = {}) { /** * This creates a loader file at the target location as part of the rollup build. * This loader script can then be used in combination with various Node.js flags (like --import=...) to monkeypatch 3rd party modules. + * + * @param {string} outputFolder Build output folder. + * @param {'otel' | 'sentry-node'} hookVariant Which hook template to use. + * @param {{ injectDiagnosticsChannel?: boolean }} [options] When `injectDiagnosticsChannel` + * is set (only valid for the `'otel'` variant), the generated `import-hook.mjs` + * additionally imports `@sentry/server-utils/orchestrion/import-hook`, which + * registers the diagnostics-channel injection. Used by `@sentry/node` so that + * `node --import @sentry/node/import` injects the channels unconditionally. */ -export function makeOtelLoaders(outputFolder, hookVariant) { +export function makeOtelLoaders(outputFolder, hookVariant, options = {}) { if (hookVariant !== 'otel' && hookVariant !== 'sentry-node') { throw new Error('hookVariant is neither "otel" nor "sentry-node". Pick one.'); } + const { injectDiagnosticsChannel = false } = options; + if (injectDiagnosticsChannel && hookVariant !== 'otel') { + throw new Error('injectDiagnosticsChannel is only supported with the "otel" hookVariant.'); + } + const expectedRegisterLoaderLocation = `${outputFolder}/import-hook.mjs`; const foundRegisterLoaderExport = Object.keys(packageDotJSON.exports ?? {}).some(key => { return packageDotJSON?.exports?.[key]?.import?.default === expectedRegisterLoaderLocation; @@ -229,7 +242,11 @@ export function makeOtelLoaders(outputFolder, hookVariant) { input: path.join( __dirname, 'code', - hookVariant === 'otel' ? 'otelEsmImportHookTemplate.js' : 'sentryNodeEsmImportHookTemplate.js', + hookVariant === 'otel' + ? injectDiagnosticsChannel + ? 'otelEsmImportHookWithDiagnosticsChannelTemplate.js' + : 'otelEsmImportHookTemplate.js' + : 'sentryNodeEsmImportHookTemplate.js', ), external: /.*/, output: { diff --git a/packages/node/package.json b/packages/node/package.json index 6914dbb75ab0..914b882bed4b 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -103,5 +103,19 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/build/npm/esm", + "{projectRoot}/build/npm/cjs", + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/loader-hook.mjs" + ] + } + } + } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 741c6ec27fe5..3f6d1b28bf93 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,7 +1,12 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; export default [ - ...makeOtelLoaders('./build', 'otel'), + // `injectDiagnosticsChannel` makes the generated `@sentry/node/import` hook + // also register the diagnostics-channel injection, so `node --import + // @sentry/node/import app.js` injects the channels unconditionally (they are + // only subscribed to when the app opts in via + // `experimentalUseDiagnosticsChannelInjection()`). + ...makeOtelLoaders('./build', 'otel', { injectDiagnosticsChannel: true }), ...makeNPMConfigVariants( makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 791a8a8a5c6a..df90fd85e755 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -47,6 +47,7 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; +export { experimentalUseDiagnosticsChannelInjection } from './sdk/experimentalUseDiagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/sdk/diagnosticsChannelInjection.ts b/packages/node/src/sdk/diagnosticsChannelInjection.ts new file mode 100644 index 000000000000..9f51c053d30a --- /dev/null +++ b/packages/node/src/sdk/diagnosticsChannelInjection.ts @@ -0,0 +1,56 @@ +import type { Integration } from '@sentry/core'; + +/** + * The orchestrion-driven pieces, resolved lazily by the opt-in loader. + * + * IMPORTANT: this module (and everything `init()` imports) must NOT reference + * the orchestrion code (`@sentry/server-utils/orchestrion/*`). The only + * reference lives inside `experimentalUseDiagnosticsChannelInjection()` (a + * separate module, reachable solely through that public export). That's the + * tree-shaking boundary: if an app never calls the opt-in function, then a + * bundler drops the entire orchestrion subtree, including its transitive + * dependencies, while an app that does call it gets it bundled + * normally. + */ +export interface DiagnosticsChannelInjection { + /** Channel-based integrations to register, replacing their OTel equivalents. */ + integrations: Integration[]; + /** OTel integration names these replace; filtered out of the default set. */ + replacedOtelIntegrationNames: string[]; + /** Installs the module hooks that inject the diagnostics channels. */ + register: () => void; + /** Warns (DEBUG only) about missing or doubled channel injection. */ + detect: () => void; +} + +let loader: (() => DiagnosticsChannelInjection) | undefined; +let cached: DiagnosticsChannelInjection | undefined; + +/** + * Set by `experimentalUseDiagnosticsChannelInjection()`. The loader + * is the only thing that pulls in the orchestrion modules; see + * {@link DiagnosticsChannelInjection) re tree-shaking concerns this addresses. + * + * @internal + */ +export function setDiagnosticsChannelInjectionLoader(load: () => DiagnosticsChannelInjection): void { + loader = load; +} + +/** Whether `experimentalUseDiagnosticsChannelInjection()` was called. */ +export function isDiagnosticsChannelInjectionEnabled(): boolean { + return !!loader; +} + +/** + * Resolve and memoize the orchestrion pieces. This is what actually loads + * the orchestrion modules. Returns `undefined` if the app never opted in. + * Callers gate this on span recording, so the modules load only when both + * opted in and tracing is enabled. + */ +export function resolveDiagnosticsChannelInjection(): DiagnosticsChannelInjection | undefined { + if (!loader) { + return undefined; + } + return (cached ??= loader()); +} diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts new file mode 100644 index 000000000000..d51f2d86a610 --- /dev/null +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -0,0 +1,47 @@ +import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; +import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; + +/** + * EXPERIMENTAL: opt into diagnostics-channel-based auto-instrumentation. + * + * Call this BEFORE `Sentry.init()`: + * + * ```ts + * import * as Sentry from '@sentry/node'; + * + * Sentry.experimentalUseDiagnosticsChannelInjection(); + * Sentry.init({ + * dsn: '__DSN__', + * // other settings... + * }); + * ``` + * + * When this has been called AND span recording is enabled, `Sentry.init()` + * uses the diagnostics-channel-injection-based integrations instead of the + * OpenTelemetry ones, and installs the module hooks that inject the channels + * (so libraries imported after `init()` publish the channel events). + * + * This is a standalone function rather than an `init()` option so that a + * bundler drops all of it (and its transitive deps) when this function isn't + * called. `init()` reads the loader registered below. + * + * An app that DOES call it gets the orchestrion code bundled as intended. + * + * In an unbundled (server-side runtime) app this eagerly loads only the small + * subscriber/channel modules; the heavy code-transform dependencies stay lazy + * inside `register()` and load only when injection actually runs. + * + * @experimental May change or be removed in any release. + */ +export function experimentalUseDiagnosticsChannelInjection(): void { + setDiagnosticsChannelInjectionLoader( + (): DiagnosticsChannelInjection => ({ + integrations: [mysqlChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql'], + register: registerDiagnosticsChannelInjection, + detect: detectOrchestrionSetup, + }), + ); +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 6942c6500f84..8c8d2e887541 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -10,6 +10,10 @@ import { httpIntegration } from '../integrations/http'; import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; import { getAutoPerformanceIntegrations } from '../integrations/tracing'; import type { NodeOptions } from '../types'; +import { + isDiagnosticsChannelInjectionEnabled, + resolveDiagnosticsChannelInjection, +} from './diagnosticsChannelInjection'; import { initOpenTelemetry } from './initOtel'; /** @@ -26,7 +30,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - return [ + const integrations: Integration[] = [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added @@ -34,6 +38,24 @@ export function getDefaultIntegrations(options: Options): Integration[] { // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; + + // When the app opted into diagnostics-channel injection (via + // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is + // enabled, swap the channel-based integrations in place of OTel equivalents + // so the two don't both instrument the same library. + // + // Every channel-based integration we ship today is a 1:1 replacement for an + // OTel performance/tracing integration and produces nothing but spans (those + // only come from `getAutoPerformanceIntegrations()` above), so it's gated on + // span recording. + if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { + const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); + if (diagnosticsChannelInjection) { + const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); + return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; + } + } + return integrations; } /** @@ -52,6 +74,22 @@ function _init( ): NodeClient | undefined { applySdkMetadata(options, 'node'); + // EXPERIMENTAL: diagnostics-channel injection, opted into via + // `experimentalUseDiagnosticsChannelInjection()`. Gated on span recording to + // match the OTel integrations it replaces. With tracing off there are no + // channel subscribers, so injecting is pointless work. `resolve...()` is + // memoized, so `getDefaultIntegrations()` (below) sees the same instance. + const diagnosticsChannelInjection = + isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options) + ? resolveDiagnosticsChannelInjection() + : undefined; + + // Install the channel-injection hooks as early as possible, before the app + // imports its instrumented modules. + if (diagnosticsChannelInjection) { + diagnosticsChannelInjection.register(); + } + const client = initNodeCore({ ...options, // Only use Node SDK defaults if none provided @@ -66,6 +104,12 @@ function _init( validateOpenTelemetrySetup(); } + // Warn about missing or doubled channel injection. Runs after the client + // is created so the debug logger is enabled and the warning is emitted. + if (diagnosticsChannelInjection) { + diagnosticsChannelInjection.detect(); + } + return client; } diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index c775339201c0..4ce497259d59 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -26,12 +26,79 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./orchestrion": { + "import": { + "types": "./build/types/orchestrion/index.d.ts", + "default": "./build/esm/orchestrion/index.js" + }, + "require": { + "types": "./build/types/orchestrion/index.d.ts", + "default": "./build/cjs/orchestrion/index.js" + } + }, + "./orchestrion/config": { + "import": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/esm/orchestrion/config.js" + }, + "require": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/cjs/orchestrion/config.js" + } + }, + "./orchestrion/register": { + "import": { + "types": "./build/types/orchestrion/runtime/register.d.ts", + "default": "./build/esm/orchestrion/runtime/register.js" + }, + "require": { + "types": "./build/types/orchestrion/runtime/register.d.ts", + "default": "./build/cjs/orchestrion/runtime/register.js" + } + }, + "./orchestrion/vite": { + "types": "./build/types/orchestrion/bundler/vite.d.ts", + "import": { + "default": "./build/esm/orchestrion/bundler/vite.js" + } + }, + "./orchestrion/import-hook": { + "import": { + "default": "./build/orchestrion/import-hook.mjs" + } } }, "typesVersions": { "<5.0": { "build/types/index.d.ts": [ "build/types-ts3.8/index.d.ts" + ], + "orchestrion": [ + "build/types-ts3.8/orchestrion/index.d.ts" + ], + "orchestrion/config": [ + "build/types-ts3.8/orchestrion/config.d.ts" + ], + "orchestrion/register": [ + "build/types-ts3.8/orchestrion/runtime/register.d.ts" + ], + "orchestrion/vite": [ + "build/types-ts3.8/orchestrion/bundler/vite.d.ts" + ] + }, + "*": { + "orchestrion": [ + "build/types/orchestrion/index.d.ts" + ], + "orchestrion/config": [ + "build/types/orchestrion/config.d.ts" + ], + "orchestrion/register": [ + "build/types/orchestrion/runtime/register.d.ts" + ], + "orchestrion/vite": [ + "build/types/orchestrion/bundler/vite.d.ts" ] } }, @@ -39,8 +106,24 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/tracing-hooks": "^0.10.0", "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.58.0" + "@sentry/core": "10.58.0", + "magic-string": "~0.30.0" + }, + "devDependencies": { + "@types/node": "^18.19.1", + "vite": "^5.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "scripts": { "build": "run-p build:transpile build:types", @@ -65,5 +148,18 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/build/npm/esm", + "{projectRoot}/build/npm/cjs", + "{projectRoot}/build/orchestrion" + ] + } + } + } } diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 7416307b5bac..21942c9340c8 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -1,14 +1,48 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - packageSpecificConfig: { - output: { - // set exports to 'named' or 'auto' so that rollup doesn't warn - exports: 'named', - // set preserveModules to true because we don't want to bundle everything into one file. - preserveModules: true, - }, - }, +// EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that +// SDKs reference via a `--import .../orchestrion/import-hook` flag. We pass it +// through rollup only to copy it into `build/orchestrion/` at the path the +// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. +// `@sentry/server-utils/orchestrion/config`) as a runtime resolution +// against the installed package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/orchestrion/runtime/import-hook.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, }), -); +]; + +export default [ + ...orchestrionRuntimeHooks, + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are + // loaded via dedicated subpath exports (`.../orchestrion/config`, + // `.../orchestrion/vite`) — neither is reachable from `src/index.ts`, so we + // list them as separate entrypoints to guarantee they end up in build/esm + // and build/cjs. `src/orchestrion/index.ts` backs the `./orchestrion` + // subpath export. + entrypoints: [ + 'src/index.ts', + 'src/orchestrion/index.ts', + 'src/orchestrion/config.ts', + // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` + // subpath export; the Node SDK `require`s it synchronously from + // `Sentry.init()` to install the channel-injection hooks. + 'src/orchestrion/runtime/register.ts', + 'src/orchestrion/bundler/vite.ts', + ], + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to true because we don't want to bundle everything into one file. + preserveModules: true, + }, + }, + }), + ), +]; diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql.ts b/packages/server-utils/src/integrations/tracing-channel/mysql.ts new file mode 100644 index 000000000000..5384b84f95f5 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mysql.ts @@ -0,0 +1,283 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, OTel 'Mysql' integration is omitted from the default set. +const INTEGRATION_NAME = 'Mysql'; + +// OpenTelemetry "OLD" db/net semantic-conventions. We inline them rather than +// importing `@opentelemetry/semantic-conventions` to keep this integration's +// dependency surface free of OTel — orchestrion's whole point is to step away +// from the OTel auto-instrumentation stack. +// +// We emit the OLD conventions to match `@opentelemetry/instrumentation-mysql`'s +// default (it only emits the stable `db.system.name` / `db.query.text` set when +// `OTEL_SEMCONV_STABILITY_OPT_IN=database` is opted into) and the rest of the +// Sentry JS SDK, whose `inferDbSpanData` processor renames spans based on +// `db.statement`. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_USER = 'db.user'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; + +/** + * The shape orchestrion's wrapCallback transform attaches to the tracing-channel + * `context` object. Documented here rather than imported because orchestrion's + * runtime doesn't export it — see `node_modules/@apm-js-collab/code-transformer/lib/transforms.js`. + * + * `arguments` is the *live* args array the wrapper passes to the wrapped function: + * orchestrion splices the user's callback out and inserts its own wrapper at + * the same index before publishing `start`. We mutate that last entry again in + * our `start` hook so the callback (and any nested `connection.query(...)`) + * runs inside `withActiveSpan(parent, …)` — mysql v2 loses ALS state when it + * dispatches callbacks from its socket handler, which would otherwise cause + * nested queries to begin a fresh root trace. + */ +interface MysqlQueryChannelContext { + arguments: unknown[]; + self?: MysqlConnection; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +interface MysqlConnectionConfig { + host?: string; + port?: number | string; + database?: string; + user?: string; + // Pool connections nest the real config one level deeper. + connectionConfig?: MysqlConnectionConfig; +} + +interface MysqlConnection { + config?: MysqlConnectionConfig; +} + +const _mysqlChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + DEBUG_BUILD && debug.log(`[orchestrion:mysql] subscribing to channel "${CHANNELS.MYSQL_QUERY}"`); + const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); + + // Each `context` object is shared across start/end/asyncStart/asyncEnd/error + // for one call (orchestrion creates one per invocation). We key the span + // off the same identity. WeakMap so we don't leak if a path never reaches + // asyncEnd for some reason. + const spans = new WeakMap(); + + // `subscribe()` requires all five lifecycle hooks. The orchestrion + // `wrapAuto` transform fires events in one of four orders depending on + // call shape: + // - sync throw : start → error → end + // (NO asyncEnd) + // - async-callback error : start → end → error → + // asyncStart → asyncEnd + // - async-callback success : start → end → asyncStart → + // asyncEnd + // - no-callback (streamable Query) : start → end + // (ctx.result is the Query + // emitter, no async events) + // + // We end the span on `asyncEnd` for the two callback paths (so the span + // covers the full network round-trip + callback duration). For the + // sync-throw path, `end` finishes the span because `ctx.error` is set + // there. For the streamable no-callback path, `end` finishes by + // attaching `'end'`/`'error'` listeners to `ctx.result` (the returned + // `Query` emitter). + // + // The discriminator between "end fired before any error" and "end fired + // after a sync throw" is whether `ctx.error` is set when `end` runs — + // orchestrion populates it before publishing `error`. The discriminator + // between callback and no-callback is whether `ctx.result` is set — only + // the `wrapPromise` (no-callback) path stores it. + queryCh.subscribe({ + start(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const sql = extractSql(ctx.arguments[0]); + const { host, port, database, user } = getConnectionConfig(ctx.self); + const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; + const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); + + const span = startInactiveSpan({ + name: sql ?? 'mysql.query', + op: 'db', + attributes: { + [ATTR_DB_SYSTEM]: 'mysql', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), + ...(database ? { [ATTR_DB_NAME]: database } : {}), + ...(user ? { [ATTR_DB_USER]: user } : {}), + ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), + ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), + ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), + }, + }); + spans.set(rawCtx, span); + + // Restore the Sentry/OTel context across mysql's internal callback + // dispatch. The orchestrion transform has already spliced the user's + // callback out of `ctx.arguments` and put its own wrapper + // (`__apm$wrappedCb`) at the same index. mysql v2 drains callbacks + // from a socket data handler — by the time the response arrives, the + // AsyncLocalStorage store backing `getActiveSpan()` no longer + // reflects the caller's context. We re-wrap orchestrion's wrapper so + // the user's callback (and any nested `connection.query(...)` inside + // it) runs with the parent span active again. + // + // This must happen at `start` (we're synchronously inside the + // caller's `connection.query` call, so OTel context is still + // correct). `asyncStart`/`asyncEnd` fire from the same lost context + // as the callback itself, so they're too late. + const parentSpan = getActiveSpan(); + if (parentSpan && ctx.arguments.length > 0) { + const cbIdx = ctx.arguments.length - 1; + const orchestrionWrappedCb = ctx.arguments[cbIdx]; + if (typeof orchestrionWrappedCb === 'function') { + const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown; + ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown { + return withActiveSpan(parentSpan, () => wrapped.apply(this, args)); + }; + } + } + }, + + end(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + + // Sync throw: `end` fires AFTER `error` (both inside the wrapper's + // `try/catch/finally`), so `ctx.error` is already set. Close the + // span now since no `asyncEnd` will fire. + if (ctx.error !== undefined) { + finishSpan(rawCtx); + return; + } + + // No-callback (streamable Query) path: orchestrion's `wrapPromise` + // stores the synchronous return value on `ctx.result` and never + // fires `asyncStart`/`asyncEnd`. The returned `Query` is an + // `EventEmitter` that emits `'end'` on success and `'error'` on + // failure — hook those to close the span. + // TODO: streaming spans aren't finished on `connection.destroy()` — + // mysql guarantees no further events/callbacks for a destroyed + // connection, so neither `'end'` nor `'error'` fires and the span + // never ends (it's dropped, never reported). Closing this gap needs + // connection-level lifecycle hooks, which the per-query channel + // context doesn't expose here. The `WeakMap` still prevents a leak. + const result = ctx.result; + if (result && typeof result === 'object' && hasOnMethod(result)) { + const span = spans.get(rawCtx); + if (!span) return; + result.on('error', err => { + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: err instanceof Error ? err.message : 'unknown_error', + }); + // Defensive: end the span here too in case `'end'` never fires + // (e.g. abrupt socket destruction). `finishSpan` is idempotent — + // `spans.delete` makes the subsequent `'end'` listener a no-op. + finishSpan(rawCtx); + }); + result.on('end', () => finishSpan(rawCtx)); + return; + } + + // Callback path: `asyncEnd` will close the span. Nothing to do here. + }, + + error(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const span = spans.get(rawCtx); + if (!span) return; + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: ctx.error instanceof Error ? ctx.error.message : 'unknown_error', + }); + }, + + asyncStart() { + // No-op: we end on `asyncEnd` so the span covers the full callback duration. + }, + + asyncEnd(rawCtx) { + finishSpan(rawCtx); + }, + }); + + function finishSpan(rawCtx: object): void { + const span = spans.get(rawCtx); + if (!span) return; + span.end(); + spans.delete(rawCtx); + } + }, + }; +}) satisfies IntegrationFn; + +function hasOnMethod(obj: object): obj is { on: (event: string, listener: (arg?: unknown) => void) => unknown } { + return 'on' in obj && typeof (obj as { on?: unknown }).on === 'function'; +} + +function extractSql(firstArg: unknown): string | undefined { + if (typeof firstArg === 'string') { + return firstArg; + } + if (firstArg && typeof firstArg === 'object' && 'sql' in firstArg) { + const sql = (firstArg as { sql?: unknown }).sql; + return typeof sql === 'string' ? sql : undefined; + } + return undefined; +} + +function getConnectionConfig(connection: MysqlConnection | undefined): { + host?: string; + port?: number | string; + database?: string; + user?: string; +} { + // Pool connections nest the real config under `.connectionConfig`; single + // connections expose it directly. Matches `@opentelemetry/instrumentation-mysql`. + const config = connection?.config?.connectionConfig ?? connection?.config ?? {}; + return { + host: config.host, + port: config.port, + database: config.database, + user: config.user, + }; +} + +function getJDBCString(host: string | undefined, port: number | undefined, database: string | undefined): string { + let s = `jdbc:mysql://${host || 'localhost'}`; + if (typeof port === 'number') { + s += `:${port}`; + } + if (database) { + s += `/${database}`; + } + return s; +} + +/** + * EXPERIMENTAL — orchestrion-driven mysql integration. + * + * Subscribes to the `orchestrion:mysql:query` diagnostics_channel that the + * orchestrion code transform injects into `mysql/lib/Connection.js`'s + * `Connection.prototype.query`. Requires the orchestrion runtime hook or + * bundler plugin to be active — wire that up via `_experimentalSetupOrchestrion`. + */ +export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts new file mode 100644 index 000000000000..52032d4ae3a3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -0,0 +1,92 @@ +// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build +// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries +// listed in `SENTRY_INSTRUMENTATIONS`. +// +// This file is published ESM-only via the `@sentry/node/orchestrion/vite` +// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is +// `"type": "module"`, so consuming it from a CJS build is intentionally +// unsupported — vite.config.ts is almost always ESM in practice. The CJS +// rollup variant still emits this file, but `package.json` only exposes the +// ESM entry, so attempts to `require('@sentry/node/orchestrion/vite')` will +// fail at resolution time rather than producing a half-broken plugin. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; +import MagicString from 'magic-string'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +// `vite` types live in the package's ESM-only subpath; under Node16 module +// resolution with TS treating @sentry/node as CJS, importing them produces a +// false positive. We don't need the runtime value for typing — `UnknownPlugin` +// is sufficient — so we omit the import entirely. + +/** + * Vite plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro + * pipeline, SvelteKit). For unbundled Node processes use the runtime hook + * instead (`node --import @sentry/node/orchestrion app.js`). + * + * Returns two plugins: + * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a + * single-line banner to entry chunks. The banner sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the + * `_experimentalSetupOrchestrion()` detector can confirm the bundler path + * ran (rather than relying on a build-time flag that wouldn't be visible + * to the runtime). + * Also injects every instrumented package name into `ssr.noExternal` via + * the `config` hook, since externalized deps are `require()`d at runtime + * from `node_modules` and never pass through the transform. + * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. + * + * @example + * ```ts + * // vite.config.ts + * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; + * export default { plugins: [sentryOrchestrionPlugin()] }; + * ``` + */ +export function sentryOrchestrionPlugin(): UnknownPlugin[] { + const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); + const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) + ? codeTransformerPlugins + : [codeTransformerPlugins]; + return [bundlerMarkerPlugin(), ...codeTransformerArray]; +} + +function bundlerMarkerPlugin(): UnknownPlugin { + const banner = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + const instrumentedModules = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); + + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre' as const, + config(): { ssr: { noExternal: string[] } } { + // Force-bundle every instrumented package so the code transform actually + // sees its source. Vite externalizes dependencies in SSR builds by + // default, leaving them as bare `require()`/`import` calls resolved from + // `node_modules` at runtime — those copies are untouched and the + // diagnostics_channel calls never get injected. Vite merges array + // `noExternal` entries with the user's config, so we don't overwrite + // their additions. + return { ssr: { noExternal: instrumentedModules } }; + }, + renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { + if (!chunk.isEntry) return null; + // Prepend via magic-string so the entry chunk's sourcemap stays aligned — + // returning `map: null` here would shift every mapping by the banner's + // line count and misattribute server stack traces. + const ms = new MagicString(code); + ms.prepend(banner); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts new file mode 100644 index 000000000000..28dcf0c33468 --- /dev/null +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -0,0 +1,18 @@ +/** + * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. + * + * Orchestrion's transform always prefixes the configured `channelName` with + * `orchestrion:${module.name}:`. So a config of + * `{ channelName: 'query', module: { name: 'mysql' } }` + * publishes to `orchestrion:mysql:query`. + * + * Subscribers (`integrations//tracing-channel.ts`) consume the full + * prefixed string from this map; the config files set only the unprefixed + * suffix in `channelName`. Keeping both pieces in one file is what guarantees + * they don't drift apart and silently stop firing. + */ +export const CHANNELS = { + MYSQL_QUERY: 'orchestrion:mysql:query', +} as const; + +export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts new file mode 100644 index 000000000000..03985cc64686 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config.ts @@ -0,0 +1,35 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * The central list of channel injections orchestrion should perform. + * + * This module has NO side effects — it's the only thing both the runtime hook + * (`runtime/import-hook.mjs`) and the bundler plugins (`bundler/vite.ts`, …) + * import from. Adding a new instrumented method is one entry here plus one + * subscriber in `integrations//tracing-channel.ts`. + * + * `channelName` here is the unprefixed suffix; the actual diagnostics_channel + * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). + */ +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + { + channelName: 'query', + module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, + // `Connection` in mysql v2 is a constructor function (NOT a class): + // `function Connection(options) { ... }` + // `Connection.prototype.query = function query(sql, values, cb) { ... }` + // orchestrion's `className`+`methodName` query only matches `class` declarations. + // The named function expression on the right-hand side of the prototype + // assignment is what we want — that's matched by `expressionName: 'query'`, + // which produces the esquery selector + // `AssignmentExpression[left.property.name="query"] > FunctionExpression[async]`. + // `Auto` so both `connection.query(sql, cb)` and `connection.query(sql)` + // (streamable, no callback) get channel events. The transform picks + // `wrapCallback` when the last arg is a function and `wrapPromise` + // otherwise — for mysql's no-callback path the latter publishes + // `start`/`end` synchronously around the original call and stores the + // returned `Query` emitter on `ctx.result`, which the integration uses to + // attach `'end'`/`'error'` listeners that finish the span. + functionQuery: { expressionName: 'query', kind: 'Auto' }, + }, +]; diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts new file mode 100644 index 000000000000..60b6070740ba --- /dev/null +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -0,0 +1,41 @@ +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Verifies that the diagnostics channels have been injected either by the + * runtime `--import` hook (or init-time registration), a bundler plugin, or + * both, and warns if not. + * + * Both injectors being active at once is fine: they operate on disjoint module + * sets (a module is either loaded through Node's loader and transformed by the + * runtime hook, or inlined by the bundler and transformed by the plugin), so + * a single module can't be double-wrapped. A hybrid setup, with some deps + * external and runtime-instrumented, others bundled and plugin-instrumented, + * is fine. + * + * Note: intentionally does NOT warn in production, only in debug builds, + * because production warnings are reserved for truly critical issues. + */ +export function detectOrchestrionSetup(): void { + if (!DEBUG_BUILD) return; + + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + + if (!runtime && !bundler) { + DEBUG_BUILD && + debug.warn( + '[Sentry] No diagnostics-channel injection detected. Channel-based integrations ' + + '(mysql, …) will not record spans. Make sure the diagnostics channels are injected ' + + 'via the runtime `--import` hook or a bundler plugin before the instrumented modules load.', + ); + } +} diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts new file mode 100644 index 000000000000..dd3ecd0f8f19 --- /dev/null +++ b/packages/server-utils/src/orchestrion/index.ts @@ -0,0 +1,2 @@ +export { detectOrchestrionSetup } from './detect'; +export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs new file mode 100644 index 000000000000..49a08597cfba --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs @@ -0,0 +1,18 @@ +// EXPERIMENTAL — diagnostics-channel injection runtime hook. The side-effecting +// `--import` entry (e.g. `node --import @sentry/node/import app.js`) that injects +// the channels unconditionally before the app loads. +// +// All of the registration logic lives in `register.ts` — it has to be a +// CJS-compatible, dual-built module so `Sentry.init()` can `require()` it +// synchronously, and keeping a single source of truth means the `--import` path +// and the `init()` path can never drift apart. This file is just the +// side-effecting wrapper that invokes it. +// +// This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a +// single self-contained `.mjs` file with no relative-path imports — `--import` +// resolves it (and the bare specifier below) via Node's module resolution +// against the installed package. + +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; + +registerDiagnosticsChannelInjection(); diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts new file mode 100644 index 000000000000..48a63a732a5d --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -0,0 +1,120 @@ +import { debug } from '@sentry/core'; +import { createRequire } from 'node:module'; +import * as Module from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { DEBUG_BUILD } from '../../debug-build'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Synchronously register the diagnostics-channel injection module hooks. + * + * This is the single source of truth for the registration logic. It is used by: + * - `Sentry.init()` (the Node SDK calls it directly — that's why this module + * must be CJS-compatible / dual-built, so it can be `require()`d synchronously + * before the app's `import`s resolve), and + * - `import-hook.mjs`, the side-effecting `--import` entry, which just calls it. + * + * Libraries imported *after* this call publish the `tracingChannel` events that + * the channel-based integrations subscribe to. + * + * Idempotent via `globalThis.__SENTRY_ORCHESTRION__` — a no-op if the runtime + * `--import` hook or a bundler plugin already injected the channels. + */ +export function registerDiagnosticsChannelInjection(): void { + const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + + // Already injected (runtime --import hook or bundler plugin) — nothing to do. + if (g.runtime || g.bundler) { + return; + } + + const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; + const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); + const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); + const denoVersion = parseVersion(globalAny.Deno?.version?.deno ?? '0.0.0'); + // `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. + const stableSyncHooks = + (nodeVersion[0] ?? 0) > 25 || + (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || + (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || + (denoVersion[0] ?? 0) > 2 || + (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + + // Prefer the builtin `require` if possible. This is present in CommonJS, + // including a bundler's CJS output, so no need to ever have to evaluate + // `import.meta.url` there. + // + // esbuild and friends rewrite `import.meta.url` to `{}` for CJS output, + // which would make `createRequire(undefined)` throw. + // Only use `import.meta.url` in true ESM, where there's no `require` + const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url); + + // `Module.registerHooks` / `Module.register` are newer than the @types/node + // we build against, hence the cast. + const mod = Module as unknown as { + registerHooks?: (hooks: unknown) => void; + register?: (specifier: string, options: unknown) => void; + }; + + // runs both at `--import` time and (synchronously) inside `Sentry.init()`, + // so an unguarded throw would either abort startup or make `init()` throw. + // On any failure (e.g. dep resolution, `require(esm)` / Node-compat + // incompatibility) we warn (DEBUG only) and continue without channel + // injection + try { + if (typeof mod.registerHooks === 'function' && stableSyncHooks) { + // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. + // We require() the module here so that we can synchronously load it, + // including from a CommonJS Sentry build, without bundlers pulling in. + // All versions in stableSyncHooks support this. + const { initialize, resolve, load } = nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') as { + initialize: (opts: { instrumentations: unknown }) => void; + resolve: unknown; + load: unknown; + }; + initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + mod.registerHooks({ resolve, load }); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); + } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { + // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 + // path. Bun/Deno are excluded: they don't support this combination and + // must use the stable `registerHooks` path above (or none at all). + // Resolve the hook to an absolute file URL ourselves so + // `Module.register` needs no `parentURL`, so no need for + // `import.meta.url` polyfilling + mod.register(pathToFileURL(nodeRequire.resolve('@apm-js-collab/tracing-hooks/hook.mjs')).href, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + + // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM + // file `import`s a CJS package, the package's internal `require()` calls + // are resolved through the CJS machinery and never reach the ESM + // register hook, so without this patch the file we want to instrument + // loads untransformed. + const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { + patch: () => void; + }; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); + } else { + DEBUG_BUILD && + debug.warn('[Sentry] No available Node API to register diagnostics-channel injection hooks; skipping.'); + return; + } + } catch (error) { + DEBUG_BUILD && + debug.warn( + '[Sentry] Failed to register diagnostics-channel injection hooks; channel-based integrations ' + + 'will not record spans.', + error, + ); + return; + } + + g.runtime = true; +} diff --git a/packages/server-utils/tsconfig.json b/packages/server-utils/tsconfig.json index b0eb9ecb6476..5e5830ae75dc 100644 --- a/packages/server-utils/tsconfig.json +++ b/packages/server-utils/tsconfig.json @@ -3,5 +3,12 @@ "include": ["src/**/*"], - "compilerOptions": {} + "compilerOptions": {}, + // The orchestrion runtime hook is a hand-written `.mjs` file that self-references + // `@sentry/server-utils/orchestrion/config`. If tsc picks it up, it + // follows that subpath export back to `build/types/orchestrion/config.d.ts`, + // treats the .d.ts as an input, and then collides with the .d.ts it wants to + // emit from `src/orchestrion/config.ts`. Excluding it keeps tsc focused on the + // .ts sources — rollup copies the file through to `build/orchestrion/` unchanged. + "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/server-utils/tsconfig.types.json b/packages/server-utils/tsconfig.types.json index b1a51db073c2..ab12a03a64f2 100644 --- a/packages/server-utils/tsconfig.types.json +++ b/packages/server-utils/tsconfig.types.json @@ -4,6 +4,7 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "build/types" + "outDir": "build/types", + "rootDir": "src" } } diff --git a/yarn.lock b/yarn.lock index 230a0e493deb..22e6cd0fb1a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,6 +379,37 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer-bundler-plugins@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz#8a08136e8281f7e8e36ac6810d2b122c5917de93" + integrity sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ== + dependencies: + "@apm-js-collab/code-transformer" "^0.15.0" + es-module-lexer "^2.1.0" + magic-string "^0.30.21" + module-details-from-path "^1.0.4" + +"@apm-js-collab/code-transformer@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz#a3a1b6c7b92db16f8277636b4a72a1626e2fa52a" + integrity sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww== + dependencies: + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + +"@apm-js-collab/tracing-hooks@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz#b31f4bd474380475dc72f57f1b84dd71ba98edde" + integrity sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA== + dependencies: + "@apm-js-collab/code-transformer" "^0.15.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/cache-control-types@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz#5da62cf64c3b4419dabfef4536b57a40c8ff0b47" @@ -11233,10 +11264,10 @@ ast-walker-scope@^0.8.1: "@babel/parser" "^7.28.4" ast-kit "^2.1.3" -astring@^1.8.6: - version "1.8.6" - resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" - integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== +astring@^1.8.6, astring@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" + integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== astro@^3.5.0: version "3.5.0" @@ -15587,10 +15618,10 @@ es-module-lexer@^1.3.0, es-module-lexer@^1.3.1, es-module-lexer@^1.6.0, es-modul resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== -es-module-lexer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" - integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== +es-module-lexer@^2.0.0, es-module-lexer@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c" + integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== es-object-atoms@1.1.1, es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -15955,6 +15986,38 @@ esbuild@0.27.3: "@esbuild/win32-ia32" "0.27.3" "@esbuild/win32-x64" "0.27.3" +esbuild@0.28.0, esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + esbuild@^0.15.0: version "0.15.18" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d" @@ -16131,38 +16194,6 @@ esbuild@^0.25.0, esbuild@^0.25.3, esbuild@^0.25.6: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.28.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" - integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.0" - "@esbuild/android-arm" "0.28.0" - "@esbuild/android-arm64" "0.28.0" - "@esbuild/android-x64" "0.28.0" - "@esbuild/darwin-arm64" "0.28.0" - "@esbuild/darwin-x64" "0.28.0" - "@esbuild/freebsd-arm64" "0.28.0" - "@esbuild/freebsd-x64" "0.28.0" - "@esbuild/linux-arm" "0.28.0" - "@esbuild/linux-arm64" "0.28.0" - "@esbuild/linux-ia32" "0.28.0" - "@esbuild/linux-loong64" "0.28.0" - "@esbuild/linux-mips64el" "0.28.0" - "@esbuild/linux-ppc64" "0.28.0" - "@esbuild/linux-riscv64" "0.28.0" - "@esbuild/linux-s390x" "0.28.0" - "@esbuild/linux-x64" "0.28.0" - "@esbuild/netbsd-arm64" "0.28.0" - "@esbuild/netbsd-x64" "0.28.0" - "@esbuild/openbsd-arm64" "0.28.0" - "@esbuild/openbsd-x64" "0.28.0" - "@esbuild/openharmony-arm64" "0.28.0" - "@esbuild/sunos-x64" "0.28.0" - "@esbuild/win32-arm64" "0.28.0" - "@esbuild/win32-ia32" "0.28.0" - "@esbuild/win32-x64" "0.28.0" - escalade@3.2.0, escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -16494,10 +16525,10 @@ esprima@~3.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.0.0.tgz#53cf247acda77313e551c3aa2e73342d3fb4f7d9" integrity sha1-U88kes2ncxPlUcOqLnM0LT+099k= -esquery@^1.4.2, esquery@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== +esquery@^1.4.2, esquery@^1.6.0, esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -21288,6 +21319,11 @@ merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +meriyah@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" + integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -26829,6 +26865,11 @@ selfsigned@^2.0.1: "@types/node-forge" "^1.3.0" node-forge "^1" +semifies@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semifies/-/semifies-1.0.0.tgz#b69569f32c2ba2ac04f705ea82831364289b2ae2" + integrity sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw== + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"