autotel
Version:
Write Once, Observe Anywhere
126 lines (124 loc) • 5.49 kB
JavaScript
import { g as nonEmptyString } from "./values-xBtdXtjA.js";
//#region src/exception-fingerprint.ts
/** Attribute the fingerprint is written to. */
const EXCEPTION_FINGERPRINT_ATTRIBUTE = "exception.fingerprint";
/**
* Frames deep enough to separate two different bugs, shallow enough that the
* same bug reached through different callers still groups.
*/
const DEFAULT_FRAMES = 5;
const NODE_FRAME = /^at\s+(.+?)\s+\((.+?):(\d+):\d+\)$/;
const ANON_FRAME = /^at\s+(.+?):(\d+):\d+$/;
const BROWSER_FRAME = /^(.+?)@(.+?):(\d+):\d+$/;
/**
* Collapse a path to the part that identifies the code rather than the machine
* it ran on: a dependency becomes its package name, and an absolute path
* becomes the project-relative one. Without this, the same error fingerprints
* differently in CI, in Docker, and on a laptop.
*/
function normalizeFilePath(filePath) {
const nodeModulesMatch = filePath.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
if (nodeModulesMatch) return `[npm]/${nodeModulesMatch[1]}`;
return filePath.replace(/^.*?\/src\//, "src/").replace(/^.*?\/dist\//, "dist/").replace(/^.*?\/lib\//, "lib/").replace(/^file:\/\//, "");
}
/**
* Strip the parts of a message that change on every occurrence, so
* `timeout after 341ms` and `timeout after 78ms` are one issue rather than two.
*/
function normalizeMessage(message) {
return message.replaceAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "[UUID]").replaceAll(/\b[0-9a-f]{16,}\b/gi, "[ID]").replaceAll(/\d+/g, "[N]").replaceAll(/"[^"]*"/g, "\"[STR]\"").replaceAll(/'[^']*'/g, "'[STR]'").slice(0, 200);
}
/**
* `function@normalized-path` for the top N frames. Line and column are
* deliberately dropped: an edit one line above the throw is the same bug.
*/
function normalizeStackFrames(stack, count) {
const frames = [];
for (const line of stack.split("\n")) {
if (frames.length >= count) break;
const trimmed = line.trim();
const node = NODE_FRAME.exec(trimmed);
if (node) {
frames.push(`${node[1] ?? "anonymous"}@${normalizeFilePath(node[2] ?? "")}`);
continue;
}
const anon = ANON_FRAME.exec(trimmed);
if (anon) {
frames.push(`anonymous@${normalizeFilePath(anon[1] ?? "")}`);
continue;
}
const browser = BROWSER_FRAME.exec(trimmed);
if (browser) frames.push(`${browser[1] ?? "anonymous"}@${normalizeFilePath(browser[2] ?? "")}`);
}
return frames;
}
/** 32-bit djb2-style hash, rendered as 8 hex characters. */
function hashFingerprintParts(parts) {
const value = parts.join("|");
let hash = 0;
for (let index = 0; index < value.length; index++) {
hash = (hash << 5) - hash + value.charCodeAt(index);
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(8, "0");
}
/**
* Group key for an exception. Returns `undefined` when there is nothing to
* group on, so callers never write an attribute that means "no information".
*/
function fingerprintException(input, frames = DEFAULT_FRAMES) {
const stackFrames = input.stack ? normalizeStackFrames(input.stack, frames) : [];
if (stackFrames.length === 0 && !input.message && !input.type) return;
const parts = [input.type || "Error"];
if (stackFrames.length > 0) parts.push(...stackFrames);
else if (input.message) parts.push(normalizeMessage(input.message));
return hashFingerprintParts(parts);
}
/**
* Read the error off a finished span, wherever it was recorded: the OTel
* `exception` event, the `exception.*` attributes autotel-web sets, or the
* `error.*` attributes structured errors write instead of an event.
*/
function readException(span) {
const { attributes } = span;
const event = span.events?.find((e) => e.name === "exception");
const eventAttributes = event?.attributes ?? {};
const type = nonEmptyString(attributes["exception.type"]) ?? nonEmptyString(attributes["error.type"]) ?? nonEmptyString(eventAttributes["exception.type"]);
const stack = nonEmptyString(attributes["exception.stacktrace"]) ?? nonEmptyString(attributes["exception.stack"]) ?? nonEmptyString(attributes["error.stack"]) ?? nonEmptyString(eventAttributes["exception.stacktrace"]) ?? nonEmptyString(eventAttributes["exception.stack"]);
if (!type && !stack && !event) return void 0;
return {
type,
message: nonEmptyString(attributes["exception.message"]) ?? nonEmptyString(attributes["error.message"]) ?? nonEmptyString(eventAttributes["exception.message"]) ?? nonEmptyString(span.status.message),
stack
};
}
/**
* Span enricher that stamps {@link EXCEPTION_FINGERPRINT_ATTRIBUTE} on every
* span that recorded an exception.
*
* Pass it to `init({ spanEnrichers: [...] })` rather than `spanProcessors`:
* enrichers add to the pipeline instead of replacing it, and sit outside the
* redaction wrapper so the attribute reaches every exporter.
*/
function exceptionFingerprint(options = {}) {
const frames = options.frames ?? DEFAULT_FRAMES;
return {
onStart(_span, _context) {},
onEnd(span) {
const { attributes } = span;
if (attributes["exception.fingerprint"] !== void 0) return;
const exception = readException(span);
if (!exception) return;
const fingerprint = fingerprintException(exception, frames);
if (fingerprint) attributes[EXCEPTION_FINGERPRINT_ATTRIBUTE] = fingerprint;
},
forceFlush() {
return Promise.resolve();
},
shutdown() {
return Promise.resolve();
}
};
}
//#endregion
export { EXCEPTION_FINGERPRINT_ATTRIBUTE, exceptionFingerprint, fingerprintException, hashFingerprintParts, normalizeFilePath, normalizeMessage, normalizeStackFrames };