From ffcb23947a6d4733dc8b851debb4fded2f588971 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 24 Feb 2025 18:46:46 +0000 Subject: [PATCH 01/31] Record cold start and execution metrics on attempt executions. Add cold start metrics as span events on attempt spans and display them in the run dashboard --- .../app/components/primitives/DateTime.tsx | 138 +- .../webapp/app/components/run/RunTimeline.tsx | 407 ++++++ .../components/runs/v3/InspectorTimeline.tsx | 63 - .../app/components/runs/v3/RunInspector.tsx | 634 -------- .../app/components/runs/v3/SpanEvents.tsx | 8 +- .../app/components/runs/v3/SpanInspector.tsx | 264 ---- apps/webapp/app/hooks/useUser.ts | 4 +- .../app/presenters/v3/RunPresenter.server.ts | 7 +- .../app/presenters/v3/SpanPresenter.server.ts | 16 +- .../route.tsx | 110 +- .../route.tsx | 1279 ----------------- .../route.tsx | 246 +--- .../storybook.run-and-span-timeline/route.tsx | 114 ++ apps/webapp/app/routes/storybook/route.tsx | 4 + .../app/v3/marqs/devQueueConsumer.server.ts | 10 + packages/cli-v3/src/dev/workerRuntime.ts | 17 +- .../cli-v3/src/entryPoints/dev-run-worker.ts | 22 +- .../cli-v3/src/executions/taskRunProcess.ts | 4 +- packages/core/src/v3/index.ts | 1 + .../core/src/v3/run-timeline-metrics-api.ts | 5 + .../core/src/v3/runTimelineMetrics/index.ts | 203 +++ .../runTimelineMetricsManager.ts | 57 + .../core/src/v3/runTimelineMetrics/types.ts | 13 + packages/core/src/v3/schemas/messages.ts | 2 + packages/core/src/v3/schemas/schemas.ts | 15 + .../core/src/v3/semanticInternalAttributes.ts | 1 + packages/core/src/v3/taskContext/index.ts | 2 +- packages/core/src/v3/tracer.ts | 53 +- packages/core/src/v3/utils/globals.ts | 2 + packages/core/src/v3/workers/index.ts | 1 + packages/core/src/v3/workers/taskExecutor.ts | 55 +- references/test-tasks/src/trigger/helpers.ts | 4 +- 32 files changed, 1212 insertions(+), 2549 deletions(-) create mode 100644 apps/webapp/app/components/run/RunTimeline.tsx delete mode 100644 apps/webapp/app/components/runs/v3/InspectorTimeline.tsx delete mode 100644 apps/webapp/app/components/runs/v3/RunInspector.tsx delete mode 100644 apps/webapp/app/components/runs/v3/SpanInspector.tsx delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.electric.$runParam/route.tsx create mode 100644 apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx create mode 100644 packages/core/src/v3/run-timeline-metrics-api.ts create mode 100644 packages/core/src/v3/runTimelineMetrics/index.ts create mode 100644 packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts create mode 100644 packages/core/src/v3/runTimelineMetrics/types.ts 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..b940fd02a5 --- /dev/null +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -0,0 +1,407 @@ +import { ClockIcon } from "@heroicons/react/20/solid"; +import { + formatDuration, + formatDurationMilliseconds, + millisecondsToNanoseconds, + nanosecondsToMilliseconds, +} from "@trigger.dev/core/v3/utils/durations"; +import { Fragment, ReactNode, useState, useEffect } from "react"; +import type { SpanPresenter } from "~/presenters/v3/SpanPresenter.server"; +import { cn } from "~/utils/cn"; +import { DateTime, DateTimeAccurate, SmartDateTime } from "../primitives/DateTime"; +import { Spinner } from "../primitives/Spinner"; +import { LiveTimer } from "../runs/v3/LiveTimer"; +import type { SpanEvent } from "@trigger.dev/core/v3"; + +type SpanPresenterResult = Awaited>; + +export type TimelineSpan = NonNullable["span"]>; +export type TimelineSpanRun = NonNullable["run"]>; + +export function RunTimeline({ run }: { run: TimelineSpanRun }) { + return ( +
+ } + 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} +
+ ); +} + +export type RunTimelineEventProps = { + title: ReactNode; + subtitle?: ReactNode; + state: "complete" | "error"; +}; + +export function RunTimelineEvent({ title, subtitle, state }: RunTimelineEventProps) { + return ( +
+
+
+
+
+ {title} + {subtitle ? ( + {subtitle} + ) : null} +
+
+ ); +} + +export type SpanTimelineProps = { + startTime: Date; + duration: number; + inProgress: boolean; + isError: boolean; + events?: TimelineSpanEvent[]; + showAdminOnlyEvents?: boolean; +}; + +export type SpanTimelineState = "error" | "pending" | "complete"; + +export function SpanTimeline({ + startTime, + duration, + inProgress, + isError, + events, + showAdminOnlyEvents, +}: SpanTimelineProps) { + const state = isError ? "error" : inProgress ? "pending" : "complete"; + + // Filter events if needed + const visibleEvents = events?.filter((event) => !event.adminOnly || showAdminOnlyEvents) ?? []; + + // Keep track of the last date shown to avoid repeating + const [lastShownDate, setLastShownDate] = useState(null); + + return ( + <> +
+ {visibleEvents.map((event, index) => { + // Store previous date to compare + const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp; + + return ( + + } + state={"complete"} + /> + + + ); + })} + 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null + } + /> + } + state="complete" + /> + {state === "pending" ? ( + + + + + + + } + state={"inprogress"} + /> + ) : ( + <> + + + } + state={isError ? "error" : "complete"} + /> + + )} +
+ + ); +} + +export type RunTimelineLineProps = { + title: ReactNode; + state: "complete" | "delayed" | "inprogress"; +}; + +export function RunTimelineLine({ title, state }: RunTimelineLineProps) { + return ( +
+
+
+
+
+ {title} +
+
+ ); +} + +export type TimelineSpanEvent = { + name: string; + offset: number; + timestamp: Date; + duration?: number; + helpText?: string; + adminOnly: boolean; +}; + +export function createTimelineSpanEventsFromSpanEvents( + spanEvents: SpanEvent[], + 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 firstEventTime = + typeof sortedSpanEvents[0].time === "string" + ? new Date(sortedSpanEvents[0].time) + : sortedSpanEvents[0].time; + + const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime(); + + const events = matchingSpanEvents.map((spanEvent) => { + 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; + + return { + name: getFriendlyNameForEvent(name), + offset, + timestamp, + duration, + properties: spanEvent.properties, + adminOnly: getAdminOnlyForEvent(name), + helpText: getHelpTextForEvent(name), + }; + }); + + // 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"; + } + 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; + } + default: { + return true; + } + } +} + +function getHelpTextForEvent(event: string): string | undefined { + switch (event) { + case "dequeue": { + return "The task 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 task"; + } + case "import": { + return "A task file was imported"; + } + 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/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..9dbbe26c33 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"; @@ -138,6 +139,10 @@ export class RunPresenter { ...n, data: { ...n.data, + timelineEvents: createTimelineSpanEventsFromSpanEvents( + n.data.events, + 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..6c6ebac169 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, @@ -193,14 +193,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"], @@ -332,7 +324,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..5d77d9bf22 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"; @@ -64,7 +65,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 +741,7 @@ function TimelineView({ showDurations, treeScrollRef, }: TimelineViewProps) { + const isAdmin = useHasAdminAccess(); const timelineContainerRef = useRef(null); const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); const minTimelineWidth = initialTimelineDimensions?.width ?? 300; @@ -760,6 +762,9 @@ function TimelineView({ return () => clearInterval(interval); }, [totalDuration, rootSpanStatus]); + console.log("nodes", nodes); + console.log("events", events); + return (
{/* Follows the cursor */} - + {/* The duration labels */} @@ -888,16 +893,63 @@ 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 + ?.filter((event) => !event.adminOnly || isAdmin) + .map((event, eventIndex) => + eventIndex === 0 ? ( + + {(ms) => ( + + )} + + ) : ( + + {(ms) => ( + + )} + + ) + )} + {node.data.timelineEvents && + node.data.timelineEvents[0] && + node.data.timelineEvents[0].offset < node.data.offset ? ( + + + + ) : null} + + ) : ( {(ms) => ( @@ -1110,7 +1162,13 @@ function SpanWithDuration({ const edgeBoundary = 0.05; -function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) { +function CurrentTimeIndicator({ + totalDuration, + rootStartedAt, +}: { + totalDuration: number; + rootStartedAt: Date | undefined; +}) { return ( {(ms) => { @@ -1122,6 +1180,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 +1193,25 @@ 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..1c40a22907 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,8 @@ function SpanBody({ duration={span.duration} inProgress={span.isPartial} isError={span.isError} + showAdminOnlyEvents={isAdmin} + events={createTimelineSpanEventsFromSpanEvents(span.events)} /> ) : ( @@ -810,151 +816,6 @@ function RunBody({ ); } -function RunTimeline({ run }: { run: SpanRun }) { - return ( -
- } - 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 +926,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..6518af3dd3 --- /dev/null +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -0,0 +1,114 @@ +import { + RunTimeline, + RunTimelineEvent, + SpanTimeline, + SpanTimelineProps, +} 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, + adminOnly: false, + }, + { + name: "Launched", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + adminOnly: false, + }, + { + name: "Imported task file", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + adminOnly: true, + }, + ], + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + showAdminOnlyEvents: true, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 5000), + duration: 4000, + adminOnly: false, + }, + { + name: "Forked", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + adminOnly: true, + }, + ], + }, + { + startTime: new Date(), + duration: 1000 * 1_000_000, + inProgress: false, + isError: false, + showAdminOnlyEvents: true, + events: [ + { + name: "Dequeued", + offset: 0, + timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000), + duration: 4000, + adminOnly: false, + }, + { + name: "Forked", + offset: 0, + timestamp: new Date(Date.now() - 1000), + duration: 1000, + adminOnly: true, + }, + ], + }, +] satisfies SpanTimelineProps[]; + +export default function Story() { + return ( +
+ Span Timeline + {spanTimelines.map((props, 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/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/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index e299dd1ebf..08ca6b0781 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,19 @@ const zodIpc = new ZodIpcConnection({ } try { + const perfImportStart = Date.now(); + await import(normalizeImportPath(taskManifest.entryPoint)); + + runTimelineMetrics.registerMetric({ + name: "trigger.dev/start", + event: "import", + attributes: { + entryPoint: taskManifest.entryPoint, + duration: Date.now() - perfImportStart, + }, + timestamp: perfImportStart, + }); } 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/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..05b7100faf 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"; @@ -202,6 +203,7 @@ export const WorkerToExecutorMessageCatalog = { execution: TaskRunExecution, traceContext: z.record(z.unknown()), metadata: ServerBackgroundWorker, + metrics: TaskRunExecutionMetrics.optional(), }), }, TASK_RUN_COMPLETED_NOTIFICATION: { diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 8ed9b7b0be..047fb42ce1 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; @@ -247,6 +261,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)); }, }); From 5b2001a2401db160efe8c12cc35ef1ddb9dfeb2e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 25 Feb 2025 17:47:31 +0000 Subject: [PATCH 02/31] Add deployed tasks run timeline metrics --- apps/coordinator/src/index.ts | 18 ++- apps/docker-provider/src/index.ts | 6 +- apps/kubernetes-provider/src/index.ts | 5 +- .../webapp/app/components/run/RunTimeline.tsx | 12 ++ .../v3/marqs/sharedQueueConsumer.server.ts | 123 ++++++++++-------- .../migration.sql | 5 + .../database/prisma/schema.prisma | 7 +- .../src/entryPoints/deploy-run-controller.ts | 50 ++++++- .../src/entryPoints/deploy-run-worker.ts | 31 ++++- .../cli-v3/src/entryPoints/dev-run-worker.ts | 19 ++- packages/core/src/v3/apps/provider.ts | 2 + packages/core/src/v3/schemas/messages.ts | 2 + packages/core/src/v3/schemas/schemas.ts | 1 + 13 files changed, 199 insertions(+), 82 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250225164413_add_executed_at_to_task_run/migration.sql 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/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index b940fd02a5..2402a5fca3 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -360,6 +360,12 @@ function getFriendlyNameForEvent(event: string): string { case "import": { return "Imported task file"; } + case "lazy_payload": { + return "Lazy attempt initialized"; + } + case "pod_scheduled": { + return "Pod scheduled"; + } default: { return event; } @@ -380,6 +386,12 @@ function getAdminOnlyForEvent(event: string): boolean { case "import": { return true; } + case "lazy_payload": { + return true; + } + case "pod_scheduled": { + return true; + } default: { return true; } 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/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/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 08ca6b0781..60b8294750 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -241,19 +241,16 @@ const zodIpc = new ZodIpcConnection({ } try { - const perfImportStart = Date.now(); - - await import(normalizeImportPath(taskManifest.entryPoint)); - - runTimelineMetrics.registerMetric({ - name: "trigger.dev/start", - event: "import", - attributes: { + await runTimelineMetrics.measureMetric( + "trigger.dev/start", + "import", + { entryPoint: taskManifest.entryPoint, - duration: Date.now() - perfImportStart, }, - timestamp: perfImportStart, - }); + async () => { + await import(normalizeImportPath(taskManifest.entryPoint)); + } + ); } catch (err) { console.error(`Failed to import task ${execution.task.id}`, err); 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/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index 05b7100faf..23ac53c4f8 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -53,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"), @@ -674,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 047fb42ce1..525f916bba 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -49,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; From 7f7cdc5118ce37be1c00a739247b7921c8553ff0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 25 Feb 2025 21:33:35 +0000 Subject: [PATCH 03/31] Add Dequeued event to run timeline and cleanup the run timeline code --- .../webapp/app/components/run/RunTimeline.tsx | 420 +++++++++++++----- .../app/presenters/v3/SpanPresenter.server.ts | 2 + .../route.tsx | 3 - .../storybook.run-and-span-timeline/route.tsx | 39 ++ .../services/createTaskRunAttempt.server.ts | 23 +- 5 files changed, 350 insertions(+), 137 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 2402a5fca3..542df3311d 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -1,107 +1,286 @@ import { ClockIcon } from "@heroicons/react/20/solid"; +import type { SpanEvent } from "@trigger.dev/core/v3"; import { formatDuration, - formatDurationMilliseconds, millisecondsToNanoseconds, nanosecondsToMilliseconds, } from "@trigger.dev/core/v3/utils/durations"; -import { Fragment, ReactNode, useState, useEffect } from "react"; -import type { SpanPresenter } from "~/presenters/v3/SpanPresenter.server"; +import { Fragment, ReactNode, useState } from "react"; import { cn } from "~/utils/cn"; -import { DateTime, DateTimeAccurate, SmartDateTime } from "../primitives/DateTime"; +import { DateTime, DateTimeAccurate } from "../primitives/DateTime"; import { Spinner } from "../primitives/Spinner"; import { LiveTimer } from "../runs/v3/LiveTimer"; -import type { SpanEvent } from "@trigger.dev/core/v3"; -type SpanPresenterResult = Awaited>; +// Types for the RunTimeline component +export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed"; + +// Timeline item type definitions +export type TimelineEventDefinition = { + type: "event"; + id: string; + title: string; + date?: Date; + previousDate: Date | undefined; + state: TimelineEventState; + shouldRender: boolean; +}; + +export type TimelineLineDefinition = { + type: "line"; + id: string; + title: React.ReactNode; + state: TimelineEventState; + shouldRender: boolean; +}; -export type TimelineSpan = NonNullable["span"]>; -export type TimelineSpanRun = NonNullable["run"]>; +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 (
- } - 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"} - /> - - ) : ( - - - - - - + {visibleItems.map((item) => { + if (item.type === "event") { + return ( + + ) : null } - state={"inprogress"} + state={item.state as "complete" | "error"} /> - )} - - ) : null} + ); + } else { + return ; + } + })}
); } +// Centralized function to build all timeline items +function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { + const items: TimelineItem[] = []; + + // 1. Triggered Event + items.push({ + type: "event", + id: "triggered", + title: "Triggered", + date: run.createdAt, + previousDate: undefined, + state: "complete", + shouldRender: true, + }); + + // 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: "delayed", + shouldRender: true, + }); + } 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: "complete", + shouldRender: true, + }); + } else if (run.expiredAt) { + // Expired before dequeuing + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: formatDuration(run.createdAt, run.expiredAt), + state: "complete", + shouldRender: true, + }); + } else { + // Still waiting to be dequeued + items.push({ + type: "line", + id: "waiting-to-dequeue", + title: ( + <> + {" "} + {run.ttl && <>(TTL {run.ttl})} + + ), + state: "inprogress", + shouldRender: true, + }); + } + + // 3. Dequeued Event (if applicable) + if (run.startedAt) { + items.push({ + type: "event", + id: "dequeued", + title: "Dequeued", + date: run.startedAt, + previousDate: run.createdAt, + state: "complete", + shouldRender: true, + }); + } + + // 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: "complete", + shouldRender: true, + }); + + // 4b. Show Started event + items.push({ + type: "event", + id: "started", + title: "Started", + date: run.executedAt, + previousDate: run.startedAt, + state: "complete", + shouldRender: true, + }); + + // 4c. Show executing line if applicable + if (run.isFinished) { + items.push({ + type: "line", + id: "executing", + title: formatDuration(run.executedAt, run.updatedAt), + state: "complete", + shouldRender: true, + }); + } else { + items.push({ + type: "line", + id: "executing", + title: ( + + + + + + + ), + state: "inprogress", + shouldRender: true, + }); + } + } 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: "complete", + shouldRender: true, + }); + } else { + // Still waiting to start or execute (can't distinguish without executedAt) + items.push({ + type: "line", + id: "legacy-waiting-or-executing", + title: ( + + + + + + + ), + state: "inprogress", + shouldRender: true, + }); + } + } + } + + // 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: run.isError ? "error" : "complete", + shouldRender: true, + }); + } + + // 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, + }); + } + + return items; +} + export type RunTimelineEventProps = { title: ReactNode; subtitle?: ReactNode; @@ -129,6 +308,42 @@ export function RunTimelineEvent({ title, subtitle, state }: RunTimelineEventPro ); } +export type RunTimelineLineProps = { + title: ReactNode; + state: TimelineEventState; +}; + +export function RunTimelineLine({ title, state }: RunTimelineLineProps) { + return ( +
+
+
+
+
+ {title} +
+
+ ); +} + export type SpanTimelineProps = { startTime: Date; duration: number; @@ -153,9 +368,6 @@ export function SpanTimeline({ // Filter events if needed const visibleEvents = events?.filter((event) => !event.adminOnly || showAdminOnlyEvents) ?? []; - // Keep track of the last date shown to avoid repeating - const [lastShownDate, setLastShownDate] = useState(null); - return ( <>
@@ -233,42 +445,6 @@ export function SpanTimeline({ ); } -export type RunTimelineLineProps = { - title: ReactNode; - state: "complete" | "delayed" | "inprogress"; -}; - -export function RunTimelineLine({ title, state }: RunTimelineLineProps) { - return ( -
-
-
-
-
- {title} -
-
- ); -} - export type TimelineSpanEvent = { name: string; offset: number; diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 6c6ebac169..9ea10cac72 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -88,6 +88,7 @@ export class SpanPresenter extends BasePresenter { //status + duration status: true, startedAt: true, + executedAt: true, createdAt: true, updatedAt: true, queuedAt: true, @@ -249,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, 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 5d77d9bf22..e472b7dd25 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 @@ -762,9 +762,6 @@ function TimelineView({ return () => clearInterval(interval); }, [totalDuration, rootSpanStatus]); - console.log("nodes", nodes); - console.log("events", events); - return (
@@ -109,6 +144,10 @@ export default function Story() { {spanTimelines.map((props, index) => ( ))} + Run Timeline + {runTimelines.map((run, index) => ( + + ))}
); } 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; From b82acecd59177ec38bf95fa6c15a00195c0b131d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 15:59:20 +0000 Subject: [PATCH 04/31] Adds variants to storybook --- .../app/routes/storybook.run-and-span-timeline/route.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 598d1bf355..0089562251 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -38,6 +38,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 5000), duration: 4000, adminOnly: false, + variant: "start-cap", }, { name: "Launched", @@ -45,6 +46,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: false, + variant: "dot-hollow", }, { name: "Imported task file", @@ -52,6 +54,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, + variant: "dot-hollow", }, ], }, @@ -68,6 +71,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 5000), duration: 4000, adminOnly: false, + variant: "start-cap", }, { name: "Forked", @@ -75,6 +79,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, + variant: "dot-hollow", }, ], }, @@ -91,6 +96,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000), duration: 4000, adminOnly: false, + variant: "start-cap", }, { name: "Forked", @@ -98,6 +104,7 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, + variant: "dot-hollow", }, ], }, @@ -139,7 +146,7 @@ const runTimelines = [ export default function Story() { return ( -
+
Span Timeline {spanTimelines.map((props, index) => ( From d9f895215b80cf86d4c73be4ea1e17ad47604ca3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 15:59:50 +0000 Subject: [PATCH 05/31] WIP adding new span styles --- .../webapp/app/components/run/RunTimeline.tsx | 188 ++++++++++++++++-- 1 file changed, 167 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 542df3311d..5686fcf3e8 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -10,10 +10,21 @@ import { cn } from "~/utils/cn"; import { DateTime, DateTimeAccurate } from "../primitives/DateTime"; import { Spinner } from "../primitives/Spinner"; import { LiveTimer } from "../runs/v3/LiveTimer"; +import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; // Types for the RunTimeline component export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed"; +type TimelineLineVariant = "light" | "normal"; + +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"; @@ -23,6 +34,7 @@ export type TimelineEventDefinition = { previousDate: Date | undefined; state: TimelineEventState; shouldRender: boolean; + variant: TimelineEventVariant; }; export type TimelineLineDefinition = { @@ -31,6 +43,7 @@ export type TimelineLineDefinition = { title: React.ReactNode; state: TimelineEventState; shouldRender: boolean; + variant: TimelineLineVariant; }; export type TimelineItem = TimelineEventDefinition | TimelineLineDefinition; @@ -101,6 +114,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { previousDate: undefined, state: "complete", shouldRender: true, + variant: "start-cap", }); // 2. Waiting to dequeue line @@ -119,6 +133,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), state: "delayed", shouldRender: true, + variant: "light", }); } else if (run.startedAt) { // Already dequeued - show the waiting duration @@ -128,6 +143,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: formatDuration(run.createdAt, run.startedAt), state: "complete", shouldRender: true, + variant: "light", }); } else if (run.expiredAt) { // Expired before dequeuing @@ -137,6 +153,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: formatDuration(run.createdAt, run.expiredAt), state: "complete", shouldRender: true, + variant: "light", }); } else { // Still waiting to be dequeued @@ -154,6 +171,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), state: "inprogress", shouldRender: true, + variant: "light", }); } @@ -167,6 +185,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { previousDate: run.createdAt, state: "complete", shouldRender: true, + variant: "dot-hollow", }); } @@ -182,6 +201,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: formatDuration(run.startedAt, run.executedAt), state: "complete", shouldRender: true, + variant: "light", }); // 4b. Show Started event @@ -193,6 +213,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { previousDate: run.startedAt, state: "complete", shouldRender: true, + variant: "start-cap-thick", }); // 4c. Show executing line if applicable @@ -203,6 +224,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: formatDuration(run.executedAt, run.updatedAt), state: "complete", shouldRender: true, + variant: "normal", }); } else { items.push({ @@ -218,6 +240,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), state: "inprogress", shouldRender: true, + variant: "normal", }); } } else { @@ -231,6 +254,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: formatDuration(run.startedAt, run.updatedAt), state: "complete", shouldRender: true, + variant: "normal", }); } else { // Still waiting to start or execute (can't distinguish without executedAt) @@ -247,6 +271,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), state: "inprogress", shouldRender: true, + variant: "normal", }); } } @@ -262,6 +287,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { previousDate: run.executedAt ?? run.startedAt ?? undefined, state: run.isError ? "error" : "complete", shouldRender: true, + variant: "dot-solid", }); } @@ -275,6 +301,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { previousDate: run.createdAt, state: "error", shouldRender: true, + variant: "dot-solid", }); } @@ -285,18 +312,19 @@ export type RunTimelineEventProps = { title: ReactNode; subtitle?: ReactNode; state: "complete" | "error"; + variant?: TimelineEventVariant; }; -export function RunTimelineEvent({ title, subtitle, state }: RunTimelineEventProps) { +export function RunTimelineEvent({ + title, + subtitle, + state, + variant = "dot-hollow", +}: RunTimelineEventProps) { return ( -
-
-
+
+
+
{title} @@ -308,34 +336,137 @@ export function RunTimelineEvent({ title, subtitle, state }: RunTimelineEventPro ); } +function EventMarker({ + variant, + state, +}: { + variant: TimelineEventVariant; + state: TimelineEventState; +}) { + switch (variant) { + case "start-cap": + return ( + <> +
+
+ + ); + case "dot-hollow": + return ( + <> +
+
+
+ + ); + case "dot-solid": + return ( +
+ ); + case "start-cap-thick": + return ( +
+ ); + case "end-cap-thick": + return ( +
+ ); + default: + return
; + } +} + export type RunTimelineLineProps = { title: ReactNode; state: TimelineEventState; + variant?: TimelineLineVariant; }; -export function RunTimelineLine({ title, state }: RunTimelineLineProps) { +export function RunTimelineLine({ title, state, variant = "normal" }: RunTimelineLineProps) { return ( -
+
+ />
{title} @@ -381,6 +512,7 @@ export function SpanTimeline({ title={event.name} subtitle={} state={"complete"} + variant={event.variant} /> } state="complete" + variant={"start-cap-thick"} /> {state === "pending" ? ( } state={"inprogress"} + variant={visibleEvents.length > 0 ? "normal" : "light"} /> ) : ( <> @@ -427,6 +561,7 @@ export function SpanTimeline({ new Date(startTime.getTime() + nanosecondsToMilliseconds(duration)) )} state={"complete"} + variant={visibleEvents.length > 0 ? "normal" : "light"} /> } state={isError ? "error" : "complete"} + variant={"end-cap-thick"} /> )} @@ -452,6 +588,7 @@ export type TimelineSpanEvent = { duration?: number; helpText?: string; adminOnly: boolean; + variant: TimelineEventVariant; }; export function createTimelineSpanEventsFromSpanEvents( @@ -489,7 +626,7 @@ export function createTimelineSpanEventsFromSpanEvents( const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime(); - const events = matchingSpanEvents.map((spanEvent) => { + const events = matchingSpanEvents.map((spanEvent, index) => { const timestamp = typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time; @@ -505,6 +642,14 @@ export function createTimelineSpanEventsFromSpanEvents( ? spanEvent.properties.event : spanEvent.name; + let variant: TimelineEventVariant = "dot-hollow"; + + if (index === 0) { + variant = "start-cap"; + } else if (index === matchingSpanEvents.length - 1) { + variant = "end-cap-thick"; + } + return { name: getFriendlyNameForEvent(name), offset, @@ -513,6 +658,7 @@ export function createTimelineSpanEventsFromSpanEvents( properties: spanEvent.properties, adminOnly: getAdminOnlyForEvent(name), helpText: getHelpTextForEvent(name), + variant, }; }); From 61e4268111e6894f2e582e2a5d31411e3bd3d37d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 18:05:51 +0000 Subject: [PATCH 06/31] Added offset progress bar animation --- apps/webapp/tailwind.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index e57368fb3c..20ccb53b1f 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-move-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)", From 381d3beee5f6137e0b2ed944cf5e7af7aa52956a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 18:06:11 +0000 Subject: [PATCH 07/31] More storybook states --- .../storybook.run-and-span-timeline/route.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) 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 index 0089562251..c24adae452 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -38,7 +38,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 5000), duration: 4000, adminOnly: false, - variant: "start-cap", + markerVariant: "start-cap", + lineVariant: "light", }, { name: "Launched", @@ -46,7 +47,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: false, - variant: "dot-hollow", + markerVariant: "dot-hollow", + lineVariant: "light", }, { name: "Imported task file", @@ -54,7 +56,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, - variant: "dot-hollow", + markerVariant: "dot-hollow", + lineVariant: "light", }, ], }, @@ -71,7 +74,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 5000), duration: 4000, adminOnly: false, - variant: "start-cap", + markerVariant: "start-cap", + lineVariant: "light", }, { name: "Forked", @@ -79,7 +83,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, - variant: "dot-hollow", + markerVariant: "dot-hollow", + lineVariant: "light", }, ], }, @@ -96,7 +101,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000), duration: 4000, adminOnly: false, - variant: "start-cap", + markerVariant: "start-cap", + lineVariant: "light", }, { name: "Forked", @@ -104,7 +110,8 @@ const spanTimelines = [ timestamp: new Date(Date.now() - 1000), duration: 1000, adminOnly: true, - variant: "dot-hollow", + markerVariant: "dot-hollow", + lineVariant: "light", }, ], }, @@ -126,6 +133,15 @@ const runTimelines = [ 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(), From d199647e5d2b7e3db09f67d953445325e11afa3d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 18:07:58 +0000 Subject: [PATCH 08/31] Adds support for the full vertical span to show the same state --- .../webapp/app/components/run/RunTimeline.tsx | 200 ++++++++++++------ 1 file changed, 139 insertions(+), 61 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 5686fcf3e8..09532f731d 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -32,7 +32,7 @@ export type TimelineEventDefinition = { title: string; date?: Date; previousDate: Date | undefined; - state: TimelineEventState; + state?: TimelineEventState; shouldRender: boolean; variant: TimelineEventVariant; }; @@ -41,7 +41,7 @@ export type TimelineLineDefinition = { type: "line"; id: string; title: React.ReactNode; - state: TimelineEventState; + state?: TimelineEventState; shouldRender: boolean; variant: TimelineLineVariant; }; @@ -91,10 +91,18 @@ export function RunTimeline({ run }: { run: TimelineSpanRun }) { ) : null } state={item.state as "complete" | "error"} + variant={item.variant} /> ); } else { - return ; + return ( + + ); } })}
@@ -103,6 +111,7 @@ export function RunTimeline({ run }: { run: TimelineSpanRun }) { // Centralized function to build all timeline items function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { + const state = run.isError ? "error" : run.isFinished ? "complete" : "inprogress"; const items: TimelineItem[] = []; // 1. Triggered Event @@ -112,7 +121,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: "Triggered", date: run.createdAt, previousDate: undefined, - state: "complete", + state, shouldRender: true, variant: "start-cap", }); @@ -131,7 +140,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), - state: "delayed", + state, shouldRender: true, variant: "light", }); @@ -141,7 +150,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { type: "line", id: "waiting-to-dequeue", title: formatDuration(run.createdAt, run.startedAt), - state: "complete", + state, shouldRender: true, variant: "light", }); @@ -151,7 +160,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { type: "line", id: "waiting-to-dequeue", title: formatDuration(run.createdAt, run.expiredAt), - state: "complete", + state, shouldRender: true, variant: "light", }); @@ -169,7 +178,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { {run.ttl && <>(TTL {run.ttl})} ), - state: "inprogress", + state, shouldRender: true, variant: "light", }); @@ -183,7 +192,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: "Dequeued", date: run.startedAt, previousDate: run.createdAt, - state: "complete", + state, shouldRender: true, variant: "dot-hollow", }); @@ -199,7 +208,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { type: "line", id: "waiting-to-execute", title: formatDuration(run.startedAt, run.executedAt), - state: "complete", + state, shouldRender: true, variant: "light", }); @@ -211,7 +220,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: "Started", date: run.executedAt, previousDate: run.startedAt, - state: "complete", + state, shouldRender: true, variant: "start-cap-thick", }); @@ -222,7 +231,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { type: "line", id: "executing", title: formatDuration(run.executedAt, run.updatedAt), - state: "complete", + state, shouldRender: true, variant: "normal", }); @@ -238,7 +247,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), - state: "inprogress", + state, shouldRender: true, variant: "normal", }); @@ -252,7 +261,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { type: "line", id: "legacy-executing", title: formatDuration(run.startedAt, run.updatedAt), - state: "complete", + state, shouldRender: true, variant: "normal", }); @@ -269,7 +278,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { ), - state: "inprogress", + state, shouldRender: true, variant: "normal", }); @@ -285,9 +294,9 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { title: "Finished", date: run.updatedAt, previousDate: run.executedAt ?? run.startedAt ?? undefined, - state: run.isError ? "error" : "complete", + state, shouldRender: true, - variant: "dot-solid", + variant: "end-cap-thick", }); } @@ -311,7 +320,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { export type RunTimelineEventProps = { title: ReactNode; subtitle?: ReactNode; - state: "complete" | "error"; + state?: "complete" | "error" | "inprogress"; variant?: TimelineEventVariant; }; @@ -341,7 +350,7 @@ function EventMarker({ state, }: { variant: TimelineEventVariant; - state: TimelineEventState; + state?: TimelineEventState; }) { switch (variant) { case "start-cap": @@ -354,6 +363,8 @@ function EventMarker({ ? "border-success" : state === "error" ? "border-error" + : state === "inprogress" + ? "border-pending" : "border-text-dimmed" )} /> @@ -364,8 +375,19 @@ function EventMarker({ ? "bg-success" : state === "error" ? "bg-error" + : state === "inprogress" + ? "animate-tile-scroll bg-pending" : "bg-text-dimmed" )} + style={ + state === "inprogress" + ? { + height: "100%", + backgroundImage: `url(${tileBgPath})`, + backgroundSize: "8px 8px", + } + : undefined + } /> ); @@ -409,7 +431,7 @@ function EventMarker({
); @@ -417,17 +439,33 @@ function EventMarker({ return (
); case "end-cap-thick": return (
); @@ -438,7 +476,7 @@ function EventMarker({ export type RunTimelineLineProps = { title: ReactNode; - state: TimelineEventState; + state?: TimelineEventState; variant?: TimelineLineVariant; }; @@ -446,16 +484,37 @@ export function RunTimelineLine({ title, state, variant = "normal" }: RunTimelin return (
+ +
+
+ {title} +
+
+ ); +} + +function LineMarker({ + state, + variant, +}: { + state?: TimelineEventState; + variant: TimelineLineVariant; +}) { + switch (variant) { + case "normal": + return (
-
-
- {title} -
-
- ); + ); + case "light": + return ( +
+ ); + default: + return
; + } } export type SpanTimelineProps = { @@ -494,7 +577,7 @@ export function SpanTimeline({ events, showAdminOnlyEvents, }: SpanTimelineProps) { - const state = isError ? "error" : inProgress ? "pending" : "complete"; + const state = isError ? "error" : inProgress ? "inprogress" : undefined; // Filter events if needed const visibleEvents = events?.filter((event) => !event.adminOnly || showAdminOnlyEvents) ?? []; @@ -511,8 +594,8 @@ export function SpanTimeline({ } - state={"complete"} - variant={event.variant} + variant={event.markerVariant} + state={state} /> ); @@ -537,21 +621,14 @@ export function SpanTimeline({ } /> } - state="complete" variant={"start-cap-thick"} + state={state} /> - {state === "pending" ? ( + {state === "inprogress" ? ( - - - - - - } - state={"inprogress"} - variant={visibleEvents.length > 0 ? "normal" : "light"} + title={} + state={state} + variant={visibleEvents.length > 0 ? "light" : "normal"} /> ) : ( <> @@ -560,8 +637,7 @@ export function SpanTimeline({ startTime, new Date(startTime.getTime() + nanosecondsToMilliseconds(duration)) )} - state={"complete"} - variant={visibleEvents.length > 0 ? "normal" : "light"} + variant="normal" /> } - state={isError ? "error" : "complete"} - variant={"end-cap-thick"} + state={isError ? "error" : undefined} + variant="end-cap-thick" /> )} @@ -588,7 +664,8 @@ export type TimelineSpanEvent = { duration?: number; helpText?: string; adminOnly: boolean; - variant: TimelineEventVariant; + markerVariant: TimelineEventVariant; + lineVariant: TimelineLineVariant; }; export function createTimelineSpanEventsFromSpanEvents( @@ -642,12 +719,12 @@ export function createTimelineSpanEventsFromSpanEvents( ? spanEvent.properties.event : spanEvent.name; - let variant: TimelineEventVariant = "dot-hollow"; + let markerVariant: TimelineEventVariant = "dot-hollow"; if (index === 0) { - variant = "start-cap"; + markerVariant = "start-cap"; } else if (index === matchingSpanEvents.length - 1) { - variant = "end-cap-thick"; + markerVariant = "end-cap-thick"; } return { @@ -658,7 +735,8 @@ export function createTimelineSpanEventsFromSpanEvents( properties: spanEvent.properties, adminOnly: getAdminOnlyForEvent(name), helpText: getHelpTextForEvent(name), - variant, + markerVariant, + lineVariant: "light" as const, }; }); From 07b7374f1a37afa7e0984f7bc04f4686097e1479 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 18:54:34 +0000 Subject: [PATCH 09/31] Adds error state to timelineLine --- apps/webapp/app/components/run/RunTimeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 09532f731d..c040b47f5b 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -637,6 +637,7 @@ export function SpanTimeline({ startTime, new Date(startTime.getTime() + nanosecondsToMilliseconds(duration)) )} + state={isError ? "error" : undefined} variant="normal" /> Date: Wed, 26 Feb 2025 19:25:28 +0000 Subject: [PATCH 10/31] Added additional state --- .../app/routes/storybook.run-and-span-timeline/route.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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 index c24adae452..41b83e511e 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -124,6 +124,13 @@ const runTimelines = [ 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(), From 9c651b1c687912b5b1ba0bc525fce177493fba86 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 26 Feb 2025 19:26:29 +0000 Subject: [PATCH 11/31] Added more line styling --- .../webapp/app/components/run/RunTimeline.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index c040b47f5b..7042c7d8a2 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -239,14 +239,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { items.push({ type: "line", id: "executing", - title: ( - - - - - - - ), + title: , state, shouldRender: true, variant: "normal", @@ -270,17 +263,10 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { items.push({ type: "line", id: "legacy-waiting-or-executing", - title: ( - - - - - - - ), + title: , state, shouldRender: true, - variant: "normal", + variant: "light", }); } } @@ -454,7 +440,6 @@ function EventMarker({ height: "100%", backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px", - // backgroundPosition: "0px 2px", } : undefined } From efe359be51e4f3c00d3cc1650839cefec1ae436e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 09:57:08 +0000 Subject: [PATCH 12/31] Added progress state to dequeued --- .../webapp/app/components/run/RunTimeline.tsx | 26 ++++++++++++++++++- apps/webapp/tailwind.config.js | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 7042c7d8a2..078b1d31c9 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -387,8 +387,19 @@ function EventMarker({ ? "bg-success" : state === "error" ? "bg-error" + : state === "inprogress" + ? "animate-tile-scroll-offset bg-pending" : "bg-text-dimmed" )} + style={ + state === "inprogress" + ? { + height: "100%", + backgroundImage: `url(${tileBgPath})`, + backgroundSize: "8px 8px", + } + : undefined + } />
@@ -407,8 +420,19 @@ function EventMarker({ ? "bg-success" : state === "error" ? "bg-error" + : state === "inprogress" + ? "animate-tile-scroll-offset bg-pending" : "bg-text-dimmed" )} + style={ + state === "inprogress" + ? { + height: "100%", + backgroundImage: `url(${tileBgPath})`, + backgroundSize: "8px 8px", + } + : undefined + } /> ); @@ -431,7 +455,7 @@ function EventMarker({ : state === "error" ? "bg-error" : state === "inprogress" - ? "animate-tile-move-offset bg-pending" + ? "animate-tile-scroll-offset bg-pending" : "bg-text-dimmed" )} style={ diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 20ccb53b1f..af13215ecc 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -260,7 +260,7 @@ module.exports = { "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-move-offset": "tile-move-offset 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))", From 0be59a1ea3ae1d5232d110b387213f3cf6a50a12 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 10:00:13 +0000 Subject: [PATCH 13/31] Added another state to storybook --- .../app/routes/storybook.run-and-span-timeline/route.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 41b83e511e..e0857d0a11 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -131,6 +131,14 @@ const runTimelines = [ 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(), From d6af00ba2901affe955ea2b3b2f1ce50a1dabb9a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 11:49:07 +0000 Subject: [PATCH 14/31] Fixed classname error --- apps/webapp/app/components/primitives/Badge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": From ca578627523f080d103afd9e0f5d813413837236 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 11:49:45 +0000 Subject: [PATCH 15/31] Updated styles for the span timeline points --- .../route.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 e472b7dd25..ca6a1da00a 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 @@ -902,7 +902,7 @@ function TimelineView({ > {(ms) => ( )} @@ -911,10 +911,11 @@ function TimelineView({ {(ms) => ( )} From 6af700831b840dd34f5ce8da03e0f3d6afcc91b6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 13:52:59 +0000 Subject: [PATCH 16/31] Fixes alignment of timeline follow cursor indicator --- .../route.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 ca6a1da00a..dab17990a3 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 @@ -1158,7 +1158,7 @@ function SpanWithDuration({ ); } -const edgeBoundary = 0.05; +const edgeBoundary = 0.17; function CurrentTimeIndicator({ totalDuration, @@ -1197,9 +1197,7 @@ function CurrentTimeIndicator({ style: "short", maxDecimalPoints: ms < 1000 ? 0 : 1, })} - - - - + {currentTimeComponent} ) : ( From 53d0437752ed41c420d9b6c0d8d9fdc5e3e8915c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 13:57:59 +0000 Subject: [PATCH 17/31] Adds help text tooltip to timeline span type titles --- .../webapp/app/components/run/RunTimeline.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 078b1d31c9..65f98f35e2 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -11,6 +11,7 @@ import { DateTime, DateTimeAccurate } from "../primitives/DateTime"; import { Spinner } from "../primitives/Spinner"; 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"; @@ -35,6 +36,7 @@ export type TimelineEventDefinition = { state?: TimelineEventState; shouldRender: boolean; variant: TimelineEventVariant; + helpText?: string; }; export type TimelineLineDefinition = { @@ -92,6 +94,7 @@ export function RunTimeline({ run }: { run: TimelineSpanRun }) { } state={item.state as "complete" | "error"} variant={item.variant} + helpText={item.helpText} /> ); } else { @@ -124,6 +127,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { state, shouldRender: true, variant: "start-cap", + helpText: getHelpTextForEvent("Triggered"), }); // 2. Waiting to dequeue line @@ -195,6 +199,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { state, shouldRender: true, variant: "dot-hollow", + helpText: getHelpTextForEvent("Dequeued"), }); } @@ -223,6 +228,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { state, shouldRender: true, variant: "start-cap-thick", + helpText: getHelpTextForEvent("Started"), }); // 4c. Show executing line if applicable @@ -283,6 +289,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { state, shouldRender: true, variant: "end-cap-thick", + helpText: getHelpTextForEvent("Finished"), }); } @@ -297,6 +304,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { state: "error", shouldRender: true, variant: "dot-solid", + helpText: getHelpTextForEvent("Expired"), }); } @@ -308,6 +316,7 @@ export type RunTimelineEventProps = { subtitle?: ReactNode; state?: "complete" | "error" | "inprogress"; variant?: TimelineEventVariant; + helpText?: string; }; export function RunTimelineEvent({ @@ -315,6 +324,7 @@ export function RunTimelineEvent({ subtitle, state, variant = "dot-hollow", + helpText, }: RunTimelineEventProps) { return (
@@ -322,7 +332,18 @@ export function RunTimelineEvent({
- {title} + + + + {title} + + {helpText && ( + + {helpText} + + )} + + {subtitle ? ( {subtitle} ) : null} @@ -605,6 +626,7 @@ export function SpanTimeline({ subtitle={} variant={event.markerVariant} state={state} + helpText={event.helpText} /> {state === "inprogress" ? ( )} @@ -822,6 +846,27 @@ function getHelpTextForEvent(event: string): string | undefined { 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 the task"; + } + case "Triggered": { + return "When the run was initially triggered"; + } + case "Dequeued": { + return "When the run was taken from the queue for processing"; + } + case "Started": { + return "When the run began execution"; + } + case "Finished": { + return "The run completed execution"; + } + case "Expired": { + return "The run expired before it could be processed"; + } default: { return undefined; } From f9aec47c6b5bcac7df377f3bbdfa510b2b0af1f0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 13:58:07 +0000 Subject: [PATCH 18/31] Fixes type error --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c40a22907..0d7a9108ee 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 @@ -690,7 +690,7 @@ function RunBody({ "–" ) : (
- {run.tags.map((tag) => ( + {run.tags.map((tag: string) => ( Date: Thu, 27 Feb 2025 14:01:48 +0000 Subject: [PATCH 19/31] Tweaked wording of tooltips --- apps/webapp/app/components/run/RunTimeline.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 65f98f35e2..c269b11a9b 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -856,16 +856,16 @@ function getHelpTextForEvent(event: string): string | undefined { return "When the run was initially triggered"; } case "Dequeued": { - return "When the run was taken from the queue for processing"; + return "When the run is taken from the queue for processing"; } case "Started": { - return "When the run began execution"; + return "When the run begins executing"; } case "Finished": { - return "The run completed execution"; + return "When the run completes execution"; } case "Expired": { - return "The run expired before it could be processed"; + return "When the run expires before it can be processed"; } default: { return undefined; From a63145f9365c9462dfd0aa3a1cbf80f5d56c0ca2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 14:04:41 +0000 Subject: [PATCH 20/31] Fixed type error (check this) --- .../route.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 dab17990a3..3202f1bfd1 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 @@ -168,7 +168,16 @@ export default function Page() { Run #{run.number}
From f1e904bd11c16965c3099d8ffa5e4bdea53c2f49 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 15:21:45 +0000 Subject: [PATCH 21/31] Moved isAdmin to a higher level --- .../webapp/app/components/run/RunTimeline.tsx | 46 +++++++++++-------- .../app/components/runs/v3/SpanTitle.tsx | 44 ++++++++++++++++++ .../app/presenters/v3/RunPresenter.server.ts | 10 ++++ .../route.tsx | 24 +++++++--- .../route.tsx | 3 +- 5 files changed, 100 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index c269b11a9b..39547a4300 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -594,7 +594,6 @@ export type SpanTimelineProps = { inProgress: boolean; isError: boolean; events?: TimelineSpanEvent[]; - showAdminOnlyEvents?: boolean; }; export type SpanTimelineState = "error" | "pending" | "complete"; @@ -605,12 +604,10 @@ export function SpanTimeline({ inProgress, isError, events, - showAdminOnlyEvents, }: SpanTimelineProps) { const state = isError ? "error" : inProgress ? "inprogress" : undefined; - // Filter events if needed - const visibleEvents = events?.filter((event) => !event.adminOnly || showAdminOnlyEvents) ?? []; + const visibleEvents = events ?? []; return ( <> @@ -697,13 +694,13 @@ export type TimelineSpanEvent = { timestamp: Date; duration?: number; helpText?: string; - adminOnly: boolean; markerVariant: TimelineEventVariant; lineVariant: TimelineLineVariant; }; export function createTimelineSpanEventsFromSpanEvents( spanEvents: SpanEvent[], + isAdmin: boolean, relativeStartTime?: number ): Array { // Rest of function remains the same @@ -730,14 +727,28 @@ export function createTimelineSpanEventsFromSpanEvents( 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 sortedSpanEvents[0].time === "string" - ? new Date(sortedSpanEvents[0].time) - : sortedSpanEvents[0].time; + typeof visibleSpanEvents[0].time === "string" + ? new Date(visibleSpanEvents[0].time) + : visibleSpanEvents[0].time; const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime(); - const events = matchingSpanEvents.map((spanEvent, index) => { + const events = visibleSpanEvents.map((spanEvent, index) => { const timestamp = typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time; @@ -767,7 +778,6 @@ export function createTimelineSpanEventsFromSpanEvents( timestamp, duration, properties: spanEvent.properties, - adminOnly: getAdminOnlyForEvent(name), helpText: getHelpTextForEvent(name), markerVariant, lineVariant: "light" as const, @@ -835,13 +845,13 @@ function getAdminOnlyForEvent(event: string): boolean { function getHelpTextForEvent(event: string): string | undefined { switch (event) { case "dequeue": { - return "The task was dequeued from the queue"; + 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 task"; + return "An attempt was created for the run"; } case "import": { return "A task file was imported"; @@ -850,22 +860,22 @@ function getHelpTextForEvent(event: string): string | undefined { return "The payload was initialized lazily"; } case "pod_scheduled": { - return "The Kubernetes pod was scheduled to run the task"; + return "The Kubernetes pod was scheduled to run"; } case "Triggered": { - return "When the run was initially triggered"; + return "The run was triggered"; } case "Dequeued": { - return "When the run is taken from the queue for processing"; + return "The run was dequeued from the queue"; } case "Started": { - return "When the run begins executing"; + return "The run began executing"; } case "Finished": { - return "When the run completes execution"; + return "The run completed execution"; } case "Expired": { - return "When the run expires before it can be processed"; + return "The run expired before it could be started"; } default: { return undefined; diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index 7e36652df8..b4431c62ec 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -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 backgroundClassNameForVariant(event.style.variant, event.isPartial); + } + case "LOG": + case "INFO": + case "DEBUG": { + return backgroundClassNameForVariant(event.style.variant, event.isPartial); + } + case "WARN": { + return "border-amber-400"; + } + case "ERROR": { + return "border-error"; + } + default: { + return backgroundClassNameForVariant(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/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 9dbbe26c33..5fe820c973 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -123,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); @@ -141,6 +150,7 @@ export class RunPresenter { ...n.data, timelineEvents: createTimelineSpanEventsFromSpanEvents( n.data.events, + user?.admin ?? false, treeRootStartTimeMs ), //set partial nodes to null duration 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 3202f1bfd1..98a4bcacbd 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 @@ -55,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"; @@ -911,7 +915,10 @@ function TimelineView({ > {(ms) => ( )} @@ -924,7 +931,10 @@ function TimelineView({ > {(ms) => ( )} @@ -941,7 +951,7 @@ function TimelineView({ )} > @@ -962,7 +972,7 @@ function TimelineView({ {(ms) => ( + - + ); } 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 0d7a9108ee..92f3e1ed4b 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 @@ -310,8 +310,7 @@ function SpanBody({ duration={span.duration} inProgress={span.isPartial} isError={span.isError} - showAdminOnlyEvents={isAdmin} - events={createTimelineSpanEventsFromSpanEvents(span.events)} + events={createTimelineSpanEventsFromSpanEvents(span.events, isAdmin)} /> ) : ( From c36012f0f943b638c9613fa101aae17e36848573 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 15:37:43 +0000 Subject: [PATCH 22/31] removed unused admin props --- .../app/routes/storybook.run-and-span-timeline/route.tsx | 9 --------- 1 file changed, 9 deletions(-) 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 index e0857d0a11..b7e365cadb 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -37,7 +37,6 @@ const spanTimelines = [ offset: 0, timestamp: new Date(Date.now() - 5000), duration: 4000, - adminOnly: false, markerVariant: "start-cap", lineVariant: "light", }, @@ -46,7 +45,6 @@ const spanTimelines = [ offset: 0, timestamp: new Date(Date.now() - 1000), duration: 1000, - adminOnly: false, markerVariant: "dot-hollow", lineVariant: "light", }, @@ -55,7 +53,6 @@ const spanTimelines = [ offset: 0, timestamp: new Date(Date.now() - 1000), duration: 1000, - adminOnly: true, markerVariant: "dot-hollow", lineVariant: "light", }, @@ -66,14 +63,12 @@ const spanTimelines = [ duration: 1000 * 1_000_000, inProgress: false, isError: false, - showAdminOnlyEvents: true, events: [ { name: "Dequeued", offset: 0, timestamp: new Date(Date.now() - 5000), duration: 4000, - adminOnly: false, markerVariant: "start-cap", lineVariant: "light", }, @@ -82,7 +77,6 @@ const spanTimelines = [ offset: 0, timestamp: new Date(Date.now() - 1000), duration: 1000, - adminOnly: true, markerVariant: "dot-hollow", lineVariant: "light", }, @@ -93,14 +87,12 @@ const spanTimelines = [ duration: 1000 * 1_000_000, inProgress: false, isError: false, - showAdminOnlyEvents: true, events: [ { name: "Dequeued", offset: 0, timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000), duration: 4000, - adminOnly: false, markerVariant: "start-cap", lineVariant: "light", }, @@ -109,7 +101,6 @@ const spanTimelines = [ offset: 0, timestamp: new Date(Date.now() - 1000), duration: 1000, - adminOnly: true, markerVariant: "dot-hollow", lineVariant: "light", }, From 27c7ae21b9b2b8dd0cd63b6672f93e09efad5d3e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 15:38:07 +0000 Subject: [PATCH 23/31] Removed unused Admin filter --- .../route.tsx | 70 +++++++++---------- 1 file changed, 34 insertions(+), 36 deletions(-) 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 98a4bcacbd..8e89fcee83 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 @@ -905,42 +905,40 @@ 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 - ?.filter((event) => !event.adminOnly || isAdmin) - .map((event, eventIndex) => - eventIndex === 0 ? ( - - {(ms) => ( - - )} - - ) : ( - - {(ms) => ( - - )} - - ) - )} + {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 ? ( From 490edec4c9fe7ae67949095e98bfc59d5334807c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 15:38:17 +0000 Subject: [PATCH 24/31] Fixed border styling --- apps/webapp/app/components/runs/v3/SpanTitle.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index b4431c62ec..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 && ( - + )} @@ -155,12 +155,12 @@ export function eventBorderClassName(event: RunEvent) { switch (event.level) { case "TRACE": { - return backgroundClassNameForVariant(event.style.variant, event.isPartial); + return borderClassNameForVariant(event.style.variant, event.isPartial); } case "LOG": case "INFO": case "DEBUG": { - return backgroundClassNameForVariant(event.style.variant, event.isPartial); + return borderClassNameForVariant(event.style.variant, event.isPartial); } case "WARN": { return "border-amber-400"; @@ -169,7 +169,7 @@ export function eventBorderClassName(event: RunEvent) { return "border-error"; } default: { - return backgroundClassNameForVariant(event.style.variant, event.isPartial); + return borderClassNameForVariant(event.style.variant, event.isPartial); } } } From 76fcff70279a72d84c05ec46880648bd9c9f55ea Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 15:54:24 +0000 Subject: [PATCH 25/31] made the opacity of the timeline states 30% less --- .../webapp/app/components/run/RunTimeline.tsx | 150 +++++++++--------- .../route.tsx | 2 +- 2 files changed, 79 insertions(+), 73 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 39547a4300..d8fc00dc10 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -377,25 +377,26 @@ function EventMarker({ />
+ > + {state === "inprogress" && ( +
+ )} +
); case "dot-hollow": @@ -403,25 +404,26 @@ function EventMarker({ <>
+ > + {state === "inprogress" && ( +
+ )} +
+ > + {state === "inprogress" && ( +
+ )} +
); case "dot-solid": @@ -470,25 +473,26 @@ function EventMarker({ return (
+ > + {state === "inprogress" && ( +
+ )} +
); case "end-cap-thick": return ( @@ -535,7 +539,7 @@ function LineMarker({ return (
+ > + {state === "inprogress" && ( +
+ )} +
); case "light": return (
+ > + {state === "inprogress" && ( +
+ )} +
); default: return
; 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 8e89fcee83..6814fe68d9 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 @@ -1146,7 +1146,7 @@ function SpanWithDuration({ Date: Thu, 27 Feb 2025 15:58:39 +0000 Subject: [PATCH 26/31] Undo type cast --- .../route.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 6814fe68d9..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 @@ -172,16 +172,7 @@ export default function Page() { Run #{run.number}
From 63d6efc6a12d56403370e4c5f67a1715bda5faa5 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 16:13:04 +0000 Subject: [PATCH 27/31] =?UTF-8?q?Added=20a=20diminished=20style=20that?= =?UTF-8?q?=E2=80=99s=20used=20for=20spans=20(grey=20progress=20bar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webapp/app/components/run/RunTimeline.tsx | 206 ++++++++---------- 1 file changed, 89 insertions(+), 117 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index d8fc00dc10..231041c368 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -18,6 +18,8 @@ export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed" type TimelineLineVariant = "light" | "normal"; +type TimelineStyle = "normal" | "diminished"; + type TimelineEventVariant = | "start-cap" | "dot-hollow" @@ -317,6 +319,7 @@ export type RunTimelineEventProps = { state?: "complete" | "error" | "inprogress"; variant?: TimelineEventVariant; helpText?: string; + style?: TimelineStyle; }; export function RunTimelineEvent({ @@ -325,11 +328,12 @@ export function RunTimelineEvent({ state, variant = "dot-hollow", helpText, + style = "normal", }: RunTimelineEventProps) { return (
- +
@@ -355,38 +359,53 @@ export function RunTimelineEvent({ 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" && (
-
+
{state === "inprogress" && (
-
+
{state === "inprogress" && (
); case "dot-solid": - return ( -
- ); + return
; case "start-cap-thick": return ( -
+
{state === "inprogress" && (
); case "end-cap-thick": - return ( -
- ); + return
; default: return
; } @@ -512,13 +475,19 @@ export type RunTimelineLineProps = { title: ReactNode; state?: TimelineEventState; variant?: TimelineLineVariant; + style?: TimelineStyle; }; -export function RunTimelineLine({ title, state, variant = "normal" }: RunTimelineLineProps) { +export function RunTimelineLine({ + title, + state, + variant = "normal", + style = "normal", +}: RunTimelineLineProps) { return (
- +
{title} @@ -530,27 +499,35 @@ export function RunTimelineLine({ title, state, variant = "normal" }: RunTimelin 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" && (
+
{state === "inprogress" && (
); @@ -658,12 +626,14 @@ export function SpanTimeline({ variant={"start-cap-thick"} state={state} helpText={getHelpTextForEvent("Started")} + style={style} /> {state === "inprogress" ? ( } state={state} variant={visibleEvents.length > 0 ? "light" : "normal"} + style={style} /> ) : ( <> @@ -674,6 +644,7 @@ export function SpanTimeline({ )} state={isError ? "error" : undefined} variant="normal" + style={style} /> )} From 2ec523461d58cb80416f758f541d0be74207c2b9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 17:07:21 +0000 Subject: [PATCH 28/31] Adds new storybook state --- .../storybook.run-and-span-timeline/route.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index b7e365cadb..7fe7273808 100644 --- a/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx +++ b/apps/webapp/app/routes/storybook.run-and-span-timeline/route.tsx @@ -58,6 +58,30 @@ const spanTimelines = [ }, ], }, + { + 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, From 53b6d6df95dd3c403a2d5832d71b5a32c1d32c77 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 17:08:53 +0000 Subject: [PATCH 29/31] Fixed timeline state --- apps/webapp/app/components/run/RunTimeline.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 231041c368..53e6cc5845 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -8,7 +8,6 @@ import { import { Fragment, ReactNode, useState } from "react"; import { cn } from "~/utils/cn"; import { DateTime, DateTimeAccurate } from "../primitives/DateTime"; -import { Spinner } from "../primitives/Spinner"; import { LiveTimer } from "../runs/v3/LiveTimer"; import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; @@ -544,7 +543,7 @@ function LineMarker({
{state === "inprogress" && (
} state={state} - variant={visibleEvents.length > 0 ? "light" : "normal"} + variant="normal" style={style} /> ) : ( From 221f4442ad688e30dda16bb4a9109f30af406bf8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 17:28:39 +0000 Subject: [PATCH 30/31] =?UTF-8?q?Removed=20state=20if=20span=20isn?= =?UTF-8?q?=E2=80=99t=20the=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/run/RunTimeline.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 53e6cc5845..531e1187ed 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -115,7 +115,17 @@ export function RunTimeline({ run }: { run: TimelineSpanRun }) { // Centralized function to build all timeline items function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { - const state = run.isError ? "error" : run.isFinished ? "complete" : "inprogress"; + 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 @@ -745,8 +755,6 @@ export function createTimelineSpanEventsFromSpanEvents( if (index === 0) { markerVariant = "start-cap"; - } else if (index === matchingSpanEvents.length - 1) { - markerVariant = "end-cap-thick"; } return { From 15680e70f13abfe433f97c9ed99cb6699feeea6c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 27 Feb 2025 17:35:18 +0000 Subject: [PATCH 31/31] Changed the timestamp span icon --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92f3e1ed4b..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 @@ -318,7 +318,7 @@ function SpanBody({ } - state="complete" + variant="dot-solid" />
)}