Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Spawned by test.ts via `deno run`, without `@sentry/deno/import`.
//
// `init()` registers the orchestrion module hook, so the `mysql` import that
// follows it is transformed to publish to the `orchestrion:mysql:query`
// tracing channel. Pass `disabled` to opt out with
// `enableRuntimeChannelInjection: false`.
import { init } from '@sentry/deno';
import { tracingChannel } from 'node:diagnostics_channel';

init({
debug: true,
enableRuntimeChannelInjection: Deno.args[0] !== 'disabled',
});

const { default: mysql } = await import('mysql');

const events = [];
tracingChannel('orchestrion:mysql:query').subscribe({
start() {
events.push('start');
},
end() {},
asyncStart() {},
asyncEnd() {},
error() {},
});

const conn = mysql.createConnection({ host: '127.0.0.1', user: 'root' });
try {
conn.query('SELECT 1 AS solution', () => {});
} catch {
// No live server — `start` has already published synchronously by now.
}
try {
conn.destroy();
} catch {
// ignore
}

const marker = globalThis.__SENTRY_ORCHESTRION__ ?? null;
// eslint-disable-next-line no-console
console.log(`SCENARIO events=${events.join(',')} marker=${JSON.stringify(marker)}`);
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,50 @@ Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion
assert(line.includes('"runtime":["mysql"]'), `expected runtime marker, got: ${line}`);
});

async function runInitScenario(...args: string[]): Promise<{ line: string; out: string; err: string }> {
const scenario = new URL('./scenario-init.mjs', import.meta.url);
const cwd = new URL('../../', import.meta.url);

// Deno throws on a `process.env` read without permission. Grant only the
// variable `mysql` itself reads, so any read by the SDK fails the run.
const command = new Deno.Command('deno', {
args: ['run', '--allow-read', '--allow-net', '--allow-env=READABLE_STREAM', scenario.pathname, ...args],
cwd: cwd.pathname,
stdout: 'piped',
stderr: 'piped',
});

const { code, stdout, stderr } = await command.output();
const out = new TextDecoder().decode(stdout);
const err = new TextDecoder().decode(stderr);

assertEquals(code, 0, `scenario exited ${code}\nstdout:\n${out}\nstderr:\n${err}`);

const line = out.split('\n').find(l => l.startsWith('SCENARIO')) ?? '';
assert(line, `no SCENARIO line in output:\n${out}\nstderr:\n${err}`);
return { line, out, err };
}

Deno.test('init(): transforms mysql imported after it', async () => {
const { line, out, err } = await runInitScenario();
assert(line.includes('events=start'), `expected channel 'start' event, got: ${line}`);
assert(line.includes('"runtime":["mysql"]'), `expected runtime marker, got: ${line}`);
assert(
!`${out}${err}`.includes('No diagnostics-channel injection detected'),
`unexpected missing-injection warning:\n${out}\n${err}`,
);
});

Deno.test('init(): enableRuntimeChannelInjection: false skips the hook and warns', async () => {
const { line, out, err } = await runInitScenario('disabled');
assert(line.includes('events= '), `expected no channel events, got: ${line}`);
assert(!line.includes('"runtime"'), `expected no runtime marker, got: ${line}`);
assert(
`${out}${err}`.includes('No diagnostics-channel injection detected'),
`expected missing-injection warning:\n${out}\n${err}`,
);
});

Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nested db span', async () => {
resetGlobals();
const sink = spanSink();
Expand Down
12 changes: 12 additions & 0 deletions packages/deno/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { ServerRuntimeClientOptions } from '@sentry/core/server';
import { _INTERNAL_flushLogsBuffer, _INTERNAL_flushMetricsBuffer, SDK_VERSION } from '@sentry/core';
import { ServerRuntimeClient } from '@sentry/core/server';
import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils';
import process from 'node:process';
import type { DenoClientOptions } from './types';

declare const __SENTRY_CHANNEL_INJECTION__: boolean | undefined;

function getHostName(): string | undefined {
// Deno.permissions.querySync is not available on Deno Deploy
if (!Deno.permissions.querySync) {
Expand Down Expand Up @@ -75,6 +78,15 @@ export class DenoClient extends ServerRuntimeClient<DenoClientOptions> {
process.on('beforeExit', this._metricsOnExitFlushListener);
globalThis.addEventListener('unload', this._logOnExitFlushListener);
globalThis.addEventListener('unload', this._metricsOnExitFlushListener);

// In the constructor, as in `NodeClient`, so a directly constructed client installs the hooks
// too. Registration is idempotent.
if (
(typeof __SENTRY_CHANNEL_INJECTION__ === 'undefined' || __SENTRY_CHANNEL_INJECTION__) &&
options.enableRuntimeChannelInjection !== false
) {
registerDiagnosticsChannelInjection();
}
}

/** @inheritDoc */
Expand Down
6 changes: 4 additions & 2 deletions packages/deno/src/import.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* Orchestrion runtime hook for Deno.
*
* Load this via the `--preload` argument to `deno run` to enable the
* channel-based instrumentations.
* `init()` registers the same hook, but it only applies to modules imported
* after `init()`. Load this via the `--preload` argument to `deno run` to also
* instrument modules imported before it, such as static imports in the entry
* module.
*
* For example:
*
Expand Down
5 changes: 4 additions & 1 deletion packages/deno/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
requestDataIntegration,
stackParserFromStackParserOptions,
} from '@sentry/core';
import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils';
import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils';
import { DenoClient } from './client';
import { nodeStackLineParser } from '@sentry/core/server';
import { breadcrumbsIntegration } from './integrations/breadcrumbs';
Expand Down Expand Up @@ -138,5 +138,8 @@ function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Optio
setupOpenTelemetryTracer();
}

// After client creation, so the debug logger is enabled.
detectOrchestrionSetup();

return client;
}
13 changes: 13 additions & 0 deletions packages/deno/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export interface BaseDenoOptions {
*/
enableOpenTelemetrySetup?: boolean;

/**
* Controls whether the SDK installs its runtime diagnostics-channel injection hooks. These hooks
* transform supported modules (e.g. Express) at load time so they emit the diagnostics channels
* that the channel-based integrations subscribe to. They only apply to modules imported after
* `init()`. To instrument modules imported before it, run Deno with
* `--preload=npm:@sentry/deno/import`.
*
* Set this to `false` to opt out.
*
* @default true
*/
enableRuntimeChannelInjection?: boolean;

/** Callback that is executed when a fatal global error occurs. */
onFatalError?(this: void, error: Error): void;
}
Expand Down
55 changes: 29 additions & 26 deletions packages/server-runtime-injection/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,41 +30,44 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
// to the default itself, not a `{ default }` namespace — CJS callers use it as
// `require('esquery').parse(...)`.
//
// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules (e.g.
// `debug`'s browser/node split) in lazy initializers exported as `__require` — an export name that
// downstream re-bundlers mishandle (Turbopack renames it, producing `.require is not a function`
// crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing
// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules in lazy
// initializers exported as `__require` — an export name that downstream re-bundlers mishandle
// (Turbopack renames it, producing `.require is not a function` crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing
// optional/missing) and has no require cycles that depend on lazy evaluation.
const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false };
const commonJSPlugin = commonjs(commonJSOptions);

// Always vendor `debug`'s Node build. Its default entry picks browser vs node at require time,
// which drags the browser build into this server-only bundle — and, hoisted by
// `strictRequires: false`, the browser build's storage detection probes `localStorage` at import
// time, which on Node >= 26 emits an ExperimentalWarning that pollutes stderr and console
// breadcrumbs in every user app. `order: 'pre'` because the base config's node-resolve plugin
// sorts ahead of package-specific plugins and would otherwise resolve `debug` first.
const debugNodeAlias = {
name: 'debug-node-alias',
// Replace `debug` with `src/debug.ts`, which logs through the Sentry logger. `debug` and the
// `supports-color` it loads read `process.env` at import time, which throws on Deno without env
// permission. `order: 'pre'` because the base config's node-resolve plugin sorts ahead of
// package-specific plugins and would otherwise resolve `debug` first.
const debugShim = {
name: 'debug-shim',
resolveId: {
order: 'pre',
handler(source, importer) {
return source === 'debug' ? this.resolve('debug/src/node.js', importer, { skipSelf: true }) : null;
handler(source) {
return source === 'debug' ? path.join(import.meta.dirname, 'src/debug.ts') : null;
},
},
};

// `debug` vendors the `supports-color` that it finds, and which version that is depends on how the package manager
// installs it. Resolve it from this package instead, so the vendored version is the one in its `devDependencies`.
const supportsColorFromPackage = {
name: 'supports-color-from-package',
resolveId: {
order: 'pre',
handler(source) {
return source === 'supports-color'
? this.resolve(source, path.join(import.meta.dirname, 'package.json'), { skipSelf: true })
: null;
},
// `ModulePatch` reads `process.env.TRACING_DUMP` after each module it transforms. On Deno without
// env permission that read throws, and the module counts as failed. Treat the throw as unset.
const guardTracingDump = {
name: 'guard-tracing-dump',
transform(code, id) {
if (!id.includes('@apm-js-collab/tracing-hooks/index.js')) {
return null;
}
const read = 'process.env.TRACING_DUMP)';
if (!code.includes(read)) {
this.error(`Expected \`${read}\` in ${id}`);
}
// `map: null` keeps the existing mappings, which stay right because no line moves.
return {
code: code.replace(read, '(() => { try { return process.env.TRACING_DUMP } catch { return undefined } })())'),
map: null,
};
},
};

Expand Down Expand Up @@ -113,7 +116,7 @@ export default [
// `import` condition, so the `build/cjs` copy is unused.
entrypoints: ['src/register.ts', 'src/hook.mjs'],
packageSpecificConfig: {
plugins: [debugNodeAlias, supportsColorFromPackage, commonJSPlugin, thirdPartyLicensePlugin],
plugins: [debugShim, guardTracingDump, commonJSPlugin, thirdPartyLicensePlugin],
output: {
exports: 'named',
preserveModules: true,
Expand Down
14 changes: 14 additions & 0 deletions packages/server-runtime-injection/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Stands in for the `debug` package in the vendored dependencies (see `rollup.npm.config.mjs`).
// `debug` reads `process.env` when it loads, which throws on Deno without env permission, and the
// Deno SDK loads this package from `init()`. The messages go to the Sentry logger instead, so
// `debug: true` turns them on.
import { debug } from '@sentry/core';
import { format } from 'node:util';

export default function createDebug(namespace: string): (...args: unknown[]) => void {
return (...args) => {
if (debug.isEnabled()) {
debug.log(`[${namespace}]`, format(...args));
}
};
}
48 changes: 48 additions & 0 deletions packages/server-runtime-injection/test/envAccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';

/**
* Deno throws `NotCapable` on any `process.env` access when the app has no env permission. The Deno
* SDK imports this package from `init()`, so loading it and registering the hooks must not touch
* `process.env`. The vendored dependencies are the risk here, so this runs on the built files.
*/
const buildDir = join(__dirname, '..', 'build');

// Stands in for Deno's permission check. Node's own loader reads `process.env` too, even while it
// loads a file that one of ours required, so only reads whose direct caller is one of this
// package's built files throw. Stack lines: the `Error` line, this trap, then the caller.
const lockEnv = `
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const env = process.env;
const guard = trap => (...args) => {
if (new Error().stack.split('\\n')[2].includes(${JSON.stringify(buildDir)})) {
throw new Error('process.env access denied');
}
return Reflect[trap](env, ...args.slice(1));
};
const traps = Object.fromEntries(['get', 'has', 'ownKeys', 'getOwnPropertyDescriptor', 'set'].map(t => [t, guard(t)]));
Object.defineProperty(process, 'env', { value: new Proxy(env, traps) });
`;

describe.each([
['cjs', `require(${JSON.stringify(join(buildDir, 'cjs', 'register.js'))})`],
['esm', `await import(${JSON.stringify(pathToFileURL(join(buildDir, 'esm', 'register.js')).href)})`],
])('the %s build', (_variant, load) => {
it('loads and registers without reading process.env', { timeout: 30_000 }, () => {
const script = `${lockEnv}
const { registerDiagnosticsChannelInjection } = ${load};
registerDiagnosticsChannelInjection();
console.log(JSON.stringify(globalThis.__SENTRY_ORCHESTRION__));
`;

const out = execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});

expect(JSON.parse(out.trim())).toEqual({ runtime: [] });
});
});
Loading