chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { formatDurationNanoseconds } from "@trigger.dev/core/v3/utils/durations";
|
||||
import type { StreamedTraceEvent } from "./eventRepository.types";
|
||||
|
||||
// Lines are batched into ~64KB chunks before being yielded to the gzip stream:
|
||||
// per-line chunks make `Readable.from` ~10x slower, while chunks this size keep
|
||||
// the pipe fed yet stay small enough to release the event loop frequently.
|
||||
const DEFAULT_FLUSH_BYTES = 64 * 1024;
|
||||
|
||||
export type TraceExportContext = {
|
||||
runFriendlyId: string;
|
||||
traceId: string;
|
||||
taskIdentifier?: string;
|
||||
/** Absolute dashboard URL for the run (used by formats that link out). */
|
||||
runUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A trace export format. `formatEvent` renders one {@link StreamedTraceEvent} to
|
||||
* a string; `header`/`footer` bookend the stream. Formats are intentionally
|
||||
* stateless across events so the export stays O(1) memory — see
|
||||
* {@link streamTraceExport}.
|
||||
*/
|
||||
export type TraceExportFormat = {
|
||||
name: TraceExportFormatName;
|
||||
extension: string;
|
||||
header?: (ctx: TraceExportContext) => string;
|
||||
formatEvent: (event: StreamedTraceEvent, ctx: TraceExportContext) => string;
|
||||
footer?: (ctx: TraceExportContext) => string;
|
||||
};
|
||||
|
||||
export type TraceExportFormatName = "log" | "jsonl" | "markdown";
|
||||
|
||||
/**
|
||||
* Streams a trace export by piping events through a {@link TraceExportFormat}.
|
||||
* Batches output into ~`flushBytes` chunks and releases the event loop between
|
||||
* flushes; holds nothing across events but the buffer, so an arbitrarily large
|
||||
* trace exports in bounded memory regardless of format.
|
||||
*/
|
||||
export async function* streamTraceExport(
|
||||
events: AsyncIterable<StreamedTraceEvent>,
|
||||
format: TraceExportFormat,
|
||||
ctx: TraceExportContext,
|
||||
options: { flushBytes?: number } = {}
|
||||
): AsyncGenerator<string> {
|
||||
const flushBytes = options.flushBytes ?? DEFAULT_FLUSH_BYTES;
|
||||
|
||||
let buffer = format.header ? format.header(ctx) : "";
|
||||
|
||||
for await (const event of events) {
|
||||
buffer += format.formatEvent(event, ctx);
|
||||
if (buffer.length >= flushBytes) {
|
||||
yield buffer;
|
||||
buffer = "";
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
if (format.footer) {
|
||||
buffer += format.footer(ctx);
|
||||
}
|
||||
|
||||
if (buffer.length > 0) {
|
||||
yield buffer;
|
||||
}
|
||||
}
|
||||
|
||||
function hasProperties(propertiesText: string): boolean {
|
||||
const trimmed = propertiesText.trim();
|
||||
return trimmed.length > 0 && trimmed !== "{}";
|
||||
}
|
||||
|
||||
// For error events, pull the error message out of the properties so formats can
|
||||
// surface it inline instead of burying it in the JSON blob. Only parses when the
|
||||
// event is actually an error (rare), so the common path stays parse-free. Handles
|
||||
// both trigger.dev's `error.*` shape and OTel's `exception.*` shape; the full
|
||||
// object (incl. stacktrace) still rides along in the properties.
|
||||
function errorMessage(event: StreamedTraceEvent): string | undefined {
|
||||
if (!event.isError || !hasProperties(event.propertiesText)) return undefined;
|
||||
try {
|
||||
const props = JSON.parse(event.propertiesText) as {
|
||||
error?: { message?: unknown };
|
||||
exception?: { message?: unknown };
|
||||
};
|
||||
const message = props.error?.message ?? props.exception?.message;
|
||||
return typeof message === "string" && message.length > 0
|
||||
? message.replace(/\s+/g, " ").trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function lineage(event: StreamedTraceEvent): string {
|
||||
return event.parentSpanId ? `${event.spanId} ← ${event.parentSpanId}` : event.spanId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// log — flat, chronological, grep-friendly. One line per event (+ a props line).
|
||||
// ---------------------------------------------------------------------------
|
||||
const logFormat: TraceExportFormat = {
|
||||
name: "log",
|
||||
extension: "txt",
|
||||
formatEvent(event) {
|
||||
const time = event.startTime.toISOString();
|
||||
const level = event.level.padEnd(5);
|
||||
const errMsg = errorMessage(event);
|
||||
const status = event.isError ? (errMsg ? ` [ERROR: ${errMsg}]` : " [ERROR]") : "";
|
||||
const duration =
|
||||
event.durationNs > 0 ? ` (${formatDurationNanoseconds(event.durationNs)})` : "";
|
||||
|
||||
let out = `${time} ${level} [${lineage(event)}] ${event.message}${status}${duration}\n`;
|
||||
if (hasProperties(event.propertiesText)) {
|
||||
out += ` props: ${event.propertiesText.trim()}\n`;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsonl — one JSON object per line, properties inlined as a nested object.
|
||||
// ---------------------------------------------------------------------------
|
||||
const jsonlFormat: TraceExportFormat = {
|
||||
name: "jsonl",
|
||||
extension: "jsonl",
|
||||
formatEvent(event) {
|
||||
let properties: unknown = undefined;
|
||||
if (hasProperties(event.propertiesText)) {
|
||||
try {
|
||||
properties = JSON.parse(event.propertiesText);
|
||||
} catch {
|
||||
properties = event.propertiesText;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
JSON.stringify({
|
||||
time: event.startTime.toISOString(),
|
||||
level: event.level,
|
||||
spanId: event.spanId,
|
||||
parentSpanId: event.parentSpanId || undefined,
|
||||
message: event.message,
|
||||
durationNs: event.durationNs,
|
||||
isError: event.isError || undefined,
|
||||
errorMessage: errorMessage(event),
|
||||
properties,
|
||||
}) + "\n"
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// markdown — AI-friendly: YAML frontmatter (ids, task, dashboard URL) + a
|
||||
// scannable table, one row per event. Properties stay (inline code) so the
|
||||
// export isn't lossy; a column-friendly cell escaper keeps the table intact.
|
||||
// ---------------------------------------------------------------------------
|
||||
function mdCell(value: string): string {
|
||||
// Pipes and newlines would break the table row; escape/flatten them. (GFM
|
||||
// treats `\|` inside a table cell — including code spans — as a literal pipe.)
|
||||
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
const markdownFormat: TraceExportFormat = {
|
||||
name: "markdown",
|
||||
extension: "md",
|
||||
header(ctx) {
|
||||
const lines = ["---", `run: ${ctx.runFriendlyId}`, `trace: ${ctx.traceId}`];
|
||||
if (ctx.taskIdentifier) lines.push(`task: ${ctx.taskIdentifier}`);
|
||||
if (ctx.runUrl) lines.push(`url: ${ctx.runUrl}`);
|
||||
lines.push("---", "", `# Trace for ${ctx.runFriendlyId}`, "");
|
||||
if (ctx.runUrl) {
|
||||
lines.push(`[View in dashboard](${ctx.runUrl})`, "");
|
||||
}
|
||||
lines.push(
|
||||
"| time | level | event | duration | span ← parent | properties |",
|
||||
"| --- | --- | --- | --- | --- | --- |"
|
||||
);
|
||||
return lines.join("\n") + "\n";
|
||||
},
|
||||
formatEvent(event) {
|
||||
const time = event.startTime.toISOString();
|
||||
const level = event.isError ? `${event.level} ❌` : event.level;
|
||||
const duration = event.durationNs > 0 ? formatDurationNanoseconds(event.durationNs) : "—";
|
||||
const lineage = event.parentSpanId ? `${event.spanId} ← ${event.parentSpanId}` : event.spanId;
|
||||
const errMsg = errorMessage(event);
|
||||
const eventCell = errMsg ? `${event.message} — ${errMsg}` : event.message;
|
||||
const properties = hasProperties(event.propertiesText)
|
||||
? "`" + mdCell(event.propertiesText.trim()) + "`"
|
||||
: "—";
|
||||
|
||||
return `| ${time} | ${level} | ${mdCell(eventCell)} | ${duration} | \`${lineage}\` | ${properties} |\n`;
|
||||
},
|
||||
};
|
||||
|
||||
const FORMATS: Record<TraceExportFormatName, TraceExportFormat> = {
|
||||
log: logFormat,
|
||||
jsonl: jsonlFormat,
|
||||
markdown: markdownFormat,
|
||||
};
|
||||
|
||||
/** Resolve a `?format=` value to a format, defaulting to `log`. */
|
||||
export function getTraceExportFormat(name: string | null | undefined): TraceExportFormat {
|
||||
if (name && Object.prototype.hasOwnProperty.call(FORMATS, name)) {
|
||||
return FORMATS[name as TraceExportFormatName];
|
||||
}
|
||||
return logFormat;
|
||||
}
|
||||
Reference in New Issue
Block a user