9a7ad926aa
* WIP on using react-window-splitter
* WIP with new resizable panels and SSR
* Use the cookie package
* Resizable storybook page
* Increase indexing memory limit
* Fixed v2 usage meter displaying when on paid plan (#1255)
* Fixed v2 usage meter displaying when on paid plan
* Show the free usage panel only for v3 projects
* Concurrency page and more accurate tracking (#1252)
* Initial TaskRunConcurrencyTracker implementation
* MARQS calls a subscriber to events
* When enqueuing add the extra required metadata
* Track concurrency per environment for tasks too
* Admin page for global concurrency
* Use the new concurrency tracker on the tasks page
* Useful performance test task
* getAllTaskIdentifiers()
* New page for concurrency
* BackgroundWorkerTask index for quick lookup of task identifiers
* Added a way to get concurrency for environments
* Added upgrade/request more concurrency button
* Queued task column working
* Use defer and suspense
* Added queue column to the concurrency environments table
* Some comments added for clarity
* Fixed bad log message
* Sidemenu: move lower and rename to “Concurrency limits”
* Only show the environments, not tasks. Renamed to “Concurrency limits”
* v3: fix unfreezable state crashes for runs with multiple waits (#1253)
* support named capture groups
* write crash errors to attempt.error
* make restored pod names unique per checkpoint
* use last eight characters of checkpoint id instead
* add more chaos monkey env vars
* Ignore unfreezable states
* prevent excessive queue config parsing errors
* handle dependency resume edge case
* better entry point logging
* ignore checkpoint cancellation timeouts
* add missing idempotency keys to wait for dep replays
* remove checkpoints between attempts
* fix retry container names on kubernetes
* add changeset
* fix types
* bring back internal duration timers
* Added more logging to TaskRunConcurrencyTracker and some more try/catches
* Call subscriber.messageDequeued in dequeueMessageInSharedQueue
* Added messageReplaced to concurrency tracking (when freezing)
* Added depenenciesToBundle guide to bundle all packages
* Include the old message data when replacing, so we get the projectId etc.
* Fix restored container names
* Fix for schedule page not scrolling
* Added a description panel to the Concurrency admin page
* chore: Update version for release (beta) (#1256)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Release 3.0.0-beta.53
* Added a note to use batchTrigger() instead of trigger()
* The latest react-window-splitter fixes the ESM issues
* Set sensible defaults for the run page
* Deployments page
* Test page
* Schedules page
* Latest version of react-window-splitter (0.2.5)
* Updated to the latest version: react-window-splitter
* Callout if runs don’t start right away now has some top margin
* Small padding fix
* styled the handle focus state
* Added isStaticAtRest prop to resizable panel
* Updated resizable storybook
* Inline code blocks behave nicer when text wraps
* Added ElectricSQL to docker-compose, available on 3060
* Extracted some logic out of the eventRepository for getting a trace. This will be used on the frontend
* Use the new util
* More restructuring ready to use the trace summary from the frontend
* Using ElectricSQL for the run page data
* Min size for resizable panel on test page
* Don’t load the trace in the RunPresenter anymore
* Fix for the resizable panels on the run page
* Added overflow hidden to the panel group
* min size for the test page left hand panel
* Updated to latest window-splitter version
* Removed unused const
* One fix for client-server mismatch
* Slight improvement in the loading state
* Restructured the page so the loading is better
* Improvement to the loading states
* Improved the loading behaviour with the inspector
* WIP on auth, having problems with it
* Upgrade Remix to 2.9.1 (same as PR #1096)
* Switched structure around again so we only call the useTrace hook from the client
* Added auth to the sync
* Overscan more rows in the tree view
* Fix for TS error
* Remove duplicate import
* Revert "Upgrade Remix to 2.9.1 (same as PR #1096)"
This reverts commit e63ee9e924.
* save cookie only when id is used
* Deployment table now scrolls
* removed imports
* A lot of changes to make the inspector live too… WIP
* More major overhauls to get the synced version of the run page working…
* If a span is completed show that
* Set the debounce much lower for selecting the span view
* Load the details run inspector data on demand
* Delete the SpanPresenter
* Use the async payload because it deals with superjson
* Fixed weird merge conflict
* Share some inspector timeline components
* A couple of layout tweaks
* Improved the run inspector loading states
* Fix for paragaph errors
* Fix for focusing on a span
* Undefined typre for useSyncedShape
* ELECTRIC_ORIGIN env var doesn’t have a default, added to the examples
* Updated @electric-sql/react package to the latest
* Fix the timeline duration stretching
* Added some better error handling for the electric sync
* More logging
* Better error when there are bad responses
* Turn off resizable snapshots, there’s a bug
* Added getSpan back
* Added SpanPresenter back
* Updated to the new Electric hooks package
* Made a copy so we have the old run page and the new electric one
* Put the main eventRepository back for now
---------
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: James Ritchie <james@trigger.dev>
Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
575 lines
14 KiB
TypeScript
575 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 } 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 = {
|
|
recordId: event.id,
|
|
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,
|
|
},
|
|
};
|
|
|
|
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 = {
|
|
recordId: event.id,
|
|
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,
|
|
};
|
|
}
|