Skip to content

Commit

Permalink
feat: source map strings
Browse files Browse the repository at this point in the history
Co-authored-by: Steve Lam <stevyo99@yahoo.ca>
  • Loading branch information
mrbbot and ssttevee committed Nov 28, 2023
1 parent 1b34878 commit 70d6a5d
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 41 deletions.
9 changes: 9 additions & 0 deletions .changeset/lazy-gifts-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": minor
---

feat: apply source mapping to logged strings

Previously, Wrangler would only apply source mapping to uncaught exceptions. This meant if you caught an exception and logged its stack trace, the call sites would reference built JavaScript files as opposed to source files. This change looks for stack traces in logged messages, and tries to source map them.

Note source mapping is only applied when outputting logs. `Error#stack` does not return a source mapped stack trace. This means the actual runtime value of `new Error().stack` and the output from `console.log(new Error().stack)` may be different.
4 changes: 4 additions & 0 deletions packages/wrangler/src/dev/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isAllowedSourcePath,
} from "../api/startDevWorker/bundle-allowed-paths";
import { logger } from "../logger";
import { getSourceMappedString } from "../sourcemap";
import type { EsbuildBundle } from "../dev/use-esbuild";
import type Protocol from "devtools-protocol";
import type { RawSourceMap } from "source-map";
Expand Down Expand Up @@ -57,6 +58,9 @@ export function logConsoleMessage(
case "undefined":
case "symbol":
case "bigint":
if (typeof ro.value === "string") {
ro.value = getSourceMappedString(ro.value);

Check warning on line 62 in packages/wrangler/src/dev/inspect.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/dev/inspect.ts#L62

Added line #L62 was not covered by tests
}
args.push(ro.value);
break;
case "function":
Expand Down
5 changes: 3 additions & 2 deletions packages/wrangler/src/dev/miniflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ModuleTypeToRuleType } from "../deployment-bundle/module-collection";
import { withSourceURLs } from "../deployment-bundle/source-url";
import { getHttpsOptions } from "../https-options";
import { logger } from "../logger";
import { getSourceMappedString } from "../sourcemap";
import { updateCheck } from "../update-check";
import type { Config } from "../config";
import type {
Expand Down Expand Up @@ -450,7 +451,7 @@ export function handleRuntimeStdio(stdout: Readable, stderr: Readable) {

// anything not exlicitly handled above should be logged as info (via stdout)
else {
logger.info(chunk);
logger.info(getSourceMappedString(chunk));

Check warning on line 454 in packages/wrangler/src/dev/miniflare.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/dev/miniflare.ts#L454

Added line #L454 was not covered by tests
}
});

Expand Down Expand Up @@ -489,7 +490,7 @@ export function handleRuntimeStdio(stdout: Readable, stderr: Readable) {

// anything not exlicitly handled above should be logged as an error (via stderr)
else {
logger.error(chunk);
logger.error(getSourceMappedString(chunk));
}
});
}
Expand Down
205 changes: 166 additions & 39 deletions packages/wrangler/src/sourcemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@ import assert from "node:assert";
import type Protocol from "devtools-protocol";

let sourceMappingPrepareStackTrace: typeof Error.prepareStackTrace;
function getSourceMappingPrepareStackTrace(): NonNullable<
typeof Error.prepareStackTrace
> {
if (sourceMappingPrepareStackTrace !== undefined) {
return sourceMappingPrepareStackTrace;
}

// `source-map-support` will only modify `Error.prepareStackTrace` if this
// is the first time `install()` has been called. This is governed by a
// module level variable: `errorFormatterInstalled`. To ensure we're not
// affecting external user's use of this package, and so
// `Error.prepareStackTrace` is always updated, load a fresh copy, by
// resetting then restoring the `require` cache.
const originalSupport = require.cache["source-map-support"];
delete require.cache["source-map-support"];
// eslint-disable-next-line @typescript-eslint/consistent-type-imports,@typescript-eslint/no-var-requires
const support: typeof import("source-map-support") = require("source-map-support");
require.cache["source-map-support"] = originalSupport;

const originalPrepareStackTrace = Error.prepareStackTrace;
support.install({
environment: "node",
// Don't add Node `uncaughtException` handler
handleUncaughtExceptions: false,
// Don't hook Node `require` function
hookRequire: false,
// Make sure we're using fresh copies of files each time we source map
emptyCacheBetweenOperations: true,
});
sourceMappingPrepareStackTrace = Error.prepareStackTrace;
assert(sourceMappingPrepareStackTrace !== undefined);
Error.prepareStackTrace = originalPrepareStackTrace;

return sourceMappingPrepareStackTrace;
}

export function getSourceMappedStack(
details: Protocol.Runtime.ExceptionDetails
Expand All @@ -12,72 +47,164 @@ export function getSourceMappedStack(
// mapping without parsing the stack, so just return the description as is
if (callFrames === undefined) return description;

if (sourceMappingPrepareStackTrace === undefined) {
// `source-map-support` will only modify `Error.prepareStackTrace` if this
// is the first time `install()` has been called. This is governed by a
// module level variable: `errorFormatterInstalled`. To ensure we're not
// affecting external user's use of this package, and so
// `Error.prepareStackTrace` is always updated, load a fresh copy, by
// resetting then restoring the `require` cache.
const originalSupport = require.cache["source-map-support"];
delete require.cache["source-map-support"];
// eslint-disable-next-line @typescript-eslint/consistent-type-imports,@typescript-eslint/no-var-requires
const support: typeof import("source-map-support") = require("source-map-support");
require.cache["source-map-support"] = originalSupport;

const originalPrepareStackTrace = Error.prepareStackTrace;
support.install({
environment: "node",
// Don't add Node `uncaughtException` handler
handleUncaughtExceptions: false,
// Don't hook Node `require` function
hookRequire: false,
// Make sure we're using fresh copies of files each time we source map
emptyCacheBetweenOperations: true,
});
sourceMappingPrepareStackTrace = Error.prepareStackTrace;
assert(sourceMappingPrepareStackTrace !== undefined);
Error.prepareStackTrace = originalPrepareStackTrace;
}

const nameMessage = details.exception?.description?.split("\n")[0] ?? "";
const colonIndex = nameMessage.indexOf(":");
const error = new Error(nameMessage.substring(colonIndex + 2));
error.name = nameMessage.substring(0, colonIndex);
const callSites = callFrames.map((frame) => new CallSite(frame));
return sourceMappingPrepareStackTrace(error, callSites);
const callSites = callFrames.map(callFrameToCallSite);
return getSourceMappingPrepareStackTrace()(error, callSites);

Check warning on line 55 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L54-L55

Added lines #L54 - L55 were not covered by tests
}

function callFrameToCallSite(frame: Protocol.Runtime.CallFrame): CallSite {
return new CallSite({

Check warning on line 59 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L58-L59

Added lines #L58 - L59 were not covered by tests
typeName: null,
functionName: frame.functionName,
methodName: null,
fileName: frame.url,
lineNumber: frame.lineNumber + 1,
columnNumber: frame.columnNumber + 1,
native: false,
});
}

const placeholderError = new Error();
export function getSourceMappedString(value: string): string {
// We could use `.replace()` here with a function replacer, but
// `getSourceMappingPrepareStackTrace()` clears its source map caches between
// operations. It's likely call sites in this `value` will share source maps,
// so instead we find all call sites, source map them together, then replace.
// Note this still works if there are multiple instances of the same call site
// (e.g. stack overflow error), as the final `.replace()`s will only replace
// the first instance. If they replace the value with itself, all instances
// of the call site would've been replaced with the same thing.
const callSiteLines = Array.from(value.matchAll(CALL_SITE_REGEXP));
const callSites = callSiteLines.map(lineMatchToCallSite);
const sourceMappedStackTrace: string = getSourceMappingPrepareStackTrace()(
placeholderError,
callSites
);
const sourceMappedCallSiteLines = sourceMappedStackTrace.split("\n").slice(1);
for (let i = 0; i < callSiteLines.length; i++) {
value = value.replace(

Check warning on line 88 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L88

Added line #L88 was not covered by tests
callSiteLines[i][0],
sourceMappedCallSiteLines[i].substring(4) // Trim indent from stack
);
}
return value;
}

// Adapted from `node-stack-trace`:
/*!
* Copyright (c) 2011 Felix Geisendörfer (felix@debuggable.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

const CALL_SITE_REGEXP =
/at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/g;
function lineMatchToCallSite(lineMatch: RegExpMatchArray): CallSite {
let object: string | null = null;
let method: string | null = null;
let functionName: string | null = null;
let typeName: string | null = null;
let methodName: string | null = null;
const isNative = lineMatch[5] === "native";

Check warning on line 127 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L121-L127

Added lines #L121 - L127 were not covered by tests

if (lineMatch[1]) {
functionName = lineMatch[1];
let methodStart = functionName.lastIndexOf(".");

Check warning on line 131 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L130-L131

Added lines #L130 - L131 were not covered by tests
if (functionName[methodStart - 1] == ".") methodStart--;
if (methodStart > 0) {
object = functionName.substring(0, methodStart);
method = functionName.substring(methodStart + 1);
const objectEnd = object.indexOf(".Module");

Check warning on line 136 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L134-L136

Added lines #L134 - L136 were not covered by tests
if (objectEnd > 0) {
functionName = functionName.substring(objectEnd + 1);
object = object.substring(0, objectEnd);

Check warning on line 139 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L138-L139

Added lines #L138 - L139 were not covered by tests
}
}
}

if (method) {
typeName = object;
methodName = method;

Check warning on line 146 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L145-L146

Added lines #L145 - L146 were not covered by tests
}

if (method === "<anonymous>") {
methodName = null;
functionName = null;

Check warning on line 151 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L150-L151

Added lines #L150 - L151 were not covered by tests
}

return new CallSite({

Check warning on line 154 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L154

Added line #L154 was not covered by tests
typeName,
functionName,
methodName,
fileName: lineMatch[2] || null,
lineNumber: parseInt(lineMatch[3]) || null,
columnNumber: parseInt(lineMatch[4]) || null,

Check warning on line 160 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L158-L160

Added lines #L158 - L160 were not covered by tests
native: isNative,
});
}

export interface CallSiteOptions {
typeName: string | null;
functionName: string | null;
methodName: string | null;
fileName: string | null;
lineNumber: number | null;
columnNumber: number | null;
native: boolean;
}

// https://v8.dev/docs/stack-trace-api#customizing-stack-traces
// This class supports the subset of options implemented by `node-stack-trace`:
// https://github.com/felixge/node-stack-trace/blob/4c41a4526e74470179b3b6dd5d75191ca8c56c17/index.js
export class CallSite implements NodeJS.CallSite {
constructor(private readonly frame: Protocol.Runtime.CallFrame) {}
constructor(private readonly opts: CallSiteOptions) {}

Check warning on line 179 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L179

Added line #L179 was not covered by tests

getThis(): unknown {
return null;
}
getTypeName(): string | null {
return null;
return this.opts.typeName;

Check warning on line 185 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L185

Added line #L185 was not covered by tests
}
// eslint-disable-next-line @typescript-eslint/ban-types
getFunction(): Function | undefined {
return undefined;
}
getFunctionName(): string | null {
return this.frame.functionName;
return this.opts.functionName;

Check warning on line 192 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L192

Added line #L192 was not covered by tests
}
getMethodName(): string | null {
return null;
return this.opts.methodName;

Check warning on line 195 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L195

Added line #L195 was not covered by tests
}
getFileName(): string | undefined {
return this.frame.url;
return this.opts.fileName ?? undefined;
}
getScriptNameOrSourceURL(): string | null {
return this.frame.url;
return this.opts.fileName;

Check warning on line 201 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L201

Added line #L201 was not covered by tests
}
getLineNumber(): number | null {
return this.frame.lineNumber + 1;
return this.opts.lineNumber;

Check warning on line 204 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L204

Added line #L204 was not covered by tests
}
getColumnNumber(): number | null {
return this.frame.columnNumber + 1;
return this.opts.columnNumber;

Check warning on line 207 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L207

Added line #L207 was not covered by tests
}
getEvalOrigin(): string | undefined {
return undefined;
Expand All @@ -89,7 +216,7 @@ export class CallSite implements NodeJS.CallSite {
return false;
}
isNative(): boolean {
return false;
return this.opts.native;

Check warning on line 219 in packages/wrangler/src/sourcemap.ts

View check run for this annotation

Codecov / codecov/patch

packages/wrangler/src/sourcemap.ts#L219

Added line #L219 was not covered by tests
}
isConstructor(): boolean {
return false;
Expand Down

0 comments on commit 70d6a5d

Please sign in to comment.