Skip to content

Commit cbb54ad

Browse files
committed
fix(live-debugger): emit correct sourcemaps for instrumented functions
Live Debugger probes capture stack traces from instrumented functions. For those traces to point at the right place in the user's source, the source map for each transformed file must (a) carry segments for the purely-injected probe code and (b) report positions in the original source rather than in whatever intermediate buffer the transform was handed. For (a), wrap each function body's boundary characters (the opening `{`, the closing `}`, and the trailing terminator of any leading directive) via `MagicString#update` rather than `appendLeft`. `update` routes content through `Mappings.addEdit`, which emits one source-map segment per line of the new content anchored at the boundary character's original location; `appendLeft` content goes through `Mappings.advance` and produces no segments at all. Combined with `hires: true`, every injected line resolves back to the function it wraps, and the preamble and postamble can stay multi-line and readable in the bundled output. For (b), compose the magic-string delta map with the input source map produced by the previous loader (e.g. `builtin:swc-loader` stripping TypeScript types). With `enforce: 'post'` the transform sees the post-loader buffer, so magic-string anchors to positions in *that* buffer and labels them with the original file path. Without composition the bundler reports those post-loader positions as if they were original-source positions — captured probe stack frames in production resolved to import statements near the top of the file rather than the function body. Compose via `@jridgewell/remapping` (already in the lockfile via `unplugin`) using the `inputSourceMap` exposed by unplugin's native build context on webpack/rspack; rollup/vite/esbuild compose maps through their own pipelines and don't need this. Fall back to the un-composed map and log at error level — matching the `Instrumentation Error` precedent in the same handler — when composition throws, so a single malformed input map doesn't kill instrumentation for every other file. The integration test that exercises the full rspack pipeline uses a non-identity input map (a banner-shift loader that shifts source line numbers) so composition is validated end-to-end; an identity shim is indistinguishable from no composition.
1 parent 7dd0c81 commit cbb54ad

14 files changed

Lines changed: 481 additions & 30 deletions

File tree

Binary file not shown.

LICENSES-3rdparty.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Component,Origin,Licence,Copyright
165165
@jridgewell/set-array,npm,MIT,Justin Ridgewell (https://www.npmjs.com/package/@jridgewell/set-array)
166166
@jridgewell/source-map,npm,MIT,Justin Ridgewell (https://www.npmjs.com/package/@jridgewell/source-map)
167167
@jridgewell/sourcemap-codec,npm,MIT,Justin Ridgewell (https://github.com/jridgewell/sourcemaps/tree/main/packages/sourcemap-codec)
168-
@jridgewell/trace-mapping,npm,MIT,Justin Ridgewell (https://www.npmjs.com/package/@jridgewell/trace-mapping)
168+
@jridgewell/trace-mapping,npm,MIT,Justin Ridgewell (https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping)
169169
@kwsites/file-exists,npm,MIT,Steve King (https://www.npmjs.com/package/@kwsites/file-exists)
170170
@kwsites/promise-deferred,npm,MIT,Steve King (https://www.npmjs.com/package/@kwsites/promise-deferred)
171171
@module-federation/error-codes,npm,MIT,zhanghang (https://www.npmjs.com/package/@module-federation/error-codes)

packages/plugins/live-debugger/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
},
2525
"dependencies": {
2626
"@dd/core": "workspace:*",
27+
"@jridgewell/remapping": "2.3.5",
2728
"chalk": "2.3.1"
2829
},
2930
"devDependencies": {
3031
"@babel/parser": "7.24.5",
3132
"@babel/traverse": "7.24.5",
3233
"@babel/types": "7.24.5",
34+
"@jridgewell/trace-mapping": "0.3.31",
3335
"magic-string": "0.30.21",
3436
"typescript": "5.4.3"
3537
},

packages/plugins/live-debugger/src/index.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,119 @@ describe('getLiveDebuggerPlugin', () => {
212212
});
213213
});
214214

215+
describe('source-map composition', () => {
216+
const LINES_SHIFTED = 4;
217+
218+
const buildShiftedInputMap = (sourcePath: string, source: string): string =>
219+
JSON.stringify({
220+
version: 3,
221+
sources: [sourcePath],
222+
sourcesContent: [source],
223+
names: [],
224+
mappings:
225+
';'.repeat(LINES_SHIFTED) +
226+
source
227+
.split('\n')
228+
.map((_, idx) => (idx === 0 ? 'AAAA' : 'AACA'))
229+
.join(';'),
230+
});
231+
232+
const makeBuildContext = (
233+
inputSourceMap?: string | null,
234+
): UnpluginBuildContext & UnpluginContext => ({
235+
...mockBuildContext,
236+
getNativeBuildContext: () => ({
237+
framework: 'rspack',
238+
compiler: {} as never,
239+
compilation: {} as never,
240+
inputSourceMap,
241+
}),
242+
});
243+
244+
const callHandler = (
245+
ctx: UnpluginBuildContext & UnpluginContext,
246+
code: string,
247+
id: string,
248+
) => {
249+
const pluginContext = getContextMock({
250+
buildRoot: '/',
251+
getLogger: jest.fn(() => mockLog),
252+
});
253+
const plugin = getLiveDebuggerPlugin(
254+
makeOptions({ include: [], exclude: [] }),
255+
pluginContext,
256+
);
257+
const { handler } = getTransformHook(plugin);
258+
const result = handler.call(ctx, code, id);
259+
if (typeof result !== 'object' || result === null || !('code' in result)) {
260+
throw new Error('Unexpected handler result');
261+
}
262+
return result;
263+
};
264+
265+
it('composes its delta map with the previous loader so positions resolve to original-source lines', async () => {
266+
const original = 'function getDebuggerServicesStatus() { return 0; }';
267+
const id = '/src/use-debugger-services.hook.ts';
268+
const postLoader = `// banner\n// banner\n// banner\n// banner\n${original}`;
269+
const inputMap = buildShiftedInputMap(id, original);
270+
271+
const ctx = makeBuildContext(inputMap);
272+
const result = callHandler(ctx, postLoader, id);
273+
274+
expect(result.map).toBeDefined();
275+
276+
const lines = result.code.split('\n');
277+
const entryLineIndex = lines.findIndex((line) => line.includes('$dd_entry($dd_p'));
278+
expect(entryLineIndex).toBeGreaterThan(-1);
279+
const entryColumn = lines[entryLineIndex].indexOf('$dd_entry');
280+
281+
const { originalPositionFor, TraceMap } = await import('@jridgewell/trace-mapping');
282+
const traceMap = new TraceMap(
283+
typeof result.map === 'string' ? result.map : JSON.parse(String(result.map)),
284+
);
285+
const original_pos = originalPositionFor(traceMap, {
286+
line: entryLineIndex + 1,
287+
column: entryColumn,
288+
});
289+
290+
expect(original_pos.line).toBe(1);
291+
expect(original_pos.source).toBe(id);
292+
});
293+
294+
it('returns the magic-string map verbatim when the previous loader did not provide one', async () => {
295+
const id = '/src/utils.ts';
296+
const code = 'function f() { return 1; }';
297+
298+
// No inputSourceMap, no getNativeBuildContext at all.
299+
const result = callHandler(mockBuildContext, code, id);
300+
expect(result.map).toBeDefined();
301+
302+
const map = JSON.parse(String(result.map));
303+
expect(map.sources).toContain(id);
304+
});
305+
306+
it('returns no map when the file has no instrumentable functions', () => {
307+
const result = callHandler(mockBuildContext, 'const x = 42;', '/src/utils.ts');
308+
expect(result.map).toBeUndefined();
309+
});
310+
311+
it('falls back to the un-composed map and logs an error when composition throws', () => {
312+
const id = '/src/utils.ts';
313+
const code = 'function f() { return 1; }';
314+
315+
const ctx = makeBuildContext('not a valid sourcemap, this should throw');
316+
const result = callHandler(ctx, code, id);
317+
318+
expect(result.map).toBeDefined();
319+
expect(() => JSON.parse(String(result.map))).not.toThrow();
320+
321+
expect(mockLog.error).toHaveBeenCalledWith(
322+
expect.stringContaining('Failed to compose source map'),
323+
expect.objectContaining({ forward: true }),
324+
);
325+
});
326+
});
327+
215328
describe('error handling', () => {
216329
it('should return original code when transformCode throws', () => {
217330
jest.isolateModules(() => {

packages/plugins/live-debugger/src/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
import type { GetPlugins, GlobalContext, PluginOptions } from '@dd/core/types';
66
import { InjectPosition } from '@dd/core/types';
7+
import remapping from '@jridgewell/remapping';
8+
import type { SourceMapInput } from '@jridgewell/remapping';
9+
import type { SourceMap } from 'magic-string';
10+
import type { SourceMapCompact, UnpluginBuildContext } from 'unplugin';
711

812
import { CONFIG_KEY, PLUGIN_NAME } from './constants';
913
import { getRuntimeBootstrap } from './runtime-bootstrap';
@@ -103,9 +107,15 @@ export const getLiveDebuggerPlugin = (
103107

104108
transformedFileCount++;
105109

110+
const inputMap = getInputSourceMap(this);
111+
const composedMap =
112+
result.map && inputMap
113+
? composeWithInputMap(result.map, inputMap, id, log)
114+
: result.map;
115+
106116
return {
107117
code: result.code,
108-
map: result.map,
118+
map: composedMap,
109119
};
110120
} catch (e) {
111121
log.error(`Instrumentation Error in ${id}: ${e}`, { forward: true });
@@ -158,3 +168,31 @@ export const getPlugins: GetPlugins = ({ options, context }) => {
158168

159169
return [getLiveDebuggerPlugin(validatedOptions, context)];
160170
};
171+
172+
/**
173+
* Return the source map produced by the previous loader, if any.
174+
*/
175+
function getInputSourceMap(ctx: UnpluginBuildContext): SourceMapInput | undefined {
176+
const native = ctx.getNativeBuildContext?.();
177+
return (native as { inputSourceMap?: SourceMapInput })?.inputSourceMap;
178+
}
179+
180+
/**
181+
* Compose a local source map with the previous loader's source map. The result maps instrumented
182+
* output directly back to original source coordinates.
183+
*/
184+
function composeWithInputMap(
185+
instrumentMap: SourceMap,
186+
inputMap: SourceMapInput,
187+
id: string,
188+
log: ReturnType<GlobalContext['getLogger']>,
189+
): SourceMapCompact | SourceMap {
190+
try {
191+
return remapping(instrumentMap as unknown as SourceMapInput, (_file, ctx) =>
192+
ctx.depth === 1 ? inputMap : null,
193+
) as unknown as SourceMapCompact;
194+
} catch (e) {
195+
log.error(`Failed to compose source map for ${id}: ${e}`, { forward: true });
196+
return instrumentMap;
197+
}
198+
}

0 commit comments

Comments
 (0)