Files
Eric Allam e89fb92532 re2: dev runs work without worker groups, fixed some type issues (#1756)
* In dev, the worker group is optional when triggering tasks (the master queue is defined by the environment). Also deprecated the TaskEvent.isDebug column and using TaskEventKind.LOG instead for debug events

* Fixed a couple of type issues

* More type fixes
2025-03-05 16:29:41 +00:00

573 lines
14 KiB
TypeScript

import { Attributes, Link } from "@opentelemetry/api";
import {
correctErrorStackTrace,
ExceptionEventProperties,
isExceptionSpanEvent,
millisecondsToNanoseconds,
NULL_SENTINEL,
SemanticInternalAttributes,
SpanEvent,
SpanEvents,
SpanMessagingEvent,
TaskEventStyle,
unflattenAttributes,
} from "@trigger.dev/core/v3";
import { Prisma, TaskEvent, TaskEventKind } from "@trigger.dev/database";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import type {
PreparedEvent,
SpanLink,
SpanSummary,
TraceSummary,
} from "~/v3/eventRepository.server";
export type TraceSpan = NonNullable<ReturnType<typeof createSpanFromEvents>>;
export function prepareTrace(events: TaskEvent[]): TraceSummary | undefined {
let preparedEvents: Array<PreparedEvent> = [];
let rootSpanId: string | undefined;
const eventsBySpanId = new Map<string, PreparedEvent>();
for (const event of events) {
preparedEvents.push(prepareEvent(event));
if (!rootSpanId && !event.parentId) {
rootSpanId = event.spanId;
}
}
for (const event of preparedEvents) {
const existingEvent = eventsBySpanId.get(event.spanId);
if (!existingEvent) {
eventsBySpanId.set(event.spanId, event);
continue;
}
if (event.isCancelled || !event.isPartial) {
eventsBySpanId.set(event.spanId, event);
}
}
preparedEvents = Array.from(eventsBySpanId.values());
const spansBySpanId = new Map<string, SpanSummary>();
const spans = preparedEvents.map((event) => {
const ancestorCancelled = isAncestorCancelled(eventsBySpanId, event.spanId);
const duration = calculateDurationIfAncestorIsCancelled(
eventsBySpanId,
event.spanId,
event.duration
);
const span = {
id: event.spanId,
parentId: event.parentId ?? undefined,
runId: event.runId,
data: {
message: event.message,
style: event.style,
duration,
isError: event.isError,
isPartial: ancestorCancelled ? false : event.isPartial,
isCancelled: event.isCancelled === true ? true : event.isPartial && ancestorCancelled,
startTime: getDateFromNanoseconds(event.startTime),
level: event.level,
events: event.events,
environmentType: event.environmentType,
isDebug: event.kind === TaskEventKind.LOG,
},
} satisfies SpanSummary;
spansBySpanId.set(event.spanId, span);
return span;
});
if (!rootSpanId) {
return;
}
const rootSpan = spansBySpanId.get(rootSpanId);
if (!rootSpan) {
return;
}
return {
rootSpan,
spans,
};
}
export function createTraceTreeFromEvents(traceSummary: TraceSummary, spanId: string) {
//this tree starts at the passed in span (hides parent elements if there are any)
const tree = createTreeFromFlatItems(traceSummary.spans, spanId);
//we need the start offset for each item, and the total duration of the entire tree
const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0;
let totalDuration = tree?.data.duration ?? 0;
const events = tree
? flattenTree(tree).map((n) => {
const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs);
totalDuration = Math.max(totalDuration, offset + n.data.duration);
return {
...n,
data: {
...n.data,
//set partial nodes to null duration
duration: n.data.isPartial ? null : n.data.duration,
offset,
isRoot: n.id === traceSummary.rootSpan.id,
},
};
})
: [];
//total duration should be a minimum of 1ms
totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1));
let rootSpanStatus: "executing" | "completed" | "failed" = "executing";
if (events[0]) {
if (events[0].data.isError) {
rootSpanStatus = "failed";
} else if (!events[0].data.isPartial) {
rootSpanStatus = "completed";
}
}
return {
rootSpanStatus,
events: events,
parentRunFriendlyId:
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
};
}
export function createSpanFromEvents(events: TaskEvent[], spanId: string) {
const spanEvent = getSpanEvent(events, spanId);
if (!spanEvent) {
return;
}
const preparedEvent = prepareEvent(spanEvent);
const span = createSpanFromEvent(events, preparedEvent);
const output = rehydrateJson(spanEvent.output);
const payload = rehydrateJson(spanEvent.payload);
const show = rehydrateShow(spanEvent.properties);
const properties = sanitizedAttributes(spanEvent.properties);
const messagingEvent = SpanMessagingEvent.optional().safeParse((properties as any)?.messaging);
const links: SpanLink[] = [];
if (messagingEvent.success && messagingEvent.data) {
if (messagingEvent.data.message && "id" in messagingEvent.data.message) {
if (messagingEvent.data.message.id.startsWith("run_")) {
links.push({
type: "run",
icon: "runs",
title: `Run ${messagingEvent.data.message.id}`,
runId: messagingEvent.data.message.id,
});
}
}
}
const backLinks = spanEvent.links as any as Link[] | undefined;
if (backLinks && backLinks.length > 0) {
backLinks.forEach((l) => {
const title = String(l.attributes?.[SemanticInternalAttributes.LINK_TITLE] ?? "Triggered by");
links.push({
type: "span",
icon: "trigger",
title,
traceId: l.context.traceId,
spanId: l.context.spanId,
});
});
}
const spanEvents = transformEvents(
preparedEvent.events,
spanEvent.metadata as Attributes,
spanEvent.environmentType === "DEVELOPMENT"
);
return {
...spanEvent,
...span.data,
payload,
output,
events: spanEvents,
show,
links,
properties: properties ? JSON.stringify(properties, null, 2) : undefined,
showActionBar: show?.actions === true,
};
}
export function createSpanFromEvent(events: TaskEvent[], event: PreparedEvent) {
let ancestorCancelled = false;
let duration = event.duration;
if (!event.isCancelled && event.isPartial) {
walkSpanAncestors(events, event, (ancestorEvent, level) => {
if (level >= 8) {
return { stop: true };
}
if (ancestorEvent.isCancelled) {
ancestorCancelled = true;
// We need to get the cancellation time from the cancellation span event
const cancellationEvent = ancestorEvent.events.find(
(event) => event.name === "cancellation"
);
if (cancellationEvent) {
duration = calculateDurationFromStart(event.startTime, cancellationEvent.time);
}
return { stop: true };
}
return { stop: false };
});
}
const span = {
id: event.spanId,
parentId: event.parentId ?? undefined,
runId: event.runId,
idempotencyKey: event.idempotencyKey,
data: {
message: event.message,
style: event.style,
duration,
isError: event.isError,
isPartial: ancestorCancelled ? false : event.isPartial,
isCancelled: event.isCancelled === true ? true : event.isPartial && ancestorCancelled,
startTime: getDateFromNanoseconds(event.startTime),
level: event.level,
events: event.events,
environmentType: event.environmentType,
},
};
return span;
}
function walkSpanAncestors(
events: TaskEvent[],
event: PreparedEvent,
callback: (event: PreparedEvent, level: number) => { stop: boolean }
) {
const parentId = event.parentId;
if (!parentId) {
return;
}
let parentEvent = getSpanEvent(events, parentId);
let level = 1;
while (parentEvent) {
const preparedParentEvent = prepareEvent(parentEvent);
const result = callback(preparedParentEvent, level);
if (result.stop) {
return;
}
if (!preparedParentEvent.parentId) {
return;
}
parentEvent = getSpanEvent(events, preparedParentEvent.parentId);
level++;
}
}
function getSpanEvent(events: TaskEvent[], spanId: string) {
const spans = events.filter((e) => e.spanId === spanId);
const completedSpan = spans.find((s) => !s.isPartial);
if (completedSpan) {
return completedSpan;
}
return spans.at(0);
}
export function prepareEvent(event: TaskEvent): PreparedEvent {
return {
...event,
duration: Number(event.duration),
events: parseEventsField(event.events),
style: parseStyleField(event.style),
};
}
function parseEventsField(events: Prisma.JsonValue): SpanEvents {
const unsafe = events
? (events as any[]).map((e) => ({
...e,
properties: unflattenAttributes(e.properties as Attributes),
}))
: undefined;
return unsafe as SpanEvents;
}
function parseStyleField(style: Prisma.JsonValue): TaskEventStyle {
const unsafe = unflattenAttributes(style as Attributes);
if (!unsafe) {
return {};
}
if (typeof unsafe === "object") {
return Object.assign(
{
icon: undefined,
variant: undefined,
},
unsafe
) as TaskEventStyle;
}
return {};
}
export function isAncestorCancelled(events: Map<string, PreparedEvent>, spanId: string) {
const event = events.get(spanId);
if (!event) {
return false;
}
if (event.isCancelled) {
return true;
}
if (event.parentId) {
return isAncestorCancelled(events, event.parentId);
}
return false;
}
function calculateDurationIfAncestorIsCancelled(
events: Map<string, PreparedEvent>,
spanId: string,
defaultDuration: number
) {
const event = events.get(spanId);
if (!event) {
return defaultDuration;
}
if (event.isCancelled) {
return defaultDuration;
}
if (!event.isPartial) {
return defaultDuration;
}
if (event.parentId) {
const cancelledAncestor = findFirstCancelledAncestor(events, event.parentId);
if (cancelledAncestor) {
// We need to get the cancellation time from the cancellation span event
const cancellationEvent = cancelledAncestor.events.find(
(event) => event.name === "cancellation"
);
if (cancellationEvent) {
return calculateDurationFromStart(event.startTime, cancellationEvent.time);
}
}
}
return defaultDuration;
}
export function calculateDurationFromStart(startTime: bigint, endTime: Date = new Date()) {
const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime;
return Number(BigInt($endtime.getTime() * 1_000_000) - startTime);
}
function findFirstCancelledAncestor(events: Map<string, PreparedEvent>, spanId: string) {
const event = events.get(spanId);
if (!event) {
return;
}
if (event.isCancelled) {
return event;
}
if (event.parentId) {
return findFirstCancelledAncestor(events, event.parentId);
}
return;
}
export function getDateFromNanoseconds(nanoseconds: bigint) {
return new Date(Number(nanoseconds) / 1_000_000);
}
export function getNowInNanoseconds(): bigint {
return BigInt(new Date().getTime() * 1_000_000);
}
export function rehydrateJson(json: Prisma.JsonValue): any {
if (json === null) {
return undefined;
}
if (json === NULL_SENTINEL) {
return null;
}
if (typeof json === "string") {
return json;
}
if (typeof json === "number") {
return json;
}
if (typeof json === "boolean") {
return json;
}
if (Array.isArray(json)) {
return json.map((item) => rehydrateJson(item));
}
if (typeof json === "object") {
return unflattenAttributes(json as Attributes);
}
return null;
}
export function rehydrateShow(properties: Prisma.JsonValue): { actions?: boolean } | undefined {
if (properties === null || properties === undefined) {
return;
}
if (typeof properties !== "object") {
return;
}
if (Array.isArray(properties)) {
return;
}
const actions = properties[SemanticInternalAttributes.SHOW_ACTIONS];
if (typeof actions === "boolean") {
return { actions };
}
return;
}
export function sanitizedAttributes(json: Prisma.JsonValue) {
if (json === null || json === undefined) {
return;
}
const withoutPrivateProperties = removePrivateProperties(json as Attributes);
if (!withoutPrivateProperties) {
return;
}
return unflattenAttributes(withoutPrivateProperties);
}
// removes keys that start with a $ sign. If there are no keys left, return undefined
function removePrivateProperties(
attributes: Attributes | undefined | null
): Attributes | undefined {
if (!attributes) {
return undefined;
}
const result: Attributes = {};
for (const [key, value] of Object.entries(attributes)) {
if (key.startsWith("$")) {
continue;
}
result[key] = value;
}
if (Object.keys(result).length === 0) {
return undefined;
}
return result;
}
export function transformEvents(
events: SpanEvents,
properties: Attributes,
isDev: boolean
): SpanEvents {
return (events ?? []).map((event) => transformEvent(event, properties, isDev));
}
function transformEvent(event: SpanEvent, properties: Attributes, isDev: boolean): SpanEvent {
if (isExceptionSpanEvent(event)) {
return {
...event,
properties: {
exception: transformException(event.properties.exception, properties, isDev),
},
};
}
return event;
}
function transformException(
exception: ExceptionEventProperties,
properties: Attributes,
isDev: boolean
): ExceptionEventProperties {
const projectDirAttributeValue = properties[SemanticInternalAttributes.PROJECT_DIR];
if (projectDirAttributeValue !== undefined && typeof projectDirAttributeValue !== "string") {
return exception;
}
return {
...exception,
stacktrace: exception.stacktrace
? correctErrorStackTrace(exception.stacktrace, projectDirAttributeValue, {
removeFirstLine: true,
isDev,
})
: undefined,
};
}