Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Sentry.withDebugger #13425

Closed
wants to merge 13 commits into from
1 change: 1 addition & 0 deletions packages/node/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils';
import { builtinModules } from 'module';
import { makeBaseBundleConfig, plugins } from '@sentry-internal/rollup-utils';
import { rollup } from 'rollup';

export function createWorkerCodeBuilder(entry, outDir) {
let base64Code;
Expand Down Expand Up @@ -30,3 +32,19 @@ export function createWorkerCodeBuilder(entry, outDir) {
},
];
}

export async function getBase64WorkerCode(entry) {
const bundle = await rollup({
input: entry,
plugins: [plugins.makeSucrasePlugin(), plugins.makeTerserPlugin()],
external: builtinModules,
});

const { output } = await bundle.generate({ format: 'es' });

if (output.length !== 1) {
throw new Error('Expected output to have length 1');
}

return Buffer.from(output[0].code).toString('base64');
}
3 changes: 2 additions & 1 deletion packages/node/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import replace from '@rollup/plugin-replace';
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils';
import { createWorkerCodeBuilder } from './rollup.anr-worker.config.mjs';
import { createWorkerCodeBuilder, getBase64WorkerCode } from './rollup.inspector-worker.config.mjs';

const [anrWorkerConfig, getAnrBase64Code] = createWorkerCodeBuilder(
'src/integrations/anr/worker.ts',
Expand Down Expand Up @@ -34,6 +34,7 @@ export default [
values: {
AnrWorkerScript: () => getAnrBase64Code(),
LocalVariablesWorkerScript: () => getLocalVariablesBase64Code(),
DebuggerWorkerScript: await getBase64WorkerCode('src/debugger-worker.ts'),
},
}),
],
Expand Down
237 changes: 237 additions & 0 deletions packages/node/src/debugger-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import inspector from 'inspector';
import { parentPort } from 'worker_threads';
import type { ParentThreadMessage, PayloadEvent, Step } from './with-debugger';

let refCount = 0;

const CONTEXT_LINZE_WINDOW_SIZE = 6;

const session = new inspector.Session();

const allowedScriptIds = new Set<string>();
const parsedScripts = new Map<
string, // scriptId
{ url?: string }
>();
let nextFrameIsAllowed = false;

function unrollArray(objectId: string | undefined): Promise<unknown[]> {
if (!objectId) return Promise.resolve([]);

return new Promise(resolve => {
session.post(
'Runtime.getProperties',
{
objectId,
ownProperties: true,
},
async (err, params) => {
const arrayProps = await Promise.all(
params.result
.filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10)))
.sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10))
.map(async v => {
if (v.value?.type === 'object') {
if (v.value?.subtype === 'array') {
return unrollArray(v?.value?.objectId);
} else {
return unrollObject(v?.value?.objectId);
}
} else {
return v?.value?.value;
}
}),
);

resolve(arrayProps);
},
);
});
}

function unrollObject(objectId: string | undefined): Promise<Record<string, unknown>> {
if (!objectId) return Promise.resolve({});

return new Promise(resolve => {
session.post(
'Runtime.getProperties',
{
objectId,
ownProperties: true,
},
async (err, params) => {
const obj = await params.result.reduce(async (accPromise, v) => {
const acc = await accPromise;

if (v.value?.type === 'object') {
if (v.value.subtype === 'array') {
acc[v.name] = await unrollArray(v.value.objectId);
} else {
acc[v.name] = await unrollObject(v.value.objectId);
}
} else {
acc[v.name] = v?.value?.value;
}

return acc;
}, Promise.resolve({} as Record<string, unknown>));

resolve(obj);
},
);
});
}

const steps: Step[] = [];

async function collectVariablesFromCurrentFrame(objectId: undefined | string): Promise<{ [name: string]: unknown }> {
if (!objectId) {
return Promise.resolve({});
}

return new Promise(resolve => {
session.post(
'Runtime.getProperties',
{
objectId,
ownProperties: true,
},
async (err, params) => {
const vars: Record<string, unknown> = {};
for (const param of params.result) {
const name = param.name;
const value = param.value;

if (value?.type === 'object' && value.subtype === 'array') {
vars[name] = await unrollArray(value.objectId);
} else if (value?.type === 'object') {
vars[name] = await unrollObject(value.objectId);
} else {
// numbers, strings
vars[name] = value?.value;
}
}
resolve(vars);
},
);
});
}

async function getFileDataForCurrentFrame(
topCallframe: inspector.Debugger.CallFrame,
parsedScript: { url?: string },
): Promise<{
filename?: string;
lineno?: number;
colno?: number;
pre_lines?: string[];
line?: string;
post_lines?: string[];
}> {
return new Promise(resolve => {
session.post(
'Debugger.getScriptSource',
{ scriptId: topCallframe.location.scriptId },
(err, scriptSourceMessage): void => {
const scriptSource: string = scriptSourceMessage.scriptSource || '';
const lines = scriptSource.split('\n');

resolve({
filename: parsedScript.url,
lineno: topCallframe.location.lineNumber,
colno: topCallframe.location.columnNumber,
pre_lines: lines.slice(
Math.max(0, topCallframe.location.lineNumber - CONTEXT_LINZE_WINDOW_SIZE),
topCallframe.location.lineNumber,
),
line: lines[topCallframe.location.lineNumber] || '',
post_lines: lines.slice(
topCallframe.location.lineNumber + 1,
topCallframe.location.lineNumber + 1 + CONTEXT_LINZE_WINDOW_SIZE,
),
});
},
);
});
}

async function onPaused(
pausedEvent: inspector.InspectorNotification<inspector.Debugger.PausedEventDataType>,
): Promise<void> {
const topCallFrame = pausedEvent.params.callFrames[0];
if (topCallFrame) {
const parsedScript = parsedScripts.get(topCallFrame.location.scriptId);
if (parsedScript) {
if (nextFrameIsAllowed) {
allowedScriptIds.add(topCallFrame.location.scriptId);
nextFrameIsAllowed = false;
}

if (allowedScriptIds.has(topCallFrame.location.scriptId)) {
const objectId = topCallFrame.scopeChain[0]?.object.objectId;
const [variablesForCurrentFrame, fileDataForCurrentFrame] = await Promise.all([
collectVariablesFromCurrentFrame(objectId),
getFileDataForCurrentFrame(topCallFrame, parsedScript),
]);

steps.push({
...fileDataForCurrentFrame,
vars: variablesForCurrentFrame,
});
}
}
}

session.post('Debugger.stepOver');
}

function onScriptParsed(
scriptParsedMessage: inspector.InspectorNotification<inspector.Debugger.ScriptParsedEventDataType>,
): void {
const { scriptId, url } = scriptParsedMessage.params;

if (!url.startsWith('node:')) {
parsedScripts.set(scriptId, { url });
}
}

parentPort?.on('message', (message: ParentThreadMessage) => {
if (message.type === 'incrRefCount') {
refCount++;
}

if (message.type === 'decRefCount') {
refCount--;
if (refCount === 0) {
session.off('Debugger.paused', onPaused);
session.off('Debugger.scriptParsed', onScriptParsed);
session.post('Debugger.resume');
parentPort?.postMessage({
type: 'Payload',
steps,
} satisfies PayloadEvent);
session.disconnect();
}
}

if (message.type === 'requestPayload') {
parentPort?.postMessage({
type: 'Payload',
steps,
} satisfies PayloadEvent);
}

if (message.type === 'waiting') {
nextFrameIsAllowed = true;
session.post('Debugger.pause');
session.post('Runtime.runIfWaitingForDebugger');
}
});

session.on('Debugger.scriptParsed', onScriptParsed);

session.connectToMainThread();

session.post('Debugger.enable', () => {
session.on('Debugger.paused', onPaused);
});
2 changes: 2 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export { onUncaughtExceptionIntegration } from './integrations/onuncaughtexcepti
export { onUnhandledRejectionIntegration } from './integrations/onunhandledrejection';
export { anrIntegration } from './integrations/anr';

export { withDebugger } from './with-debugger';

export { expressIntegration, expressErrorHandler, setupExpressErrorHandler } from './integrations/tracing/express';
export { fastifyIntegration, setupFastifyErrorHandler } from './integrations/tracing/fastify';
export { graphqlIntegration } from './integrations/tracing/graphql';
Expand Down
36 changes: 36 additions & 0 deletions packages/node/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '@sentry/opentelemetry';
import type { Integration, Options } from '@sentry/types';
import {
addNonEnumerableProperty,
consoleSandbox,
dropUndefinedKeys,
logger,
Expand All @@ -45,6 +46,9 @@ import { envToBool } from '../utils/envToBool';
import { defaultStackParser, getSentryRelease } from './api';
import { NodeClient } from './client';
import { initOpenTelemetry, maybeInitializeEsmLoader } from './initOtel';
import type { WorkerThreadMessage } from '../with-debugger';
import { debuggerALS } from '../with-debugger';
import zlib from 'zlib';

function getCjsOnlyIntegrations(): Integration[] {
return isCjs() ? [modulesIntegration()] : [];
Expand Down Expand Up @@ -175,6 +179,38 @@ function _init(
enhanceDscWithOpenTelemetryRootSpanName(client);
setupEventContextTrace(client);

client.addEventProcessor(event => {
if (event.type === undefined) {
const debuggerWorker = debuggerALS.getStore();
if (debuggerWorker) {
return new Promise(resolve => {
function onMessage(message: WorkerThreadMessage): void {
if (message.type === 'Payload') {
debuggerWorker?.off('message', onMessage);

const payload = message.steps.slice(-100);
const stringifiedPayload = JSON.stringify(payload);

// create a gzipped base64 string from stringifiedPayload
zlib.gzip(stringifiedPayload, (err, compressedPayload) => {
event.contexts = event.contexts || {};
event.contexts['debugger'] = { steps: compressedPayload.toString('base64') };
addNonEnumerableProperty(event.contexts['debugger'], '__sentry_override_normalization_depth__', 10);
resolve(event);
});
}
}

debuggerWorker.on('message', onMessage);

debuggerWorker.postMessage({ type: 'requestPayload' });
});
}
}

return event;
});

return client;
}

Expand Down
Loading
Loading