diff --git a/apps/coordinator/src/index.ts b/apps/coordinator/src/index.ts index 01c66417dc..815012fe04 100644 --- a/apps/coordinator/src/index.ts +++ b/apps/coordinator/src/index.ts @@ -663,9 +663,25 @@ class TaskCoordinator { await chaosMonkey.call(); + const lazyPayload = { + ...lazyAttempt.lazyPayload, + metrics: [ + ...(message.startTime + ? [ + { + name: "start", + event: "lazy_payload", + timestamp: message.startTime, + duration: Date.now() - message.startTime, + }, + ] + : []), + ], + }; + socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", { version: "v1", - lazyPayload: lazyAttempt.lazyPayload, + lazyPayload, }); } catch (error) { if (error instanceof ChaosMonkey.Error) { diff --git a/apps/docker-provider/src/index.ts b/apps/docker-provider/src/index.ts index f411ac2ec7..b789b0d023 100644 --- a/apps/docker-provider/src/index.ts +++ b/apps/docker-provider/src/index.ts @@ -122,7 +122,7 @@ class DockerTaskOperations implements TaskOperations { `--env=POD_NAME=${containerName}`, `--env=COORDINATOR_HOST=${COORDINATOR_HOST}`, `--env=COORDINATOR_PORT=${COORDINATOR_PORT}`, - `--env=SCHEDULED_AT_MS=${Date.now()}`, + `--env=TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`, `--name=${containerName}`, ]; @@ -130,6 +130,10 @@ class DockerTaskOperations implements TaskOperations { runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`); } + if (opts.dequeuedAt) { + runArgs.push(`--env=TRIGGER_RUN_DEQUEUED_AT_MS=${opts.dequeuedAt}`); + } + runArgs.push(`${opts.image}`); try { diff --git a/apps/kubernetes-provider/src/index.ts b/apps/kubernetes-provider/src/index.ts index e2f0c04fc2..23a6ad56ce 100644 --- a/apps/kubernetes-provider/src/index.ts +++ b/apps/kubernetes-provider/src/index.ts @@ -202,6 +202,9 @@ class KubernetesTaskOperations implements TaskOperations { name: "TRIGGER_RUN_ID", value: opts.runId, }, + ...(opts.dequeuedAt + ? [{ name: "TRIGGER_RUN_DEQUEUED_AT_MS", value: String(opts.dequeuedAt) }] + : []), ], volumeMounts: [ { @@ -518,7 +521,7 @@ class KubernetesTaskOperations implements TaskOperations { }, }, { - name: "SCHEDULED_AT_MS", + name: "TRIGGER_POD_SCHEDULED_AT_MS", value: Date.now().toString(), }, ...this.#coordinatorEnvVars, diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index 26a4486bb5..beb347a337 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -7,7 +7,7 @@ const variants = { small: "grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap", "extra-small": - "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 border-tracking-wider text-xxs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 tracking-wide text-xxs bg-background-bright text-blue-500 whitespace-nowrap", outline: "grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap", "outline-rounded": diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index e64fdd4aa9..bef6281cc7 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -7,6 +7,7 @@ type DateTimeProps = { includeSeconds?: boolean; includeTime?: boolean; showTimezone?: boolean; + previousDate?: Date | string | null; // Add optional previous date for comparison }; export const DateTime = ({ @@ -70,20 +71,116 @@ export function formatDateTime( }).format(date); } -export const DateTimeAccurate = ({ date, timeZone = "UTC" }: DateTimeProps) => { +// New component that only shows date when it changes +export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => { const locales = useLocales(); - const realDate = typeof date === "string" ? new Date(date) : date; + const realPrevDate = previousDate + ? typeof previousDate === "string" + ? new Date(previousDate) + : previousDate + : null; + + // Initial formatted values + const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales); + const initialWithDate = formatSmartDateTime(realDate, timeZone, locales); + + // State for the formatted time + const [formattedDateTime, setFormattedDateTime] = useState( + realPrevDate && isSameDay(realDate, realPrevDate) ? initialTimeOnly : initialWithDate + ); + + useEffect(() => { + const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + const userTimeZone = resolvedOptions.timeZone; + + // Check if we should show the date + const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate); + + // Format with appropriate function + setFormattedDateTime( + showDatePart + ? formatSmartDateTime(realDate, userTimeZone, locales) + : formatTimeOnly(realDate, userTimeZone, locales) + ); + }, [locales, realDate, realPrevDate]); + + return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; +}; + +// Helper function to check if two dates are on the same day +function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +// Format with date and time +function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string { + return new Intl.DateTimeFormat(locales, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZone, + // @ts-ignore fractionalSecondDigits works in most modern browsers + fractionalSecondDigits: 3, + }).format(date); +} + +// Format time only +function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string { + return new Intl.DateTimeFormat(locales, { + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZone, + // @ts-ignore fractionalSecondDigits works in most modern browsers + fractionalSecondDigits: 3, + }).format(date); +} - const initialFormattedDateTime = formatDateTimeAccurate(realDate, timeZone, locales); +export const DateTimeAccurate = ({ + date, + timeZone = "UTC", + previousDate = null, +}: DateTimeProps) => { + const locales = useLocales(); + const realDate = typeof date === "string" ? new Date(date) : date; + const realPrevDate = previousDate + ? typeof previousDate === "string" + ? new Date(previousDate) + : previousDate + : null; + + // Use the new Smart formatting if previousDate is provided + const initialFormattedDateTime = realPrevDate + ? isSameDay(realDate, realPrevDate) + ? formatTimeOnly(realDate, timeZone, locales) + : formatDateTimeAccurate(realDate, timeZone, locales) + : formatDateTimeAccurate(realDate, timeZone, locales); const [formattedDateTime, setFormattedDateTime] = useState(initialFormattedDateTime); useEffect(() => { const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); - - setFormattedDateTime(formatDateTimeAccurate(realDate, resolvedOptions.timeZone, locales)); - }, [locales, realDate]); + const userTimeZone = resolvedOptions.timeZone; + + if (realPrevDate) { + // Smart formatting based on whether date changed + setFormattedDateTime( + isSameDay(realDate, realPrevDate) + ? formatTimeOnly(realDate, userTimeZone, locales) + : formatDateTimeAccurate(realDate, userTimeZone, locales) + ); + } else { + // Default behavior when no previous date + setFormattedDateTime(formatDateTimeAccurate(realDate, userTimeZone, locales)); + } + }, [locales, realDate, realPrevDate]); return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; }; @@ -96,7 +193,34 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]) minute: "numeric", second: "numeric", timeZone, - // @ts-ignore this works in 92.5% of browsers https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_datetimeformat_options_parameter_options_fractionalseconddigits_parameter + // @ts-ignore fractionalSecondDigits works in most modern browsers + fractionalSecondDigits: 3, + }).format(date); + + return formattedDateTime; +} + +export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => { + const locales = useLocales(); + const realDate = typeof date === "string" ? new Date(date) : date; + const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales); + const [formattedDateTime, setFormattedDateTime] = useState(initialFormattedDateTime); + + useEffect(() => { + const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales)); + }, [locales, realDate]); + + return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; +}; + +function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string { + const formattedDateTime = new Intl.DateTimeFormat(locales, { + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZone, + // @ts-ignore fractionalSecondDigits works in most modern browsers fractionalSecondDigits: 3, }).format(date); diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx new file mode 100644 index 0000000000..531e1187ed --- /dev/null +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -0,0 +1,869 @@ +import { ClockIcon } from "@heroicons/react/20/solid"; +import type { SpanEvent } from "@trigger.dev/core/v3"; +import { + formatDuration, + millisecondsToNanoseconds, + nanosecondsToMilliseconds, +} from "@trigger.dev/core/v3/utils/durations"; +import { Fragment, ReactNode, useState } from "react"; +import { cn } from "~/utils/cn"; +import { DateTime, DateTimeAccurate } from "../primitives/DateTime"; +import { LiveTimer } from "../runs/v3/LiveTimer"; +import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; + +// Types for the RunTimeline component +export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed"; + +type TimelineLineVariant = "light" | "normal"; + +type TimelineStyle = "normal" | "diminished"; + +type TimelineEventVariant = + | "start-cap" + | "dot-hollow" + | "dot-solid" + | "start-cap-thick" + | "end-cap-thick" + | "end-cap"; + +// Timeline item type definitions +export type TimelineEventDefinition = { + type: "event"; + id: string; + title: string; + date?: Date; + previousDate: Date | undefined; + state?: TimelineEventState; + shouldRender: boolean; + variant: TimelineEventVariant; + helpText?: string; +}; + +export type TimelineLineDefinition = { + type: "line"; + id: string; + title: React.ReactNode; + state?: TimelineEventState; + shouldRender: boolean; + variant: TimelineLineVariant; +}; + +export type TimelineItem = TimelineEventDefinition | TimelineLineDefinition; + +/** + * TimelineSpanRun represents the minimal set of run properties needed + * to render the RunTimeline component. + */ +export type TimelineSpanRun = { + // Core timestamps + createdAt: Date; // When the run was created/triggered + startedAt?: Date | null; // When the run was dequeued + executedAt?: Date | null; // When the run actually started executing + updatedAt: Date; // Last update timestamp (used for finish time) + expiredAt?: Date | null; // When the run expired (if applicable) + completedAt?: Date | null; // When the run completed + + // Delay information + delayUntil?: Date | null; // If the run is delayed, when it will be processed + ttl?: string | null; // Time-to-live value if applicable + + // Status flags + isFinished: boolean; // Whether the run has completed + isError: boolean; // Whether the run ended with an error +}; + +export function RunTimeline({ run }: { run: TimelineSpanRun }) { + // Build timeline items based on the run state + const timelineItems = buildTimelineItems(run); + + // Filter out items that shouldn't be rendered + const visibleItems = timelineItems.filter((item) => item.shouldRender); + + return ( +
+ {visibleItems.map((item) => { + if (item.type === "event") { + return ( + + ) : null + } + state={item.state as "complete" | "error"} + variant={item.variant} + helpText={item.helpText} + /> + ); + } else { + return ( + + ); + } + })} +
+ ); +} + +// Centralized function to build all timeline items +function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { + let state: TimelineEventState; + if (run.isError) { + state = "error"; + } else if (run.expiredAt) { + state = "error"; + } else if (run.isFinished) { + state = "complete"; + } else { + state = "inprogress"; + } + + const items: TimelineItem[] = []; + + // 1. Triggered Event + items.push({ + type: "event", + id: "triggered", + title: "Triggered", + date: run.createdAt, + previousDate: undefined, + state, + shouldRender: true, + variant: "start-cap", + helpText: getHelpTextForEvent("Triggered"), + }); + + // 2. Waiting to dequeue line + if (run.delayUntil && !run.startedAt && !run.expiredAt) { + // Delayed, not yet started + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: ( + + + + Delayed until {run.ttl && <>(TTL {run.ttl})} + + + ), + state, + shouldRender: true, + variant: "light", + }); + } else if (run.startedAt) { + // Already dequeued - show the waiting duration + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: formatDuration(run.createdAt, run.startedAt), + state, + shouldRender: true, + variant: "light", + }); + } else if (run.expiredAt) { + // Expired before dequeuing + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: formatDuration(run.createdAt, run.expiredAt), + state, + shouldRender: true, + variant: "light", + }); + } else { + // Still waiting to be dequeued + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: ( + <> + {" "} + {run.ttl && <>(TTL {run.ttl})} + + ), + state, + shouldRender: true, + variant: "light", + }); + } + + // 3. Dequeued Event (if applicable) + if (run.startedAt) { + items.push({ + type: "event", + id: "dequeued", + title: "Dequeued", + date: run.startedAt, + previousDate: run.createdAt, + state, + shouldRender: true, + variant: "dot-hollow", + helpText: getHelpTextForEvent("Dequeued"), + }); + } + + // 4. Handle the case based on whether executedAt exists + if (run.startedAt && !run.expiredAt) { + if (run.executedAt) { + // New behavior: Run has executedAt timestamp + + // 4a. Show waiting to execute line + items.push({ + type: "line", + id: "waiting-to-execute", + title: formatDuration(run.startedAt, run.executedAt), + state, + shouldRender: true, + variant: "light", + }); + + // 4b. Show Started event + items.push({ + type: "event", + id: "started", + title: "Started", + date: run.executedAt, + previousDate: run.startedAt, + state, + shouldRender: true, + variant: "start-cap-thick", + helpText: getHelpTextForEvent("Started"), + }); + + // 4c. Show executing line if applicable + if (run.isFinished) { + items.push({ + type: "line", + id: "executing", + title: formatDuration(run.executedAt, run.updatedAt), + state, + shouldRender: true, + variant: "normal", + }); + } else { + items.push({ + type: "line", + id: "executing", + title: , + state, + shouldRender: true, + variant: "normal", + }); + } + } else { + // Legacy behavior: Run doesn't have executedAt timestamp + + // If the run is finished, show a line directly from Dequeued to Finished + if (run.isFinished) { + items.push({ + type: "line", + id: "legacy-executing", + title: formatDuration(run.startedAt, run.updatedAt), + state, + shouldRender: true, + variant: "normal", + }); + } else { + // Still waiting to start or execute (can't distinguish without executedAt) + items.push({ + type: "line", + id: "legacy-waiting-or-executing", + title: , + state, + shouldRender: true, + variant: "light", + }); + } + } + } + + // 5. Finished Event (if applicable) + if (run.isFinished && !run.expiredAt) { + items.push({ + type: "event", + id: "finished", + title: "Finished", + date: run.updatedAt, + previousDate: run.executedAt ?? run.startedAt ?? undefined, + state, + shouldRender: true, + variant: "end-cap-thick", + helpText: getHelpTextForEvent("Finished"), + }); + } + + // 6. Expired Event (if applicable) + if (run.expiredAt) { + items.push({ + type: "event", + id: "expired", + title: "Expired", + date: run.expiredAt, + previousDate: run.createdAt, + state: "error", + shouldRender: true, + variant: "dot-solid", + helpText: getHelpTextForEvent("Expired"), + }); + } + + return items; +} + +export type RunTimelineEventProps = { + title: ReactNode; + subtitle?: ReactNode; + state?: "complete" | "error" | "inprogress"; + variant?: TimelineEventVariant; + helpText?: string; + style?: TimelineStyle; +}; + +export function RunTimelineEvent({ + title, + subtitle, + state, + variant = "dot-hollow", + helpText, + style = "normal", +}: RunTimelineEventProps) { + return ( +
+
+ +
+
+ + + + {title} + + {helpText && ( + + {helpText} + + )} + + + {subtitle ? ( + {subtitle} + ) : null} +
+
+ ); +} + +function EventMarker({ + variant, + state, + style, +}: { + variant: TimelineEventVariant; + state?: TimelineEventState; + style?: TimelineStyle; +}) { + let bgClass = "bg-text-dimmed"; + switch (state) { + case "complete": + bgClass = "bg-success"; + break; + case "error": + bgClass = "bg-error"; + break; + case "delayed": + bgClass = "bg-text-dimmed"; + break; + case "inprogress": + bgClass = style === "normal" ? "bg-pending" : "bg-text-dimmed"; + break; + } + + let borderClass = "border-text-dimmed"; + switch (state) { + case "complete": + borderClass = "border-success"; + break; + case "error": + borderClass = "border-error"; + break; + case "delayed": + borderClass = "border-text-dimmed"; + break; + case "inprogress": + borderClass = style === "normal" ? "border-pending" : "border-text-dimmed"; + break; + default: + borderClass = "border-text-dimmed"; + break; + } + + switch (variant) { + case "start-cap": + return ( + <> +
+
+ {state === "inprogress" && ( +
+ )} +
+ + ); + case "dot-hollow": + return ( + <> +
+ {state === "inprogress" && ( +
+ )} +
+
+
+ {state === "inprogress" && ( +
+ )} +
+ + ); + case "dot-solid": + return
; + case "start-cap-thick": + return ( +
+ {state === "inprogress" && ( +
+ )} +
+ ); + case "end-cap-thick": + return
; + default: + return
; + } +} + +export type RunTimelineLineProps = { + title: ReactNode; + state?: TimelineEventState; + variant?: TimelineLineVariant; + style?: TimelineStyle; +}; + +export function RunTimelineLine({ + title, + state, + variant = "normal", + style = "normal", +}: RunTimelineLineProps) { + return ( +
+
+ +
+
+ {title} +
+
+ ); +} + +function LineMarker({ + state, + variant, + style, +}: { + state?: TimelineEventState; + variant: TimelineLineVariant; + style?: TimelineStyle; +}) { + let containerClass = "bg-text-dimmed"; + switch (state) { + case "complete": + containerClass = "bg-success"; + break; + case "error": + containerClass = "bg-error"; + break; + case "delayed": + containerClass = "bg-text-dimmed"; + break; + case "inprogress": + containerClass = + style === "normal" + ? "rounded-b-[0.125rem] bg-pending" + : "rounded-b-[0.125rem] bg-text-dimmed"; + break; + } + + switch (variant) { + case "normal": + return ( +
+ {state === "inprogress" && ( +
+ )} +
+ ); + case "light": + return ( +
+ {state === "inprogress" && ( +
+ )} +
+ ); + default: + return
; + } +} + +export type SpanTimelineProps = { + startTime: Date; + duration: number; + inProgress: boolean; + isError: boolean; + events?: TimelineSpanEvent[]; + style?: TimelineStyle; +}; + +export type SpanTimelineState = "error" | "pending" | "complete"; + +export function SpanTimeline({ + startTime, + duration, + inProgress, + isError, + events, + style = "diminished", +}: SpanTimelineProps) { + const state = isError ? "error" : inProgress ? "inprogress" : undefined; + + const visibleEvents = events ?? []; + + return ( + <> +
+ {visibleEvents.map((event, index) => { + // Store previous date to compare + const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp; + + return ( + + } + variant={event.markerVariant} + state={state} + helpText={event.helpText} + style={style} + /> + + + ); + })} + 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null + } + /> + } + variant={"start-cap-thick"} + state={state} + helpText={getHelpTextForEvent("Started")} + style={style} + /> + {state === "inprogress" ? ( + } + state={state} + variant="normal" + style={style} + /> + ) : ( + <> + + + } + state={isError ? "error" : undefined} + variant="end-cap-thick" + helpText={getHelpTextForEvent("Finished")} + style={style} + /> + + )} +
+ + ); +} + +export type TimelineSpanEvent = { + name: string; + offset: number; + timestamp: Date; + duration?: number; + helpText?: string; + markerVariant: TimelineEventVariant; + lineVariant: TimelineLineVariant; +}; + +export function createTimelineSpanEventsFromSpanEvents( + spanEvents: SpanEvent[], + isAdmin: boolean, + relativeStartTime?: number +): Array { + // Rest of function remains the same + if (!spanEvents) { + return []; + } + + const matchingSpanEvents = spanEvents.filter((spanEvent) => + spanEvent.name.startsWith("trigger.dev/") + ); + + if (matchingSpanEvents.length === 0) { + return []; + } + + const sortedSpanEvents = [...matchingSpanEvents].sort((a, b) => { + if (a.time === b.time) { + return a.name.localeCompare(b.name); + } + + const aTime = typeof a.time === "string" ? new Date(a.time) : a.time; + const bTime = typeof b.time === "string" ? new Date(b.time) : b.time; + + return aTime.getTime() - bTime.getTime(); + }); + + const visibleSpanEvents = sortedSpanEvents.filter( + (spanEvent) => + isAdmin || + !getAdminOnlyForEvent( + "event" in spanEvent.properties && typeof spanEvent.properties.event === "string" + ? spanEvent.properties.event + : spanEvent.name + ) + ); + + if (visibleSpanEvents.length === 0) { + return []; + } + + const firstEventTime = + typeof visibleSpanEvents[0].time === "string" + ? new Date(visibleSpanEvents[0].time) + : visibleSpanEvents[0].time; + + const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime(); + + const events = visibleSpanEvents.map((spanEvent, index) => { + const timestamp = + typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time; + + const offset = millisecondsToNanoseconds(timestamp.getTime() - $relativeStartTime); + + const duration = + "duration" in spanEvent.properties && typeof spanEvent.properties.duration === "number" + ? spanEvent.properties.duration + : undefined; + + const name = + "event" in spanEvent.properties && typeof spanEvent.properties.event === "string" + ? spanEvent.properties.event + : spanEvent.name; + + let markerVariant: TimelineEventVariant = "dot-hollow"; + + if (index === 0) { + markerVariant = "start-cap"; + } + + return { + name: getFriendlyNameForEvent(name), + offset, + timestamp, + duration, + properties: spanEvent.properties, + helpText: getHelpTextForEvent(name), + markerVariant, + lineVariant: "light" as const, + }; + }); + + // Now sort by offset, ascending + events.sort((a, b) => a.offset - b.offset); + + return events; +} + +function getFriendlyNameForEvent(event: string): string { + switch (event) { + case "dequeue": { + return "Dequeued"; + } + case "fork": { + return "Launched"; + } + case "create_attempt": { + return "Attempt created"; + } + case "import": { + return "Imported task file"; + } + case "lazy_payload": { + return "Lazy attempt initialized"; + } + case "pod_scheduled": { + return "Pod scheduled"; + } + default: { + return event; + } + } +} + +function getAdminOnlyForEvent(event: string): boolean { + switch (event) { + case "dequeue": { + return false; + } + case "fork": { + return false; + } + case "create_attempt": { + return true; + } + case "import": { + return true; + } + case "lazy_payload": { + return true; + } + case "pod_scheduled": { + return true; + } + default: { + return true; + } + } +} + +function getHelpTextForEvent(event: string): string | undefined { + switch (event) { + case "dequeue": { + return "The run was dequeued from the queue"; + } + case "fork": { + return "The process was created to run the task"; + } + case "create_attempt": { + return "An attempt was created for the run"; + } + case "import": { + return "A task file was imported"; + } + case "lazy_payload": { + return "The payload was initialized lazily"; + } + case "pod_scheduled": { + return "The Kubernetes pod was scheduled to run"; + } + case "Triggered": { + return "The run was triggered"; + } + case "Dequeued": { + return "The run was dequeued from the queue"; + } + case "Started": { + return "The run began executing"; + } + case "Finished": { + return "The run completed execution"; + } + case "Expired": { + return "The run expired before it could be started"; + } + default: { + return undefined; + } + } +} diff --git a/apps/webapp/app/components/runs/v3/InspectorTimeline.tsx b/apps/webapp/app/components/runs/v3/InspectorTimeline.tsx deleted file mode 100644 index 1d574ff6a0..0000000000 --- a/apps/webapp/app/components/runs/v3/InspectorTimeline.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ReactNode } from "react"; -import { cn } from "~/utils/cn"; - -type RunTimelineItemProps = { - title: ReactNode; - subtitle?: ReactNode; - state: "complete" | "error"; -}; - -export function RunTimelineEvent({ title, subtitle, state }: RunTimelineItemProps) { - return ( -
-
-
-
-
- {title} - {subtitle ? {subtitle} : null} -
-
- ); -} - -type RunTimelineLineProps = { - title: ReactNode; - state: "complete" | "delayed" | "inprogress"; -}; - -export function RunTimelineLine({ title, state }: RunTimelineLineProps) { - return ( -
-
-
-
-
- {title} -
-
- ); -} diff --git a/apps/webapp/app/components/runs/v3/RunInspector.tsx b/apps/webapp/app/components/runs/v3/RunInspector.tsx deleted file mode 100644 index 67282f123a..0000000000 --- a/apps/webapp/app/components/runs/v3/RunInspector.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid"; -import { Link } from "@remix-run/react"; -import { - formatDuration, - formatDurationMilliseconds, - TaskRunError, - taskRunErrorEnhancer, -} from "@trigger.dev/core/v3"; -import { useEffect } from "react"; -import { useTypedFetcher } from "remix-typedjson"; -import { ExitIcon } from "~/assets/icons/ExitIcon"; -import { CodeBlock, TitleRow } from "~/components/code/CodeBlock"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; -import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime"; -import { Header2, Header3 } from "~/components/primitives/Headers"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import * as Property from "~/components/primitives/PropertyTable"; -import { Spinner } from "~/components/primitives/Spinner"; -import { TabButton, TabContainer } from "~/components/primitives/Tabs"; -import { TextLink } from "~/components/primitives/TextLink"; -import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; -import { LiveTimer } from "~/components/runs/v3/LiveTimer"; -import { RunIcon } from "~/components/runs/v3/RunIcon"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useSearchParams } from "~/hooks/useSearchParam"; -import { RawRun } from "~/hooks/useSyncTraceRuns"; -import { loader } from "~/routes/resources.runs.$runParam"; -import { cn } from "~/utils/cn"; -import { formatCurrencyAccurate } from "~/utils/numberFormatter"; -import { - v3RunDownloadLogsPath, - v3RunSpanPath, - v3RunsPath, - v3SchedulePath, -} from "~/utils/pathBuilder"; -import { TraceSpan } from "~/utils/taskEvent"; -import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; -import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline"; -import { RunTag } from "./RunTag"; -import { TaskRunStatusCombo } from "./TaskRunStatus"; - -/** - * The RunInspector displays live information about a run. - * Most of that data comes in as params but for some we need to fetch it. - */ -export function RunInspector({ - run, - span, - runParam, - closePanel, -}: { - run?: RawRun; - span?: TraceSpan; - runParam: string; - closePanel?: () => void; -}) { - const organization = useOrganization(); - const project = useProject(); - const { value, replace } = useSearchParams(); - const tab = value("tab"); - - const fetcher = useTypedFetcher(); - - useEffect(() => { - if (run?.friendlyId === undefined) return; - fetcher.load(`/resources/runs/${run.friendlyId}`); - }, [run?.friendlyId, run?.updatedAt]); - - if (!run) { - return ( -
-
-
- - - - -
- {closePanel && ( -
-
-
- ); - } - - const environment = project.environments.find((e) => e.id === run.runtimeEnvironmentId); - const clientRunData = fetcher.state === "idle" ? fetcher.data : undefined; - - return ( -
-
-
- - - {run.taskIdentifier} - -
- {closePanel && ( -
-
- - { - replace({ tab: "overview" }); - }} - shortcut={{ key: "o" }} - > - Overview - - { - replace({ tab: "detail" }); - }} - shortcut={{ key: "d" }} - > - Detail - - { - replace({ tab: "context" }); - }} - shortcut={{ key: "c" }} - > - Context - - -
-
-
- {tab === "detail" ? ( -
- - - Status - - {run ? : } - - - - Task - - - {run.taskIdentifier} - - } - content={`Filter runs by ${run.taskIdentifier}`} - /> - - - - Version - - {clientRunData ? ( - clientRunData?.version ? ( - clientRunData.version - ) : ( - - Never started - - - ) - ) : ( - - )} - - - - SDK version - - {clientRunData ? ( - clientRunData?.sdkVersion ? ( - clientRunData.sdkVersion - ) : ( - - Never started - - - ) - ) : ( - - )} - - - - Test run - - {run.isTest ? : "–"} - - - {environment && ( - - Environment - - - - - )} - - - Schedule - - {clientRunData ? ( - clientRunData.schedule ? ( -
-
- - {clientRunData.schedule.generatorExpression} - - ({clientRunData.schedule.timezone}) -
- - {clientRunData.schedule.description} - - } - content={`Go to schedule ${clientRunData.schedule.friendlyId}`} - /> -
- ) : ( - "No schedule" - ) - ) : ( - - )} -
-
- - Queue - - {clientRunData ? ( - <> -
Name: {clientRunData.queue.name}
-
- Concurrency key:{" "} - {clientRunData.queue.concurrencyKey - ? clientRunData.queue.concurrencyKey - : "–"} -
- - ) : ( - - )} -
-
- - Time to live (TTL) - {run.ttl ?? "–"} - - - Tags - - {clientRunData ? ( - clientRunData.tags.length === 0 ? ( - "–" - ) : ( -
- {clientRunData.tags.map((tag) => ( - - - - } - content={`Filter runs by ${tag}`} - /> - ))} -
- ) - ) : ( - - )} -
-
- - Max duration - - {run.maxDurationInSeconds ? `${run.maxDurationInSeconds}s` : "–"} - - - - Run invocation cost - - {run.baseCostInCents > 0 - ? formatCurrencyAccurate(run.baseCostInCents / 100) - : "–"} - - - - Compute cost - - {run.costInCents > 0 ? formatCurrencyAccurate(run.costInCents / 100) : "–"} - - - - Total cost - - {run.costInCents > 0 - ? formatCurrencyAccurate((run.baseCostInCents + run.costInCents) / 100) - : "–"} - - - - Usage duration - - {run.usageDurationMs > 0 - ? formatDurationMilliseconds(run.usageDurationMs, { style: "short" }) - : "–"} - - - - Run ID - {run.id} - -
-
- ) : tab === "context" ? ( -
- {clientRunData ? ( - - ) : ( -
- - Context loading… - - - } - /> -
- )} -
- ) : ( -
-
- -
- - <> - {clientRunData ? ( - <> - {clientRunData.payload !== undefined && ( - - )} - {clientRunData.error !== undefined ? ( - - ) : clientRunData.output !== undefined ? ( - - ) : null} - - ) : ( -
- - Payload loading… - - - } - /> -
- )} - -
- )} -
-
-
-
- {run.friendlyId !== runParam && ( - - Focus on run - - )} -
-
- {run.logsDeletedAt === null ? ( - - Download logs - - ) : null} -
-
-
- ); -} - -function PropertyLoading() { - return ; -} - -function RunTimeline({ run }: { run: RawRun }) { - const createdAt = new Date(run.createdAt); - const startedAt = run.startedAt ? new Date(run.startedAt) : null; - const delayUntil = run.delayUntil ? new Date(run.delayUntil) : null; - const expiredAt = run.expiredAt ? new Date(run.expiredAt) : null; - const updatedAt = new Date(run.updatedAt); - - const isFinished = isFinalRunStatus(run.status); - const isError = isFailedRunStatus(run.status); - - return ( -
- } - state="complete" - /> - {delayUntil && !expiredAt ? ( - {formatDuration(createdAt, delayUntil)} delay - ) : ( - - - - Delayed until {run.ttl && <>(TTL {run.ttl})} - - - ) - } - state={run.startedAt ? "complete" : "delayed"} - /> - ) : startedAt ? ( - - ) : ( - - {" "} - {run.ttl && <>(TTL {run.ttl})} - - } - state={run.startedAt || run.expiredAt ? "complete" : "inprogress"} - /> - )} - {expiredAt ? ( - } - state="error" - /> - ) : startedAt ? ( - <> - } - state="complete" - /> - {isFinished ? ( - <> - - } - state={isError ? "error" : "complete"} - /> - - ) : ( - - - - - - - } - state={"inprogress"} - /> - )} - - ) : null} -
- ); -} - -function RunError({ error }: { error: TaskRunError }) { - const enhancedError = taskRunErrorEnhancer(error); - - switch (enhancedError.type) { - case "STRING_ERROR": - case "CUSTOM_ERROR": { - return ( -
- -
- ); - } - case "BUILT_IN_ERROR": - case "INTERNAL_ERROR": { - const name = "name" in enhancedError ? enhancedError.name : enhancedError.code; - return ( -
- {name} - {enhancedError.message && {enhancedError.message}} - {enhancedError.link && ( - - {enhancedError.link.name} - - )} - {enhancedError.stackTrace && ( - - )} -
- ); - } - } -} - -function PacketDisplay({ - data, - dataType, - title, -}: { - data: string; - dataType: string; - title: string; -}) { - switch (dataType) { - case "application/store": { - return ( -
- - {title} - - - Download - -
- ); - } - case "text/plain": { - return ( - - ); - } - default: { - return ( - - ); - } - } -} diff --git a/apps/webapp/app/components/runs/v3/SpanEvents.tsx b/apps/webapp/app/components/runs/v3/SpanEvents.tsx index 868ffde50b..679cf579ae 100644 --- a/apps/webapp/app/components/runs/v3/SpanEvents.tsx +++ b/apps/webapp/app/components/runs/v3/SpanEvents.tsx @@ -18,9 +18,15 @@ type SpanEventsProps = { }; export function SpanEvents({ spanEvents }: SpanEventsProps) { + const displayableEvents = spanEvents.filter((event) => !event.name.startsWith("trigger.dev/")); + + if (displayableEvents.length === 0) { + return null; + } + return (
- {spanEvents.map((event, index) => ( + {displayableEvents.map((event, index) => ( ))}
diff --git a/apps/webapp/app/components/runs/v3/SpanInspector.tsx b/apps/webapp/app/components/runs/v3/SpanInspector.tsx deleted file mode 100644 index 1e7efe63e6..0000000000 --- a/apps/webapp/app/components/runs/v3/SpanInspector.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { formatDuration, nanosecondsToMilliseconds } from "@trigger.dev/core/v3"; -import { ExitIcon } from "~/assets/icons/ExitIcon"; -import { CodeBlock } from "~/components/code/CodeBlock"; -import { Button } from "~/components/primitives/Buttons"; -import { DateTimeAccurate } from "~/components/primitives/DateTime"; -import { Header2 } from "~/components/primitives/Headers"; -import * as Property from "~/components/primitives/PropertyTable"; -import { Spinner } from "~/components/primitives/Spinner"; -import { TabButton, TabContainer } from "~/components/primitives/Tabs"; -import { TextLink } from "~/components/primitives/TextLink"; -import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; -import { RunIcon } from "~/components/runs/v3/RunIcon"; -import { SpanEvents } from "~/components/runs/v3/SpanEvents"; -import { SpanTitle } from "~/components/runs/v3/SpanTitle"; -import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useSearchParams } from "~/hooks/useSearchParam"; -import { cn } from "~/utils/cn"; -import { v3RunsPath } from "~/utils/pathBuilder"; -import { TraceSpan } from "~/utils/taskEvent"; -import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline"; -import { LiveTimer } from "./LiveTimer"; - -export function SpanInspector({ - span, - runParam, - closePanel, -}: { - span?: TraceSpan; - runParam?: string; - closePanel?: () => void; -}) { - const organization = useOrganization(); - const project = useProject(); - const { value, replace } = useSearchParams(); - let tab = value("tab"); - - if (tab === "context") { - tab = "overview"; - } - - if (span === undefined) { - return null; - } - - return ( -
-
-
- - - - -
- {runParam && closePanel && ( -
-
- - { - replace({ tab: "overview" }); - }} - shortcut={{ key: "o" }} - > - Overview - - { - replace({ tab: "detail" }); - }} - shortcut={{ key: "d" }} - > - Detail - - -
-
-
- {tab === "detail" ? ( -
- - - Status - - - - - - Task - - - {span.taskSlug} - - } - content={`Filter runs by ${span.taskSlug}`} - /> - - - {span.idempotencyKey && ( - - Idempotency key - {span.idempotencyKey} - - )} - - Version - - {span.workerVersion ? ( - span.workerVersion - ) : ( - - Never started - - - )} - - - -
- ) : ( -
- {span.level === "TRACE" ? ( - <> -
- -
- - - ) : ( -
- } - state="complete" - /> -
- )} - - - Message - {span.message} - - - - {span.events !== undefined && } - {span.properties !== undefined && ( - - )} -
- )} -
-
-
- ); -} - -type TimelineProps = { - startTime: Date; - duration: number; - inProgress: boolean; - isError: boolean; -}; - -export function SpanTimeline({ startTime, duration, inProgress, isError }: TimelineProps) { - const state = isError ? "error" : inProgress ? "pending" : "complete"; - return ( - <> -
- } - state="complete" - /> - {state === "pending" ? ( - - - - - - - } - state={"inprogress"} - /> - ) : ( - <> - - - } - state={isError ? "error" : "complete"} - /> - - )} -
- - ); -} diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index 7e36652df8..6d7df431c0 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -75,7 +75,7 @@ export function SpanCodePathAccessory({ {item.text} {index < accessory.items.length - 1 && ( - + )} @@ -144,6 +144,36 @@ export function eventBackgroundClassName(event: RunEvent) { } } +export function eventBorderClassName(event: RunEvent) { + if (event.isError) { + return "border-error"; + } + + if (event.isCancelled) { + return "border-charcoal-600"; + } + + switch (event.level) { + case "TRACE": { + return borderClassNameForVariant(event.style.variant, event.isPartial); + } + case "LOG": + case "INFO": + case "DEBUG": { + return borderClassNameForVariant(event.style.variant, event.isPartial); + } + case "WARN": { + return "border-amber-400"; + } + case "ERROR": { + return "border-error"; + } + default: { + return borderClassNameForVariant(event.style.variant, event.isPartial); + } + } +} + function textClassNameForVariant(variant: TaskEventStyle["variant"]) { switch (variant) { case "primary": { @@ -168,3 +198,17 @@ function backgroundClassNameForVariant(variant: TaskEventStyle["variant"], isPar } } } + +function borderClassNameForVariant(variant: TaskEventStyle["variant"], isPartial: boolean) { + switch (variant) { + case "primary": { + if (isPartial) { + return "border-blue-500"; + } + return "border-success"; + } + default: { + return "border-charcoal-500"; + } + } +} diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts index e1ee341901..e31455cf92 100644 --- a/apps/webapp/app/hooks/useUser.ts +++ b/apps/webapp/app/hooks/useUser.ts @@ -3,6 +3,7 @@ import type { User } from "~/models/user.server"; import { loader } from "~/root"; import { useChanged } from "./useChanged"; import { useTypedMatchesData } from "./useTypedMatchData"; +import { useIsImpersonating } from "./useOrganizations"; export function useOptionalUser(matches?: UIMatch[]): User | undefined { const routeMatch = useTypedMatchesData({ @@ -29,6 +30,7 @@ export function useUserChanged(callback: (user: User | undefined) => void) { export function useHasAdminAccess(matches?: UIMatch[]): boolean { const user = useOptionalUser(matches); + const isImpersonating = useIsImpersonating(matches); - return Boolean(user?.admin); + return Boolean(user?.admin) || isImpersonating; } diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 347ea49af0..5fe820c973 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -1,6 +1,7 @@ import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView"; -import { PrismaClient, prisma } from "~/db.server"; +import { createTimelineSpanEventsFromSpanEvents } from "~/components/run/RunTimeline"; +import { prisma, PrismaClient } from "~/db.server"; import { getUsername } from "~/utils/username"; import { eventRepository } from "~/v3/eventRepository.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -122,6 +123,15 @@ export class RunPresenter { }; } + const user = await this.#prismaClient.user.findFirst({ + where: { + id: userId, + }, + select: { + admin: true, + }, + }); + //this tree starts at the passed in span (hides parent elements if there are any) const tree = createTreeFromFlatItems(traceSummary.spans, run.spanId); @@ -138,6 +148,11 @@ export class RunPresenter { ...n, data: { ...n.data, + timelineEvents: createTimelineSpanEventsFromSpanEvents( + n.data.events, + user?.admin ?? false, + treeRootStartTimeMs + ), //set partial nodes to null duration duration: n.data.isPartial ? null : n.data.duration, offset, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 0f6bb9de4b..9ea10cac72 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -40,7 +40,7 @@ export class SpanPresenter extends BasePresenter { throw new Error("Project not found"); } - const run = await this.getRun(spanId); + const run = await this.#getRun(spanId); if (run) { return { type: "run" as const, @@ -49,7 +49,7 @@ export class SpanPresenter extends BasePresenter { } //get the run - const span = await this.getSpan(runFriendlyId, spanId); + const span = await this.#getSpan(runFriendlyId, spanId); if (!span) { throw new Error("Span not found"); @@ -61,7 +61,7 @@ export class SpanPresenter extends BasePresenter { }; } - async getRun(spanId: string) { + async #getRun(spanId: string) { const run = await this._replica.taskRun.findFirst({ select: { id: true, @@ -88,6 +88,7 @@ export class SpanPresenter extends BasePresenter { //status + duration status: true, startedAt: true, + executedAt: true, createdAt: true, updatedAt: true, queuedAt: true, @@ -193,14 +194,6 @@ export class SpanPresenter extends BasePresenter { } } - const span = await eventRepository.getSpan( - getTaskEventStoreTableForRun(run), - spanId, - run.traceId, - run.rootTaskRun?.createdAt ?? run.createdAt, - run.completedAt ?? undefined - ); - const metadata = run.metadata ? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"], @@ -257,6 +250,7 @@ export class SpanPresenter extends BasePresenter { status: run.status, createdAt: run.createdAt, startedAt: run.startedAt, + executedAt: run.executedAt, updatedAt: run.updatedAt, delayUntil: run.delayUntil, expiredAt: run.expiredAt, @@ -332,7 +326,7 @@ export class SpanPresenter extends BasePresenter { }; } - async getSpan(runFriendlyId: string, spanId: string) { + async #getSpan(runFriendlyId: string, spanId: string) { const run = await this._prisma.taskRun.findFirst({ select: { traceId: true, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx index cf368c8473..8813459477 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx @@ -31,6 +31,7 @@ import { PageBody } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { DateTimeShort } from "~/components/primitives/DateTime"; import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Header3 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; @@ -54,7 +55,11 @@ import { NodesState } from "~/components/primitives/TreeView/reducer"; import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog"; import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog"; import { RunIcon } from "~/components/runs/v3/RunIcon"; -import { SpanTitle, eventBackgroundClassName } from "~/components/runs/v3/SpanTitle"; +import { + SpanTitle, + eventBackgroundClassName, + eventBorderClassName, +} from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; import { env } from "~/env.server"; import { useDebounce } from "~/hooks/useDebounce"; @@ -64,7 +69,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams"; import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { useUser } from "~/hooks/useUser"; +import { useHasAdminAccess, useUser } from "~/hooks/useUser"; import { RunPresenter } from "~/presenters/v3/RunPresenter.server"; import { getImpersonationId } from "~/services/impersonation.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; @@ -740,6 +745,7 @@ function TimelineView({ showDurations, treeScrollRef, }: TimelineViewProps) { + const isAdmin = useHasAdminAccess(); const timelineContainerRef = useRef(null); const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); const minTimelineWidth = initialTimelineDimensions?.width ?? 300; @@ -773,7 +779,7 @@ function TimelineView({ maxWidth={maxTimelineWidth} > {/* Follows the cursor */} - + {/* The duration labels */} @@ -888,22 +894,74 @@ function TimelineView({ }} > {node.data.level === "TRACE" ? ( - + <> + {/* Add a span for the line, Make the vertical line the first one with 1px wide, and full height */} + {node.data.timelineEvents.map((event, eventIndex) => + eventIndex === 0 ? ( + + {(ms) => ( + + )} + + ) : ( + + {(ms) => ( + + )} + + ) + )} + {node.data.timelineEvents && + node.data.timelineEvents[0] && + node.data.timelineEvents[0].offset < node.data.offset ? ( + + + + ) : null} + + ) : ( {(ms) => ( + - + ); } @@ -1079,7 +1137,7 @@ function SpanWithDuration({ {(ms) => { @@ -1122,6 +1186,9 @@ function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) { offset = lerp(0.5, 1, (ratio - (1 - edgeBoundary)) / edgeBoundary); } + const currentTime = rootStartedAt ? new Date(rootStartedAt.getTime() + ms) : undefined; + const currentTimeComponent = currentTime ? : <>; + return (
@@ -1132,10 +1199,23 @@ function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) { transform: `translateX(-${offset * 100}%)`, }} > - {formatDurationMilliseconds(ms, { - style: "short", - maxDecimalPoints: ms < 1000 ? 0 : 1, - })} + {currentTimeComponent ? ( + + {formatDurationMilliseconds(ms, { + style: "short", + maxDecimalPoints: ms < 1000 ? 0 : 1, + })} + + {currentTimeComponent} + + ) : ( + <> + {formatDurationMilliseconds(ms, { + style: "short", + maxDecimalPoints: ms < 1000 ? 0 : 1, + })} + + )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.electric.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.electric.$runParam/route.tsx deleted file mode 100644 index a2055fc78b..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.electric.$runParam/route.tsx +++ /dev/null @@ -1,1279 +0,0 @@ -import { - ArrowUturnLeftIcon, - ChevronDownIcon, - ChevronRightIcon, - InformationCircleIcon, - LockOpenIcon, - MagnifyingGlassMinusIcon, - MagnifyingGlassPlusIcon, - StopCircleIcon, -} from "@heroicons/react/20/solid"; -import type { Location } from "@remix-run/react"; -import { useParams } from "@remix-run/react"; -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Virtualizer } from "@tanstack/react-virtual"; -import { - formatDurationMilliseconds, - millisecondsToNanoseconds, - nanosecondsToMilliseconds, -} from "@trigger.dev/core/v3"; -import { RuntimeEnvironmentType } from "@trigger.dev/database"; -import { motion } from "framer-motion"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; -import { ClientOnly } from "remix-utils/client-only"; -import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon"; -import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; -import { PageBody } from "~/components/layout/AppLayout"; -import { Badge } from "~/components/primitives/Badge"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; -import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; -import { Input } from "~/components/primitives/Input"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; -import * as Property from "~/components/primitives/PropertyTable"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; -import { ShortcutKey, variants } from "~/components/primitives/ShortcutKey"; -import { Slider } from "~/components/primitives/Slider"; -import { Spinner } from "~/components/primitives/Spinner"; -import { Switch } from "~/components/primitives/Switch"; -import * as Timeline from "~/components/primitives/Timeline"; -import { TreeView, UseTreeStateOutput, useTree } from "~/components/primitives/TreeView/TreeView"; -import { NodesState } from "~/components/primitives/TreeView/reducer"; -import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog"; -import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog"; -import { RunIcon } from "~/components/runs/v3/RunIcon"; -import { RunInspector } from "~/components/runs/v3/RunInspector"; -import { SpanInspector } from "~/components/runs/v3/SpanInspector"; -import { SpanTitle, eventBackgroundClassName } from "~/components/runs/v3/SpanTitle"; -import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; -import { useAppOrigin } from "~/hooks/useAppOrigin"; -import { useDebounce } from "~/hooks/useDebounce"; -import { useInitialDimensions } from "~/hooks/useInitialDimensions"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams"; -import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { useSyncRunPage } from "~/hooks/useSyncRunPage"; -import { Trace, TraceEvent } from "~/hooks/useSyncTrace"; -import { RawRun } from "~/hooks/useSyncTraceRuns"; -import { useUser } from "~/hooks/useUser"; -import { Run, RunPresenter } from "~/presenters/v3/RunPresenterElectric.server"; -import { getResizableSnapshot } from "~/services/resizablePanel.server"; -import { requireUserId } from "~/services/session.server"; -import { cn } from "~/utils/cn"; -import { lerp } from "~/utils/lerp"; -import { - v3BillingPath, - v3RunParamsSchema, - v3RunPath, - v3RunSpanPath, - v3RunsPath, -} from "~/utils/pathBuilder"; -import { - TraceSpan, - createSpanFromEvents, - createTraceTreeFromEvents, - prepareTrace, -} from "~/utils/taskEvent"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; - -const resizableSettings = { - parent: { - autosaveId: "panel-run-parent", - handleId: "parent-handle", - main: { - id: "run", - min: "100px" as const, - }, - inspector: { - id: "inspector", - default: "430px" as const, - min: "50px" as const, - }, - }, - tree: { - autosaveId: "panel-run-tree", - handleId: "tree-handle", - tree: { - id: "tree", - default: "50%" as const, - min: "50px" as const, - }, - timeline: { - id: "timeline", - default: "50%" as const, - min: "50px" as const, - }, - }, -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params); - - const presenter = new RunPresenter(); - const result = await presenter.call({ - userId, - organizationSlug, - projectSlug: projectParam, - runFriendlyId: runParam, - }); - - //resizable settings - const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); - const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); - - return typedjson({ - run: result.run, - resizable: { - parent, - tree, - }, - }); -}; - -type LoaderData = UseDataFunctionReturn; - -export default function Page() { - const { run, resizable } = useTypedLoaderData(); - - const user = useUser(); - const organization = useOrganization(); - const project = useProject(); - - const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined; - - return ( - <> - - - Run #{run.number} - -
- } - /> - - - - - ID - {run.id} - - - Trace ID - {run.traceId} - - - Env ID - {run.environment.id} - - - Org ID - {run.environment.organizationId} - - - - - - - - - - {run.isFinished ? null : ( - - - - - - - )} - - - -
- }> - {() => } - -
-
- - ); -} - -type InspectorState = - | { - type: "span"; - span?: TraceSpan; - } - | { - type: "run"; - run?: RawRun; - span?: TraceSpan; - } - | undefined; - -function Panels({ resizable, run: originalRun }: LoaderData) { - const { searchParams, replaceSearchParam } = useReplaceSearchParams(); - const selectedSpanId = searchParams.get("span") ?? undefined; - - const appOrigin = useAppOrigin(); - const { isUpToDate, events, runs } = useSyncRunPage({ - origin: appOrigin, - traceId: originalRun.traceId, - }); - - const initialLoad = !isUpToDate || !runs; - - const trace = useMemo(() => { - if (!events) return undefined; - const preparedEvents = prepareTrace(events); - if (!preparedEvents) return undefined; - return createTraceTreeFromEvents(preparedEvents, originalRun.spanId); - }, [events, originalRun.spanId]); - - const inspectorState = useMemo(() => { - if (originalRun.logsDeletedAt) { - return { - type: "run", - run: runs?.find((r) => r.friendlyId === originalRun.friendlyId), - }; - } - - if (selectedSpanId) { - if (runs && runs.length > 0) { - const spanRun = runs.find((r) => r.spanId === selectedSpanId); - if (spanRun && events) { - const span = createSpanFromEvents(events, selectedSpanId); - return { - type: "run", - run: spanRun, - span, - }; - } - } - - if (!events) { - return { - type: "span", - span: undefined, - }; - } - - const span = createSpanFromEvents(events, selectedSpanId); - return { - type: "span", - span, - }; - } - }, [selectedSpanId, runs, events]); - - return ( - - - {initialLoad ? ( - - ) : ( - - )} - - - {inspectorState ? ( - - {inspectorState.type === "span" ? ( - replaceSearchParam("span") : undefined} - /> - ) : inspectorState.type === "run" ? ( - replaceSearchParam("span") : undefined} - /> - ) : null} - - ) : null} - - ); -} - -type TraceData = { - run: Run; - environmentType: RuntimeEnvironmentType; - trace?: Trace; - selectedSpanId: string | undefined; - replaceSearchParam: (key: string, value?: string) => void; -}; - -function TraceView({ run, environmentType, trace, selectedSpanId, replaceSearchParam }: TraceData) { - const changeToSpan = useDebounce((selectedSpan: string) => { - replaceSearchParam("span", selectedSpan); - }, 100); - - if (!trace) { - return ; - } - - const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt } = trace; - - return ( - { - //instantly close the panel if no span is selected - if (!selectedSpan) { - replaceSearchParam("span"); - return; - } - - changeToSpan(selectedSpan); - }} - totalDuration={duration} - rootSpanStatus={rootSpanStatus} - rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined} - environmentType={environmentType} - /> - ); -} - -function NoLogsView({ run }: { run: Run }) { - const plan = useCurrentPlan(); - const organization = useOrganization(); - - const logRetention = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30; - - const completedAt = run.completedAt ? new Date(run.completedAt) : undefined; - const now = new Date(); - - const daysSinceCompleted = completedAt - ? Math.floor((now.getTime() - completedAt.getTime()) / (1000 * 60 * 60 * 24)) - : undefined; - - const isWithinLogRetention = - daysSinceCompleted !== undefined && daysSinceCompleted <= logRetention; - - return ( -
- {daysSinceCompleted === undefined ? ( - - - We tidy up older logs to keep things running smoothly. - - - ) : isWithinLogRetention ? ( - - - Your log retention is {logRetention} days but these logs had already been deleted. From - now on only logs from runs that completed {logRetention} days ago will be deleted. - - - ) : daysSinceCompleted <= 30 ? ( - - - The logs for this run have been deleted because the run completed {daysSinceCompleted}{" "} - days ago. - - Upgrade your plan to keep logs for longer. - - ) : ( - - - We tidy up older logs to keep things running smoothly. - - - )} -
- ); -} - -type TasksTreeViewProps = { - events: TraceEvent[]; - selectedId?: string; - parentRunFriendlyId?: string; - onSelectedIdChanged: (selectedId: string | undefined) => void; - totalDuration: number; - rootSpanStatus: "executing" | "completed" | "failed"; - rootStartedAt: Date | undefined; - environmentType: RuntimeEnvironmentType; -}; - -function TasksTreeView({ - events, - selectedId, - parentRunFriendlyId, - onSelectedIdChanged, - totalDuration, - rootSpanStatus, - rootStartedAt, - environmentType, -}: TasksTreeViewProps) { - const [filterText, setFilterText] = useState(""); - const [errorsOnly, setErrorsOnly] = useState(false); - const [showDurations, setShowDurations] = useState(true); - const [scale, setScale] = useState(0); - const parentRef = useRef(null); - const treeScrollRef = useRef(null); - const timelineScrollRef = useRef(null); - - const { - nodes, - getTreeProps, - getNodeProps, - toggleNodeSelection, - toggleExpandNode, - expandAllBelowDepth, - toggleExpandLevel, - collapseAllBelowDepth, - selectNode, - scrollToNode, - virtualizer, - } = useTree({ - tree: events, - selectedId, - // collapsedIds, - onSelectedIdChanged, - estimatedRowHeight: () => 32, - parentRef, - filter: { - value: { text: filterText, errorsOnly }, - fn: (value, node) => { - const nodePassesErrorTest = (value.errorsOnly && node.data.isError) || !value.errorsOnly; - if (!nodePassesErrorTest) return false; - - if (value.text === "") return true; - if (node.data.message.toLowerCase().includes(value.text.toLowerCase())) { - return true; - } - return false; - }, - }, - }); - - return ( -
-
- - setErrorsOnly(e.valueOf())} - /> -
- - {/* Tree list */} - -
-
- {parentRunFriendlyId ? ( - - ) : ( - - This is the root task - - )} - -
- ( - <> -
{ - selectNode(node.id); - }} - > -
- {Array.from({ length: node.level }).map((_, index) => ( - - ))} -
{ - e.stopPropagation(); - if (e.altKey) { - if (state.expanded) { - collapseAllBelowDepth(node.level); - } else { - expandAllBelowDepth(node.level); - } - } else { - toggleExpandNode(node.id); - } - scrollToNode(node.id); - }} - > - {node.hasChildren ? ( - state.expanded ? ( - - ) : ( - - ) - ) : ( -
- )} -
-
- -
-
- - - {node.data.isRoot && Root} -
-
- -
-
-
- {events.length === 1 && environmentType === "DEVELOPMENT" && ( - - )} - - )} - onScroll={(scrollTop) => { - //sync the scroll to the tree - if (timelineScrollRef.current) { - timelineScrollRef.current.scrollTop = scrollTop; - } - }} - /> -
- - - {/* Timeline */} - - - - -
-
-
- -
-
- - Shortcuts - - Keyboard shortcuts -
- -
-
-
-
-
-
- setScale(value[0])} - min={0} - max={1} - step={0.05} - /> -
-
-
- ); -} - -type TimelineViewProps = Pick< - TasksTreeViewProps, - "totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" -> & { - scale: number; - parentRef: React.RefObject; - timelineScrollRef: React.RefObject; - virtualizer: Virtualizer; - nodes: NodesState; - getNodeProps: UseTreeStateOutput["getNodeProps"]; - getTreeProps: UseTreeStateOutput["getTreeProps"]; - toggleNodeSelection: UseTreeStateOutput["toggleNodeSelection"]; - showDurations: boolean; - treeScrollRef: React.RefObject; -}; - -const tickCount = 5; - -function TimelineView({ - totalDuration, - scale, - rootSpanStatus, - rootStartedAt, - parentRef, - timelineScrollRef, - virtualizer, - events, - nodes, - getNodeProps, - getTreeProps, - toggleNodeSelection, - showDurations, - treeScrollRef, -}: TimelineViewProps) { - const timelineContainerRef = useRef(null); - const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); - const minTimelineWidth = initialTimelineDimensions?.width ?? 300; - const maxTimelineWidth = minTimelineWidth * 10; - - //we want to live-update the duration if the root span is still executing - const [duration, setDuration] = useState(totalDuration); - useEffect(() => { - if (rootSpanStatus !== "executing" || !rootStartedAt) { - setDuration(totalDuration); - return; - } - - const interval = setInterval(() => { - setDuration(millisecondsToNanoseconds(Date.now() - rootStartedAt.getTime())); - }, 500); - - return () => clearInterval(interval); - }, [totalDuration, rootSpanStatus]); - - return ( -
- - {/* Follows the cursor */} - - - - {/* The duration labels */} - - - - {(ms: number, index: number) => { - if (index === tickCount - 1) return null; - return ( - - {(ms) => ( -
- {formatDurationMilliseconds(ms, { - style: "short", - maxDecimalPoints: ms < 1000 ? 0 : 1, - })} -
- )} -
- ); - }} -
- {rootSpanStatus !== "executing" && ( - - {(ms) => ( -
- {formatDurationMilliseconds(ms, { - style: "short", - maxDecimalPoints: ms < 1000 ? 0 : 1, - })} -
- )} -
- )} -
- - - {(ms: number, index: number) => { - if (index === 0 || index === tickCount - 1) return null; - return ( - - ); - }} - - - -
- {/* Main timeline body */} - - {/* The vertical tick lines */} - - {(ms: number, index: number) => { - if (index === 0) return null; - return ; - }} - - {/* The completed line */} - {rootSpanStatus !== "executing" && ( - - )} - { - return ( - console.log(`hover ${index}`)} - onClick={(e) => { - toggleNodeSelection(node.id); - }} - > - {node.data.level === "TRACE" ? ( - - ) : ( - - {(ms) => ( - - )} - - )} - - ); - }} - onScroll={(scrollTop) => { - //sync the scroll to the tree - if (treeScrollRef.current) { - treeScrollRef.current.scrollTop = scrollTop; - } - }} - /> - -
-
-
- ); -} - -function NodeText({ node }: { node: TraceEvent }) { - const className = "truncate"; - return ( - - - - ); -} - -function NodeStatusIcon({ node }: { node: TraceEvent }) { - if (node.data.level !== "TRACE") return null; - if (node.data.style.variant !== "primary") return null; - - if (node.data.isCancelled) { - return ( - <> - - Canceled - - - - ); - } - - if (node.data.isError) { - return ; - } - - if (node.data.isPartial) { - return ; - } - - return ; -} - -function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boolean }) { - return
; -} - -function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) { - const [mouseOver, setMouseOver] = useState(false); - const organization = useOrganization(); - const project = useProject(); - const { spanParam } = useParams(); - - return ( - setMouseOver(true)} - onMouseLeave={() => setMouseOver(false)} - fullWidth - textAlignLeft - shortcut={{ key: "p" }} - className="flex-1" - > - {mouseOver ? ( - - ) : ( - - )} - - Show parent items - - - ); -} - -function LiveReloadingStatus({ rootSpanCompleted }: { rootSpanCompleted: boolean }) { - if (rootSpanCompleted) return null; - - return ( -
- - - Live reloading - -
- ); -} - -function PulsingDot() { - return ( - - - - - ); -} - -function SpanWithDuration({ - showDuration, - node, - ...props -}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean }) { - return ( - - - {node.data.isPartial && ( -
- )} -
-
- {formatDurationMilliseconds(props.durationMs, { - style: "short", - maxDecimalPoints: props.durationMs < 1000 ? 0 : 1, - })} -
-
- - - ); -} - -const edgeBoundary = 0.05; - -function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) { - return ( - - {(ms) => { - const ratio = ms / nanosecondsToMilliseconds(totalDuration); - let offset = 0.5; - if (ratio < edgeBoundary) { - offset = lerp(0, 0.5, ratio / edgeBoundary); - } else if (ratio > 1 - edgeBoundary) { - offset = lerp(0.5, 1, (ratio - (1 - edgeBoundary)) / edgeBoundary); - } - - return ( -
-
-
- {formatDurationMilliseconds(ms, { - style: "short", - maxDecimalPoints: ms < 1000 ? 0 : 1, - })} -
-
-
-
- ); - }} - - ); -} - -function ConnectedDevWarning() { - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => { - setIsVisible(true); - }, 6000); - - return () => clearTimeout(timer); - }, []); - - return ( -
- -
- - Runs usually start within 2 seconds in{" "} - . Check you're running the - CLI: npx trigger.dev@latest dev - -
-
-
- ); -} - -function KeyboardShortcuts({ - expandAllBelowDepth, - collapseAllBelowDepth, - toggleExpandLevel, - setShowDurations, -}: { - expandAllBelowDepth: (depth: number) => void; - collapseAllBelowDepth: (depth: number) => void; - toggleExpandLevel: (depth: number) => void; - setShowDurations: (show: (show: boolean) => boolean) => void; -}) { - return ( - <> - - expandAllBelowDepth(0)} - title="Expand all" - /> - collapseAllBelowDepth(1)} - title="Collapse all" - /> - toggleExpandLevel(number)} /> - - ); -} - -function ArrowKeyShortcuts() { - return ( -
- - - - - - Navigate - -
- ); -} - -function ShortcutWithAction({ - shortcut, - title, - action, -}: { - shortcut: Shortcut; - title: string; - action: () => void; -}) { - useShortcutKeys({ - shortcut, - action, - }); - - return ( -
- - - {title} - -
- ); -} - -function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void }) { - useHotkeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], (event, hotkeysEvent) => { - toggleLevel(Number(event.key)); - }); - - return ( -
- 0 - - 9 - - Toggle level - -
- ); -} - -function SearchField({ onChange }: { onChange: (value: string) => void }) { - const [value, setValue] = useState(""); - - const updateFilterText = useDebounce((text: string) => { - onChange(text); - }, 250); - - const updateValue = useCallback((value: string) => { - setValue(value); - updateFilterText(value); - }, []); - - return ( - updateValue(e.target.value)} - /> - ); -} - -export function Loading() { - return ( -
-
- - - Loading logs - -
-
-
- ); -} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx index 2f4c111952..04afd2c37c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1,6 +1,5 @@ import { CheckIcon, - ClockIcon, CloudArrowDownIcon, EnvelopeIcon, QueueListIcon, @@ -8,13 +7,11 @@ import { import { Link } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { - formatDuration, formatDurationMilliseconds, - nanosecondsToMilliseconds, TaskRunError, taskRunErrorEnhancer, } from "@trigger.dev/core/v3"; -import { ReactNode, useEffect } from "react"; +import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { AdminDebugRun } from "~/components/admin/debugRun"; @@ -39,7 +36,12 @@ import { import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; -import { LiveTimer } from "~/components/runs/v3/LiveTimer"; +import { + createTimelineSpanEventsFromSpanEvents, + RunTimeline, + RunTimelineEvent, + SpanTimeline, +} from "~/components/run/RunTimeline"; import { RunIcon } from "~/components/runs/v3/RunIcon"; import { RunTag } from "~/components/runs/v3/RunTag"; import { SpanEvents } from "~/components/runs/v3/SpanEvents"; @@ -49,6 +51,7 @@ import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; import { Span, SpanPresenter, SpanRun } from "~/presenters/v3/SpanPresenter.server"; import { logger } from "~/services/logger.server"; @@ -167,6 +170,7 @@ function SpanBody({ runParam?: string; closePanel?: () => void; }) { + const isAdmin = useHasAdminAccess(); const organization = useOrganization(); const project = useProject(); const { value, replace } = useSearchParams(); @@ -306,6 +310,7 @@ function SpanBody({ duration={span.duration} inProgress={span.isPartial} isError={span.isError} + events={createTimelineSpanEventsFromSpanEvents(span.events, isAdmin)} /> ) : ( @@ -313,7 +318,7 @@ function SpanBody({ } - state="complete" + variant="dot-solid" />
)} @@ -684,7 +689,7 @@ function RunBody({ "–" ) : (
- {run.tags.map((tag) => ( + {run.tags.map((tag: string) => ( - } - state="complete" - /> - {run.delayUntil && !run.expiredAt ? ( - {formatDuration(run.createdAt, run.delayUntil)} delay - ) : ( - - - - Delayed until {run.ttl && <>(TTL {run.ttl})} - - - ) - } - state={run.startedAt ? "complete" : "delayed"} - /> - ) : run.startedAt ? ( - - ) : ( - - {" "} - {run.ttl && <>(TTL {run.ttl})} - - } - state={run.startedAt || run.expiredAt ? "complete" : "inprogress"} - /> - )} - {run.expiredAt ? ( - } - state="error" - /> - ) : run.startedAt ? ( - <> - } - state="complete" - /> - {run.isFinished ? ( - <> - - } - state={run.isError ? "error" : "complete"} - /> - - ) : ( - - - - - - - } - state={"inprogress"} - /> - )} - - ) : null} -
- ); -} - -type RunTimelineItemProps = { - title: ReactNode; - subtitle?: ReactNode; - state: "complete" | "error"; -}; - -function RunTimelineEvent({ title, subtitle, state }: RunTimelineItemProps) { - return ( -
-
-
-
-
- {title} - {subtitle ? {subtitle} : null} -
-
- ); -} - -type RunTimelineLineProps = { - title: ReactNode; - state: "complete" | "delayed" | "inprogress"; -}; - -function RunTimelineLine({ title, state }: RunTimelineLineProps) { - return ( -
-
-
-
-
- {title} -
-
- ); -} - function RunError({ error }: { error: TaskRunError }) { const enhancedError = taskRunErrorEnhancer(error); @@ -1065,88 +925,3 @@ function PacketDisplay({ } } } - -type TimelineProps = { - startTime: Date; - duration: number; - inProgress: boolean; - isError: boolean; -}; - -type TimelineState = "error" | "pending" | "complete"; - -function SpanTimeline({ startTime, duration, inProgress, isError }: TimelineProps) { - const state = isError ? "error" : inProgress ? "pending" : "complete"; - return ( - <> -
- } - state="complete" - /> - {state === "pending" ? ( - - - - - - - } - state={"inprogress"} - /> - ) : ( - <> - - - } - state={isError ? "error" : "complete"} - /> - - )} -
- - ); -} - -function VerticalBar({ state }: { state: TimelineState }) { - return
; -} - -function DottedLine() { - return ( -
-
-
-
-
-
- ); -} - -function classNameForState(state: TimelineState) { - switch (state) { - case "pending": { - return "bg-pending"; - } - case "complete": { - return "bg-success"; - } - case "error": { - return "bg-error"; - } - } -} diff --git a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx new file mode 100644 index 0000000000..7fe7273808 --- /dev/null +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -0,0 +1,206 @@ +import { + RunTimeline, + RunTimelineEvent, + SpanTimeline, + SpanTimelineProps, + TimelineSpanRun, +} from "~/components/run/RunTimeline"; +import { Header2 } from "~/components/primitives/Headers"; + +const spanTimelines = [ + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: true, + isError: false, + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: true, + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 5000), + duration: 4000, + markerVariant: "start-cap", + lineVariant: "light", + }, + { + name: "Launched", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + markerVariant: "dot-hollow", + lineVariant: "light", + }, + { + name: "Imported task file", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + markerVariant: "dot-hollow", + lineVariant: "light", + }, + ], + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 5000), + duration: 4000, + markerVariant: "start-cap", + lineVariant: "light", + }, + { + name: "Launched", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + markerVariant: "dot-hollow", + lineVariant: "light", + }, + ], + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 5000), + duration: 4000, + markerVariant: "start-cap", + lineVariant: "light", + }, + { + name: "Forked", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + markerVariant: "dot-hollow", + lineVariant: "light", + }, + ], + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000), + duration: 4000, + markerVariant: "start-cap", + lineVariant: "light", + }, + { + name: "Forked", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + markerVariant: "dot-hollow", + lineVariant: "light", + }, + ], + }, +] satisfies SpanTimelineProps[]; + +const runTimelines = [ + { + createdAt: new Date(), + updatedAt: new Date(), + isFinished: false, + isError: false, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + startedAt: new Date(Date.now() - 1000 * 30), + isFinished: false, + isError: false, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + startedAt: new Date(Date.now() - 1000 * 30), + executedAt: new Date(Date.now() - 1000 * 20), + isFinished: false, + isError: false, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + startedAt: new Date(Date.now() - 1000 * 30), + executedAt: new Date(Date.now() - 1000 * 20), + completedAt: new Date(Date.now() - 1000 * 15), + isFinished: true, + isError: false, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + startedAt: new Date(Date.now() - 1000 * 30), + executedAt: new Date(Date.now() - 1000 * 20), + completedAt: new Date(Date.now() - 1000 * 15), + isFinished: true, + isError: true, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + startedAt: new Date(Date.now() - 1000 * 30), + completedAt: new Date(Date.now() - 1000 * 15), + isFinished: true, + isError: false, + }, + { + createdAt: new Date(Date.now() - 1000 * 60), + updatedAt: new Date(), + delayUntil: new Date(Date.now() + 1000 * 60), + ttl: "1m", + isFinished: false, + isError: false, + }, +] satisfies TimelineSpanRun[]; + +export default function Story() { + return ( +
+ Span Timeline + {spanTimelines.map((props, index) => ( + + ))} + Run Timeline + {runTimelines.map((run, index) => ( + + ))} +
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 0b052ef441..aea6f80e91 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -116,6 +116,10 @@ const stories: Story[] = [ name: "Timeline", slug: "timeline", }, + { + name: "Run & Span timeline", + slug: "run-and-span-timeline", + }, { name: "Typography", slug: "typography", diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 948850410b..bb0c9c9089 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -316,6 +316,8 @@ export class DevQueueConsumer { return; } + const dequeuedStart = Date.now(); + const messageBody = MessageBody.safeParse(message.data); if (!messageBody.success) { @@ -472,6 +474,14 @@ export class DevQueueConsumer { runId: lockedTaskRun.friendlyId, messageId: lockedTaskRun.id, isTest: lockedTaskRun.isTest, + metrics: [ + { + name: "start", + event: "dequeue", + timestamp: dequeuedStart, + duration: Date.now() - dequeuedStart, + }, + ], }; try { diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 0ab0675a7e..a3adcefddb 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -439,6 +439,8 @@ export class SharedQueueConsumer { }; } + const dequeuedAt = new Date(); + logger.log("dequeueMessageInSharedQueue()", { queueMessage: message }); const messageBody = SharedQueueMessageBody.safeParse(message.data); @@ -472,7 +474,7 @@ export class SharedQueueConsumer { this._currentMessage = message; this._currentMessageData = messageBody.data; - const messageResult = await this.#handleMessage(message, messageBody.data); + const messageResult = await this.#handleMessage(message, messageBody.data, dequeuedAt); switch (messageResult.action) { case "noop": { @@ -525,29 +527,30 @@ export class SharedQueueConsumer { async #handleMessage( message: MessagePayload, - data: SharedQueueMessageBody + data: SharedQueueMessageBody, + dequeuedAt: Date ): Promise { return await this.#startActiveSpan("handleMessage()", async (span) => { // TODO: For every ACK, decide what should be done with the existing run and attempts. Make sure to check the current statuses first. switch (data.type) { // MARK: EXECUTE case "EXECUTE": { - return await this.#handleExecuteMessage(message, data); + return await this.#handleExecuteMessage(message, data, dequeuedAt); } // MARK: DEP RESUME // Resume after dependency completed with no remaining retries case "RESUME": { - return await this.#handleResumeMessage(message, data); + return await this.#handleResumeMessage(message, data, dequeuedAt); } // MARK: DURATION RESUME // Resume after duration-based wait case "RESUME_AFTER_DURATION": { - return await this.#handleResumeAfterDurationMessage(message, data); + return await this.#handleResumeAfterDurationMessage(message, data, dequeuedAt); } // MARK: FAIL // Fail for whatever reason, usually runs that have been resumed but stopped heartbeating case "FAIL": { - return await this.#handleFailMessage(message, data); + return await this.#handleFailMessage(message, data, dequeuedAt); } } }); @@ -555,7 +558,8 @@ export class SharedQueueConsumer { async #handleExecuteMessage( message: MessagePayload, - data: SharedQueueExecuteMessageBody + data: SharedQueueExecuteMessageBody, + dequeuedAt: Date ): Promise { const existingTaskRun = await prisma.taskRun.findFirst({ where: { @@ -711,7 +715,7 @@ export class SharedQueueConsumer { taskVersion: worker.version, sdkVersion: worker.sdkVersion, cliVersion: worker.cliVersion, - startedAt: existingTaskRun.startedAt ?? new Date(), + startedAt: existingTaskRun.startedAt ?? dequeuedAt, baseCostInCents: env.CENTS_PER_RUN, machinePreset: existingTaskRun.machinePreset ?? @@ -884,55 +888,56 @@ export class SharedQueueConsumer { action: "noop", reason: "retry_checkpoints_disabled", }; - } else { - const machine = - machinePresetFromRun(lockedTaskRun) ?? - machinePresetFromConfig(lockedTaskRun.lockedBy?.machineConfig ?? {}); - - return await this.#startActiveSpan("scheduleAttemptOnProvider", async (span) => { - span.setAttributes({ - run_id: lockedTaskRun.id, - }); + } - if (await this._providerSender.validateCanSendMessage()) { - await this._providerSender.send("BACKGROUND_WORKER_MESSAGE", { - backgroundWorkerId: worker.friendlyId, - data: { - type: "SCHEDULE_ATTEMPT", - image: imageReference, - version: deployment.version, - machine, - nextAttemptNumber, - // identifiers - id: "placeholder", // TODO: Remove this completely in a future release - envId: lockedTaskRun.runtimeEnvironment.id, - envType: lockedTaskRun.runtimeEnvironment.type, - orgId: lockedTaskRun.runtimeEnvironment.organizationId, - projectId: lockedTaskRun.runtimeEnvironment.projectId, - runId: lockedTaskRun.id, - }, - }); + const machine = + machinePresetFromRun(lockedTaskRun) ?? + machinePresetFromConfig(lockedTaskRun.lockedBy?.machineConfig ?? {}); - return { - action: "noop", - reason: "scheduled_attempt", - attrs: { - next_attempt_number: nextAttemptNumber, - }, - }; - } else { - return { - action: "nack_and_do_more_work", - reason: "provider_not_connected", - attrs: { - run_id: lockedTaskRun.id, - }, - interval: this._options.nextTickInterval, - retryInMs: 5_000, - }; - } + return await this.#startActiveSpan("scheduleAttemptOnProvider", async (span) => { + span.setAttributes({ + run_id: lockedTaskRun.id, }); - } + + if (await this._providerSender.validateCanSendMessage()) { + await this._providerSender.send("BACKGROUND_WORKER_MESSAGE", { + backgroundWorkerId: worker.friendlyId, + data: { + type: "SCHEDULE_ATTEMPT", + image: imageReference, + version: deployment.version, + machine, + nextAttemptNumber, + // identifiers + id: "placeholder", // TODO: Remove this completely in a future release + envId: lockedTaskRun.runtimeEnvironment.id, + envType: lockedTaskRun.runtimeEnvironment.type, + orgId: lockedTaskRun.runtimeEnvironment.organizationId, + projectId: lockedTaskRun.runtimeEnvironment.projectId, + runId: lockedTaskRun.id, + dequeuedAt: dequeuedAt.getTime(), + }, + }); + + return { + action: "noop", + reason: "scheduled_attempt", + attrs: { + next_attempt_number: nextAttemptNumber, + }, + }; + } else { + return { + action: "nack_and_do_more_work", + reason: "provider_not_connected", + attrs: { + run_id: lockedTaskRun.id, + }, + interval: this._options.nextTickInterval, + retryInMs: 5_000, + }; + } + }); } catch (e) { // We now need to unlock the task run and delete the task run attempt await prisma.$transaction([ @@ -966,7 +971,8 @@ export class SharedQueueConsumer { async #handleResumeMessage( message: MessagePayload, - data: SharedQueueResumeMessageBody + data: SharedQueueResumeMessageBody, + dequeuedAt: Date ): Promise { if (data.checkpointEventId) { try { @@ -1332,7 +1338,8 @@ export class SharedQueueConsumer { async #handleResumeAfterDurationMessage( message: MessagePayload, - data: SharedQueueResumeAfterDurationMessageBody + data: SharedQueueResumeAfterDurationMessageBody, + dequeuedAt: Date ): Promise { try { const restoreService = new RestoreCheckpointService(); @@ -1374,7 +1381,8 @@ export class SharedQueueConsumer { async #handleFailMessage( message: MessagePayload, - data: SharedQueueFailMessageBody + data: SharedQueueFailMessageBody, + dequeuedAt: Date ): Promise { const existingTaskRun = await prisma.taskRun.findFirst({ where: { @@ -2057,6 +2065,7 @@ class SharedQueueTasks { messageId: run.id, isTest: run.isTest, attemptCount, + metrics: [], } satisfies TaskRunExecutionLazyAttemptPayload; } diff --git a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts index 327844f201..e259378268 100644 --- a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts +++ b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts @@ -152,19 +152,18 @@ export class CreateTaskRunAttemptService extends BaseService { }, }); - if (setToExecuting) { - await tx.taskRun.update({ - where: { - id: taskRun.id, - }, - data: { - status: "EXECUTING", - }, - }); + await tx.taskRun.update({ + where: { + id: taskRun.id, + }, + data: { + status: setToExecuting ? "EXECUTING" : undefined, + executedAt: taskRun.executedAt ?? new Date(), + }, + }); - if (taskRun.ttl) { - await ExpireEnqueuedRunService.ack(taskRun.id, tx); - } + if (taskRun.ttl) { + await ExpireEnqueuedRunService.ack(taskRun.id, tx); } return taskRunAttempt; diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index e57368fb3c..af13215ecc 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -251,18 +251,23 @@ module.exports = { "0%": { "background-position": "0px" }, "100%": { "background-position": "8px" }, }, + "tile-move-offset": { + "0%": { "background-position": "-1px" }, + "100%": { "background-position": "7px" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "tile-scroll": "tile-move 0.5s infinite linear", + "tile-scroll-offset": "tile-move-offset 0.5s infinite linear", }, backgroundImage: { "gradient-radial": "radial-gradient(closest-side, var(--tw-gradient-stops))", "gradient-primary": `linear-gradient(90deg, acid-500 0%, toxic-500 100%)`, "gradient-primary-hover": `linear-gradient(80deg, acid-600 0%, toxic-600 100%)`, "gradient-secondary": `linear-gradient(90deg, hsl(271 91 65) 0%, hsl(221 83 53) 100%)`, - "gradient-radial-secondary": `radial-gradient(hsl(271 91 65), hsl(221 83 53))`, + "gradient-radial-secondary ": `radial-gradient(hsl(271 91 65), hsl(221 83 53))`, }, gridTemplateColumns: { carousel: "repeat(6, 200px)", diff --git a/internal-packages/database/prisma/migrations/20250225164413_add_executed_at_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20250225164413_add_executed_at_to_task_run/migration.sql new file mode 100644 index 0000000000..ca0fd49099 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250225164413_add_executed_at_to_task_run/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE + "TaskRun" +ADD + COLUMN "executedAt" TIMESTAMP(3); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7617be6dd5..2fa3ef4886 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1703,7 +1703,10 @@ model TaskRun { checkpoints Checkpoint[] + /// startedAt marks the point at which a run is dequeued from MarQS startedAt DateTime? + /// executedAt is set when the first attempt is about to execute + executedAt DateTime? completedAt DateTime? machinePreset String? @@ -1915,8 +1918,8 @@ model TaskRunDependency { dependentBatchRun BatchTaskRun? @relation("dependentBatchRun", fields: [dependentBatchRunId], references: [id]) dependentBatchRunId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt resumedAt DateTime? @@index([dependentAttemptId]) diff --git a/packages/cli-v3/src/dev/workerRuntime.ts b/packages/cli-v3/src/dev/workerRuntime.ts index 02474295a7..d3edf3aaac 100644 --- a/packages/cli-v3/src/dev/workerRuntime.ts +++ b/packages/cli-v3/src/dev/workerRuntime.ts @@ -6,6 +6,7 @@ import { serverWebsocketMessages, TaskManifest, TaskRunExecutionLazyAttemptPayload, + TaskRunExecutionMetrics, WorkerManifest, } from "@trigger.dev/core/v3"; import { ResolvedConfig } from "@trigger.dev/core/v3/build"; @@ -313,6 +314,8 @@ class DevWorkerRuntime implements WorkerRuntime { } async #executeTaskRunLazyAttempt(id: string, payload: TaskRunExecutionLazyAttemptPayload) { + const createAttemptStart = Date.now(); + const attemptResponse = await this.options.client.createTaskRunAttempt(payload.runId); if (!attemptResponse.success) { @@ -325,7 +328,19 @@ class DevWorkerRuntime implements WorkerRuntime { const completion = await this.backgroundWorkerCoordinator.executeTaskRun( id, - { execution, traceContext: payload.traceContext, environment: payload.environment }, + { + execution, + traceContext: payload.traceContext, + environment: payload.environment, + metrics: [ + { + name: "start", + event: "create_attempt", + timestamp: createAttemptStart, + duration: Date.now() - createAttemptStart, + }, + ].concat(payload.metrics ?? []), + }, payload.messageId ); diff --git a/packages/cli-v3/src/entryPoints/deploy-run-controller.ts b/packages/cli-v3/src/entryPoints/deploy-run-controller.ts index e17a152241..76dfabd010 100644 --- a/packages/cli-v3/src/entryPoints/deploy-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/deploy-run-controller.ts @@ -39,6 +39,14 @@ const COORDINATOR_PORT = Number(env.COORDINATOR_PORT || 50080); const MACHINE_NAME = env.MACHINE_NAME || "local"; const POD_NAME = env.POD_NAME || "some-pod"; const SHORT_HASH = env.TRIGGER_CONTENT_HASH!.slice(0, 9); +const TRIGGER_POD_SCHEDULED_AT_MS = + typeof env.TRIGGER_POD_SCHEDULED_AT_MS === "string" + ? parseInt(env.TRIGGER_POD_SCHEDULED_AT_MS, 10) + : undefined; +const TRIGGER_RUN_DEQUEUED_AT_MS = + typeof env.TRIGGER_RUN_DEQUEUED_AT_MS === "string" + ? parseInt(env.TRIGGER_RUN_DEQUEUED_AT_MS, 10) + : undefined; const logger = new SimpleLogger(`[${MACHINE_NAME}][${SHORT_HASH}]`); @@ -426,8 +434,9 @@ class ProdWorker { async #readyForLazyAttempt() { const idempotencyKey = randomUUID(); + const startTime = Date.now(); - logger.log("ready for lazy attempt", { idempotencyKey }); + logger.log("ready for lazy attempt", { idempotencyKey, startTime }); this.readyForLazyAttemptReplay = { idempotencyKey, @@ -444,6 +453,7 @@ class ProdWorker { version: "v1", runId: this.runId, totalCompletions: this.completed.size, + startTime, }); await timeout(delay.milliseconds); @@ -831,6 +841,8 @@ class ProdWorker { this.executing = true; + const createAttemptStart = Date.now(); + const createAttempt = await defaultBackoff.execute(async ({ retry }) => { logger.log("Create task run attempt with backoff", { retry, @@ -876,11 +888,45 @@ class ProdWorker { ...environment, }; + const payload = { + ...createAttempt.result.executionPayload, + metrics: [ + ...(createAttempt.result.executionPayload.metrics ?? []), + ...(message.lazyPayload.metrics ?? []), + { + name: "start", + event: "create_attempt", + timestamp: createAttemptStart, + duration: Date.now() - createAttemptStart, + }, + ...(TRIGGER_POD_SCHEDULED_AT_MS && TRIGGER_RUN_DEQUEUED_AT_MS + ? [ + ...(TRIGGER_POD_SCHEDULED_AT_MS !== TRIGGER_RUN_DEQUEUED_AT_MS + ? [ + { + name: "start", + event: "pod_scheduled", + timestamp: TRIGGER_POD_SCHEDULED_AT_MS, + duration: Date.now() - TRIGGER_POD_SCHEDULED_AT_MS, + }, + ] + : []), + { + name: "start", + event: "dequeue", + timestamp: TRIGGER_RUN_DEQUEUED_AT_MS, + duration: TRIGGER_POD_SCHEDULED_AT_MS - TRIGGER_RUN_DEQUEUED_AT_MS, + }, + ] + : []), + ], + }; + this._taskRunProcess = new TaskRunProcess({ workerManifest: this.workerManifest, env, serverWorker: execution.worker, - payload: createAttempt.result.executionPayload, + payload, messageId: message.lazyPayload.messageId, }); diff --git a/packages/cli-v3/src/entryPoints/deploy-run-worker.ts b/packages/cli-v3/src/entryPoints/deploy-run-worker.ts index 2c6154a65a..a2be8f6452 100644 --- a/packages/cli-v3/src/entryPoints/deploy-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/deploy-run-worker.ts @@ -17,6 +17,7 @@ import { runMetadata, waitUntil, apiClientManager, + runTimelineMetrics, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { ProdRuntimeManager } from "@trigger.dev/core/v3/prod"; @@ -37,6 +38,7 @@ import { UsageTimeoutManager, StandardMetadataManager, StandardWaitUntilManager, + StandardRunTimelineMetricsManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -117,6 +119,10 @@ waitUntil.register({ promise: () => runMetadataManager.waitForAllStreams(), }); +const standardRunTimelineMetricsManager = new StandardRunTimelineMetricsManager(); +runTimelineMetrics.setGlobalManager(standardRunTimelineMetricsManager); +standardRunTimelineMetricsManager.seedMetricsFromEnvironment(); + const triggerLogLevel = getEnvVar("TRIGGER_LOG_LEVEL"); async function importConfig( @@ -200,7 +206,9 @@ const zodIpc = new ZodIpcConnection({ emitSchema: ExecutorToWorkerMessageCatalog, process, handlers: { - EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata }, sender) => { + EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata, metrics }, sender) => { + standardRunTimelineMetricsManager.registerMetricsFromExecution(metrics); + console.log(`[${new Date().toISOString()}] Received EXECUTE_TASK_RUN`, execution); if (_isRunning) { @@ -257,12 +265,21 @@ const zodIpc = new ZodIpcConnection({ } try { - const beforeImport = performance.now(); - await import(normalizeImportPath(taskManifest.entryPoint)); - const durationMs = performance.now() - beforeImport; - - console.log( - `Imported task ${execution.task.id} [${taskManifest.entryPoint}] in ${durationMs}ms` + await runTimelineMetrics.measureMetric( + "trigger.dev/start", + "import", + { + entryPoint: taskManifest.entryPoint, + }, + async () => { + const beforeImport = performance.now(); + await import(normalizeImportPath(taskManifest.entryPoint)); + const durationMs = performance.now() - beforeImport; + + console.log( + `Imported task ${execution.task.id} [${taskManifest.entryPoint}] in ${durationMs}ms` + ); + } ); } catch (err) { console.error(`Failed to import task ${execution.task.id}`, err); diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index e299dd1ebf..60b8294750 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -17,6 +17,7 @@ import { runMetadata, waitUntil, apiClientManager, + runTimelineMetrics, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { DevRuntimeManager } from "@trigger.dev/core/v3/dev"; @@ -36,6 +37,7 @@ import { getNumberEnvVar, StandardMetadataManager, StandardWaitUntilManager, + StandardRunTimelineMetricsManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -77,6 +79,8 @@ process.on("uncaughtException", function (error, origin) { } }); +const standardRunTimelineMetricsManager = new StandardRunTimelineMetricsManager(); +runTimelineMetrics.setGlobalManager(standardRunTimelineMetricsManager); taskCatalog.setGlobalTaskCatalog(new StandardTaskCatalog()); const durableClock = new DurableClock(); clock.setGlobalClock(durableClock); @@ -101,6 +105,8 @@ waitUntil.register({ const triggerLogLevel = getEnvVar("TRIGGER_LOG_LEVEL"); +standardRunTimelineMetricsManager.seedMetricsFromEnvironment(); + async function importConfig( configPath: string ): Promise<{ config: TriggerConfig; handleError?: HandleErrorFunction }> { @@ -180,7 +186,9 @@ const zodIpc = new ZodIpcConnection({ emitSchema: ExecutorToWorkerMessageCatalog, process, handlers: { - EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata }, sender) => { + EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata, metrics }, sender) => { + standardRunTimelineMetricsManager.registerMetricsFromExecution(metrics); + if (_isRunning) { console.error("Worker is already running a task"); @@ -233,7 +241,16 @@ const zodIpc = new ZodIpcConnection({ } try { - await import(normalizeImportPath(taskManifest.entryPoint)); + await runTimelineMetrics.measureMetric( + "trigger.dev/start", + "import", + { + entryPoint: taskManifest.entryPoint, + }, + async () => { + await import(normalizeImportPath(taskManifest.entryPoint)); + } + ); } catch (err) { console.error(`Failed to import task ${execution.task.id}`, err); diff --git a/packages/cli-v3/src/executions/taskRunProcess.ts b/packages/cli-v3/src/executions/taskRunProcess.ts index 22e3c9f6d5..14f97eda13 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.ts @@ -124,6 +124,7 @@ export class TaskRunProcess { // TODO: this will probably need to use something different for bun (maybe --preload?) NODE_OPTIONS: execOptionsForRuntime(workerManifest.runtime, workerManifest), PATH: process.env.PATH, + TRIGGER_PROCESS_FORK_START_TIME: String(Date.now()), }; logger.debug(`[${this.runId}] initializing task run process`, { @@ -214,7 +215,7 @@ export class TaskRunProcess { // @ts-expect-error - We know that the resolver and rejecter are defined this._attemptPromises.set(this.payload.execution.attempt.id, { resolver, rejecter }); - const { execution, traceContext } = this.payload; + const { execution, traceContext, metrics } = this.payload; this._currentExecution = execution; @@ -232,6 +233,7 @@ export class TaskRunProcess { execution, traceContext, metadata: this.options.serverWorker, + metrics, }); } diff --git a/packages/core/src/v3/apps/provider.ts b/packages/core/src/v3/apps/provider.ts index 863177509c..8cfeb81aee 100644 --- a/packages/core/src/v3/apps/provider.ts +++ b/packages/core/src/v3/apps/provider.ts @@ -50,6 +50,7 @@ export interface TaskOperationsCreateOptions { orgId: string; projectId: string; runId: string; + dequeuedAt?: number; } export interface TaskOperationsRestoreOptions { @@ -151,6 +152,7 @@ export class ProviderShell implements Provider { orgId: message.data.orgId, projectId: message.data.projectId, runId: message.data.runId, + dequeuedAt: message.data.dequeuedAt, }); } catch (error) { logger.error("create failed", error); diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 12fbc8b2d2..1a66105f0f 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -14,6 +14,7 @@ export * from "./usage-api.js"; export * from "./run-metadata-api.js"; export * from "./wait-until-api.js"; export * from "./timeout-api.js"; +export * from "./run-timeline-metrics-api.js"; export * from "./schemas/index.js"; export { SemanticInternalAttributes } from "./semanticInternalAttributes.js"; export * from "./task-catalog-api.js"; diff --git a/packages/core/src/v3/run-timeline-metrics-api.ts b/packages/core/src/v3/run-timeline-metrics-api.ts new file mode 100644 index 0000000000..0c92cf2ed2 --- /dev/null +++ b/packages/core/src/v3/run-timeline-metrics-api.ts @@ -0,0 +1,5 @@ +// Split module-level variable definition into separate files to allow +// tree-shaking on each api instance. +import { RunTimelineMetricsAPI } from "./runTimelineMetrics/index.js"; + +export const runTimelineMetrics = RunTimelineMetricsAPI.getInstance(); diff --git a/packages/core/src/v3/runTimelineMetrics/index.ts b/packages/core/src/v3/runTimelineMetrics/index.ts new file mode 100644 index 0000000000..7ce1133c61 --- /dev/null +++ b/packages/core/src/v3/runTimelineMetrics/index.ts @@ -0,0 +1,203 @@ +import { Attributes } from "@opentelemetry/api"; +import { TriggerTracerSpanEvent } from "../tracer.js"; +import { getGlobal, registerGlobal } from "../utils/globals.js"; +import { NoopRunTimelineMetricsManager } from "./runTimelineMetricsManager.js"; +import { RunTimelineMetric, RunTimelineMetricsManager } from "./types.js"; +import { flattenAttributes } from "../utils/flattenAttributes.js"; +import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; + +const API_NAME = "run-timeline-metrics"; + +const NOOP_MANAGER = new NoopRunTimelineMetricsManager(); + +export class RunTimelineMetricsAPI implements RunTimelineMetricsManager { + private static _instance?: RunTimelineMetricsAPI; + + private constructor() {} + + public static getInstance(): RunTimelineMetricsAPI { + if (!this._instance) { + this._instance = new RunTimelineMetricsAPI(); + } + + return this._instance; + } + + registerMetric(metric: RunTimelineMetric): void { + this.#getManager().registerMetric(metric); + } + + getMetrics(): RunTimelineMetric[] { + return this.#getManager().getMetrics(); + } + + /** + * Measures the execution time of an async function and registers it as a metric + * @param metricName The name of the metric + * @param eventName The event name + * @param attributesOrCallback Optional attributes or the callback function + * @param callbackFn The async function to measure (if attributes were provided) + * @returns The result of the callback function + */ + async measureMetric( + metricName: string, + eventName: string, + attributesOrCallback: Attributes | (() => Promise), + callbackFn?: () => Promise + ): Promise { + // Handle overloaded function signature + let attributes: Attributes = {}; + let callback: () => Promise; + + if (typeof attributesOrCallback === "function") { + callback = attributesOrCallback; + } else { + attributes = attributesOrCallback || {}; + if (!callbackFn) { + throw new Error("Callback function is required when attributes are provided"); + } + callback = callbackFn; + } + + // Record start time + const startTime = Date.now(); + + try { + // Execute the callback + const result = await callback(); + + // Calculate duration + const duration = Date.now() - startTime; + + // Register the metric + this.registerMetric({ + name: metricName, + event: eventName, + attributes: { + ...attributes, + duration, + }, + timestamp: startTime, + }); + + return result; + } catch (error) { + // Register the metric even if there's an error, but mark it as failed + const duration = Date.now() - startTime; + + this.registerMetric({ + name: metricName, + event: eventName, + attributes: { + ...attributes, + duration, + error: error instanceof Error ? error.message : String(error), + status: "failed", + }, + timestamp: startTime, + }); + + // Re-throw the error + throw error; + } + } + + convertMetricsToSpanEvents(): TriggerTracerSpanEvent[] { + const metrics = this.getMetrics(); + + const spanEvents: TriggerTracerSpanEvent[] = metrics.map((metric) => { + return { + name: metric.name, + startTime: metric.timestamp, + attributes: { + ...metric.attributes, + event: metric.event, + }, + }; + }); + + return spanEvents; + } + + convertMetricsToSpanAttributes(): Attributes { + const metrics = this.getMetrics(); + + if (metrics.length === 0) { + return {}; + } + + // Group metrics by name + const metricsByName = metrics.reduce( + (acc, metric) => { + if (!acc[metric.name]) { + acc[metric.name] = []; + } + acc[metric.name]!.push(metric); + return acc; + }, + {} as Record + ); + + // Process each metric type + const reducedMetrics = metrics.reduce( + (acc, metric) => { + acc[metric.event] = { + name: metric.name, + timestamp: metric.timestamp, + event: metric.event, + ...flattenAttributes(metric.attributes, "attributes"), + }; + return acc; + }, + {} as Record + ); + + const metricEventRollups: Record< + string, + { timestamp: number; duration: number; name: string } + > = {}; + + // Calculate duration for each metric type + // Calculate duration for each metric type + for (const [metricName, metricEvents] of Object.entries(metricsByName)) { + // Skip if there are no events for this metric + if (metricEvents.length === 0) continue; + + // Sort events by timestamp + const sortedEvents = [...metricEvents].sort((a, b) => a.timestamp - b.timestamp); + + // Get first event timestamp (we know it exists because we checked length above) + const firstTimestamp = sortedEvents[0]!.timestamp; + + // Get last event (we know it exists because we checked length above) + const lastEvent = sortedEvents[sortedEvents.length - 1]!; + + // Calculate total duration: from first event to (last event + its duration) + // Use optional chaining and nullish coalescing for safety + const lastEventDuration = (lastEvent.attributes?.duration as number) ?? 0; + const lastEventEndTime = lastEvent.timestamp + lastEventDuration; + + // Store the total duration for this metric type + const duration = lastEventEndTime - firstTimestamp; + const timestamp = firstTimestamp; + metricEventRollups[metricName] = { + name: metricName, + duration, + timestamp, + }; + } + + return { + ...flattenAttributes(reducedMetrics, SemanticInternalAttributes.METRIC_EVENTS), + ...flattenAttributes(metricEventRollups, SemanticInternalAttributes.METRIC_EVENTS), + }; + } + + setGlobalManager(manager: RunTimelineMetricsManager): boolean { + return registerGlobal(API_NAME, manager); + } + + #getManager(): RunTimelineMetricsManager { + return getGlobal(API_NAME) ?? NOOP_MANAGER; + } +} diff --git a/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts b/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts new file mode 100644 index 0000000000..cf6c210b29 --- /dev/null +++ b/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts @@ -0,0 +1,57 @@ +import { TaskRunExecutionMetrics } from "../schemas/schemas.js"; +import { getEnvVar } from "../utils/getEnv.js"; +import { RunTimelineMetric, RunTimelineMetricsManager } from "./types.js"; + +export class StandardRunTimelineMetricsManager implements RunTimelineMetricsManager { + private _metrics: RunTimelineMetric[] = []; + + registerMetric(metric: RunTimelineMetric): void { + this._metrics.push(metric); + } + + getMetrics(): RunTimelineMetric[] { + return this._metrics; + } + + registerMetricsFromExecution(metrics?: TaskRunExecutionMetrics): void { + if (metrics) { + metrics.forEach((metric) => { + this.registerMetric({ + name: `trigger.dev/${metric.name}`, + event: metric.event, + timestamp: metric.timestamp, + attributes: { + duration: metric.duration, + }, + }); + }); + } + } + + seedMetricsFromEnvironment() { + const forkStartTime = getEnvVar("TRIGGER_PROCESS_FORK_START_TIME"); + + if (typeof forkStartTime === "string") { + const forkStartTimeMs = parseInt(forkStartTime, 10); + + this.registerMetric({ + name: "trigger.dev/start", + event: "fork", + attributes: { + duration: Date.now() - forkStartTimeMs, + }, + timestamp: forkStartTimeMs, + }); + } + } +} + +export class NoopRunTimelineMetricsManager implements RunTimelineMetricsManager { + registerMetric(metric: RunTimelineMetric): void { + // Do nothing + } + + getMetrics(): RunTimelineMetric[] { + return []; + } +} diff --git a/packages/core/src/v3/runTimelineMetrics/types.ts b/packages/core/src/v3/runTimelineMetrics/types.ts new file mode 100644 index 0000000000..043bfcaac0 --- /dev/null +++ b/packages/core/src/v3/runTimelineMetrics/types.ts @@ -0,0 +1,13 @@ +import { Attributes } from "@opentelemetry/api"; + +export type RunTimelineMetric = { + name: string; + event: string; + timestamp: number; + attributes?: Attributes; +}; + +export interface RunTimelineMetricsManager { + registerMetric(metric: RunTimelineMetric): void; + getMetrics(): RunTimelineMetric[]; +} diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index 431504c56b..23ac53c4f8 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -13,6 +13,7 @@ import { ProdTaskRunExecution, ProdTaskRunExecutionPayload, TaskRunExecutionLazyAttemptPayload, + TaskRunExecutionMetrics, WaitReason, } from "./schemas.js"; @@ -52,6 +53,7 @@ export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [ orgId: z.string(), projectId: z.string(), runId: z.string(), + dequeuedAt: z.number().optional(), }), z.object({ type: z.literal("EXECUTE_RUN_LAZY_ATTEMPT"), @@ -202,6 +204,7 @@ export const WorkerToExecutorMessageCatalog = { execution: TaskRunExecution, traceContext: z.record(z.unknown()), metadata: ServerBackgroundWorker, + metrics: TaskRunExecutionMetrics.optional(), }), }, TASK_RUN_COMPLETED_NOTIFICATION: { @@ -672,6 +675,7 @@ export const ProdWorkerToCoordinatorMessages = { version: z.literal("v1").default("v1"), runId: z.string(), totalCompletions: z.number(), + startTime: z.number().optional(), }), }, READY_FOR_RESUME: { diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 8ed9b7b0be..525f916bba 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -8,10 +8,24 @@ import { MachineConfig, MachinePreset, MachinePresetName, TaskRunExecution } fro export const EnvironmentType = z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]); export type EnvironmentType = z.infer; +export const TaskRunExecutionMetric = z.object({ + name: z.string(), + event: z.string(), + timestamp: z.number(), + duration: z.number(), +}); + +export type TaskRunExecutionMetric = z.infer; + +export const TaskRunExecutionMetrics = z.array(TaskRunExecutionMetric); + +export type TaskRunExecutionMetrics = z.infer; + export const TaskRunExecutionPayload = z.object({ execution: TaskRunExecution, traceContext: z.record(z.unknown()), environment: z.record(z.string()).optional(), + metrics: TaskRunExecutionMetrics.optional(), }); export type TaskRunExecutionPayload = z.infer; @@ -35,6 +49,7 @@ export const ProdTaskRunExecutionPayload = z.object({ execution: ProdTaskRunExecution, traceContext: z.record(z.unknown()), environment: z.record(z.string()).optional(), + metrics: TaskRunExecutionMetrics.optional(), }); export type ProdTaskRunExecutionPayload = z.infer; @@ -247,6 +262,7 @@ export const TaskRunExecutionLazyAttemptPayload = z.object({ isTest: z.boolean(), traceContext: z.record(z.unknown()), environment: z.record(z.string()).optional(), + metrics: TaskRunExecutionMetrics.optional(), }); export type TaskRunExecutionLazyAttemptPayload = z.infer; diff --git a/packages/core/src/v3/semanticInternalAttributes.ts b/packages/core/src/v3/semanticInternalAttributes.ts index 74b09426e1..01b3beed3f 100644 --- a/packages/core/src/v3/semanticInternalAttributes.ts +++ b/packages/core/src/v3/semanticInternalAttributes.ts @@ -52,4 +52,5 @@ export const SemanticInternalAttributes = { RATE_LIMIT_REMAINING: "response.rateLimit.remaining", RATE_LIMIT_RESET: "response.rateLimit.reset", SPAN_ATTEMPT: "$span.attempt", + METRIC_EVENTS: "$metrics.events", }; diff --git a/packages/core/src/v3/taskContext/index.ts b/packages/core/src/v3/taskContext/index.ts index 0c5334b827..8edd6859dc 100644 --- a/packages/core/src/v3/taskContext/index.ts +++ b/packages/core/src/v3/taskContext/index.ts @@ -1,8 +1,8 @@ import { Attributes } from "@opentelemetry/api"; import { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js"; +import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; import { TaskContext } from "./types.js"; -import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; const API_NAME = "task-context"; diff --git a/packages/core/src/v3/tracer.ts b/packages/core/src/v3/tracer.ts index 13ab06310e..085107c017 100644 --- a/packages/core/src/v3/tracer.ts +++ b/packages/core/src/v3/tracer.ts @@ -1,7 +1,10 @@ import { + Attributes, Context, + HrTime, SpanOptions, SpanStatusCode, + TimeInput, context, propagation, trace, @@ -25,6 +28,16 @@ export type TriggerTracerConfig = logger: Logger; }; +export type TriggerTracerSpanEvent = { + name: string; + attributes?: Attributes; + startTime?: TimeInput; +}; + +export type TriggerTracerSpanOptions = SpanOptions & { + events?: TriggerTracerSpanEvent[]; +}; + export class TriggerTracer { constructor(private readonly _config: TriggerTracerConfig) {} @@ -57,7 +70,7 @@ export class TriggerTracer { startActiveSpan( name: string, fn: (span: Span) => Promise, - options?: SpanOptions, + options?: TriggerTracerSpanOptions, ctx?: Context, signal?: AbortSignal ): Promise { @@ -85,20 +98,32 @@ export class TriggerTracer { }); if (taskContext.ctx) { - this.tracer - .startSpan( - name, - { - ...options, - attributes: { - ...attributes, - [SemanticInternalAttributes.SPAN_PARTIAL]: true, - [SemanticInternalAttributes.SPAN_ID]: span.spanContext().spanId, - }, + const partialSpan = this.tracer.startSpan( + name, + { + ...options, + attributes: { + ...attributes, + [SemanticInternalAttributes.SPAN_PARTIAL]: true, + [SemanticInternalAttributes.SPAN_ID]: span.spanContext().spanId, }, - parentContext - ) - .end(); + }, + parentContext + ); + + if (options?.events) { + for (const event of options.events) { + partialSpan.addEvent(event.name, event.attributes, event.startTime); + } + } + + partialSpan.end(); + } + + if (options?.events) { + for (const event of options.events) { + span.addEvent(event.name, event.attributes, event.startTime); + } } const usageMeasurement = usage.start(); diff --git a/packages/core/src/v3/utils/globals.ts b/packages/core/src/v3/utils/globals.ts index ba4c09a1a2..7014169edf 100644 --- a/packages/core/src/v3/utils/globals.ts +++ b/packages/core/src/v3/utils/globals.ts @@ -3,6 +3,7 @@ import { ApiClientConfiguration } from "../apiClientManager/types.js"; import { Clock } from "../clock/clock.js"; import { RunMetadataManager } from "../runMetadata/types.js"; import type { RuntimeManager } from "../runtime/manager.js"; +import { RunTimelineMetricsManager } from "../runTimelineMetrics/types.js"; import { TaskCatalog } from "../task-catalog/catalog.js"; import { TaskContext } from "../taskContext/types.js"; import { TimeoutManager } from "../timeout/types.js"; @@ -61,4 +62,5 @@ type TriggerDotDevGlobalAPI = { ["run-metadata"]?: RunMetadataManager; ["timeout"]?: TimeoutManager; ["wait-until"]?: WaitUntilManager; + ["run-timeline-metrics"]?: RunTimelineMetricsManager; }; diff --git a/packages/core/src/v3/workers/index.ts b/packages/core/src/v3/workers/index.ts index 504302dde2..505b4a54c2 100644 --- a/packages/core/src/v3/workers/index.ts +++ b/packages/core/src/v3/workers/index.ts @@ -16,3 +16,4 @@ export { ProdUsageManager, type ProdUsageManagerOptions } from "../usage/prodUsa export { UsageTimeoutManager } from "../timeout/usageTimeoutManager.js"; export { StandardMetadataManager } from "../runMetadata/manager.js"; export { StandardWaitUntilManager } from "../waitUntil/manager.js"; +export { StandardRunTimelineMetricsManager } from "../runTimelineMetrics/runTimelineMetricsManager.js"; diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts index a28816b639..57b66b9c61 100644 --- a/packages/core/src/v3/workers/taskExecutor.ts +++ b/packages/core/src/v3/workers/taskExecutor.ts @@ -2,15 +2,10 @@ import { SpanKind } from "@opentelemetry/api"; import { VERSION } from "../../version.js"; import { ApiError, RateLimitError } from "../apiClient/errors.js"; import { ConsoleInterceptor } from "../consoleInterceptor.js"; -import { - InternalError, - isInternalError, - parseError, - sanitizeError, - TaskPayloadParsedError, -} from "../errors.js"; +import { isInternalError, parseError, sanitizeError, TaskPayloadParsedError } from "../errors.js"; import { runMetadata, TriggerConfig, waitUntil } from "../index.js"; import { recordSpanException, TracingSDK } from "../otel/index.js"; +import { runTimelineMetrics } from "../run-timeline-metrics-api.js"; import { ServerBackgroundWorker, TaskRunContext, @@ -97,8 +92,10 @@ export class TaskExecutor { let initOutput: any; try { - const payloadPacket = await conditionallyImportPacket(originalPacket, this._tracer); - parsedPayload = await parsePacket(payloadPacket); + await runTimelineMetrics.measureMetric("trigger.dev/execution", "payload", async () => { + const payloadPacket = await conditionallyImportPacket(originalPacket, this._tracer); + parsedPayload = await parsePacket(payloadPacket); + }); } catch (inputError) { recordSpanException(span, inputError); @@ -230,6 +227,8 @@ export class TaskExecutor { } finally { await this.#callTaskCleanup(parsedPayload, ctx, initOutput, signal); await this.#blockForWaitUntil(); + + span.setAttributes(runTimelineMetrics.convertMetricsToSpanAttributes()); } }); }, @@ -238,7 +237,14 @@ export class TaskExecutor { attributes: { [SemanticInternalAttributes.STYLE_ICON]: "attempt", [SemanticInternalAttributes.SPAN_ATTEMPT]: true, + ...(execution.attempt.number === 1 + ? runTimelineMetrics.convertMetricsToSpanAttributes() + : {}), }, + events: + execution.attempt.number === 1 + ? runTimelineMetrics.convertMetricsToSpanEvents() + : undefined, }, this._tracer.extractContext(traceContext), signal @@ -256,13 +262,18 @@ export class TaskExecutor { } if (!middlewareFn) { - return runFn(payload, { ctx, init, signal }); + return runTimelineMetrics.measureMetric("trigger.dev/execution", "run", () => + runFn(payload, { ctx, init, signal }) + ); } return middlewareFn(payload, { ctx, signal, - next: async () => runFn(payload, { ctx, init, signal }), + next: async () => + runTimelineMetrics.measureMetric("trigger.dev/execution", "run", () => + runFn(payload, { ctx, init, signal }) + ), }); } @@ -278,7 +289,9 @@ export class TaskExecutor { return this._tracer.startActiveSpan( "init", async (span) => { - return await initFn(payload, { ctx, signal }); + return await runTimelineMetrics.measureMetric("trigger.dev/execution", "init", () => + initFn(payload, { ctx, signal }) + ); }, { attributes: { @@ -298,7 +311,11 @@ export class TaskExecutor { return this._tracer.startActiveSpan( "config.init", async (span) => { - return await initFn(payload, { ctx, signal }); + return await runTimelineMetrics.measureMetric( + "trigger.dev/execution", + "config.init", + async () => initFn(payload, { ctx, signal }) + ); }, { attributes: { @@ -353,7 +370,9 @@ export class TaskExecutor { await this._tracer.startActiveSpan( name, async (span) => { - return await onSuccessFn(payload, output, { ctx, init: initOutput, signal }); + return await runTimelineMetrics.measureMetric("trigger.dev/execution", name, () => + onSuccessFn(payload, output, { ctx, init: initOutput, signal }) + ); }, { attributes: { @@ -411,7 +430,9 @@ export class TaskExecutor { return await this._tracer.startActiveSpan( name, async (span) => { - return await onFailureFn(payload, error, { ctx, init: initOutput, signal }); + return await runTimelineMetrics.measureMetric("trigger.dev/execution", name, () => + onFailureFn(payload, error, { ctx, init: initOutput, signal }) + ); }, { attributes: { @@ -472,7 +493,9 @@ export class TaskExecutor { await this._tracer.startActiveSpan( name, async (span) => { - return await onStartFn(payload, { ctx, signal }); + return await runTimelineMetrics.measureMetric("trigger.dev/execution", name, () => + onStartFn(payload, { ctx, signal }) + ); }, { attributes: { diff --git a/references/test-tasks/src/trigger/helpers.ts b/references/test-tasks/src/trigger/helpers.ts index ed3d8e7686..8fcffb3f29 100644 --- a/references/test-tasks/src/trigger/helpers.ts +++ b/references/test-tasks/src/trigger/helpers.ts @@ -1,4 +1,4 @@ -import { BatchResult, queue, task, wait } from "@trigger.dev/sdk/v3"; +import { BatchResult, logger, queue, task, wait } from "@trigger.dev/sdk/v3"; export const recursiveTask = task({ id: "recursive-task", @@ -188,6 +188,8 @@ function unwrapBatchResult(batchResult: BatchResult) { export const genericChildTask = task({ id: "generic-child-task", run: async (payload: { delayMs: number }, { ctx }) => { + logger.debug("Running generic child task"); + await new Promise((resolve) => setTimeout(resolve, payload.delayMs)); }, });