Skip to content

Commit 5f359be

Browse files
authored
feature: Errors page (#3172)
A top-level Errors page that aggregates errors from failed runs with occurrences metrics. https://github.com/user-attachments/assets/8f0ef55e-90dd-4faa-9051-59f4665181e4 Errors are “fingerprinted” so similar errors are grouped together (e.g. has an ID in the error message). You can view an individual error to view a timeline of when it fired, the runs, and bulk replay them.
1 parent e49ccc1 commit 5f359be

File tree

38 files changed

+4284
-15
lines changed

38 files changed

+4284
-15
lines changed

.server-changes/add-errors-page.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
A new Errors page for viewing and tracking errors that cause runs to fail
7+
8+
- Errors are grouped using error fingerprinting
9+
- View top errors for a time period, filter by task, or search the text
10+
- View occurrences over time
11+
- View all the runs for an error and bulk replay them

apps/webapp/app/components/BulkActionFilterSummary.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,18 @@ export function BulkActionFilterSummary({
228228
/>
229229
);
230230
}
231+
case "errorId": {
232+
return (
233+
<AppliedFilter
234+
variant="minimal/medium"
235+
key={key}
236+
label={"Error ID"}
237+
icon={filterIcon(key)}
238+
value={value}
239+
removable={false}
240+
/>
241+
);
242+
}
231243
default: {
232244
assertNever(typedKey);
233245
}

apps/webapp/app/components/logs/LogsSearchInput.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { motion } from "framer-motion";
33
import { useCallback, useEffect, useRef, useState } from "react";
44
import { Input } from "~/components/primitives/Input";
55
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
6-
import { cn } from "~/utils/cn";
7-
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
86
import { useSearchParams } from "~/hooks/useSearchParam";
7+
import { cn } from "~/utils/cn";
8+
9+
export type LogsSearchInputProps = {
10+
placeholder?: string;
11+
};
912

10-
export function LogsSearchInput() {
11-
const location = useOptimisticLocation();
13+
export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchInputProps) {
1214
const inputRef = useRef<HTMLInputElement>(null);
1315

1416
const { value, replace, del } = useSearchParams();
@@ -61,7 +63,7 @@ export function LogsSearchInput() {
6163
type="text"
6264
ref={inputRef}
6365
variant="secondary-small"
64-
placeholder="Search logs…"
66+
placeholder={placeholder}
6567
value={text}
6668
onChange={(e) => setText(e.target.value)}
6769
fullWidth

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Squares2X2Icon,
2525
TableCellsIcon,
2626
UsersIcon,
27+
BugAntIcon,
2728
} from "@heroicons/react/20/solid";
2829
import { Link, useFetcher, useNavigation } from "@remix-run/react";
2930
import { LayoutGroup, motion } from "framer-motion";
@@ -73,6 +74,7 @@ import {
7374
v3EnvironmentPath,
7475
v3EnvironmentVariablesPath,
7576
v3LogsPath,
77+
v3ErrorsPath,
7678
v3ProjectAlertsPath,
7779
v3ProjectPath,
7880
v3ProjectSettingsGeneralPath,
@@ -112,6 +114,7 @@ import { SideMenuHeader } from "./SideMenuHeader";
112114
import { SideMenuItem } from "./SideMenuItem";
113115
import { SideMenuSection } from "./SideMenuSection";
114116
import { type SideMenuSectionId } from "./sideMenuTypes";
117+
import { IconBugFilled } from "@tabler/icons-react";
115118

116119
/** Get the collapsed state for a specific side menu section from user preferences */
117120
function getSectionCollapsed(
@@ -474,6 +477,17 @@ export function SideMenu({
474477
isCollapsed={isCollapsed}
475478
/>
476479
)}
480+
{(user.admin || user.isImpersonating) && (
481+
<SideMenuItem
482+
name="Errors"
483+
icon={IconBugFilled}
484+
activeIconColor="text-amber-500"
485+
inactiveIconColor="text-amber-500"
486+
to={v3ErrorsPath(organization, project, environment)}
487+
data-action="errors"
488+
isCollapsed={isCollapsed}
489+
/>
490+
)}
477491
<SideMenuItem
478492
name="Query"
479493
icon={TableCellsIcon}

apps/webapp/app/components/primitives/DateTime.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
22
import { useRouteLoaderData } from "@remix-run/react";
3+
import { formatDistanceToNow } from "date-fns";
34
import { Laptop } from "lucide-react";
4-
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
5+
import { memo, type ReactNode, useEffect, useMemo, useState, useSyncExternalStore } from "react";
56
import { CopyButton } from "./CopyButton";
67
import { useLocales } from "./LocaleProvider";
78
import { Paragraph } from "./Paragraph";
@@ -357,6 +358,54 @@ function formatDateTimeAccurate(
357358
return `${datePart} ${timePart}`;
358359
}
359360

361+
type RelativeDateTimeProps = {
362+
date: Date | string;
363+
timeZone?: string;
364+
};
365+
366+
function getRelativeText(date: Date): string {
367+
const text = formatDistanceToNow(date, { addSuffix: true });
368+
return text.charAt(0).toUpperCase() + text.slice(1);
369+
}
370+
371+
export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => {
372+
const locales = useLocales();
373+
const userTimeZone = useUserTimeZone();
374+
375+
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
376+
377+
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate));
378+
379+
// Every 60s refresh
380+
useEffect(() => {
381+
const interval = setInterval(() => {
382+
setRelativeText(getRelativeText(realDate));
383+
}, 60_000);
384+
return () => clearInterval(interval);
385+
}, [realDate]);
386+
387+
// On first render
388+
useEffect(() => {
389+
setRelativeText(getRelativeText(realDate));
390+
}, [realDate]);
391+
392+
return (
393+
<SimpleTooltip
394+
button={<span suppressHydrationWarning>{relativeText}</span>}
395+
content={
396+
<TooltipContent
397+
realDate={realDate}
398+
timeZone={timeZone}
399+
localTimeZone={userTimeZone}
400+
locales={locales}
401+
/>
402+
}
403+
side="right"
404+
asChild={true}
405+
/>
406+
);
407+
};
408+
360409
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
361410
const locales = useLocales();
362411
const userTimeZone = useUserTimeZone();

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
XMarkIcon,
1010
} from "@heroicons/react/20/solid";
1111
import { Form, useFetcher } from "@remix-run/react";
12-
import { IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
12+
import { IconBugFilled, IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
1313
import { MachinePresetName } from "@trigger.dev/core/v3";
1414
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
1515
import { ListFilterIcon } from "lucide-react";
@@ -181,6 +181,7 @@ export const TaskRunListSearchFilters = z.object({
181181
machines: MachinePresetOrMachinePresetArray.describe(
182182
`Machine presets to filter by (${machines.join(", ")})`
183183
),
184+
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
184185
});
185186

186187
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -220,6 +221,8 @@ export function filterTitle(filterKey: string) {
220221
return "Machine";
221222
case "versions":
222223
return "Version";
224+
case "errorId":
225+
return "Error ID";
223226
default:
224227
return filterKey;
225228
}
@@ -258,6 +261,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
258261
return <MachineDefaultIcon className="size-4" />;
259262
case "versions":
260263
return <IconRotateClockwise2 className="size-4" />;
264+
case "errorId":
265+
return <IconBugFilled className="size-4" />;
261266
default:
262267
return undefined;
263268
}
@@ -304,6 +309,7 @@ export function getRunFiltersFromSearchParams(
304309
searchParams.getAll("versions").filter((v) => v.length > 0).length > 0
305310
? searchParams.getAll("versions")
306311
: undefined,
312+
errorId: searchParams.get("errorId") ?? undefined,
307313
};
308314

309315
const parsed = TaskRunListSearchFilters.safeParse(params);
@@ -344,7 +350,8 @@ export function RunsFilters(props: RunFiltersProps) {
344350
searchParams.has("scheduleId") ||
345351
searchParams.has("queues") ||
346352
searchParams.has("machines") ||
347-
searchParams.has("versions");
353+
searchParams.has("versions") ||
354+
searchParams.has("errorId");
348355

349356
return (
350357
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -380,6 +387,7 @@ const filterTypes = [
380387
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
381388
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
382389
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
390+
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
383391
] as const;
384392

385393
type FilterType = (typeof filterTypes)[number]["name"];
@@ -434,6 +442,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
434442
<AppliedBatchIdFilter />
435443
<AppliedScheduleIdFilter />
436444
<AppliedBulkActionsFilter bulkActions={bulkActions} />
445+
<AppliedErrorIdFilter />
437446
</>
438447
);
439448
}
@@ -470,6 +479,8 @@ function Menu(props: MenuProps) {
470479
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
471480
case "versions":
472481
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
482+
case "error":
483+
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
473484
}
474485
}
475486

@@ -655,7 +666,7 @@ function TasksDropdown({
655666
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
656667
}
657668
>
658-
<MiddleTruncate text={item.slug}/>
669+
<MiddleTruncate text={item.slug} />
659670
</SelectItem>
660671
))}
661672
</SelectList>
@@ -1740,3 +1751,121 @@ function AppliedScheduleIdFilter() {
17401751
</FilterMenuProvider>
17411752
);
17421753
}
1754+
1755+
function ErrorIdDropdown({
1756+
trigger,
1757+
clearSearchValue,
1758+
searchValue,
1759+
onClose,
1760+
}: {
1761+
trigger: ReactNode;
1762+
clearSearchValue: () => void;
1763+
searchValue: string;
1764+
onClose?: () => void;
1765+
}) {
1766+
const [open, setOpen] = useState<boolean | undefined>();
1767+
const { value, replace } = useSearchParams();
1768+
const errorIdValue = value("errorId");
1769+
1770+
const [errorId, setErrorId] = useState(errorIdValue);
1771+
1772+
const apply = useCallback(() => {
1773+
clearSearchValue();
1774+
replace({
1775+
cursor: undefined,
1776+
direction: undefined,
1777+
errorId: errorId === "" ? undefined : errorId?.toString(),
1778+
});
1779+
1780+
setOpen(false);
1781+
}, [errorId, replace]);
1782+
1783+
let error: string | undefined = undefined;
1784+
if (errorId) {
1785+
if (!errorId.startsWith("error_")) {
1786+
error = "Error IDs start with 'error_'";
1787+
}
1788+
}
1789+
1790+
return (
1791+
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
1792+
{trigger}
1793+
<SelectPopover
1794+
hideOnEnter={false}
1795+
hideOnEscape={() => {
1796+
if (onClose) {
1797+
onClose();
1798+
return false;
1799+
}
1800+
1801+
return true;
1802+
}}
1803+
className="max-w-[min(32ch,var(--popover-available-width))]"
1804+
>
1805+
<div className="flex flex-col gap-4 p-3">
1806+
<div className="flex flex-col gap-1">
1807+
<Label>Error ID</Label>
1808+
<Input
1809+
placeholder="error_"
1810+
value={errorId ?? ""}
1811+
onChange={(e) => setErrorId(e.target.value)}
1812+
variant="small"
1813+
className="w-[29ch] font-mono"
1814+
spellCheck={false}
1815+
/>
1816+
{error ? <FormError>{error}</FormError> : null}
1817+
</div>
1818+
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
1819+
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
1820+
Cancel
1821+
</Button>
1822+
<Button
1823+
disabled={error !== undefined || !errorId}
1824+
variant="secondary/small"
1825+
shortcut={{
1826+
modifiers: ["mod"],
1827+
key: "Enter",
1828+
enabledOnInputElements: true,
1829+
}}
1830+
onClick={() => apply()}
1831+
>
1832+
Apply
1833+
</Button>
1834+
</div>
1835+
</div>
1836+
</SelectPopover>
1837+
</SelectProvider>
1838+
);
1839+
}
1840+
1841+
function AppliedErrorIdFilter() {
1842+
const { value, del } = useSearchParams();
1843+
1844+
if (value("errorId") === undefined) {
1845+
return null;
1846+
}
1847+
1848+
const errorId = value("errorId");
1849+
1850+
return (
1851+
<FilterMenuProvider>
1852+
{(search, setSearch) => (
1853+
<ErrorIdDropdown
1854+
trigger={
1855+
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
1856+
<AppliedFilter
1857+
label="Error ID"
1858+
icon={filterIcon("errorId")}
1859+
value={errorId}
1860+
onRemove={() => del(["errorId", "cursor", "direction"])}
1861+
variant="secondary/small"
1862+
/>
1863+
</Ariakit.Select>
1864+
}
1865+
searchValue={search}
1866+
clearSearchValue={() => setSearch("")}
1867+
/>
1868+
)}
1869+
</FilterMenuProvider>
1870+
);
1871+
}

0 commit comments

Comments
 (0)