207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
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;
|
|
}
|