Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 482 additions & 0 deletions ORCHESTRIONJS_PLAN.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const NODE_EXPORTS_IGNORE = [
'preloadOpenTelemetry',
// Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration)
'_INTERNAL_normalizeCollectionInterval',
// Experimental
'_experimentalSetupOrchestrion',
];

const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Loaded BEFORE the scenario (via `--import` in ESM mode, `--require` in CJS
// mode). Pulling in `@sentry/node/orchestrion` triggers the runtime channel
// injection: the ESM build calls `module.register()` to install the
// orchestrion loader; the CJS build patches `Module.prototype._compile`.
//
// `createEsmAndCjsTests` converts this file's `import` statements to `require()`
// for the CJS variant by string substitution — the import specifier is
// unchanged. The `./orchestrion` subpath export resolves to a different file
// under the two conditions (`import` → import-hook.mjs, `require` →
// require-hook.cjs), so the same instrument file works in both modes.
import '@sentry/node/orchestrion';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';
import { _experimentalSetupOrchestrion } from '@sentry/node';
import mysql from 'mysql';

// EXPERIMENTAL — verifies the orchestrion runtime hook path for `mysql`.
//
// Pre-conditions set up by `instrument.mjs` (loaded via `--import` or `--require`
// before this file runs): orchestrion has rewritten `mysql/lib/Connection.js`
// so `Connection.prototype.query` publishes to `node:diagnostics_channel`.
// `_experimentalSetupOrchestrion()` below subscribes our channel-based mysql
// integration to those publications.

const client = Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
_experimentalUseOrchestrion: true,
});

_experimentalSetupOrchestrion(client);

// Stop the process from exiting before the transaction is sent.
setInterval(() => {}, 1000);

const connection = mysql.createConnection({
user: 'root',
password: 'docker',
});

Sentry.startSpanManual({ op: 'transaction', name: 'Test Transaction' }, span => {
connection.query('SELECT 1 + 1 AS solution', () => {
connection.query('SELECT NOW()', ['1', '2'], () => {
span.end();
connection.end();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from 'vitest';
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner';

describe('mysql auto instrumentation', () => {
afterAll(() => {
Expand Down Expand Up @@ -104,4 +104,36 @@
.start()
.completed();
});

createEsmAndCjsTests(__dirname, 'scenario-orchestrion.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => {
test('records db spans for `Connection.query` via the channel-based integration', { timeout: 75_000 }, async () => {

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (24) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (24) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (26) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (26) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (18) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (18) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (22) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (22) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (20) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (20) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (24) (TS 3.8) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > cjs > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:312:7

Check failure on line 109 in dev-packages/node-integration-tests/suites/tracing/mysql/test.ts

View workflow job for this annotation

GitHub Actions / Node (24) (TS 3.8) Integration Tests

suites/tracing/mysql/test.ts > mysql auto instrumentation > esm/cjs > esm > records db spans for `Connection.query` via the channel-based integration

Error: Test timed out in 75000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/tracing/mysql/test.ts:109:5 ❯ utils/runner.ts:302:7
const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'SELECT 1 + 1 AS solution',
op: 'db',
origin: 'auto.db.orchestrion.mysql',
data: expect.objectContaining({
'db.system.name': 'mysql',
'db.query.text': 'SELECT 1 + 1 AS solution',
'db.operation.name': 'SELECT',
}),
}),
expect.objectContaining({
description: 'SELECT NOW()',
op: 'db',
origin: 'auto.db.orchestrion.mysql',
data: expect.objectContaining({
'db.system.name': 'mysql',
'db.query.text': 'SELECT NOW()',
'db.operation.name': 'SELECT',
}),
}),
]),
};

await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
});
});
});
38 changes: 37 additions & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@
"require": {
"default": "./build/cjs/preload.js"
}
},
"./orchestrion": {
"import": {
"default": "./build/orchestrion/import-hook.mjs"
},
"require": {
"default": "./build/orchestrion/require-hook.cjs"
}
},
"./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/vite": {
"import": {
"types": "./build/types/orchestrion/bundler/vite.d.ts",
"default": "./build/esm/orchestrion/bundler/vite.js"
}
}
},
"typesVersions": {
Expand All @@ -65,6 +89,9 @@
"access": "public"
},
"dependencies": {
"@apm-js-collab/code-transformer": "^0.13.0",
"@apm-js-collab/code-transformer-bundler-plugins": "^0.1.0",
"@apm-js-collab/tracing-hooks": "^0.7.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/instrumentation": "^0.214.0",
Expand Down Expand Up @@ -96,7 +123,16 @@
"import-in-the-middle": "^3.0.0"
},
"devDependencies": {
"@types/node": "^18.19.1"
"@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",
Expand Down
34 changes: 33 additions & 1 deletion packages/node/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import { defineConfig } from 'rollup';
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils';

// EXPERIMENTAL — orchestrion.js runtime hooks. Each one is a tiny hand-written
// `.mjs`/`.cjs` shim that the user references via `node --import` or
// `node --require`. We pass them through rollup only to copy them into `build/`
// at the path the package.json `exports` map expects; `external: /.*/` keeps
// every import (e.g. `@sentry/node/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' },
}),
defineConfig({
input: 'src/orchestrion/runtime/require-hook.cjs',
external: /.*/,
output: { format: 'cjs', file: 'build/orchestrion/require-hook.cjs', strict: false },
}),
];

export default [
...makeOtelLoaders('./build', 'otel'),
...orchestrionRuntimeHooks,
...makeNPMConfigVariants(
makeBaseNPMConfig({
entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'],
// `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are
// loaded via dedicated subpath exports (`@sentry/node/orchestrion/config`,
// `@sentry/node/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.
entrypoints: [
'src/index.ts',
'src/init.ts',
'src/preload.ts',
'src/orchestrion/config.ts',
'src/orchestrion/bundler/vite.ts',
],
packageSpecificConfig: {
external: [/^@sentry\/opentelemetry/],
output: {
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
getDefaultIntegrationsWithoutPerformance,
initWithoutDefaultIntegrations,
} from './sdk';
export { _experimentalSetupOrchestrion, mysqlChannelIntegration } from './orchestrion';
export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel';
export { getAutoPerformanceIntegrations } from './integrations/tracing';

Expand Down
140 changes: 140 additions & 0 deletions packages/node/src/integrations/tracing-channel/mysql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { tracingChannel } from 'node:diagnostics_channel';
import type { IntegrationFn, Span } from '@sentry/core';
import { debug, defineIntegration, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core';
import { addOriginToSpan } from '@sentry/node-core';
import { DEBUG_BUILD } from '../../debug-build';
import { CHANNELS } from '../../orchestrion/channels';

const INTEGRATION_NAME = 'Mysql';
Comment thread
cursor[bot] marked this conversation as resolved.

// OpenTelemetry semantic-conventions strings. 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.
const ATTR_DB_SYSTEM_NAME = 'db.system.name';
const ATTR_DB_QUERY_TEXT = 'db.query.text';
const ATTR_DB_OPERATION_NAME = 'db.operation.name';

const SQL_OPERATION_REGEX =
/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|MERGE|CALL|SHOW|USE|BEGIN|COMMIT|ROLLBACK)\b/i;

/**
* 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`.
*/
interface MysqlQueryChannelContext {
arguments: unknown[];
self?: unknown;
moduleVersion?: string;
result?: unknown;
error?: unknown;
}

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<object, Span>();

// `subscribe()` requires all five lifecycle hooks. The orchestrion
// `wrapCallback` transform fires them in one of three orders:
// - sync throw from `query()` : start → error → end (NO asyncEnd)
// - async error from callback : start → end → error → asyncStart → asyncEnd
// - async success : start → end → asyncStart → asyncEnd
// We end the span on `asyncEnd` for the two async paths (so the span
// covers the full network round-trip + callback duration), and fall back
// to `end` for the sync-throw path so the span isn't left unfinished.
// 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`.
queryCh.subscribe({
start(rawCtx) {
const ctx = rawCtx as MysqlQueryChannelContext;
const sql = extractSql(ctx.arguments[0]);
const operation = sql ? extractOperation(sql) : undefined;

const span = startInactiveSpan({
name: sql ?? 'mysql.query',
op: 'db',
attributes: {
[ATTR_DB_SYSTEM_NAME]: 'mysql',
...(sql ? { [ATTR_DB_QUERY_TEXT]: sql } : {}),
...(operation ? { [ATTR_DB_OPERATION_NAME]: operation } : {}),
},
});
addOriginToSpan(span, 'auto.db.orchestrion.mysql');
spans.set(rawCtx, span);
},

end(rawCtx) {
// Only acts for sync throws: `end` fires AFTER `error` (both inside
// the wrapper's `try/catch/finally`), so `ctx.error` is already set.
// For async paths `end` fires before `error`, so `ctx.error` is still
// undefined here and we leave the span open for `asyncEnd` to close.
const ctx = rawCtx as MysqlQueryChannelContext;
if (ctx.error === undefined) return;
finishSpan(rawCtx);
},
Comment thread
cursor[bot] marked this conversation as resolved.

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 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 extractOperation(sql: string): string | undefined {
const match = sql.match(SQL_OPERATION_REGEX);
return match?.[1]?.toUpperCase();
}

/**
* 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);
Loading
Loading