Errors (versions) (#3187)

- Added versions filtering on the Errors list and page
- Added errors stacked bars to the graph on the individual error page

---------

Co-authored-by: James Ritchie <james@trigger.dev>
This commit is contained in:
Matt Aitken
2026-03-31 19:06:54 +01:00
committed by GitHub
parent 2ba77d89dd
commit 0977c56efe
54 changed files with 5182 additions and 384 deletions
@@ -0,0 +1,10 @@
export function SlackMonoIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" />
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" />
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" />
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.528 2.528 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.522h-6.313z" />
</svg>
);
}
@@ -0,0 +1,365 @@
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
EnvelopeIcon,
GlobeAltIcon,
HashtagIcon,
LockClosedIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { useFetcher, useNavigate } from "@remix-run/react";
import { SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { useToast } from "~/components/primitives/Toast";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { InlineCode } from "~/components/code/InlineCode";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { UnorderedList } from "~/components/primitives/UnorderedList";
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { organizationSlackIntegrationPath } from "~/utils/pathBuilder";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { TextLink } from "~/components/primitives/TextLink";
import { BellAlertIcon } from "@heroicons/react/24/solid";
export const ErrorAlertsFormSchema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return i === "" ? [] : [i];
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
return [];
}, z.string().email().array()),
slackChannel: z.string().optional(),
slackIntegrationId: z.string().optional(),
webhooks: z.preprocess((i) => {
if (typeof i === "string") return i === "" ? [] : [i];
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
return [];
}, z.string().url().array()),
});
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
connectToSlackHref?: string;
formAction: string;
};
export function ConfigureErrorAlerts({
emails: existingEmails,
webhooks: existingWebhooks,
slackChannel: existingSlackChannel,
slack,
emailAlertsEnabled,
connectToSlackHref,
formAction,
}: ConfigureErrorAlertsProps) {
const organization = useOrganization();
const fetcher = useFetcher<{ ok?: boolean }>();
const navigate = useNavigate();
const toast = useToast();
const location = useOptimisticLocation();
const isSubmitting = fetcher.state !== "idle";
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
existingSlackChannel
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
: undefined
);
const selectedSlackChannel =
slack.status === "READY"
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
: undefined;
const closeHref = (() => {
const params = new URLSearchParams(location.search);
params.delete("alerts");
const qs = params.toString();
return qs ? `?${qs}` : location.pathname;
})();
const hasHandledSuccess = useRef(false);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
hasHandledSuccess.current = true;
toast.success("Alert settings saved");
navigate(closeHref, { replace: true });
}
}, [fetcher.state, fetcher.data, closeHref, navigate, toast]);
const emailFieldValues = useRef<string[]>(
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
);
const webhookFieldValues = useRef<string[]>(
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
);
const [form, { emails, webhooks, slackChannel, slackIntegrationId }] = useForm({
id: "configure-error-alerts",
onValidate({ formData }) {
return parse(formData, { schema: ErrorAlertsFormSchema });
},
shouldRevalidate: "onSubmit",
defaultValue: {
emails: emailFieldValues.current,
webhooks: webhookFieldValues.current,
},
});
const emailFields = useFieldList(form.ref, emails);
const webhookFields = useFieldList(form.ref, webhooks);
return (
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<Header2 className="flex items-center gap-2">
<BellAlertIcon className="size-5 text-alerts" /> Configure alerts
</Header2>
<LinkButton
to={closeHref}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<fetcher.Form
method="post"
action={formAction}
{...form.props}
className="contents"
>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Fieldset className="flex flex-col gap-4 p-4">
<div className="flex flex-col">
<Header3>Receive alerts when</Header3>
<UnorderedList variant="small/dimmed" className="mt-1">
<li>An error is seen for the first time</li>
<li>A resolved error re-occurs</li>
<li>An ignored error re-occurs based on settings you configured</li>
</UnorderedList>
</div>
{/* Email section */}
<div>
<Header3 className="mb-1">Email</Header3>
{emailAlertsEnabled ? (
<InputGroup>
{emailFields.map((emailField, index) => (
<Fragment key={emailField.key}>
<Input
{...conform.input(emailField, { type: "email" })}
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
icon={EnvelopeIcon}
onChange={(e) => {
emailFieldValues.current[index] = e.target.value;
if (
emailFields.length === emailFieldValues.current.length &&
emailFieldValues.current.every((v) => v !== "")
) {
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
}
}}
/>
<FormError id={emailField.errorId}>{emailField.error}</FormError>
</Fragment>
))}
</InputGroup>
) : (
<Callout variant="warning">
Email integration is not available. Please contact your organization
administrator.
</Callout>
)}
</div>
{/* Slack section */}
<div>
<Header3 className="mb-1">Slack</Header3>
<InputGroup fullWidth>
{slack.status === "READY" ? (
<>
<Select
name={slackChannel.name}
placeholder={<span className="text-text-dimmed">Select a Slack channel</span>}
heading="Filter channels…"
defaultValue={selectedSlackChannelValue}
dropdownIcon
variant="tertiary/medium"
items={slack.channels}
setValue={(value) => {
typeof value === "string" && setSelectedSlackChannelValue(value);
}}
filter={(channel, search) =>
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
}
text={(value) => {
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
if (!channel) return;
return (
<span className="text-text-bright">
<SlackChannelTitle {...channel} />
</span>
);
}}
>
{(matches) => (
<>
{matches?.map((channel) => (
<SelectItem
key={channel.id}
value={`${channel.id}/${channel.name}`}
className="text-text-bright"
>
<SlackChannelTitle {...channel} />
</SelectItem>
))}
</>
)}
</Select>
{selectedSlackChannel && selectedSlackChannel.is_private && (
<Callout
variant="warning"
className={cn("text-sm", variantClasses.warning.textColor)}
>
To receive alerts in the{" "}
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
Slack and type:{" "}
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
</Callout>
)}
<Hint>
<TextLink to={organizationSlackIntegrationPath(organization)}>
Manage Slack connection
</TextLink>
</Hint>
<input
type="hidden"
name={slackIntegrationId.name}
value={slack.integrationId}
/>
</>
) : slack.status === "NOT_CONFIGURED" ? (
connectToSlackHref ? (
<LinkButton variant="tertiary/medium" to={connectToSlackHref} fullWidth>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
) : (
<Callout variant="info">
Slack is not connected. Connect Slack from the{" "}
<span className="font-medium text-text-bright">Alerts</span> page to enable
Slack notifications.
</Callout>
)
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
connectToSlackHref ? (
<div className="flex flex-col gap-4">
<Callout variant="info">
The Slack integration in your workspace has been revoked or has expired.
Please re-connect your Slack workspace.
</Callout>
<LinkButton
variant="tertiary/large"
to={`${connectToSlackHref}?reinstall=true`}
fullWidth
>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
</div>
) : (
<Callout variant="info">
The Slack integration in your workspace has been revoked or expired. Please
re-connect from the{" "}
<span className="font-medium text-text-bright">Alerts</span> page.
</Callout>
)
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
<Callout variant="warning">
Failed loading channels from Slack. Please try again later.
</Callout>
) : (
<Callout variant="warning">
Slack integration is not available. Please contact your organization
administrator.
</Callout>
)}
</InputGroup>
</div>
{/* Webhook section */}
<div>
<Header3 className="mb-1">Webhook</Header3>
<InputGroup>
{webhookFields.map((webhookField, index) => (
<Fragment key={webhookField.key}>
<Input
{...conform.input(webhookField, { type: "url" })}
placeholder={
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
}
icon={GlobeAltIcon}
onChange={(e) => {
webhookFieldValues.current[index] = e.target.value;
if (
webhookFields.length === webhookFieldValues.current.length &&
webhookFieldValues.current.every((v) => v !== "")
) {
requestIntent(form.ref.current ?? undefined, list.append(webhooks.name));
}
}}
/>
<FormError id={webhookField.errorId}>{webhookField.error}</FormError>
</Fragment>
))}
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
</InputGroup>
</div>
<FormError>{form.error}</FormError>
</Fieldset>
</div>
<div className="flex items-center justify-between border-t border-grid-bright px-3 py-3">
<LinkButton variant="secondary/medium" to={closeHref}>
Cancel
</LinkButton>
<Button
variant="primary/medium"
type="submit"
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</fetcher.Form>
</div>
);
}
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
return (
<div className="flex items-center gap-1.5">
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
<span>{name}</span>
</div>
);
}
@@ -0,0 +1,34 @@
import { type ErrorGroupStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
const styles: Record<ErrorGroupStatus, string> = {
UNRESOLVED: "bg-error/10 text-error",
RESOLVED: "bg-success/10 text-success",
IGNORED: "bg-blue-500/10 text-blue-400",
};
const labels: Record<ErrorGroupStatus, string> = {
UNRESOLVED: "Unresolved",
RESOLVED: "Resolved",
IGNORED: "Ignored",
};
export function ErrorStatusBadge({
status,
className,
}: {
status: ErrorGroupStatus;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
styles[status],
className
)}
>
{labels[status]}
</span>
);
}
@@ -0,0 +1,250 @@
import { CheckIcon } from "@heroicons/react/20/solid";
import {
IconAlarmSnooze as IconAlarmSnoozeBase,
IconArrowBackUp as IconArrowBackUpBase,
IconBugOff as IconBugOffBase,
} from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { type ErrorGroupStatus } from "@trigger.dev/database";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { useToast } from "~/components/primitives/Toast";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/primitives/Dialog";
const AlarmSnoozeIcon = ({ className }: { className?: string }) => (
<IconAlarmSnoozeBase className={className} size={18} />
);
const ArrowBackUpIcon = ({ className }: { className?: string }) => (
<IconArrowBackUpBase className={className} size={18} />
);
const BugOffIcon = ({ className }: { className?: string }) => (
<IconBugOffBase className={className} size={18} />
);
export function statusActionToastMessage(data: Record<string, string>): string {
switch (data.action) {
case "resolve":
return "Error marked as resolved";
case "unresolve":
return "Error marked as unresolved";
case "ignore": {
const duration = data.duration ? Number(data.duration) : undefined;
if (!duration) return "Error ignored indefinitely";
const hours = duration / (60 * 60 * 1000);
if (hours < 24) return `Error ignored for ${hours} ${hours === 1 ? "hour" : "hours"}`;
const days = hours / 24;
return `Error ignored for ${days} ${days === 1 ? "day" : "days"}`;
}
default:
return "Error status updated";
}
}
export function ErrorStatusMenuItems({
status,
taskIdentifier,
onAction,
onCustomIgnore,
}: {
status: ErrorGroupStatus;
taskIdentifier: string;
onAction: (data: Record<string, string>) => void;
onCustomIgnore: () => void;
}) {
return (
<>
{status === "UNRESOLVED" && (
<>
<PopoverMenuItem
icon={CheckIcon}
leadingIconClassName="text-success"
title="Resolved"
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
/>
<PopoverMenuItem
icon={AlarmSnoozeIcon}
leadingIconClassName="text-blue-500"
title="Ignored for 1 hour"
onClick={() =>
onAction({
taskIdentifier,
action: "ignore",
duration: String(60 * 60 * 1000),
})
}
/>
<PopoverMenuItem
icon={AlarmSnoozeIcon}
leadingIconClassName="text-blue-500"
title="Ignored for 24 hours"
onClick={() =>
onAction({
taskIdentifier,
action: "ignore",
duration: String(24 * 60 * 60 * 1000),
})
}
/>
<PopoverMenuItem
icon={BugOffIcon}
leadingIconClassName="text-blue-500"
title="Ignored forever"
onClick={() => onAction({ taskIdentifier, action: "ignore" })}
/>
<PopoverMenuItem
icon={AlarmSnoozeIcon}
leadingIconClassName="text-blue-500"
title="Ignored with custom condition…"
onClick={onCustomIgnore}
/>
</>
)}
{status === "IGNORED" && (
<>
<PopoverMenuItem
icon={CheckIcon}
leadingIconClassName="text-success"
title="Resolved"
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
/>
<PopoverMenuItem
icon={ArrowBackUpIcon}
leadingIconClassName="text-error"
title="Unresolved"
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
/>
</>
)}
{status === "RESOLVED" && (
<PopoverMenuItem
icon={ArrowBackUpIcon}
leadingIconClassName="text-error"
title="Unresolved"
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
/>
)}
</>
);
}
export function CustomIgnoreDialog({
open,
onOpenChange,
taskIdentifier,
formAction,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
taskIdentifier: string;
formAction?: string;
}) {
const fetcher = useFetcher<{ ok?: boolean }>();
const isSubmitting = fetcher.state !== "idle";
const [conditionError, setConditionError] = useState<string | null>(null);
const toast = useToast();
const hasHandledSuccess = useRef(false);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
hasHandledSuccess.current = true;
toast.success("Error ignored with custom condition");
onOpenChange(false);
}
}, [fetcher.state, fetcher.data, onOpenChange, toast]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-1.5">
<IconAlarmSnoozeBase className="-ml-1.5 size-6 text-blue-500" />
Custom ignore condition
</DialogTitle>
</DialogHeader>
<fetcher.Form
method="post"
action={formAction}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const rate = formData.get("occurrenceRate")?.toString().trim();
const total = formData.get("totalOccurrences")?.toString().trim();
if (!rate && !total) {
setConditionError("At least one unignore condition is required");
return;
}
setConditionError(null);
hasHandledSuccess.current = false;
fetcher.submit(e.currentTarget, { method: "post", action: formAction });
}}
>
<input type="hidden" name="action" value="ignore" />
<input type="hidden" name="taskIdentifier" value={taskIdentifier} />
<div className="flex flex-col gap-4 py-4">
<InputGroup fullWidth>
<Label htmlFor="occurrenceRate" variant="small">
Unignore when occurrence rate exceeds (per minute)
</Label>
<Input
id="occurrenceRate"
name="occurrenceRate"
type="number"
min={1}
placeholder="e.g. 10"
onChange={() => conditionError && setConditionError(null)}
/>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor="totalOccurrences" variant="small">
Unignore when total occurrences exceed
</Label>
<Input
id="totalOccurrences"
name="totalOccurrences"
type="number"
min={1}
placeholder="e.g. 100"
onChange={() => conditionError && setConditionError(null)}
/>
</InputGroup>
{conditionError && <FormError>{conditionError}</FormError>}
<InputGroup fullWidth>
<Label htmlFor="reason" variant="small" required={false}>
Reason
</Label>
<Input id="reason" name="reason" type="text" placeholder="e.g. Known flaky test" />
</InputGroup>
</div>
<DialogFooter>
<Button variant="tertiary/medium" type="button" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="primary/medium" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Ignoring…" : "Ignore error"}
</Button>
</DialogFooter>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,58 @@
import * as Ariakit from "@ariakit/react";
import { SelectTrigger } from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { filterIcon, VersionsDropdown } from "~/components/runs/v3/RunFilters";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
const shortcut = { key: "v" };
export function LogsVersionFilter() {
const { values, del } = useSearchParams();
const selectedVersions = values("versions");
if (selectedVersions.length === 0 || selectedVersions.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<VersionsDropdown
trigger={
<SelectTrigger
icon={filterIcon("versions")}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by version"
>
<span className="ml-0.5">Versions</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<VersionsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Versions"
icon={filterIcon("versions")}
value={appliedSummary(selectedVersions)}
onRemove={() => del(["versions", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -83,6 +83,7 @@ export function OrganizationSettingsSideMenu({
name="Usage"
icon={ChartBarIcon}
activeIconColor="text-indigo-500"
inactiveIconColor="text-indigo-500"
to={v3UsagePath(organization)}
data-action="usage"
/>
@@ -90,6 +91,7 @@ export function OrganizationSettingsSideMenu({
name="Billing"
icon={CreditCardIcon}
activeIconColor="text-emerald-500"
inactiveIconColor="text-emerald-500"
to={v3BillingPath(organization)}
data-action="billing"
badge={
@@ -102,6 +104,7 @@ export function OrganizationSettingsSideMenu({
name="Billing alerts"
icon={BellAlertIcon}
activeIconColor="text-rose-500"
inactiveIconColor="text-rose-500"
to={v3BillingAlertsPath(organization)}
data-action="billing-alerts"
/>
@@ -112,6 +115,7 @@ export function OrganizationSettingsSideMenu({
name="Private Connections"
icon={LockClosedIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3PrivateConnectionsPath(organization)}
data-action="private-connections"
/>
@@ -120,6 +124,7 @@ export function OrganizationSettingsSideMenu({
name="Team"
icon={UserGroupIcon}
activeIconColor="text-amber-500"
inactiveIconColor="text-amber-500"
to={organizationTeamPath(organization)}
data-action="team"
/>
@@ -127,6 +132,7 @@ export function OrganizationSettingsSideMenu({
name="Settings"
icon={Cog8ToothIcon}
activeIconColor="text-orgSettings"
inactiveIconColor="text-orgSettings"
to={organizationSettingsPath(organization)}
data-action="settings"
/>
@@ -139,6 +145,8 @@ export function OrganizationSettingsSideMenu({
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
inactiveIconColor="text-white"
iconClassName="size-4 ml-0.5"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
@@ -146,6 +154,7 @@ export function OrganizationSettingsSideMenu({
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
inactiveIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
@@ -464,10 +464,7 @@ export function SideMenu({
title="AI"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"ai"
)}
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
onCollapseToggle={handleSectionToggle("ai")}
>
<SideMenuItem
@@ -531,8 +528,8 @@ export function SideMenu({
<SideMenuItem
name="Errors"
icon={IconBugFilled}
activeIconColor="text-amber-500"
inactiveIconColor="text-amber-500"
activeIconColor="text-errors"
inactiveIconColor="text-errors"
to={v3ErrorsPath(organization, project, environment)}
data-action="errors"
isCollapsed={isCollapsed}
@@ -10,6 +10,7 @@ export function SideMenuItem({
icon,
activeIconColor,
inactiveIconColor,
iconClassName,
trailingIcon,
trailingIconClassName,
name,
@@ -22,6 +23,7 @@ export function SideMenuItem({
icon?: RenderIcon;
activeIconColor?: string;
inactiveIconColor?: string;
iconClassName?: string;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
name: string;
@@ -47,7 +49,8 @@ export function SideMenuItem({
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed",
iconClassName
)}
/>
<motion.div
@@ -361,33 +361,35 @@ function formatDateTimeAccurate(
type RelativeDateTimeProps = {
date: Date | string;
timeZone?: string;
capitalize?: boolean;
};
function getRelativeText(date: Date): string {
function getRelativeText(date: Date, capitalize = true): string {
const text = formatDistanceToNow(date, { addSuffix: true });
if (!capitalize) return text;
return text.charAt(0).toUpperCase() + text.slice(1);
}
export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => {
export const RelativeDateTime = ({ date, timeZone, capitalize = true }: RelativeDateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate));
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate, capitalize));
// Every 60s refresh
useEffect(() => {
const interval = setInterval(() => {
setRelativeText(getRelativeText(realDate));
setRelativeText(getRelativeText(realDate, capitalize));
}, 60_000);
return () => clearInterval(interval);
}, [realDate]);
}, [realDate, capitalize]);
// On first render
useEffect(() => {
setRelativeText(getRelativeText(realDate));
}, [realDate]);
setRelativeText(getRelativeText(realDate, capitalize));
}, [realDate, capitalize]);
return (
<SimpleTooltip
@@ -66,6 +66,9 @@ const PopoverMenuItem = React.forwardRef<
onClick?: React.MouseEventHandler;
disabled?: boolean;
openInNewTab?: boolean;
name?: string;
value?: string;
type?: React.ComponentProps<"button">["type"];
}
>(
(
@@ -80,6 +83,9 @@ const PopoverMenuItem = React.forwardRef<
onClick,
disabled,
openInNewTab = false,
name,
value,
type,
},
ref
) => {
@@ -114,7 +120,6 @@ const PopoverMenuItem = React.forwardRef<
return (
<button
type="button"
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
disabled={disabled}
@@ -122,6 +127,9 @@ const PopoverMenuItem = React.forwardRef<
"group/button outline-none focus-custom",
contentProps.fullWidth ? "w-full" : ""
)}
name={name}
value={value}
type={type ?? "button"}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</button>
@@ -197,6 +205,18 @@ const popoverArrowTriggerVariants = {
text: "group-hover:text-text-bright",
icon: "text-text-dimmed group-hover:text-text-bright",
},
primary: {
trigger:
"bg-indigo-600 border border-indigo-500 text-text-bright hover:bg-indigo-500 hover:border-indigo-400 disabled:opacity-50 disabled:pointer-events-none",
text: "text-text-bright hover:text-white",
icon: "text-text-bright",
},
secondary: {
trigger:
"bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 disabled:opacity-60 disabled:pointer-events-none",
text: "text-text-bright",
icon: "text-text-bright",
},
tertiary: {
trigger: "bg-tertiary text-text-bright hover:bg-charcoal-600",
text: "text-text-bright",
@@ -245,8 +265,7 @@ function PopoverArrowTrigger({
const popoverVerticalEllipseVariants = {
minimal: {
trigger:
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
trigger: "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
@@ -431,7 +431,7 @@ export const TableCellMenu = forwardRef<
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
visibleButtons?: ReactNode;
hiddenButtons?: ReactNode;
popoverContent?: ReactNode;
popoverContent?: ReactNode | ((close: () => void) => ReactNode);
children?: ReactNode;
isSelected?: boolean;
}
@@ -451,6 +451,8 @@ export const TableCellMenu = forwardRef<
) => {
const [isOpen, setIsOpen] = useState(false);
const { variant } = useContext(TableContext);
const resolvedContent =
typeof popoverContent === "function" ? popoverContent(() => setIsOpen(false)) : popoverContent;
return (
<TableCell
@@ -486,8 +488,8 @@ export const TableCellMenu = forwardRef<
{/* Always visible buttons */}
{visibleButtons}
{/* Always visible popover with ellipsis trigger */}
{popoverContent && (
<Popover onOpenChange={(open) => setIsOpen(open)}>
{resolvedContent && (
<Popover open={isOpen} onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger
isOpen={isOpen}
className="duration-0 group-hover/table-row:text-text-bright"
@@ -496,7 +498,11 @@ export const TableCellMenu = forwardRef<
className="min-w-[10rem] max-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="end"
>
<div className="flex flex-col gap-1 p-1">{popoverContent}</div>
{typeof popoverContent === "function" ? (
resolvedContent
) : (
<div className="flex flex-col gap-1 p-1">{resolvedContent}</div>
)}
</PopoverContent>
</Popover>
)}
@@ -1,7 +1,7 @@
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "@remix-run/react";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useTypedLoaderData } from "remix-typedjson";
import { Toaster, toast } from "sonner";
import { type ToastMessageAction } from "~/models/message.server";
@@ -43,6 +43,32 @@ export function Toast() {
return <Toaster />;
}
export function useToast() {
return useMemo(
() => ({
success(message: string, options?: { title?: string; ephemeral?: boolean }) {
const ephemeral = options?.ephemeral ?? true;
toast.custom(
(t) => (
<ToastUI variant="success" message={message} t={t as string} title={options?.title} />
),
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
);
},
error(message: string, options?: { title?: string; ephemeral?: boolean }) {
const ephemeral = options?.ephemeral ?? true;
toast.custom(
(t) => (
<ToastUI variant="error" message={message} t={t as string} title={options?.title} />
),
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
);
},
}),
[]
);
}
export function ToastUI({
variant,
message,
@@ -0,0 +1,129 @@
import { cn } from "~/utils/cn";
import { type ParagraphVariant } from "./Paragraph";
const listVariants: Record<
ParagraphVariant,
{ text: string; spacing: string; items: string }
> = {
base: {
text: "font-sans text-base font-normal text-text-dimmed",
spacing: "mb-3",
items: "space-y-1 [&>li]:gap-1.5",
},
"base/bright": {
text: "font-sans text-base font-normal text-text-bright",
spacing: "mb-3",
items: "space-y-1 [&>li]:gap-1.5",
},
small: {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"small/bright": {
text: "font-sans text-sm font-normal text-text-bright",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"small/dimmed": {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright": {
text: "font-sans text-xs font-normal text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/dimmed": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/dimmed/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright/mono": {
text: "font-mono text-xs text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-extra-small": {
text: "font-sans text-xxs font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/bright": {
text: "font-sans text-xxs font-normal text-text-bright",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/bright/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/dimmed/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
};
type UnorderedListProps = {
variant?: ParagraphVariant;
className?: string;
spacing?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLUListElement>;
export function UnorderedList({
variant = "base",
className,
spacing = false,
children,
...props
}: UnorderedListProps) {
const v = listVariants[variant];
return (
<ul
className={cn(
"list-none [&>li]:flex [&>li]:items-baseline [&>li]:before:shrink-0 [&>li]:before:content-['•']",
v.text,
v.items,
spacing && v.spacing,
className
)}
{...props}
>
{children}
</ul>
);
}
@@ -174,6 +174,7 @@ export function ChartBarRenderer({
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
animationDuration={0}
/>
{/* Zoom selection area - rendered before bars to appear behind them */}
@@ -180,7 +180,7 @@ export function ChartLegendCompound({
)}
>
<span className="font-medium">{currentTotalLabel}</span>
<span className="font-medium tabular-nums">
<span className="shrink-0 font-medium tabular-nums">
{currentTotal != null ? (
valueFormatter ? (
valueFormatter(currentTotal)
@@ -253,7 +253,7 @@ export function ChartLegendCompound({
/>
<span
className={cn(
"self-start tabular-nums",
"shrink-0 self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
@@ -350,7 +350,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: H
<span className="text-text-bright">{item.label}</span>
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="tabular-nums text-text-bright">
<span className="shrink-0 tabular-nums text-text-bright">
{value != null ? (
valueFormatter ? (
valueFormatter(value)
@@ -40,6 +40,8 @@ export type ChartRootProps = {
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
/** Additional className for the legend */
legendClassName?: string;
/** When true, chart fills its parent container height and distributes space between chart and legend */
fillContainer?: boolean;
/** Content rendered between the chart and the legend */
@@ -87,6 +89,7 @@ export function ChartRoot({
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
legendClassName,
fillContainer = false,
beforeLegend,
children,
@@ -114,6 +117,7 @@ export function ChartRoot({
legendValueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
legendClassName={legendClassName}
fillContainer={fillContainer}
beforeLegend={beforeLegend}
>
@@ -133,6 +137,7 @@ type ChartRootInnerProps = {
legendValueFormatter?: (value: number) => string;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
legendClassName?: string;
fillContainer?: boolean;
beforeLegend?: React.ReactNode;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
@@ -148,6 +153,7 @@ function ChartRootInner({
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
legendClassName,
fillContainer = false,
beforeLegend,
children,
@@ -193,6 +199,7 @@ function ChartRootInner({
valueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
className={legendClassName}
/>
)}
</div>
@@ -1,4 +1,4 @@
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon, CheckIcon } from "@heroicons/react/20/solid";
type EnabledStatusProps = {
enabled: boolean;
@@ -8,8 +8,8 @@ type EnabledStatusProps = {
export function EnabledStatus({
enabled,
enabledIcon = CheckCircleIcon,
disabledIcon = BoltSlashIcon,
enabledIcon = CheckIcon,
disabledIcon = NoSymbolIcon,
}: EnabledStatusProps) {
const EnabledIcon = enabledIcon;
const DisabledIcon = disabledIcon;
@@ -1216,7 +1216,7 @@ function AppliedMachinesFilter() {
);
}
function VersionsDropdown({
export function VersionsDropdown({
trigger,
clearSearchValue,
searchValue,
@@ -32,3 +32,9 @@ export const ProjectAlertSlackStorage = z.object({
});
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
export const ErrorAlertConfig = z.object({
evaluationIntervalMs: z.number().min(60_000).default(300_000),
});
export type ErrorAlertConfig = z.infer<typeof ErrorAlertConfig>;
@@ -17,6 +17,7 @@ export const ApiAlertType = z.enum([
"attempt_failure",
"deployment_failure",
"deployment_success",
"error_group",
]);
export type ApiAlertType = z.infer<typeof ApiAlertType>;
@@ -85,6 +86,8 @@ export class ApiAlertChannelPresenter {
return "deployment_failure";
case "DEPLOYMENT_SUCCESS":
return "deployment_success";
case "ERROR_GROUP":
return "error_group";
default:
assertNever(alertType);
}
@@ -100,6 +103,8 @@ export class ApiAlertChannelPresenter {
return "DEPLOYMENT_FAILURE";
case "deployment_success":
return "DEPLOYMENT_SUCCESS";
case "error_group":
return "ERROR_GROUP";
default:
assertNever(alertType);
}
@@ -0,0 +1,73 @@
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { BasePresenter } from "./basePresenter.server";
import { NewAlertChannelPresenter } from "./NewAlertChannelPresenter.server";
import { env } from "~/env.server";
export type ErrorAlertChannelData = Awaited<ReturnType<ErrorAlertChannelPresenter["call"]>>;
export class ErrorAlertChannelPresenter extends BasePresenter {
public async call(projectId: string, environmentType: RuntimeEnvironmentType) {
const channels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId,
alertTypes: { has: "ERROR_GROUP" },
environmentTypes: { has: environmentType },
},
orderBy: { createdAt: "asc" },
});
const emails: Array<{ id: string; email: string }> = [];
const webhooks: Array<{ id: string; url: string }> = [];
let slackChannel: { id: string; channelId: string; channelName: string } | null = null;
for (const channel of channels) {
switch (channel.type) {
case "EMAIL": {
const parsed = ProjectAlertEmailProperties.safeParse(channel.properties);
if (parsed.success) {
emails.push({ id: channel.id, email: parsed.data.email });
}
break;
}
case "SLACK": {
if (!channel.enabled) break;
const parsed = ProjectAlertSlackProperties.safeParse(channel.properties);
if (parsed.success) {
slackChannel = {
id: channel.id,
channelId: parsed.data.channelId,
channelName: parsed.data.channelName,
};
}
break;
}
case "WEBHOOK": {
const parsed = ProjectAlertWebhookProperties.safeParse(channel.properties);
if (parsed.success) {
webhooks.push({ id: channel.id, url: parsed.data.url });
}
break;
}
}
}
const slackPresenter = new NewAlertChannelPresenter(this._prisma, this._replica);
const slackResult = await slackPresenter.call(projectId);
const emailAlertsEnabled =
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
return {
emails,
webhooks,
slackChannel,
slack: slackResult.slack,
emailAlertsEnabled,
};
}
}
@@ -2,7 +2,7 @@ import { z } from "zod";
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
import { TimeGranularity } from "~/utils/timeGranularity";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { type Direction, DirectionSchema } from "~/components/ListPagination";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -27,6 +27,7 @@ export type ErrorGroupOptions = {
userId?: string;
projectId: string;
fingerprint: string;
versions?: string[];
runsPageSize?: number;
period?: string;
from?: number;
@@ -39,6 +40,7 @@ export const ErrorGroupOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
fingerprint: z.string(),
versions: z.array(z.string()).optional(),
runsPageSize: z.number().int().positive().max(1000).optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
@@ -59,6 +61,21 @@ function parseClickHouseDateTime(value: string): Date {
return new Date(value.replace(" ", "T") + "Z");
}
export type ErrorGroupState = {
status: ErrorGroupStatus;
resolvedAt: Date | null;
resolvedInVersion: string | null;
resolvedBy: string | null;
ignoredAt: Date | null;
ignoredUntil: Date | null;
ignoredReason: string | null;
ignoredByUserId: string | null;
ignoredByUserDisplayName: string | null;
ignoredUntilOccurrenceRate: number | null;
ignoredUntilTotalOccurrences: number | null;
ignoredAtOccurrenceCount: number | null;
};
export type ErrorGroupSummary = {
fingerprint: string;
errorType: string;
@@ -68,10 +85,12 @@ export type ErrorGroupSummary = {
firstSeen: Date;
lastSeen: Date;
affectedVersions: string[];
state: ErrorGroupState;
};
export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>;
export type ErrorGroupActivity = ErrorGroupOccurrences["data"];
export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"];
export class ErrorGroupPresenter extends BasePresenter {
constructor(
@@ -89,6 +108,7 @@ export class ErrorGroupPresenter extends BasePresenter {
userId,
projectId,
fingerprint,
versions,
runsPageSize = DEFAULT_RUNS_PAGE_SIZE,
period,
from,
@@ -110,23 +130,40 @@ export class ErrorGroupPresenter extends BasePresenter {
defaultPeriod: "7d",
});
const [summary, affectedVersions, runList] = await Promise.all([
this.getSummary(organizationId, projectId, environmentId, fingerprint),
const summary = await this.getSummary(organizationId, projectId, environmentId, fingerprint);
const [affectedVersions, runList, stateRow] = await Promise.all([
this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint),
this.getRunList(organizationId, environmentId, {
userId,
projectId,
fingerprint,
versions,
pageSize: runsPageSize,
from: time.from.getTime(),
to: time.to.getTime(),
cursor,
direction,
}),
this.getState(environmentId, summary?.taskIdentifier, fingerprint),
]);
if (summary) {
summary.affectedVersions = affectedVersions;
summary.state = stateRow ?? {
status: "UNRESOLVED",
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
ignoredAt: null,
ignoredUntil: null,
ignoredReason: null,
ignoredByUserId: null,
ignoredByUserDisplayName: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
};
}
return {
@@ -140,8 +177,8 @@ export class ErrorGroupPresenter extends BasePresenter {
}
/**
* Returns bucketed occurrence counts for a single fingerprint over a time range.
* Granularity is determined automatically from the range span.
* Returns bucketed occurrence counts for a single fingerprint over a time range,
* grouped by task_version for stacked charts.
*/
public async getOccurrences(
organizationId: string,
@@ -149,14 +186,17 @@ export class ErrorGroupPresenter extends BasePresenter {
environmentId: string,
fingerprint: string,
from: Date,
to: Date
to: Date,
versions?: string[]
): Promise<{
data: Array<{ date: Date; count: number }>;
data: Array<Record<string, number | Date>>;
versions: string[];
}> {
const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to);
const intervalExpr = msToClickHouseInterval(granularityMs);
const queryBuilder = this.logsClickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
const queryBuilder =
this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr);
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
@@ -169,7 +209,11 @@ export class ErrorGroupPresenter extends BasePresenter {
toTimeMs: to.getTime(),
});
queryBuilder.groupBy("error_fingerprint, bucket_epoch");
if (versions && versions.length > 0) {
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
}
queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch");
queryBuilder.orderBy("bucket_epoch ASC");
const [queryError, records] = await queryBuilder.execute();
@@ -186,17 +230,27 @@ export class ErrorGroupPresenter extends BasePresenter {
buckets.push(epoch);
}
const byBucket = new Map<number, number>();
// Collect distinct versions and index results by (epoch, version)
const versionSet = new Set<string>();
const byBucketVersion = new Map<string, number>();
for (const row of records ?? []) {
byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count);
const version = row.task_version || "unknown";
versionSet.add(version);
const key = `${row.bucket_epoch}:${version}`;
byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count);
}
return {
data: buckets.map((epoch) => ({
date: new Date(epoch * 1000),
count: byBucket.get(epoch) ?? 0,
})),
};
const sortedVersions = sortVersionsDescending([...versionSet]);
const data = buckets.map((epoch) => {
const point: Record<string, number | Date> = { date: new Date(epoch * 1000) };
for (const version of sortedVersions) {
point[version] = byBucketVersion.get(`${epoch}:${version}`) ?? 0;
}
return point;
});
return { data, versions: sortedVersions };
}
private async getSummary(
@@ -235,6 +289,20 @@ export class ErrorGroupPresenter extends BasePresenter {
firstSeen: parseClickHouseDateTime(record.first_seen),
lastSeen: parseClickHouseDateTime(record.last_seen),
affectedVersions: [],
state: {
status: "UNRESOLVED" as const,
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
ignoredAt: null,
ignoredUntil: null,
ignoredReason: null,
ignoredByUserId: null,
ignoredByUserDisplayName: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
},
};
}
@@ -268,6 +336,65 @@ export class ErrorGroupPresenter extends BasePresenter {
return sortVersionsDescending(versions).slice(0, 5);
}
private async getState(
environmentId: string,
taskIdentifier: string | undefined,
fingerprint: string
): Promise<ErrorGroupState | null> {
const row = await this.replica.errorGroupState.findFirst({
where: {
environmentId,
...(taskIdentifier ? { taskIdentifier } : {}),
errorFingerprint: fingerprint,
},
select: {
status: true,
resolvedAt: true,
resolvedInVersion: true,
resolvedBy: true,
ignoredAt: true,
ignoredUntil: true,
ignoredReason: true,
ignoredByUserId: true,
ignoredUntilOccurrenceRate: true,
ignoredUntilTotalOccurrences: true,
ignoredAtOccurrenceCount: true,
},
});
if (!row) {
return null;
}
let ignoredByUserDisplayName: string | null = null;
if (row.ignoredByUserId) {
const user = await this.replica.user.findFirst({
where: { id: row.ignoredByUserId },
select: { displayName: true, name: true, email: true },
});
if (user) {
ignoredByUserDisplayName = user.displayName ?? user.name ?? user.email;
}
}
return {
status: row.status,
resolvedAt: row.resolvedAt,
resolvedInVersion: row.resolvedInVersion,
resolvedBy: row.resolvedBy,
ignoredAt: row.ignoredAt,
ignoredUntil: row.ignoredUntil,
ignoredReason: row.ignoredReason,
ignoredByUserId: row.ignoredByUserId,
ignoredByUserDisplayName,
ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate,
ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences,
ignoredAtOccurrenceCount: row.ignoredAtOccurrenceCount
? Number(row.ignoredAtOccurrenceCount)
: null,
};
}
private async getRunList(
organizationId: string,
environmentId: string,
@@ -275,6 +402,7 @@ export class ErrorGroupPresenter extends BasePresenter {
userId?: string;
projectId: string;
fingerprint: string;
versions?: string[];
pageSize: number;
from?: number;
to?: number;
@@ -289,6 +417,7 @@ export class ErrorGroupPresenter extends BasePresenter {
projectId: options.projectId,
rootOnly: false,
errorId: ErrorId.toFriendlyId(options.fingerprint),
versions: options.versions,
pageSize: options.pageSize,
from: options.from,
to: options.to,
@@ -9,7 +9,7 @@ const errorsListGranularity = new TimeGranularity([
{ max: "3 months", granularity: "1w" },
{ max: "Infinity", granularity: "30d" },
]);
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -22,6 +22,8 @@ export type ErrorsListOptions = {
projectId: string;
// filters
tasks?: string[];
versions?: string[];
statuses?: ErrorGroupStatus[];
period?: string;
from?: number;
to?: number;
@@ -39,6 +41,8 @@ export const ErrorsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
versions: z.array(z.string()).optional(),
statuses: z.array(z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"])).optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
@@ -88,7 +92,11 @@ function decodeCursor(cursor: string): ErrorGroupCursor | null {
}
}
function cursorFromRow(row: { occurrence_count: number; error_fingerprint: string; task_identifier: string }): string {
function cursorFromRow(row: {
occurrence_count: number;
error_fingerprint: string;
task_identifier: string;
}): string {
return encodeCursor({
occurrenceCount: row.occurrence_count,
fingerprint: row.error_fingerprint,
@@ -123,6 +131,8 @@ export class ErrorsListPresenter extends BasePresenter {
userId,
projectId,
tasks,
versions,
statuses,
period,
search,
from,
@@ -156,20 +166,49 @@ export class ErrorsListPresenter extends BasePresenter {
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
(search !== undefined && search !== "") ||
!time.isDefault;
(statuses !== undefined && statuses.length > 0);
const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId);
const [possibleTasks, displayableEnvironment] = await Promise.all([
// Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error
// list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that
// ClickHouse pagination operates on the correctly filtered dataset.
const statusFilterAsync = this.resolveStatusFilter(environmentId, statuses);
const [possibleTasks, displayableEnvironment, statusFilter] = await Promise.all([
possibleTasksAsync,
findDisplayableEnvironment(environmentId, userId),
statusFilterAsync,
]);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
if (statusFilter.empty) {
return {
errorGroups: [],
pagination: {
next: undefined,
previous: undefined,
},
filters: {
tasks,
versions,
statuses,
search,
period: time,
from: effectiveFrom,
to: effectiveTo,
hasFilters,
possibleTasks,
wasClampedByRetention,
},
};
}
// Query the per-minute error_occurrences_v1 table for time-scoped counts
const queryBuilder = this.clickhouse.errors.occurrencesListQueryBuilder();
@@ -189,6 +228,23 @@ export class ErrorsListPresenter extends BasePresenter {
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks });
}
if (versions && versions.length > 0) {
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
}
if (statusFilter.includeKeys) {
queryBuilder.where(
"concat(task_identifier, '::', error_fingerprint) IN {statusIncludeKeys: Array(String)}",
{ statusIncludeKeys: statusFilter.includeKeys }
);
}
if (statusFilter.excludeKeys) {
queryBuilder.where(
"concat(task_identifier, '::', error_fingerprint) NOT IN {statusExcludeKeys: Array(String)}",
{ statusExcludeKeys: statusFilter.excludeKeys }
);
}
queryBuilder.groupBy("error_fingerprint, task_identifier");
// Text search via HAVING (operates on aggregated values)
@@ -254,15 +310,14 @@ export class ErrorsListPresenter extends BasePresenter {
// Fetch global first_seen / last_seen from the errors_v1 summary table
const fingerprints = errorGroups.map((e) => e.error_fingerprint);
const globalSummaryMap = await this.getGlobalSummary(
organizationId,
projectId,
environmentId,
fingerprints
);
const [globalSummaryMap, stateMap] = await Promise.all([
this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints),
this.getErrorGroupStates(environmentId, errorGroups),
]);
const transformedErrorGroups = errorGroups.map((error) => {
let transformedErrorGroups = errorGroups.map((error) => {
const global = globalSummaryMap.get(error.error_fingerprint);
const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`);
return {
errorType: error.error_type,
errorMessage: error.error_message,
@@ -271,6 +326,9 @@ export class ErrorsListPresenter extends BasePresenter {
firstSeen: global?.firstSeen ?? new Date(),
lastSeen: global?.lastSeen ?? new Date(),
count: error.occurrence_count,
status: state?.status ?? "UNRESOLVED",
resolvedAt: state?.resolvedAt ?? null,
ignoredUntil: state?.ignoredUntil ?? null,
};
});
@@ -282,6 +340,8 @@ export class ErrorsListPresenter extends BasePresenter {
},
filters: {
tasks,
versions,
statuses,
search,
period: time,
from: effectiveFrom,
@@ -367,6 +427,106 @@ export class ErrorsListPresenter extends BasePresenter {
return { data };
}
/**
* Determines which (task, fingerprint) pairs to include or exclude from the ClickHouse
* query based on the requested status filter. Since status lives in Postgres and errors
* live in ClickHouse, we resolve the filter set here so ClickHouse pagination is correct.
*
* - UNRESOLVED is the default (no ErrorGroupState row), so filtering FOR it means
* excluding groups with non-matching explicit statuses.
* - RESOLVED/IGNORED are explicit, so filtering for them means including only matching groups.
*/
private async resolveStatusFilter(
environmentId: string,
statuses?: ErrorGroupStatus[]
): Promise<{
includeKeys?: string[];
excludeKeys?: string[];
empty: boolean;
}> {
if (!statuses || statuses.length === 0) {
return { empty: false };
}
const allStatuses: ErrorGroupStatus[] = ["UNRESOLVED", "RESOLVED", "IGNORED"];
const excludedStatuses = allStatuses.filter((s) => !statuses.includes(s));
if (excludedStatuses.length === 0) {
return { empty: false };
}
if (statuses.includes("UNRESOLVED")) {
const excluded = await this.replica.errorGroupState.findMany({
where: { environmentId, status: { in: excludedStatuses } },
select: { taskIdentifier: true, errorFingerprint: true },
});
if (excluded.length === 0) {
return { empty: false };
}
return {
excludeKeys: excluded.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
empty: false,
};
}
const included = await this.replica.errorGroupState.findMany({
where: { environmentId, status: { in: statuses } },
select: { taskIdentifier: true, errorFingerprint: true },
});
if (included.length === 0) {
return { empty: true };
}
return {
includeKeys: included.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
empty: false,
};
}
/**
* Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups.
* Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`.
*/
private async getErrorGroupStates(
environmentId: string,
errorGroups: Array<{ task_identifier: string; error_fingerprint: string }>
) {
type StateValue = {
status: ErrorGroupStatus;
resolvedAt: Date | null;
ignoredUntil: Date | null;
};
const result = new Map<string, StateValue>();
if (errorGroups.length === 0) return result;
const states = await this.replica.errorGroupState.findMany({
where: {
environmentId,
OR: errorGroups.map((e) => ({
taskIdentifier: e.task_identifier,
errorFingerprint: e.error_fingerprint,
})),
},
select: {
taskIdentifier: true,
errorFingerprint: true,
status: true,
resolvedAt: true,
ignoredUntil: true,
},
});
for (const state of states) {
result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, {
status: state.status,
resolvedAt: state.resolvedAt,
ignoredUntil: state.ignoredUntil,
});
}
return result;
}
/**
* Fetches global first_seen / last_seen for a set of fingerprints from errors_v1.
*/
@@ -20,6 +20,7 @@ export class NewAlertChannelPresenter extends BasePresenter {
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
orderBy: {
createdAt: "desc",
@@ -28,6 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
});
@@ -63,6 +63,7 @@ import {
v3NewProjectAlertPath,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
import { alertsWorker } from "~/v3/alertsWorker.server";
export const meta: MetaFunction = () => {
return [
@@ -156,6 +157,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
data: { enabled: true },
});
if (alertChannel.alertTypes.includes("ERROR_GROUP")) {
await alertsWorker.enqueue({
id: `evaluateErrorAlerts:${project.id}`,
job: "v3.evaluateErrorAlerts",
payload: {
projectId: project.id,
scheduledAt: Date.now(),
},
});
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
@@ -555,8 +567,10 @@ export function alertTypeTitle(alertType: ProjectAlertType): string {
return "Deployment failure";
case "DEPLOYMENT_SUCCESS":
return "Deployment success";
case "ERROR_GROUP":
return "Error group";
default: {
assertNever(alertType);
throw new Error(`Unknown alertType: ${alertType}`);
}
}
}
@@ -5,7 +5,6 @@ import { Form, useNavigation } from "@remix-run/react";
import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { toast } from "sonner";
import { z } from "zod";
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { Feedback } from "~/components/Feedback";
@@ -33,7 +32,7 @@ import {
PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { ToastUI } from "~/components/primitives/Toast";
import { useToast } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
import { $replica, prisma } from "~/db.server";
@@ -206,7 +205,8 @@ export default function Page() {
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
const layoutActionUrl = widgetActionUrl;
// Handle sync errors by showing a toast
const toast = useToast();
const handleSyncError = useCallback((error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
@@ -218,15 +218,8 @@ export default function Page() {
const message = actionMessages[action] || "Failed to save changes";
toast.custom((t) => (
<ToastUI
variant="error"
message={`${message}. Your changes may not be saved.`}
t={t as string}
title="Sync Error"
/>
));
}, []);
toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" });
}, [toast]);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
@@ -1,8 +1,13 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs, type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react";
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react";
import { parse } from "@conform-to/zod";
import { z } from "zod";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3CreateBulkActionPath,
@@ -14,38 +19,69 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
ErrorGroupPresenter,
type ErrorGroupActivity,
type ErrorGroupActivityVersions,
type ErrorGroupOccurrences,
type ErrorGroupSummary,
type ErrorGroupState,
} from "~/presenters/v3/ErrorGroupPresenter.server";
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { logsClickhouseClient, clickhouseClient } from "~/services/clickhouseInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody } from "~/components/layout/AppLayout";
import { Suspense, useMemo } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { formatDistanceToNow } from "date-fns";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { formatDistanceToNow, isPast } from "date-fns";
import * as Property from "~/components/primitives/PropertyTable";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
type TooltipProps,
XAxis,
YAxis,
} from "recharts";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import { cn } from "~/utils/cn";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server";
import {
ErrorStatusMenuItems,
CustomIgnoreDialog,
statusActionToastMessage,
} from "~/components/errors/ErrorStatusMenu";
import { useToast } from "~/components/primitives/Toast";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
@@ -55,6 +91,119 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
];
};
const emptyStringToUndefined = z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce.number().positive().optional()
);
const actionSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("resolve"),
taskIdentifier: z.string().min(1),
resolvedInVersion: z.string().optional(),
}),
z.object({
action: z.literal("ignore"),
taskIdentifier: z.string().min(1),
duration: emptyStringToUndefined,
occurrenceRate: emptyStringToUndefined,
totalOccurrences: emptyStringToUndefined,
reason: z.preprocess((v) => (v === "" ? undefined : v), z.string().optional()),
}),
z.object({
action: z.literal("unresolve"),
taskIdentifier: z.string().min(1),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const fingerprint = params.fingerprint;
if (!fingerprint) {
return json({ error: "Fingerprint parameter is required" }, { status: 400 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: actionSchema });
if (!submission.value) {
return json(submission);
}
const actions = new ErrorGroupActions();
const identifier = {
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
taskIdentifier: submission.value.taskIdentifier,
errorFingerprint: fingerprint,
};
switch (submission.value.action) {
case "resolve": {
await actions.resolveError(identifier, {
userId,
resolvedInVersion: submission.value.resolvedInVersion,
});
return json({ ok: true });
}
case "ignore": {
let occurrenceCountAtIgnoreTime: number | undefined;
if (submission.value.totalOccurrences) {
const qb = clickhouseClient.errors.listQueryBuilder();
qb.where("organization_id = {organizationId: String}", {
organizationId: project.organizationId,
});
qb.where("project_id = {projectId: String}", { projectId: project.id });
qb.where("environment_id = {environmentId: String}", {
environmentId: environment.id,
});
qb.where("error_fingerprint = {fingerprint: String}", { fingerprint });
qb.where("task_identifier = {taskIdentifier: String}", {
taskIdentifier: submission.value.taskIdentifier,
});
qb.groupBy("error_fingerprint, task_identifier");
const [err, results] = await qb.execute();
if (err || !results || results.length === 0) {
return json(
{ error: "Failed to fetch current occurrence count. Please try again." },
{ status: 500 }
);
}
occurrenceCountAtIgnoreTime = results[0].occurrence_count;
}
await actions.ignoreError(identifier, {
userId,
duration: submission.value.duration,
occurrenceRateThreshold: submission.value.occurrenceRate,
totalOccurrencesThreshold: submission.value.totalOccurrences,
occurrenceCountAtIgnoreTime,
reason: submission.value.reason,
});
return json({ ok: true });
}
case "unresolve": {
await actions.unresolveError(identifier);
return json({ ok: true });
}
}
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
@@ -82,6 +231,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const toStr = url.searchParams.get("to");
const from = fromStr ? parseInt(fromStr, 10) : undefined;
const to = toStr ? parseInt(toStr, 10) : undefined;
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
const cursor = url.searchParams.get("cursor") ?? undefined;
const directionRaw = url.searchParams.get("direction") ?? undefined;
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
@@ -93,6 +243,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
projectId: project.id,
fingerprint,
versions: versions.length > 0 ? versions : undefined,
period,
from,
to,
@@ -115,9 +266,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
environment.id,
fingerprint,
time.from,
time.to
time.to,
versions.length > 0 ? versions : undefined
)
.catch(() => ({ data: [] as ErrorGroupActivity }));
.catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] }));
return typeddefer({
data: detailPromise,
@@ -149,10 +301,19 @@ export default function Page() {
if (period) carry.set("period", period);
if (from) carry.set("from", from);
if (to) carry.set("to", to);
for (const v of searchParams.getAll("versions")) {
if (v) carry.append("versions", v);
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
const alertsHref = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("alerts", "true");
return `?${params.toString()}`;
}, [location.search]);
return (
<>
<NavBar>
@@ -205,6 +366,7 @@ export default function Page() {
projectParam={projectParam}
envParam={envParam}
fingerprint={fingerprint}
alertsHref={alertsHref}
/>
);
}}
@@ -223,6 +385,7 @@ function ErrorGroupDetail({
projectParam,
envParam,
fingerprint,
alertsHref,
}: {
errorGroup: ErrorGroupSummary | undefined;
runList: NextRunList | undefined;
@@ -231,8 +394,9 @@ function ErrorGroupDetail({
projectParam: string;
envParam: string;
fingerprint: string;
alertsHref: string;
}) {
const { value } = useSearchParams();
const { value, values } = useSearchParams();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -252,26 +416,181 @@ function ErrorGroupDetail({
const fromValue = value("from") ?? undefined;
const toValue = value("to") ?? undefined;
const selectedVersions = values("versions").filter((v) => v !== "");
const filters: TaskRunListSearchFilters = {
period: value("period") ?? undefined,
from: fromValue ? parseInt(fromValue, 10) : undefined,
to: toValue ? parseInt(toValue, 10) : undefined,
versions: selectedVersions.length > 0 ? selectedVersions : undefined,
rootOnly: false,
errorId: ErrorId.toFriendlyId(fingerprint),
};
return (
<div className="grid h-full grid-rows-[auto_12rem_1fr] overflow-hidden">
{/* Error Summary */}
<div className="flex flex-col gap-2 border-b border-grid-bright bg-background-bright p-4">
<div className="flex flex-col gap-0.5">
<Header2>{errorGroup.errorMessage}</Header2>
<Header3>{formatNumberCompact(errorGroup.count)} total occurrences</Header3>
</div>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
{/* Main content: chart + runs */}
<ResizablePanel id="error-main" min="300px">
<div className="grid h-full grid-rows-[12rem_1fr] overflow-hidden">
{/* Activity chart */}
<div className="flex flex-col gap-3 overflow-hidden border-b border-grid-bright bg-background-bright py-2 pl-2 pr-4">
<div className="flex items-center gap-2">
<TimeFilter defaultPeriod="7d" labelName="Occurred" />
<LogsVersionFilter />
</div>
<div className="grid grid-cols-[auto_auto_auto_1fr] gap-x-12 gap-y-0.5">
<Suspense fallback={<ActivityChartBlankState />}>
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
{(result) => {
if (result.data.length > 0 && result.versions.length > 0) {
return <ActivityChart activity={result.data} versions={result.versions} />;
}
return <ActivityChartBlankState />;
}}
</TypedAwait>
</Suspense>
</div>
{/* Runs Table */}
<div className="flex flex-col gap-1 overflow-y-hidden">
<div className="flex items-center justify-between pl-3 pr-2 pt-1">
<Header3 className="mb-1 mt-2">Runs</Header3>
{runList && (
<div className="flex items-center gap-2">
<LinkButton
variant="secondary/small"
to={v3RunsPath(organization, project, environment, filters)}
LeadingIcon={RunsIcon}
>
View all runs
</LinkButton>
<LinkButton
variant="secondary/small"
to={v3CreateBulkActionPath(
organization,
project,
environment,
filters,
"filter",
"replay"
)}
LeadingIcon={ListCheckedIcon}
>
Bulk replay
</LinkButton>
<ListPagination list={runList} />
</div>
)}
</div>
{runList ? (
<TaskRunsTable
total={runList.runs.length}
hasFilters={selectedVersions.length > 0}
filters={{
tasks: [],
versions: selectedVersions,
statuses: [],
from: undefined,
to: undefined,
}}
runs={runList.runs}
isLoading={false}
variant="dimmed"
additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }}
/>
) : (
<Paragraph variant="small" className="p-4 text-text-dimmed">
No runs found for this error.
</Paragraph>
)}
</div>
</div>
</ResizablePanel>
{/* Right-hand detail sidebar */}
<ResizableHandle id="error-detail-handle" />
<ResizablePanel id="error-detail" min="280px" default="380px" max="500px" isStaticAtRest>
<ErrorDetailSidebar
errorGroup={errorGroup}
fingerprint={fingerprint}
alertsHref={alertsHref}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}
function ErrorDetailSidebar({
errorGroup,
fingerprint,
alertsHref,
}: {
errorGroup: ErrorGroupSummary;
fingerprint: string;
alertsHref: string;
}) {
return (
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="truncate">Details</Header2>
<LinkButton
to={alertsHref}
variant="secondary/small"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-alerts"
>
Configure alerts
</LinkButton>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col gap-4">
<Property.Table>
{/* Status */}
<Property.Item className="gap-1">
<Property.Label>Error status</Property.Label>
<Property.Value>
<div className="flex items-center justify-between">
<ErrorStatusBadge status={errorGroup.state.status} className="w-fit" />
<ErrorStatusDropdown
state={errorGroup.state}
taskIdentifier={errorGroup.taskIdentifier}
/>
</div>
<AnimatePresence>
{errorGroup.state.status === "IGNORED" && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
<IgnoredDetails
className="mt-2"
state={errorGroup.state}
totalOccurrences={errorGroup.count}
/>
</motion.div>
)}
</AnimatePresence>
</Property.Value>
</Property.Item>
{/* Error message */}
<Property.Item className="gap-1">
<Property.Label>Error</Property.Label>
<Property.Value>
<CodeBlock
code={errorGroup.errorMessage}
showCopyButton
showLineNumbers={false}
showOpenInModal={false}
language="typescript"
wrap
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
@@ -284,9 +603,12 @@ function ErrorGroupDetail({
<CopyableText value={errorGroup.taskIdentifier} />
</Property.Value>
</Property.Item>
</Property.Table>
<Property.Table>
<Property.Item>
<Property.Label>Occurrences</Property.Label>
<Property.Value>
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>First seen</Property.Label>
<Property.Value>
@@ -299,14 +621,11 @@ function ErrorGroupDetail({
<RelativeDateTime date={errorGroup.lastSeen} />
</Property.Value>
</Property.Item>
</Property.Table>
<Property.Table>
{errorGroup.affectedVersions.length > 0 && (
<Property.Item>
<Property.Label>Affected versions</Property.Label>
<Property.Label>Versions</Property.Label>
<Property.Value>
<span className="font-mono text-xs">
<span className="font-mono text-sm">
{errorGroup.affectedVersions.join(", ")}
</span>
</Property.Value>
@@ -315,91 +634,170 @@ function ErrorGroupDetail({
</Property.Table>
</div>
</div>
{/* Activity chart */}
<div className="flex flex-col gap-3 overflow-hidden border-b border-grid-bright px-4 py-3">
<div className="flex items-center">
<TimeFilter defaultPeriod="7d" labelName="Occurred" />
</div>
<Suspense fallback={<ActivityChartBlankState />}>
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
{(result) =>
result.data.length > 0 ? (
<ActivityChart activity={result.data} />
) : (
<ActivityChartBlankState />
)
}
</TypedAwait>
</Suspense>
</div>
{/* Runs Table */}
<div className="flex flex-col gap-1 overflow-y-hidden">
<div className="flex items-center justify-between px-4">
<Header3 className="mb-1 mt-2">Runs</Header3>
{runList && (
<div className="flex items-center gap-2">
<LinkButton
variant="secondary/small"
to={v3RunsPath(organization, project, environment, filters)}
LeadingIcon={RunsIcon}
>
View all runs
</LinkButton>
<LinkButton
variant="secondary/small"
to={v3CreateBulkActionPath(
organization,
project,
environment,
filters,
"filter",
"replay"
)}
LeadingIcon={ListCheckedIcon}
>
Bulk replay
</LinkButton>
<ListPagination list={runList} />
</div>
)}
</div>
{runList ? (
<TaskRunsTable
total={runList.runs.length}
hasFilters={false}
filters={{
tasks: [],
versions: [],
statuses: [],
from: undefined,
to: undefined,
}}
runs={runList.runs}
isLoading={false}
variant="dimmed"
additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }}
/>
) : (
<Paragraph variant="small" className="p-4 text-text-dimmed">
No runs found for this error.
</Paragraph>
)}
</div>
</div>
);
}
const activityChartConfig: ChartConfig = {
count: {
label: "Occurrences",
color: "#6366F1",
},
};
function IgnoredDetails({
state,
totalOccurrences,
className,
}: {
state: ErrorGroupState;
totalOccurrences: number;
className?: string;
}) {
if (state.status !== "IGNORED") {
return null;
}
const hasConditions =
state.ignoredUntil || state.ignoredUntilOccurrenceRate || state.ignoredUntilTotalOccurrences;
const ignoredForever = !hasConditions;
const occurrencesSinceIgnore =
state.ignoredUntilTotalOccurrences && state.ignoredAtOccurrenceCount !== null
? totalOccurrences - state.ignoredAtOccurrenceCount
: null;
return (
<div
className={cn(
"flex flex-col gap-1.5 rounded border border-text-dimmed/20 bg-text-dimmed/5 px-3 py-2.5 text-sm",
className
)}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<IconAlarmSnoozeBase className="size-5 text-blue-500" />
<Header3 className="text-text-bright">
{ignoredForever ? "Ignored permanently" : "Ignored with conditions"}
</Header3>
</div>
{(state.ignoredByUserDisplayName || state.ignoredAt) && (
<Paragraph variant="extra-small/dimmed">
{state.ignoredByUserDisplayName && <>Configured by {state.ignoredByUserDisplayName}</>}
{state.ignoredByUserDisplayName && state.ignoredAt && " "}
{state.ignoredAt && <RelativeDateTime date={state.ignoredAt} capitalize={false} />}
</Paragraph>
)}
</div>
{state.ignoredReason && (
<Paragraph variant="extra-small/dimmed">Reason: {state.ignoredReason}</Paragraph>
)}
{hasConditions && (
<div className="flex flex-col gap-1 text-xs text-text-dimmed">
{state.ignoredUntil && (
<span>
Will revert to "Unresolved" at:{" "}
<span className="text-text-bright">
<DateTime date={state.ignoredUntil} />
</span>
{isPast(state.ignoredUntil) && <span className="ml-1 text-warning">(expired)</span>}
</span>
)}
{state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && (
<span>
Will revert to "Unresolved" when: Occurrence rate exceeds{" "}
<span className="tabular-nums text-text-bright">
{state.ignoredUntilOccurrenceRate}/min
</span>
</span>
)}
{state.ignoredUntilTotalOccurrences !== null &&
state.ignoredUntilTotalOccurrences > 0 && (
<span>
Will revert to "Unresolved" when: Total occurrences exceed{" "}
<span className="tabular-nums text-text-bright">
{state.ignoredUntilTotalOccurrences.toLocaleString()}
</span>
{occurrencesSinceIgnore !== null && (
<span className="ml-1 tabular-nums text-text-dimmed">
({occurrencesSinceIgnore.toLocaleString()} since ignored)
</span>
)}
</span>
)}
</div>
)}
</div>
);
}
function ErrorStatusDropdown({
state,
taskIdentifier,
}: {
state: ErrorGroupState;
taskIdentifier: string;
}) {
const fetcher = useFetcher<{ ok?: boolean }>();
const revalidator = useRevalidator();
const [popoverOpen, setPopoverOpen] = useState(false);
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
const isSubmitting = fetcher.state !== "idle";
const toast = useToast();
const pendingToast = useRef<string | undefined>();
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
toast.success(pendingToast.current);
pendingToast.current = undefined;
revalidator.revalidate();
}
}, [fetcher.state, fetcher.data, toast, revalidator]);
const act = (data: Record<string, string>) => {
setPopoverOpen(false);
pendingToast.current = statusActionToastMessage(data);
fetcher.submit(data, { method: "post" });
};
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverArrowTrigger variant="primary" disabled={isSubmitting} className="items-center">
<IconCircleDotted className="-ml-1 mr-1 size-3.5 text-text-bright" />
Mark error as
</PopoverArrowTrigger>
<PopoverContent className="inline-flex !min-w-0 flex-col p-1" align="end">
<ErrorStatusMenuItems
status={state.status}
taskIdentifier={taskIdentifier}
onAction={act}
onCustomIgnore={() => {
setPopoverOpen(false);
setCustomIgnoreOpen(true);
}}
/>
</PopoverContent>
</Popover>
<CustomIgnoreDialog
open={customIgnoreOpen}
onOpenChange={setCustomIgnoreOpen}
taskIdentifier={taskIdentifier}
/>
</>
);
}
function ActivityChart({
activity,
versions,
}: {
activity: ErrorGroupActivity;
versions: ErrorGroupActivityVersions;
}) {
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
const colors = useMemo(
() => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]),
[versions]
);
function ActivityChart({ activity }: { activity: ErrorGroupActivity }) {
const data = useMemo(
() =>
activity.map((d) => ({
@@ -433,48 +831,91 @@ function ActivityChart({ activity }: { activity: ErrorGroupActivity }) {
};
}, []);
const tooltipLabelFormatter = useMemo(() => {
return (_label: string, payload: Array<{ payload?: Record<string, unknown> }>) => {
const timestamp = payload[0]?.payload?.__timestamp as number | undefined;
if (timestamp) {
const date = new Date(timestamp);
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
return _label;
};
}, []);
return (
<Chart.Root
config={activityChartConfig}
data={data}
dataKey="__timestamp"
series={["count"]}
fillContainer
>
<Chart.Bar
xAxisProps={{
tickFormatter: xAxisFormatter,
ticks: midnightTicks,
height: 40,
}}
yAxisProps={{
width: 30,
tickMargin: 4,
}}
tooltipLabelFormatter={tooltipLabelFormatter}
/>
</Chart.Root>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
<XAxis
dataKey="__timestamp"
tickFormatter={xAxisFormatter}
ticks={midnightTicks}
height={24}
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: "#878C99" }}
/>
<YAxis
width={30}
tickMargin={4}
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: "#878C99" }}
domain={["auto", (dataMax: number) => dataMax * 1.15]}
/>
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<ActivityTooltip versions={versions} colors={colors} />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
{versions.map((version, i) => (
<Bar
key={version}
dataKey={version}
stackId="versions"
fill={colors[i]}
strokeWidth={0}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
const ActivityTooltip = ({
active,
payload,
versions,
colors,
}: TooltipProps<number, string> & { versions: string[]; colors: string[] }) => {
if (!active || !payload?.length) return null;
const timestamp = payload[0]?.payload?.__timestamp as number | undefined;
if (!timestamp) return null;
const date = new Date(timestamp);
const formattedDate = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-charcoal-650 pb-2">{formattedDate}</Header3>
<div className="mt-2 flex flex-col gap-1">
{payload.map((entry, i) => {
const value = (entry.value as number) ?? 0;
return (
<div key={entry.dataKey} className="flex items-center gap-2 text-xs">
<div className="size-2 rounded-[2px]" style={{ backgroundColor: entry.color }} />
<span className="text-text-dimmed">{entry.dataKey}</span>
<span className="tabular-nums text-text-bright">{value}</span>
</div>
);
})}
</div>
</div>
</TooltipPortal>
);
};
function ActivityChartBlankState() {
return (
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
@@ -1,8 +1,11 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { Form, type MetaFunction } from "@remix-run/react";
import * as Ariakit from "@ariakit/react";
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { IconBugFilled } from "@tabler/icons-react";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { Suspense, useMemo } from "react";
import { type ErrorGroupStatus } from "@trigger.dev/database";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Bar,
BarChart,
@@ -13,30 +16,51 @@ import {
type TooltipProps,
} from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import { PageBody } from "~/components/layout/AppLayout";
import { SearchInput } from "~/components/primitives/SearchInput";
import { ListPagination } from "~/components/ListPagination";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { Button } from "~/components/primitives/Buttons";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { formatDateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SearchInput } from "~/components/primitives/SearchInput";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import {
CopyableTableCell,
Table,
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { PopoverSectionHeader } from "~/components/primitives/Popover";
import {
ErrorStatusMenuItems,
CustomIgnoreDialog,
statusActionToastMessage,
} from "~/components/errors/ErrorStatusMenu";
import { useToast } from "~/components/primitives/Toast";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { appliedSummary, FilterMenuProvider, TimeFilter } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { useInterval } from "~/hooks/useInterval";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
@@ -49,7 +73,6 @@ import {
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUser } from "~/services/session.server";
import { ListPagination } from "~/components/ListPagination";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -80,6 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
const statuses = url.searchParams
.getAll("status")
.filter(
(s): s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED"
);
const search = url.searchParams.get("search") ?? undefined;
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
@@ -101,6 +130,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
projectId: project.id,
tasks: tasks.length > 0 ? tasks : undefined,
versions: versions.length > 0 ? versions : undefined,
statuses: statuses.length > 0 ? statuses : undefined,
search,
period,
from,
@@ -153,6 +184,24 @@ export default function Page() {
envParam,
} = useTypedLoaderData<typeof loader>();
const revalidator = useRevalidator();
useInterval({
interval: 60_000,
onLoad: false,
callback: useCallback(() => {
if (revalidator.state === "idle") {
revalidator.revalidate();
}
}, [revalidator]),
});
const location = useOptimisticLocation();
const alertsHref = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("alerts", "true");
return `?${params.toString()}`;
}, [location.search]);
return (
<>
<NavBar>
@@ -177,7 +226,11 @@ export default function Page() {
resolve={data}
errorElement={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} />
<FiltersBar
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load errors. Please refresh the page or try again in a moment.
@@ -193,6 +246,7 @@ export default function Page() {
<FiltersBar
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
@@ -208,6 +262,7 @@ export default function Page() {
list={result}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<ErrorsList
errorGroups={result.errorGroups}
@@ -226,19 +281,133 @@ export default function Page() {
);
}
const errorStatusOptions = [
{ value: "UNRESOLVED", label: "Unresolved" },
{ value: "RESOLVED", label: "Resolved" },
{ value: "IGNORED", label: "Ignored" },
] as const;
const statusIcon = <IconBugFilled className="size-4" />;
const statusShortcut = { key: "s" };
function StatusFilter() {
const { values, del } = useSearchParams();
const selectedStatuses = values("status");
if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ErrorStatusDropdown
trigger={
<SelectTrigger
icon={statusIcon}
variant="secondary/small"
shortcut={statusShortcut}
tooltipTitle="Filter by status"
>
<span className="ml-0.5">Status</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ErrorStatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={statusIcon}
value={appliedSummary(
selectedStatuses.map((s) => {
const opt = errorStatusOptions.find((o) => o.value === s);
return opt ? opt.label : s;
})
)}
onRemove={() => del(["status", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function ErrorStatusDropdown({
trigger,
clearSearchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
status: values.length > 0 ? values : undefined,
cursor: undefined,
direction: undefined,
});
};
return (
<SelectProvider value={values("status")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<SelectList>
{errorStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
<ErrorStatusBadge status={item.value} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function FiltersBar({
list,
defaultPeriod,
retentionLimitDays,
alertsHref,
}: {
list?: ErrorsListData;
defaultPeriod?: string;
retentionLimitDays: number;
alertsHref: string;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("status") ||
searchParams.has("tasks") ||
searchParams.has("versions") ||
searchParams.has("search") ||
searchParams.has("period") ||
searchParams.has("from") ||
@@ -246,10 +415,12 @@ function FiltersBar({
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="flex flex-row flex-wrap items-center gap-2">
{list ? (
<>
<StatusFilter />
<LogsTaskFilter possibleTasks={list.filters.possibleTasks} />
<LogsVersionFilter />
<TimeFilter
defaultPeriod={defaultPeriod}
maxPeriodDays={retentionLimitDays}
@@ -268,7 +439,9 @@ function FiltersBar({
</>
) : (
<>
<StatusFilter />
<LogsTaskFilter possibleTasks={[]} />
<LogsVersionFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<SearchInput placeholder="Search errors…" />
{hasFilters && (
@@ -283,7 +456,17 @@ function FiltersBar({
</>
)}
</div>
{list && <ListPagination list={list} />}
<div className="flex shrink-0 items-center gap-2">
<LinkButton
to={alertsHref}
variant="secondary/small"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-alerts"
>
Configure alerts
</LinkButton>
{list && <ListPagination list={list} />}
</div>
</div>
);
}
@@ -303,22 +486,21 @@ function ErrorsList({
}) {
if (errorGroups.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Header3 className="mb-2">No errors found</Header3>
<Paragraph variant="small">
No errors have been recorded in the selected time period.
</Paragraph>
</div>
<div className="flex h-full flex-col items-center justify-center gap-3">
<IconBugFilled className="size-16 text-charcoal-650" />
<Paragraph className="text-center text-text-dimmed">
No errors found for this time period.
</Paragraph>
</div>
);
}
return (
<Table containerClassName="max-h-full pb-[2.5rem]">
<Table containerClassName="max-h-full pb-[2.5rem]" showTopBorder={false}>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Error</TableHeaderCell>
<TableHeaderCell>Occurrences</TableHeaderCell>
@@ -330,7 +512,7 @@ function ErrorsList({
<TableBody>
{errorGroups.map((errorGroup) => (
<ErrorGroupRow
key={errorGroup.fingerprint}
key={`${errorGroup.taskIdentifier}::${errorGroup.fingerprint}`}
errorGroup={errorGroup}
occurrences={occurrences}
organizationSlug={organizationSlug}
@@ -373,6 +555,9 @@ function ErrorGroupRow({
if (period) carry.set("period", period);
if (from) carry.set("from", from);
if (to) carry.set("to", to);
for (const v of searchParams.getAll("versions")) {
if (v) carry.append("versions", v);
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
@@ -384,11 +569,16 @@ function ErrorGroupRow({
<CopyableTableCell to={errorPath} value={ErrorId.toFriendlyId(errorGroup.fingerprint)}>
{errorGroup.fingerprint.slice(-8)}
</CopyableTableCell>
<TableCell to={errorPath}>
<ErrorStatusBadge status={errorGroup.status} />
</TableCell>
<TableCell to={errorPath}>{errorGroup.taskIdentifier}</TableCell>
<CopyableTableCell to={errorPath} className="font-mono" value={errorMessage}>
{errorMessage}
{errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}` : errorMessage}
</CopyableTableCell>
<TableCell to={errorPath}>{errorGroup.count.toLocaleString()}</TableCell>
<TableCell to={errorPath}>
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
</TableCell>
<TableCell to={errorPath} actionClassName="py-1.5">
<Suspense fallback={<ErrorActivityBlankState />}>
<TypedAwait resolve={occurrences} errorElement={<ErrorActivityBlankState />}>
@@ -403,33 +593,112 @@ function ErrorGroupRow({
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={errorPath}>
<TableCell to={errorPath} className="tabular-nums">
<RelativeDateTime date={errorGroup.firstSeen} />
</TableCell>
<TableCell to={errorPath}>
<TableCell to={errorPath} className="tabular-nums">
<RelativeDateTime date={errorGroup.lastSeen} />
</TableCell>
<ErrorActionsCell
errorGroup={errorGroup}
organizationSlug={organizationSlug}
projectParam={projectParam}
envParam={envParam}
/>
</TableRow>
);
}
function ErrorActionsCell({
errorGroup,
organizationSlug,
projectParam,
envParam,
}: {
errorGroup: ErrorGroup;
organizationSlug: string;
projectParam: string;
envParam: string;
}) {
const fetcher = useFetcher<{ ok?: boolean }>();
const revalidator = useRevalidator();
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
const toast = useToast();
const pendingToast = useRef<string | undefined>();
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
toast.success(pendingToast.current);
pendingToast.current = undefined;
revalidator.revalidate();
}
}, [fetcher.state, fetcher.data, toast, revalidator]);
const actionUrl = v3ErrorPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ fingerprint: errorGroup.fingerprint }
);
return (
<>
<TableCellMenu
isSticky
popoverContent={(close) => (
<>
<PopoverSectionHeader title="Mark error as…" />
<div className="flex flex-col gap-1 p-1">
<ErrorStatusMenuItems
status={errorGroup.status}
taskIdentifier={errorGroup.taskIdentifier}
onAction={(data) => {
close();
pendingToast.current = statusActionToastMessage(data);
fetcher.submit(data, { method: "post", action: actionUrl });
}}
onCustomIgnore={() => {
close();
setCustomIgnoreOpen(true);
}}
/>
</div>
</>
)}
/>
<CustomIgnoreDialog
open={customIgnoreOpen}
onOpenChange={setCustomIgnoreOpen}
taskIdentifier={errorGroup.taskIdentifier}
formAction={actionUrl}
/>
</>
);
}
function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) {
const maxCount = Math.max(...activity.map((d) => d.count));
return (
<div className="flex items-start gap-1.5">
<div className="h-6 w-[10.25rem] rounded-sm">
<div className="h-6 w-[7rem] rounded-sm">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={activity} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<YAxis domain={[0, maxCount || 1]} hide />
<Tooltip
cursor={{ fill: "transparent" }}
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<ErrorActivityTooltip />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
<Bar dataKey="count" fill="#6366F1" strokeWidth={0} isAnimationActive={false} />
<Bar
dataKey="count"
fill="#6366F1"
strokeWidth={0}
isAnimationActive={false}
minPointSize={1}
/>
<ReferenceLine y={0} stroke="#2C3034" strokeWidth={1} />
{maxCount > 0 && (
<ReferenceLine y={maxCount} stroke="#4D525B" strokeDasharray="4 4" strokeWidth={1} />
@@ -470,7 +739,7 @@ const ErrorActivityTooltip = ({ active, payload }: TooltipProps<number, string>)
function ErrorActivityBlankState() {
return (
<div className="flex h-6 w-[5.125rem] items-end gap-px rounded-sm">
<div className="flex h-6 w-[7rem] items-end gap-px rounded-sm">
{[...Array(24)].map((_, i) => (
<div key={i} className="h-full flex-1 bg-[#212327]" />
))}
@@ -0,0 +1,48 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3ErrorsPath,
v3ErrorsConnectToSlackPath,
} from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
const shouldReinstall = url.searchParams.get("reinstall") === "true";
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
});
if (integration && !shouldReinstall) {
return redirectWithSuccessMessage(
`${v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam })}?alerts`,
request,
"Successfully connected your Slack workspace"
);
}
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
request,
v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam })
);
}
@@ -1,10 +1,207 @@
import { Outlet } from "@remix-run/react";
import { parse } from "@conform-to/zod";
import { Outlet, useNavigate } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useCallback } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageContainer } from "~/components/layout/AppLayout";
import {
ConfigureErrorAlerts,
ErrorAlertsFormSchema,
} from "~/components/errors/ConfigureErrorAlerts";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { prisma } from "~/db.server";
import { ErrorAlertChannelPresenter } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { env } from "~/env.server";
import {
EnvironmentParamSchema,
v3ErrorsConnectToSlackPath,
v3ErrorsPath,
} from "~/utils/pathBuilder";
import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const presenter = new ErrorAlertChannelPresenter();
const alertData = await presenter.call(project.id, environment.type);
const connectToSlackHref = v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, {
slug: envParam,
});
const errorsPath = v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam });
return typedjson({
alertData,
projectRef: project.externalRef,
projectId: project.id,
environmentType: environment.type,
connectToSlackHref,
errorsPath,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return json({ status: 405, error: "Method Not Allowed" }, { status: 405 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: ErrorAlertsFormSchema });
if (!submission.value) {
return json(submission);
}
const { emails, webhooks, slackChannel, slackIntegrationId } = submission.value;
const emailEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
const slackEnabled = !!slackIntegrationId;
const existingChannels = await prisma.projectAlertChannel.findMany({
where: {
projectId: project.id,
alertTypes: { has: "ERROR_GROUP" },
environmentTypes: { has: environment.type },
},
});
const service = new CreateAlertChannelService();
const environmentTypes = [environment.type];
const processedChannelIds = new Set<string>();
if (emailEnabled) {
for (const email of emails) {
const options: CreateAlertChannelOptions = {
name: `Error alert to ${email}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-email:${email}:${environment.type}`,
channel: { type: "EMAIL", email },
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
}
}
if (slackEnabled && slackChannel) {
const [channelId, channelName] = slackChannel.split("/");
if (channelId && channelName) {
const options: CreateAlertChannelOptions = {
name: `Error alert to #${channelName}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-slack:${environment.type}`,
channel: {
type: "SLACK",
channelId,
channelName,
integrationId: slackIntegrationId,
},
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
}
}
for (const url of webhooks) {
const options: CreateAlertChannelOptions = {
name: `Error alert to ${new URL(url).hostname}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-webhook:${url}:${environment.type}`,
channel: { type: "WEBHOOK", url },
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
}
const editableTypes = new Set<string>(["WEBHOOK"]);
if (emailEnabled) {
editableTypes.add("EMAIL");
}
if (slackEnabled) {
editableTypes.add("SLACK");
}
const channelsToDelete = existingChannels.filter(
(ch) =>
!processedChannelIds.has(ch.id) &&
editableTypes.has(ch.type) &&
ch.alertTypes.length === 1 &&
ch.alertTypes[0] === "ERROR_GROUP"
);
for (const ch of channelsToDelete) {
await prisma.projectAlertChannel.delete({ where: { id: ch.id } });
}
return json({ ok: true });
};
export default function Page() {
const { alertData, connectToSlackHref, errorsPath } = useTypedLoaderData<typeof loader>();
const { has } = useSearchParams();
const showAlerts = has("alerts") ?? false;
const navigate = useNavigate();
const location = useOptimisticLocation();
const closeAlerts = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete("alerts");
const qs = params.toString();
navigate(qs ? `?${qs}` : location.pathname, { replace: true });
}, [location.search, location.pathname, navigate]);
return (
<PageContainer>
<Outlet />
<Sheet open={showAlerts} onOpenChange={(open) => !open && closeAlerts()}>
<SheetContent
side="right"
className="w-[420px] min-w-[320px] max-w-[560px] p-0 sm:max-w-[560px]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ConfigureErrorAlerts
{...alertData}
connectToSlackHref={connectToSlackHref}
formAction={errorsPath}
/>
</SheetContent>
</Sheet>
</PageContainer>
);
}
@@ -1,13 +1,14 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { fromPromise } from "neverthrow";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { SlackIcon } from "@trigger.dev/companyicons";
import { TrashIcon } from "@heroicons/react/20/solid";
import { IconBugFilled } from "@tabler/icons-react";
import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon";
import { Button } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
@@ -17,8 +18,14 @@ import {
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header1 } from "~/components/primitives/Headers";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
@@ -31,21 +38,9 @@ import {
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { $transaction, prisma } from "~/db.server";
import { requireOrganization } from "~/services/org.server";
import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder";
import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization } = await requireOrganization(request, organizationSlug);
@@ -183,12 +178,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
integrationId: slackIntegration.id,
});
return redirect(organizationSettingsPath({ slug: organizationSlug }));
return redirect(organizationSlackIntegrationPath({ slug: organizationSlug }));
};
export default function SlackIntegrationPage() {
const { slackIntegration, alertChannels, teamName } =
useTypedLoaderData<typeof loader>();
const { slackIntegration, alertChannels, teamName } = useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling =
@@ -197,12 +191,18 @@ export default function SlackIntegrationPage() {
if (!slackIntegration) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Slack integration" />
</NavBar>
<PageBody>
<div className="flex flex-col items-center justify-center py-8">
<Header1>No Slack Integration Found</Header1>
<Paragraph className="mt-2 text-center text-text-dimmed">
This organization doesn't have a Slack integration configured. You can connect Slack
when setting up alert channels in your project settings.
<div className="flex h-full flex-col items-center justify-center gap-3">
<SlackMonoIcon className="mb-2 size-16 text-charcoal-650" />
<Header2>No Slack integration found</Header2>
<Paragraph className="max-w-md text-center text-text-dimmed">
Your organization doesn't have a Slack integration configured. You can connect Slack
when setting up alerts from the{" "}
<IconBugFilled className="-ml-0.5 mb-0.5 inline size-5 text-errors" />
<span className="text-text-bright">Errors</span> page.
</Paragraph>
</div>
</PageBody>
@@ -212,114 +212,131 @@ export default function SlackIntegrationPage() {
return (
<PageContainer>
<NavBar>
<PageTitle title="Slack integration" />
</NavBar>
<PageBody>
<div className="mb-8">
<Header1>Slack Integration</Header1>
<Paragraph className="mt-2 text-text-dimmed">
Manage your organization's Slack integration and connected alert channels.
</Paragraph>
</div>
{/* Integration Info Section */}
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
<div className="flex items-center justify-between">
<MainHorizontallyCenteredContainer>
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2>Integration details</Header2>
</div>
<div className="flex flex-col gap-1">
{teamName && (
<div>
<span className="font-medium">Slack Workspace:</span> {teamName}
</div>
<Paragraph variant="small">
<span className="text-text-dimmed">Workspace:</span>{" "}
<span className="text-text-bright">{teamName}</span>
</Paragraph>
)}
<div>
<span className="font-medium">Installed:</span>{" "}
{formatDate(new Date(slackIntegration.createdAt))}
</div>
<Paragraph variant="small">
<span className="text-text-dimmed">Installed:</span>{" "}
<span className="text-text-bright">
<DateTime date={slackIntegration.createdAt} />
</span>
</Paragraph>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
Remove Integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Slack Integration</DialogTitle>
</DialogHeader>
<DialogDescription>
This will remove the Slack integration and disable all connected alert channels.
This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing..." : "Remove Integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
{actionData?.error && (
<Paragraph variant="small" className="text-error">
{actionData.error}
<div>
<Header3 spacing>
Connected alert channels
<span className="ml-1 text-text-dimmed">({alertChannels.length})</span>
</Header3>
{alertChannels.length === 0 ? (
<Paragraph variant="small" className="text-text-dimmed">
No alert channels are currently connected to this Slack integration.
</Paragraph>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Project</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.project.name}</TableCell>
<TableCell>
<EnabledStatus enabled={channel.enabled} />
</TableCell>
<TableCell>
<DateTime date={channel.createdAt} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
{/* Connected Alert Channels Section */}
<div>
<h2 className="mb-4 text-lg font-medium text-text-bright">
Connected Alert Channels ({alertChannels.length})
</h2>
{alertChannels.length === 0 ? (
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
<Paragraph className="text-text-dimmed">
No alert channels are currently connected to this Slack integration.
</Paragraph>
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Header3 spacing>Remove integration</Header3>
<Hint>
This will remove the Slack integration and disable all connected alert channels.
This action cannot be undone.
</Hint>
{actionData?.error && (
<Paragraph variant="small" className="mt-2 text-error">
{actionData.error}
</Paragraph>
)}
<FormButtons
className="mt-2"
confirmButton={
<Dialog>
<DialogTrigger asChild>
<Button
variant="danger/small"
LeadingIcon={TrashIcon}
disabled={isUninstalling}
>
Remove integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Slack integration</DialogTitle>
</DialogHeader>
<DialogDescription className="mb-2">
This will remove the Slack integration and disable all connected alert
channels. This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing…" : "Remove integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
}
/>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Channel Name</TableHeaderCell>
<TableHeaderCell>Project</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.project.name}</TableCell>
<TableCell>
<EnabledStatus enabled={channel.enabled} />
</TableCell>
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
@@ -0,0 +1,67 @@
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph, type ParagraphVariant } from "~/components/primitives/Paragraph";
import { UnorderedList } from "~/components/primitives/UnorderedList";
const sampleItems = [
"A new issue is seen for the first time",
"A resolved issue re-occurs",
"An ignored issue re-occurs depending on the settings you configured",
];
const variantGroups: { label: string; variants: ParagraphVariant[] }[] = [
{
label: "Base",
variants: ["base", "base/bright"],
},
{
label: "Small",
variants: ["small", "small/bright", "small/dimmed"],
},
{
label: "Extra small",
variants: [
"extra-small",
"extra-small/bright",
"extra-small/dimmed",
"extra-small/mono",
"extra-small/bright/mono",
"extra-small/dimmed/mono",
"extra-small/caps",
"extra-small/bright/caps",
],
},
{
label: "Extra extra small",
variants: [
"extra-extra-small",
"extra-extra-small/bright",
"extra-extra-small/caps",
"extra-extra-small/bright/caps",
"extra-extra-small/dimmed/caps",
],
},
];
export default function Story() {
return (
<div className="flex flex-col gap-12 p-8">
{variantGroups.map((group) => (
<div key={group.label} className="flex flex-col gap-6">
<Header2>{group.label}</Header2>
{group.variants.map((variant) => (
<div key={variant} className="flex flex-col">
<code className="mb-2 font-mono text-xs text-charcoal-400">{variant}</code>
<Paragraph variant={variant}>This is a paragraph before the list.</Paragraph>
<UnorderedList variant={variant}>
{sampleItems.map((item) => (
<li key={item}>{item}</li>
))}
</UnorderedList>
<Paragraph variant={variant}>This is a paragraph after the list.</Paragraph>
</div>
))}
</div>
))}
</div>
);
}
@@ -136,6 +136,10 @@ const stories: Story[] = [
name: "Typography",
slug: "typography",
},
{
name: "Unordered list",
slug: "unordered-list",
},
{
name: "Usage",
slug: "usage",
+8
View File
@@ -584,6 +584,14 @@ export function v3ErrorsPath(
return `${v3EnvironmentPath(organization, project, environment)}/errors`;
}
export function v3ErrorsConnectToSlackPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ErrorsPath(organization, project, environment)}/connect-to-slack`;
}
export function v3ErrorPath(
organization: OrgForPath,
project: ProjectForPath,
+47 -1
View File
@@ -1,10 +1,12 @@
import { Logger } from "@trigger.dev/core/logger";
import { Worker as RedisWorker } from "@trigger.dev/redis-worker";
import { CronSchema, Worker as RedisWorker } from "@trigger.dev/redis-worker";
import { z } from "zod";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { DeliverAlertService } from "./services/alerts/deliverAlert.server";
import { DeliverErrorGroupAlertService } from "./services/alerts/deliverErrorGroupAlert.server";
import { ErrorAlertEvaluator } from "./services/alerts/errorAlertEvaluator.server";
import { PerformDeploymentAlertsService } from "./services/alerts/performDeploymentAlerts.server";
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
@@ -55,6 +57,42 @@ function initializeWorker() {
},
logErrors: false,
},
"v3.evaluateErrorAlerts": {
schema: z.object({
projectId: z.string(),
scheduledAt: z.number(),
}),
visibilityTimeoutMs: 60_000 * 5,
retry: {
maxAttempts: 3,
},
logErrors: true,
},
"v3.deliverErrorGroupAlert": {
schema: z.object({
channelId: z.string(),
projectId: z.string(),
classification: z.enum(["new_issue", "regression", "unignored"]),
error: z.object({
fingerprint: z.string(),
environmentId: z.string(),
environmentSlug: z.string(),
environmentName: z.string(),
taskIdentifier: z.string(),
errorType: z.string(),
errorMessage: z.string(),
sampleStackTrace: z.string(),
firstSeen: z.string(),
lastSeen: z.string(),
occurrenceCount: z.number(),
}),
}),
visibilityTimeoutMs: 60_000,
retry: {
maxAttempts: 3,
},
logErrors: true,
},
},
concurrency: {
workers: env.ALERTS_WORKER_CONCURRENCY_WORKERS,
@@ -80,6 +118,14 @@ function initializeWorker() {
const service = new PerformTaskRunAlertsService();
await service.call(payload.runId);
},
"v3.evaluateErrorAlerts": async ({ payload }) => {
const evaluator = new ErrorAlertEvaluator();
await evaluator.evaluate(payload.projectId, payload.scheduledAt);
},
"v3.deliverErrorGroupAlert": async ({ payload }) => {
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
},
},
});
+1 -1
View File
@@ -1194,4 +1194,4 @@ function initializeOTLPExporter() {
? parseInt(process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, 10)
: 8192
);
}
}
@@ -1,12 +1,13 @@
import {
ProjectAlertChannel,
ProjectAlertType,
RuntimeEnvironmentType,
type ProjectAlertChannel,
type ProjectAlertType,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { nanoid } from "nanoid";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import { encryptSecret } from "~/services/secrets/secretStore.server";
import { alertsWorker } from "~/v3/alertsWorker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService, ServiceValidationError } from "../baseService.server";
@@ -60,7 +61,7 @@ export class CreateAlertChannelService extends BaseService {
: undefined;
if (existingAlertChannel) {
return await this._prisma.projectAlertChannel.update({
const updated = await this._prisma.projectAlertChannel.update({
where: { id: existingAlertChannel.id },
data: {
name: options.name,
@@ -68,8 +69,15 @@ export class CreateAlertChannelService extends BaseService {
type: options.channel.type,
properties: await this.#createProperties(options.channel),
environmentTypes,
enabled: true,
},
});
if (options.alertTypes.includes("ERROR_GROUP")) {
await this.#scheduleErrorAlertEvaluation(project.id);
}
return updated;
}
const alertChannel = await this._prisma.projectAlertChannel.create({
@@ -87,9 +95,24 @@ export class CreateAlertChannelService extends BaseService {
},
});
if (options.alertTypes.includes("ERROR_GROUP")) {
await this.#scheduleErrorAlertEvaluation(project.id);
}
return alertChannel;
}
async #scheduleErrorAlertEvaluation(projectId: string): Promise<void> {
await alertsWorker.enqueue({
id: `evaluateErrorAlerts:${projectId}`,
job: "v3.evaluateErrorAlerts",
payload: {
projectId,
scheduledAt: Date.now(),
},
});
}
async #createProperties(channel: CreateAlertChannelOptions["channel"]) {
switch (channel.type) {
case "EMAIL":
@@ -319,6 +319,9 @@ export class DeliverAlertService extends BaseService {
break;
}
case "ERROR_GROUP": {
break;
}
default: {
assertNever(alert.type);
}
@@ -657,6 +660,9 @@ export class DeliverAlertService extends BaseService {
break;
}
case "ERROR_GROUP": {
break;
}
default: {
assertNever(alert.type);
}
@@ -913,6 +919,9 @@ export class DeliverAlertService extends BaseService {
return;
}
}
case "ERROR_GROUP": {
break;
}
default: {
assertNever(alert.type);
}
@@ -0,0 +1,404 @@
import {
type ChatPostMessageArguments,
ErrorCode,
type WebAPIPlatformError,
type WebAPIRateLimitedError,
} from "@slack/web-api";
import { type ProjectAlertChannelType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { v3ErrorPath } from "~/utils/pathBuilder";
import {
isIntegrationForService,
type OrganizationIntegrationForService,
OrgIntegrationRepository,
} from "~/models/orgIntegration.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { sendAlertEmail } from "~/services/email.server";
import { logger } from "~/services/logger.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { subtle } from "crypto";
import { generateErrorGroupWebhookPayload } from "./errorGroupWebhook.server";
type ErrorAlertClassification = "new_issue" | "regression" | "unignored";
interface ErrorAlertPayload {
channelId: string;
projectId: string;
classification: ErrorAlertClassification;
error: {
fingerprint: string;
environmentId: string;
environmentSlug: string;
environmentName: string;
taskIdentifier: string;
errorType: string;
errorMessage: string;
sampleStackTrace: string;
firstSeen: string;
lastSeen: string;
occurrenceCount: number;
};
}
class SkipRetryError extends Error {}
export class DeliverErrorGroupAlertService {
async call(payload: ErrorAlertPayload): Promise<void> {
const channel = await prisma.projectAlertChannel.findFirst({
where: { id: payload.channelId, enabled: true },
include: {
project: {
include: {
organization: true,
},
},
},
});
if (!channel) {
logger.warn("[DeliverErrorGroupAlert] Channel not found or disabled", {
channelId: payload.channelId,
});
return;
}
const errorLink = this.#buildErrorLink(channel.project.organization, channel.project, payload.error);
try {
switch (channel.type) {
case "EMAIL":
await this.#sendEmail(channel, payload, errorLink);
break;
case "SLACK":
await this.#sendSlack(channel, payload, errorLink);
break;
case "WEBHOOK":
await this.#sendWebhook(channel, payload, errorLink);
break;
default:
assertNever(channel.type);
}
} catch (error) {
if (error instanceof SkipRetryError) {
logger.warn("[DeliverErrorGroupAlert] Skipping retry", { reason: (error as Error).message });
return;
}
throw error;
}
}
#buildErrorLink(
organization: { slug: string },
project: { slug: string },
error: ErrorAlertPayload["error"]
): string {
return `${env.APP_ORIGIN}${v3ErrorPath(organization, project, { slug: error.environmentSlug }, { fingerprint: error.fingerprint })}`;
}
#classificationLabel(classification: ErrorAlertClassification): string {
switch (classification) {
case "new_issue":
return "New error";
case "regression":
return "Regression";
case "unignored":
return "Error resurfaced";
}
}
async #sendEmail(
channel: { type: ProjectAlertChannelType; properties: unknown; project: { name: string; organization: { title: string } } },
payload: ErrorAlertPayload,
errorLink: string
): Promise<void> {
const emailProperties = ProjectAlertEmailProperties.safeParse(channel.properties);
if (!emailProperties.success) {
logger.error("[DeliverErrorGroupAlert] Failed to parse email properties", {
issues: emailProperties.error.issues,
});
return;
}
await sendAlertEmail({
email: "alert-error-group",
to: emailProperties.data.email,
classification: payload.classification,
taskIdentifier: payload.error.taskIdentifier,
environment: payload.error.environmentName,
error: {
message: payload.error.errorMessage,
type: payload.error.errorType,
stackTrace: payload.error.sampleStackTrace || undefined,
},
occurrenceCount: payload.error.occurrenceCount,
errorLink,
organization: channel.project.organization.title,
project: channel.project.name,
});
}
async #sendSlack(
channel: {
type: ProjectAlertChannelType;
properties: unknown;
project: { organizationId: string; name: string; organization: { title: string } };
},
payload: ErrorAlertPayload,
errorLink: string
): Promise<void> {
const slackProperties = ProjectAlertSlackProperties.safeParse(channel.properties);
if (!slackProperties.success) {
logger.error("[DeliverErrorGroupAlert] Failed to parse slack properties", {
issues: slackProperties.error.issues,
});
return;
}
const integration = slackProperties.data.integrationId
? await prisma.organizationIntegration.findFirst({
where: {
id: slackProperties.data.integrationId,
organizationId: channel.project.organizationId,
},
include: { tokenReference: true },
})
: await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: channel.project.organizationId,
},
orderBy: { createdAt: "desc" },
include: { tokenReference: true },
});
if (!integration || !isIntegrationForService(integration, "SLACK")) {
logger.error("[DeliverErrorGroupAlert] Slack integration not found");
return;
}
const message = this.#buildErrorGroupSlackMessage(
payload,
errorLink,
channel.project.name
);
await this.#postSlackMessage(integration, {
channel: slackProperties.data.channelId,
...message,
} as ChatPostMessageArguments);
}
async #sendWebhook(
channel: {
type: ProjectAlertChannelType;
properties: unknown;
project: { id: string; externalRef: string; slug: string; name: string; organizationId: string; organization: { slug: string; title: string } };
},
payload: ErrorAlertPayload,
errorLink: string
): Promise<void> {
const webhookProperties = ProjectAlertWebhookProperties.safeParse(channel.properties);
if (!webhookProperties.success) {
logger.error("[DeliverErrorGroupAlert] Failed to parse webhook properties", {
issues: webhookProperties.error.issues,
});
return;
}
const webhookPayload = generateErrorGroupWebhookPayload({
classification: payload.classification,
error: payload.error,
organization: {
id: channel.project.organizationId,
slug: channel.project.organization.slug,
name: channel.project.organization.title,
},
project: {
id: channel.project.id,
externalRef: channel.project.externalRef,
slug: channel.project.slug,
name: channel.project.name,
},
dashboardUrl: errorLink,
});
const rawPayload = JSON.stringify(webhookPayload);
const hashPayload = Buffer.from(rawPayload, "utf-8");
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhookProperties.data.secret);
const hmacSecret = Buffer.from(secret, "utf-8");
const key = await subtle.importKey(
"raw",
hmacSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await subtle.sign("HMAC", key, hashPayload);
const signatureHex = Buffer.from(signature).toString("hex");
const response = await fetch(webhookProperties.data.url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-trigger-signature-hmacsha256": signatureHex,
},
body: rawPayload,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
logger.info("[DeliverErrorGroupAlert] Failed to send webhook", {
status: response.status,
statusText: response.statusText,
url: webhookProperties.data.url,
});
throw new Error(`Failed to send error group alert webhook to ${webhookProperties.data.url}`);
}
}
async #postSlackMessage(
integration: OrganizationIntegrationForService<"SLACK">,
message: ChatPostMessageArguments
) {
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
integration,
{ forceBotToken: true }
);
try {
return await client.chat.postMessage({
...message,
unfurl_links: false,
unfurl_media: false,
});
} catch (error) {
if (isWebAPIRateLimitedError(error)) {
throw new Error("Slack rate limited");
}
if (isWebAPIPlatformError(error)) {
if (
(error as WebAPIPlatformError).data.error === "invalid_blocks" ||
(error as WebAPIPlatformError).data.error === "account_inactive"
) {
throw new SkipRetryError(`Slack: ${(error as WebAPIPlatformError).data.error}`);
}
throw new Error("Slack platform error");
}
throw error;
}
}
#buildErrorGroupSlackMessage(
payload: ErrorAlertPayload,
errorLink: string,
projectName: string
): { text: string; blocks: object[]; attachments: object[] } {
const label = this.#classificationLabel(payload.classification);
const errorType = payload.error.errorType || "Error";
const task = payload.error.taskIdentifier;
const envName = payload.error.environmentName;
return {
text: `${label}: ${errorType} in ${task} [${envName}]`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${label} in ${task} [${envName}]*`,
},
},
],
attachments: [
{
color: "danger",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: this.#wrapInCodeBlock(
payload.error.sampleStackTrace || payload.error.errorMessage
),
},
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Task:*\n${task}`,
},
{
type: "mrkdwn",
text: `*Environment:*\n${envName}`,
},
{
type: "mrkdwn",
text: `*Project:*\n${projectName}`,
},
{
type: "mrkdwn",
text: `*Occurrences:*\n${payload.error.occurrenceCount}`,
},
{
type: "mrkdwn",
text: `*Last seen:*\n${this.#formatTimestamp(new Date(Number(payload.error.lastSeen)))}`,
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Investigate" },
url: errorLink,
style: "primary",
},
],
},
],
},
],
};
}
#wrapInCodeBlock(text: string, maxLength = 3000) {
const wrapperLength = 6; // ``` prefix + ``` suffix
const truncationSuffix = "\n\n...truncated — check dashboard for full error";
const innerMax = maxLength - wrapperLength;
const truncated =
text.length > innerMax
? text.slice(0, innerMax - truncationSuffix.length) + truncationSuffix
: text;
return `\`\`\`${truncated}\`\`\``;
}
#formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
}
function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError {
return (error as WebAPIPlatformError).code === ErrorCode.PlatformError;
}
function isWebAPIRateLimitedError(error: unknown): error is WebAPIRateLimitedError {
return (error as WebAPIRateLimitedError).code === ErrorCode.RateLimitedError;
}
@@ -0,0 +1,484 @@
import { type ActiveErrorsSinceQueryResult, type ClickHouse } from "@internal/clickhouse";
import {
type ErrorGroupState,
type PrismaClientOrTransaction,
type ProjectAlertChannel,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { $replica, prisma } from "~/db.server";
import { ErrorAlertConfig } from "~/models/projectAlert.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logger } from "~/services/logger.server";
import { alertsWorker } from "~/v3/alertsWorker.server";
type ErrorClassification = "new_issue" | "regression" | "unignored";
interface AlertableError {
classification: ErrorClassification;
error: ActiveErrorsSinceQueryResult;
environmentSlug: string;
environmentName: string;
}
interface ResolvedEnvironment {
id: string;
slug: string;
type: RuntimeEnvironmentType;
displayName: string;
}
const DEFAULT_INTERVAL_MS = 300_000;
/**
* For a project evalutes whether to send error alerts
*
* Alerts are sent if an error is
* 1. A new issue
* 2. A regression (was resolved and now back)
* 3. Unignored (was ignored and is no longer)
*
* Unignored happens in 3 situations
* 1. It was ignored with a future date, and that's now in the past
* 2. It was ignored until reaching an error rate (e.g. 10/minute) and that has been exceeded
* 3. It was ignored until reaching a total occurrence count (e.g. 1,000) and that has been exceeded
*/
export class ErrorAlertEvaluator {
constructor(
protected readonly _prisma: PrismaClientOrTransaction = prisma,
protected readonly _replica: PrismaClientOrTransaction = $replica,
protected readonly _clickhouse: ClickHouse = clickhouseClient
) {}
async evaluate(projectId: string, scheduledAt: number): Promise<void> {
const nextScheduledAt = Date.now();
const channels = await this.resolveChannels(projectId);
if (channels.length === 0) {
logger.info("[ErrorAlertEvaluator] No active ERROR_GROUP channels, self-terminating", {
projectId,
});
return;
}
const minIntervalMs = this.computeMinInterval(channels);
const windowMs = nextScheduledAt - scheduledAt;
if (windowMs > minIntervalMs * 2) {
logger.info("[ErrorAlertEvaluator] Large evaluation window (gap detected)", {
projectId,
scheduledAt,
nextScheduledAt,
windowMs,
minIntervalMs,
});
}
const allEnvTypes = this.collectEnvironmentTypes(channels);
try {
const [project, environments] = await Promise.all([
this._replica.project.findFirst({
where: { id: projectId },
select: { organizationId: true },
}),
this.resolveEnvironments(projectId, allEnvTypes),
]);
if (!project) {
logger.error("[ErrorAlertEvaluator] Project not found", { projectId });
return;
}
if (environments.length === 0) {
return;
}
const envIds = environments.map((e) => e.id);
const envMap = new Map(environments.map((e) => [e.id, e]));
const channelsByEnvId = this.buildChannelsByEnvId(channels, environments);
const activeErrors = await this.getActiveErrors(
project.organizationId,
projectId,
envIds,
scheduledAt
);
if (activeErrors.length === 0) {
return;
}
const states = await this.getErrorGroupStates(activeErrors);
const stateMap = this.buildStateMap(states);
const occurrenceCounts = await this.getOccurrenceCountsSince(
project.organizationId,
projectId,
envIds,
scheduledAt
);
const occurrenceMap = this.buildOccurrenceMap(occurrenceCounts);
const alertableErrors: AlertableError[] = [];
for (const error of activeErrors) {
const key = `${error.environment_id}:${error.task_identifier}:${error.error_fingerprint}`;
const state = stateMap.get(key);
const env = envMap.get(error.environment_id);
const firstSeenMs = Number(error.first_seen);
const classification = this.classifyError(error, state, firstSeenMs, scheduledAt, {
occurrencesSince: occurrenceMap.get(key) ?? 0,
windowMs,
totalOccurrenceCount: error.occurrence_count,
});
if (classification) {
alertableErrors.push({
classification,
error,
environmentSlug: env?.slug ?? "",
environmentName: env?.displayName ?? error.environment_id,
});
}
}
for (const alertable of alertableErrors) {
const envChannels = channelsByEnvId.get(alertable.error.environment_id) ?? [];
for (const channel of envChannels) {
await alertsWorker.enqueue({
id: `deliverErrorGroupAlert:${channel.id}:${alertable.error.error_fingerprint}:${scheduledAt}`,
job: "v3.deliverErrorGroupAlert",
payload: {
channelId: channel.id,
projectId,
classification: alertable.classification,
error: {
fingerprint: alertable.error.error_fingerprint,
environmentId: alertable.error.environment_id,
environmentSlug: alertable.environmentSlug,
environmentName: alertable.environmentName,
taskIdentifier: alertable.error.task_identifier,
errorType: alertable.error.error_type,
errorMessage: alertable.error.error_message,
sampleStackTrace: alertable.error.sample_stack_trace,
firstSeen: alertable.error.first_seen,
lastSeen: alertable.error.last_seen,
occurrenceCount: alertable.error.occurrence_count,
},
},
});
}
}
await this.updateErrorGroupStates(
alertableErrors,
stateMap,
project.organizationId,
projectId
);
logger.info("[ErrorAlertEvaluator] Evaluation complete", {
projectId,
activeErrors: activeErrors.length,
alertableErrors: alertableErrors.length,
deliveryJobsEnqueued: alertableErrors.reduce(
(sum, a) => sum + (channelsByEnvId.get(a.error.environment_id)?.length ?? 0),
0
),
});
} catch (error) {
logger.error("[ErrorAlertEvaluator] Evaluation failed, will retry on next cycle", {
projectId,
error,
});
} finally {
await this.selfChain(projectId, nextScheduledAt, minIntervalMs);
}
}
private classifyError(
error: ActiveErrorsSinceQueryResult,
state: ErrorGroupState | undefined,
firstSeenMs: number,
scheduledAt: number,
thresholdContext: { occurrencesSince: number; windowMs: number; totalOccurrenceCount: number }
): ErrorClassification | null {
if (!state) {
return firstSeenMs > scheduledAt ? "new_issue" : null;
}
switch (state.status) {
case "UNRESOLVED":
return null;
case "RESOLVED": {
if (!state.resolvedAt) return null;
const lastSeenMs = Number(error.last_seen);
return lastSeenMs > state.resolvedAt.getTime() ? "regression" : null;
}
case "IGNORED":
return this.isIgnoreBreached(state, thresholdContext) ? "unignored" : null;
default:
return null;
}
}
private isIgnoreBreached(
state: ErrorGroupState,
context: { occurrencesSince: number; windowMs: number; totalOccurrenceCount: number }
): boolean {
if (state.ignoredUntil && state.ignoredUntil.getTime() <= Date.now()) {
return true;
}
if (
state.ignoredUntilOccurrenceRate !== null &&
state.ignoredUntilOccurrenceRate !== undefined
) {
const windowMinutes = Math.max(context.windowMs / 60_000, 1);
const rate = context.occurrencesSince / windowMinutes;
if (rate > state.ignoredUntilOccurrenceRate) {
return true;
}
}
if (
state.ignoredUntilTotalOccurrences != null &&
state.ignoredAtOccurrenceCount != null
) {
const occurrencesSinceIgnored =
context.totalOccurrenceCount - Number(state.ignoredAtOccurrenceCount);
if (occurrencesSinceIgnored >= state.ignoredUntilTotalOccurrences) {
return true;
}
}
return false;
}
private async resolveChannels(projectId: string): Promise<ProjectAlertChannel[]> {
return this._replica.projectAlertChannel.findMany({
where: {
projectId,
alertTypes: { has: "ERROR_GROUP" },
enabled: true,
},
});
}
private computeMinInterval(channels: ProjectAlertChannel[]): number {
let min = DEFAULT_INTERVAL_MS;
for (const ch of channels) {
const config = ErrorAlertConfig.safeParse(ch.errorAlertConfig);
if (config.success) {
min = Math.min(min, config.data.evaluationIntervalMs);
}
}
return min;
}
private collectEnvironmentTypes(channels: ProjectAlertChannel[]): RuntimeEnvironmentType[] {
const types = new Set<RuntimeEnvironmentType>();
for (const ch of channels) {
for (const t of ch.environmentTypes) {
types.add(t);
}
}
return Array.from(types);
}
private async resolveEnvironments(
projectId: string,
types: RuntimeEnvironmentType[]
): Promise<ResolvedEnvironment[]> {
const envs = await this._replica.runtimeEnvironment.findMany({
where: {
projectId,
type: { in: types },
},
select: {
id: true,
type: true,
slug: true,
branchName: true,
},
});
return envs.map((e) => ({
id: e.id,
slug: e.slug,
type: e.type,
displayName: e.branchName ?? e.slug,
}));
}
private buildChannelsByEnvId(
channels: ProjectAlertChannel[],
environments: ResolvedEnvironment[]
): Map<string, ProjectAlertChannel[]> {
const result = new Map<string, ProjectAlertChannel[]>();
for (const env of environments) {
const matching = channels.filter((ch) => ch.environmentTypes.includes(env.type));
if (matching.length > 0) {
result.set(env.id, matching);
}
}
return result;
}
private async getActiveErrors(
organizationId: string,
projectId: string,
envIds: string[],
scheduledAt: number
): Promise<ActiveErrorsSinceQueryResult[]> {
const qb = this._clickhouse.errors.activeErrorsSinceQueryBuilder();
qb.where("organization_id = {organizationId: String}", { organizationId });
qb.where("project_id = {projectId: String}", { projectId });
qb.where("environment_id IN {envIds: Array(String)}", { envIds });
qb.groupBy("environment_id, task_identifier, error_fingerprint");
qb.having("toInt64(last_seen) > {scheduledAt: Int64}", {
scheduledAt,
});
const [err, results] = await qb.execute();
if (err) {
logger.error("[ErrorAlertEvaluator] Failed to query active errors", { error: err });
return [];
}
return results ?? [];
}
private async getErrorGroupStates(
activeErrors: ActiveErrorsSinceQueryResult[]
): Promise<ErrorGroupState[]> {
if (activeErrors.length === 0) return [];
return this._replica.errorGroupState.findMany({
where: {
OR: activeErrors.map((e) => ({
environmentId: e.environment_id,
taskIdentifier: e.task_identifier,
errorFingerprint: e.error_fingerprint,
})),
},
});
}
private buildStateMap(states: ErrorGroupState[]): Map<string, ErrorGroupState> {
const map = new Map<string, ErrorGroupState>();
for (const s of states) {
map.set(`${s.environmentId}:${s.taskIdentifier}:${s.errorFingerprint}`, s);
}
return map;
}
private async getOccurrenceCountsSince(
organizationId: string,
projectId: string,
envIds: string[],
scheduledAt: number
): Promise<
Array<{
environment_id: string;
task_identifier: string;
error_fingerprint: string;
occurrences_since: number;
}>
> {
const qb = this._clickhouse.errors.occurrenceCountsSinceQueryBuilder();
qb.where("organization_id = {organizationId: String}", { organizationId });
qb.where("project_id = {projectId: String}", { projectId });
qb.where("environment_id IN {envIds: Array(String)}", { envIds });
qb.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({scheduledAt: Int64}))", {
scheduledAt,
});
qb.groupBy("environment_id, task_identifier, error_fingerprint");
const [err, results] = await qb.execute();
if (err) {
logger.error("[ErrorAlertEvaluator] Failed to query occurrence counts", { error: err });
return [];
}
return results ?? [];
}
private buildOccurrenceMap(
counts: Array<{
environment_id: string;
task_identifier: string;
error_fingerprint: string;
occurrences_since: number;
}>
): Map<string, number> {
const map = new Map<string, number>();
for (const c of counts) {
map.set(
`${c.environment_id}:${c.task_identifier}:${c.error_fingerprint}`,
c.occurrences_since
);
}
return map;
}
private async updateErrorGroupStates(
alertableErrors: AlertableError[],
stateMap: Map<string, ErrorGroupState>,
organizationId: string,
projectId: string
): Promise<void> {
for (const alertable of alertableErrors) {
const key = `${alertable.error.environment_id}:${alertable.error.task_identifier}:${alertable.error.error_fingerprint}`;
const state = stateMap.get(key);
if (state) {
await this._prisma.errorGroupState.update({
where: { id: state.id },
data: {
status: "UNRESOLVED",
ignoredUntil: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
ignoredAt: null,
ignoredReason: null,
ignoredByUserId: null,
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
},
});
} else if (alertable.classification === "new_issue") {
await this._prisma.errorGroupState.create({
data: {
organizationId,
projectId,
environmentId: alertable.error.environment_id,
taskIdentifier: alertable.error.task_identifier,
errorFingerprint: alertable.error.error_fingerprint,
status: "UNRESOLVED",
},
});
}
}
}
private async selfChain(
projectId: string,
nextScheduledAt: number,
intervalMs: number
): Promise<void> {
await alertsWorker.enqueue({
id: `evaluateErrorAlerts:${projectId}`,
job: "v3.evaluateErrorAlerts",
payload: {
projectId,
scheduledAt: nextScheduledAt,
},
availableAt: new Date(nextScheduledAt + intervalMs),
});
}
}
@@ -0,0 +1,74 @@
import { nanoid } from "nanoid";
import type { ErrorWebhook } from "@trigger.dev/core/v3/schemas";
export type ErrorAlertClassification = "new_issue" | "regression" | "unignored";
export type ErrorGroupAlertData = {
classification: ErrorAlertClassification;
error: {
fingerprint: string;
environmentId: string;
environmentName: string;
taskIdentifier: string;
errorType: string;
errorMessage: string;
sampleStackTrace: string;
firstSeen: string;
lastSeen: string;
occurrenceCount: number;
};
organization: {
id: string;
slug: string;
name: string;
};
project: {
id: string;
externalRef: string;
slug: string;
name: string;
};
dashboardUrl: string;
};
/**
* Generates a webhook payload for an error group alert that conforms to the
* ErrorWebhook schema from @trigger.dev/core/v3/schemas
*/
export function generateErrorGroupWebhookPayload(data: ErrorGroupAlertData): ErrorWebhook {
return {
id: nanoid(),
created: new Date(),
webhookVersion: "2025-01-01",
type: "alert.error" as const,
object: {
classification: data.classification,
error: {
fingerprint: data.error.fingerprint,
type: data.error.errorType,
message: data.error.errorMessage,
stackTrace: data.error.sampleStackTrace || undefined,
firstSeen: new Date(Number(data.error.firstSeen)),
lastSeen: new Date(Number(data.error.lastSeen)),
occurrenceCount: data.error.occurrenceCount,
taskIdentifier: data.error.taskIdentifier,
},
environment: {
id: data.error.environmentId,
name: data.error.environmentName,
},
organization: {
id: data.organization.id,
slug: data.organization.slug,
name: data.organization.name,
},
project: {
id: data.project.id,
ref: data.project.externalRef,
slug: data.project.slug,
name: data.project.name,
},
dashboardUrl: data.dashboardUrl,
},
};
}
@@ -0,0 +1,144 @@
import { type PrismaClientOrTransaction, prisma } from "~/db.server";
type ErrorGroupIdentifier = {
organizationId: string;
projectId: string;
environmentId: string;
taskIdentifier: string;
errorFingerprint: string;
};
export class ErrorGroupActions {
constructor(private readonly _prisma: PrismaClientOrTransaction = prisma) {}
async resolveError(
identifier: ErrorGroupIdentifier,
params: {
userId: string;
resolvedInVersion?: string;
}
) {
const where = {
environmentId_taskIdentifier_errorFingerprint: {
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
},
};
const now = new Date();
return this._prisma.errorGroupState.upsert({
where,
update: {
status: "RESOLVED",
resolvedAt: now,
resolvedInVersion: params.resolvedInVersion ?? null,
resolvedBy: params.userId,
ignoredUntil: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
ignoredAt: null,
ignoredReason: null,
ignoredByUserId: null,
},
create: {
organizationId: identifier.organizationId,
projectId: identifier.projectId,
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
status: "RESOLVED",
resolvedAt: now,
resolvedInVersion: params.resolvedInVersion ?? null,
resolvedBy: params.userId,
},
});
}
async ignoreError(
identifier: ErrorGroupIdentifier,
params: {
userId: string;
duration?: number;
occurrenceRateThreshold?: number;
totalOccurrencesThreshold?: number;
occurrenceCountAtIgnoreTime?: number;
reason?: string;
}
) {
const where = {
environmentId_taskIdentifier_errorFingerprint: {
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
},
};
const now = new Date();
const ignoredUntil = params.duration ? new Date(now.getTime() + params.duration) : null;
const data = {
status: "IGNORED" as const,
ignoredAt: now,
ignoredUntil,
ignoredUntilOccurrenceRate: params.occurrenceRateThreshold ?? null,
ignoredUntilTotalOccurrences: params.totalOccurrencesThreshold ?? null,
ignoredAtOccurrenceCount: params.occurrenceCountAtIgnoreTime ?? null,
ignoredReason: params.reason ?? null,
ignoredByUserId: params.userId,
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
};
return this._prisma.errorGroupState.upsert({
where,
update: data,
create: {
organizationId: identifier.organizationId,
projectId: identifier.projectId,
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
...data,
},
});
}
async unresolveError(identifier: ErrorGroupIdentifier) {
const where = {
environmentId_taskIdentifier_errorFingerprint: {
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
},
};
return this._prisma.errorGroupState.upsert({
where,
update: {
status: "UNRESOLVED",
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
ignoredUntil: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
ignoredAt: null,
ignoredReason: null,
ignoredByUserId: null,
},
create: {
organizationId: identifier.organizationId,
projectId: identifier.projectId,
environmentId: identifier.environmentId,
taskIdentifier: identifier.taskIdentifier,
errorFingerprint: identifier.errorFingerprint,
status: "UNRESOLVED",
},
});
}
}
+2
View File
@@ -177,6 +177,7 @@ const docs = colors.blue[500];
const bulkActions = colors.emerald[500];
const aiPrompts = colors.blue[500];
const aiMetrics = colors.green[500];
const errors = colors.amber[500];
/** Other variables */
const radius = "0.5rem";
@@ -262,6 +263,7 @@ module.exports = {
customDashboards,
aiPrompts,
aiMetrics,
errors,
},
focusStyles: {
outline: "1px solid",
+248
View File
@@ -0,0 +1,248 @@
import { describe, test, expect } from "vitest";
import { Webhook } from "@trigger.dev/core/v3/schemas";
import {
generateErrorGroupWebhookPayload,
type ErrorGroupAlertData,
} from "~/v3/services/alerts/errorGroupWebhook.server";
function createMockAlertData(overrides: Partial<ErrorGroupAlertData> = {}): ErrorGroupAlertData {
const now = Date.now();
const earlier = now - 3600000; // 1 hour ago
return {
classification: "new_issue",
error: {
fingerprint: "fp_test_12345",
environmentId: "env_abc123",
environmentName: "Production",
taskIdentifier: "process-payment",
errorType: "TypeError",
errorMessage: "Cannot read property 'id' of undefined",
sampleStackTrace: `TypeError: Cannot read property 'id' of undefined
at processPayment (src/tasks/payment.ts:42:15)
at Object.run (src/tasks/payment.ts:15:20)`,
firstSeen: String(earlier),
lastSeen: String(now),
occurrenceCount: 5,
},
organization: {
id: "org_xyz789",
slug: "acme-corp",
name: "Acme Corp",
},
project: {
id: "proj_123",
externalRef: "proj_abc",
slug: "my-project",
name: "My Project",
},
dashboardUrl:
"https://cloud.trigger.dev/orgs/acme-corp/projects/my-project/errors/fp_test_12345",
...overrides,
};
}
describe("generateErrorGroupWebhookPayload", () => {
test("generates a valid webhook payload", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
expect(payload).toMatchObject({
type: "alert.error",
object: {
classification: "new_issue",
error: {
fingerprint: "fp_test_12345",
type: "TypeError",
message: "Cannot read property 'id' of undefined",
taskIdentifier: "process-payment",
occurrenceCount: 5,
},
environment: {
id: "env_abc123",
name: "Production",
},
organization: {
id: "org_xyz789",
slug: "acme-corp",
name: "Acme Corp",
},
project: {
id: "proj_123",
ref: "proj_abc",
slug: "my-project",
name: "My Project",
},
dashboardUrl:
"https://cloud.trigger.dev/orgs/acme-corp/projects/my-project/errors/fp_test_12345",
},
});
expect(payload.id).toBeDefined();
expect(payload.created).toBeInstanceOf(Date);
expect(payload.webhookVersion).toBe("2025-01-01");
});
test("payload is valid according to Webhook schema", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
expect(parsed.type).toBe("alert.error");
});
test("payload can be serialized and deserialized", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
// Serialize to JSON (simulating sending over HTTP)
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
// Verify it can still be parsed by the schema
const parsed = Webhook.parse(deserialized);
expect(parsed.type).toBe("alert.error");
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("new_issue");
expect(parsed.object.error.fingerprint).toBe("fp_test_12345");
}
});
test("handles new_issue classification", () => {
const alertData = createMockAlertData({ classification: "new_issue" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("new_issue");
}
});
test("handles regression classification", () => {
const alertData = createMockAlertData({ classification: "regression" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("regression");
}
});
test("handles unignored classification", () => {
const alertData = createMockAlertData({ classification: "unignored" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("unignored");
}
});
test("handles empty stack trace", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
sampleStackTrace: "",
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.stackTrace).toBeUndefined();
}
});
test("includes stack trace when present", () => {
const stackTrace = "Error at line 42";
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
sampleStackTrace: stackTrace,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.stackTrace).toBe(stackTrace);
}
});
test("preserves date fields correctly", () => {
const firstSeen = new Date("2024-01-01T00:00:00Z");
const lastSeen = new Date("2024-01-02T12:00:00Z");
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
firstSeen: String(firstSeen.getTime()),
lastSeen: String(lastSeen.getTime()),
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.firstSeen).toEqual(firstSeen);
expect(parsed.object.error.lastSeen).toEqual(lastSeen);
}
});
test("handles special characters in error messages", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
errorMessage: "Unexpected token `<` in JSON at position 0",
sampleStackTrace: `SyntaxError: Unexpected token \`<\` in JSON
at JSON.parse (<anonymous>)
at fetch("https://api.example.com/data?query=test&limit=10")`,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
const parsed = Webhook.parse(deserialized);
if (parsed.type === "alert.error") {
expect(parsed.object.error.message).toBe("Unexpected token `<` in JSON at position 0");
}
});
test("handles unicode and emoji in error messages", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
errorMessage: "Failed to process emoji 🔥 in message: Hello 世界",
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
const parsed = Webhook.parse(deserialized);
if (parsed.type === "alert.error") {
expect(parsed.object.error.message).toBe("Failed to process emoji 🔥 in message: Hello 世界");
}
});
test("handles large occurrence counts", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
occurrenceCount: 999999,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.occurrenceCount).toBe(999999);
}
});
});
+403
View File
@@ -0,0 +1,403 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import type { PrismaClient } from "@trigger.dev/database";
let DeliverErrorGroupAlertService: typeof import("../app/v3/services/alerts/deliverErrorGroupAlert.server.js").DeliverErrorGroupAlertService;
let prisma: PrismaClient;
let getSecretStore: typeof import("../app/services/secrets/secretStore.server.js").getSecretStore;
type ErrorAlertPayload = {
channelId: string;
projectId: string;
classification: "new_issue" | "regression" | "unignored";
error: {
fingerprint: string;
environmentId: string;
environmentSlug: string;
environmentName: string;
taskIdentifier: string;
errorType: string;
errorMessage: string;
sampleStackTrace: string;
firstSeen: string;
lastSeen: string;
occurrenceCount: number;
};
};
let testChannelId: string;
let testProjectId: string;
let testOrganizationId: string;
let testSecretKey: string;
let testSecretReferenceId: string;
// Helper to create mock error payloads
function createMockErrorPayload(
overrides: Partial<Omit<ErrorAlertPayload, "error">> & {
error?: Partial<ErrorAlertPayload["error"]>;
} = {}
): ErrorAlertPayload {
const { error: errorOverrides, ...payloadOverrides } = overrides;
const defaultError: ErrorAlertPayload["error"] = {
fingerprint: "fp_test_" + Date.now(),
environmentId: "env_test_dev",
environmentSlug: "dev",
environmentName: "Development",
taskIdentifier: "process-payment",
errorType: "TypeError",
errorMessage: "Cannot read property 'id' of undefined",
sampleStackTrace: `TypeError: Cannot read property 'id' of undefined
at processPayment (src/tasks/payment.ts:42:15)
at Object.run (src/tasks/payment.ts:15:20)
at TaskExecutor.execute (node_modules/@trigger.dev/core/dist/index.js:234:18)`,
firstSeen: Date.now().toString(),
lastSeen: Date.now().toString(),
occurrenceCount: 42,
...errorOverrides,
};
return {
channelId: testChannelId,
projectId: testProjectId,
classification: "new_issue",
...payloadOverrides,
error: defaultError,
};
}
// Skip tests if Slack credentials not configured
const hasSlackCredentials =
!!process.env.TEST_SLACK_CHANNEL_ID &&
!!process.env.TEST_SLACK_BOT_TOKEN;
describe.skipIf(!hasSlackCredentials)("Slack Error Alert Visual Tests", () => {
beforeAll(async () => {
const dbModule = await import("../app/db.server.js");
prisma = dbModule.prisma;
const secretModule = await import("../app/services/secrets/secretStore.server.js");
getSecretStore = secretModule.getSecretStore;
const alertModule = await import(
"../app/v3/services/alerts/deliverErrorGroupAlert.server.js"
);
DeliverErrorGroupAlertService = alertModule.DeliverErrorGroupAlertService;
const organization = await prisma.organization.create({
data: {
title: "Slack Test Org",
slug: "slack-test-org-" + Date.now(),
},
});
testOrganizationId = organization.id;
// Create test project
const project = await prisma.project.create({
data: {
name: "Slack Test Project",
slug: "slack-test-project-" + Date.now(),
externalRef: "proj_slack_test_" + Date.now(),
organizationId: organization.id,
},
});
testProjectId = project.id;
const secretStore = getSecretStore("DATABASE");
testSecretKey = `slack-test-token-${Date.now()}`;
await secretStore.setSecret(testSecretKey, {
botAccessToken: process.env.TEST_SLACK_BOT_TOKEN!,
});
const secretReference = await prisma.secretReference.create({
data: {
key: testSecretKey,
provider: "DATABASE",
},
});
testSecretReferenceId = secretReference.id;
// Create Slack organization integration
const integration = await prisma.organizationIntegration.create({
data: {
friendlyId: "integration_test_" + Date.now(),
organizationId: organization.id,
service: "SLACK",
integrationData: {},
tokenReferenceId: secretReference.id,
},
});
// Create alert channel
const channel = await prisma.projectAlertChannel.create({
data: {
friendlyId: "channel_test_" + Date.now(),
name: "Test Slack Channel",
type: "SLACK",
enabled: true,
projectId: project.id,
integrationId: integration.id,
properties: {
channelId: process.env.TEST_SLACK_CHANNEL_ID!,
channelName: "test-slack-alerts",
integrationId: integration.id,
},
},
});
testChannelId = channel.id;
});
afterAll(async () => {
if (testChannelId) {
await prisma.projectAlertChannel.deleteMany({
where: { id: testChannelId },
});
}
if (testOrganizationId) {
await prisma.organizationIntegration.deleteMany({
where: { organizationId: testOrganizationId },
});
}
if (testSecretReferenceId) {
await prisma.secretReference.deleteMany({
where: { id: testSecretReferenceId },
});
}
if (testSecretKey) {
const secretStore = getSecretStore("DATABASE");
await secretStore.deleteSecret(testSecretKey);
}
if (testProjectId) {
await prisma.project.deleteMany({
where: { id: testProjectId },
});
}
if (testOrganizationId) {
await prisma.organization.deleteMany({
where: { id: testOrganizationId },
});
}
});
test("new_issue classification", async () => {
const payload = createMockErrorPayload({
classification: "new_issue",
error: {
taskIdentifier: "process-order",
errorMessage: "Failed to process order due to invalid payment method",
errorType: "PaymentError",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
// Message sent - check Slack channel visually
expect(true).toBe(true);
});
test("regression classification", async () => {
const payload = createMockErrorPayload({
classification: "regression",
error: {
taskIdentifier: "send-email",
errorMessage: "SMTP connection timeout after 30 seconds",
errorType: "TimeoutError",
occurrenceCount: 156,
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("unignored (resurfaced) classification", async () => {
const payload = createMockErrorPayload({
classification: "unignored",
error: {
taskIdentifier: "sync-database",
errorMessage: "Connection pool exhausted",
errorType: "DatabaseError",
occurrenceCount: 99,
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("short error message", async () => {
const payload = createMockErrorPayload({
error: {
errorMessage: "Not found",
errorType: "NotFoundError",
sampleStackTrace: "NotFoundError: Not found\n at findUser (src/db.ts:10:5)",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("long stack trace", async () => {
const longStackTrace = `ReferenceError: processData is not defined
at handler (src/tasks/data-processor.ts:125:15)
at async TaskRunner.execute (node_modules/@trigger.dev/sdk/dist/runner.js:89:12)
at async WorkerThread.processTask (node_modules/@trigger.dev/sdk/dist/worker.js:234:18)
at async WorkerPool.run (src/lib/worker-pool.ts:56:10)
at async TaskQueue.dequeue (src/lib/queue.ts:142:8)
at async Orchestrator.processNextTask (src/orchestrator.ts:98:5)
at async Orchestrator.start (src/orchestrator.ts:45:7)
at async main (src/index.ts:12:3)
at Object.<anonymous> (src/index.ts:20:1)
at Module._compile (node:internal/modules/cjs/loader:1376:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
at Module.load (node:internal/modules/cjs/loader:1207:32)
at Module._load (node:internal/modules/cjs/loader:1023:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
at node:internal/main/run_main_module:28:49`;
const payload = createMockErrorPayload({
error: {
errorType: "ReferenceError",
errorMessage: "processData is not defined",
sampleStackTrace: longStackTrace,
taskIdentifier: "data-processor",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("very long error message (triggers truncation)", async () => {
// Create a message that's over 3000 characters
const longMessage = "x".repeat(3500);
const longStackTrace = `Error: ${longMessage}
at verylongfunctionname (src/tasks/long-task.ts:1:1)
${" at stackframe (file.ts:1:1)\n".repeat(100)}`;
const payload = createMockErrorPayload({
error: {
errorMessage: longMessage,
sampleStackTrace: longStackTrace,
taskIdentifier: "long-error-task",
errorType: "Error",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
// Should see truncation message in Slack
expect(true).toBe(true);
});
test("special characters in error", async () => {
const payload = createMockErrorPayload({
error: {
errorMessage: "Unexpected token `<` in JSON at position 0",
errorType: "SyntaxError",
sampleStackTrace: `SyntaxError: Unexpected token \`<\` in JSON at position 0
at JSON.parse (<anonymous>)
at parseResponse (src/api/client.ts:42:15)
at fetch("https://api.example.com/data?query=test&limit=10")`,
taskIdentifier: "api-fetch-task",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("unicode and emoji in error", async () => {
const payload = createMockErrorPayload({
error: {
errorMessage: "Failed to process emoji 🔥 in message: Hello 世界",
errorType: "EncodingError",
sampleStackTrace: `EncodingError: Failed to process emoji 🔥 in message: Hello 世界
at encodeMessage (src/utils/encoding.ts:15:10)
at sendMessage (src/tasks/messaging.ts:42:8)`,
taskIdentifier: "messaging-task",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("different error types - TypeError", async () => {
const payload = createMockErrorPayload({
error: {
errorType: "TypeError",
errorMessage: "Cannot call method 'map' on undefined",
sampleStackTrace: `TypeError: Cannot call method 'map' on undefined
at transformData (src/transformers/data.ts:18:25)`,
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("different error types - ReferenceError", async () => {
const payload = createMockErrorPayload({
error: {
errorType: "ReferenceError",
errorMessage: "userConfig is not defined",
sampleStackTrace: `ReferenceError: userConfig is not defined
at initializeApp (src/app.ts:32:10)`,
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("different error types - Custom Error", async () => {
const payload = createMockErrorPayload({
error: {
errorType: "InvalidConfigurationError",
errorMessage: "API key is missing or invalid",
sampleStackTrace: `InvalidConfigurationError: API key is missing or invalid
at validateConfig (src/config/validator.ts:45:11)
at loadConfig (src/config/loader.ts:23:5)`,
taskIdentifier: "config-loader",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
test("error with no stack trace", async () => {
const payload = createMockErrorPayload({
error: {
errorMessage: "An unknown error occurred",
errorType: "Error",
sampleStackTrace: "",
},
});
const service = new DeliverErrorGroupAlertService();
await service.call(payload);
expect(true).toBe(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, test, expect } from "vitest";
import { Webhook } from "@trigger.dev/core/v3/schemas";
import { generateErrorGroupWebhookPayload } from "~/v3/services/alerts/errorGroupWebhook.server";
type ErrorData = {
fingerprint: string;
environmentId: string;
environmentName: string;
taskIdentifier: string;
errorType: string;
errorMessage: string;
sampleStackTrace: string;
firstSeen: string;
lastSeen: string;
occurrenceCount: number;
};
const TEST_ORG = { id: "org_test_123", slug: "webhook-test-org", name: "Webhook Test Org" };
const TEST_PROJECT = {
id: "proj_test_456",
externalRef: "proj_webhook_test",
slug: "webhook-test-project",
name: "Webhook Test Project",
};
const DASHBOARD_URL = "https://cloud.trigger.dev/test";
function createMockError(overrides: Partial<ErrorData> = {}): ErrorData {
return {
fingerprint: "fp_test_default",
environmentId: "env_test_dev",
environmentName: "Development",
taskIdentifier: "process-payment",
errorType: "TypeError",
errorMessage: "Cannot read property 'id' of undefined",
sampleStackTrace: `TypeError: Cannot read property 'id' of undefined
at processPayment (src/tasks/payment.ts:42:15)
at Object.run (src/tasks/payment.ts:15:20)
at TaskExecutor.execute (node_modules/@trigger.dev/core/dist/index.js:234:18)`,
firstSeen: Date.now().toString(),
lastSeen: Date.now().toString(),
occurrenceCount: 42,
...overrides,
};
}
function buildPayload(classification: "new_issue" | "regression" | "unignored", error: ErrorData) {
return generateErrorGroupWebhookPayload({
classification,
error,
organization: TEST_ORG,
project: TEST_PROJECT,
dashboardUrl: DASHBOARD_URL,
});
}
describe("Webhook Error Alert Payload", () => {
test("payload structure is valid and parseable", () => {
const payload = buildPayload("new_issue", createMockError());
const parsed = Webhook.parse(payload);
expect(parsed.type).toBe("alert.error");
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("new_issue");
expect(parsed.object.error.type).toBe("TypeError");
expect(parsed.object.organization.slug).toBe("webhook-test-org");
expect(parsed.object.project.ref).toBe("proj_webhook_test");
}
});
test("payload survives JSON round-trip", () => {
const error = createMockError();
const payload = buildPayload("regression", error);
const deserialized = JSON.parse(JSON.stringify(payload));
const parsed = Webhook.parse(deserialized);
expect(parsed.type).toBe("alert.error");
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("regression");
expect(parsed.object.error.fingerprint).toBe(error.fingerprint);
}
});
test("all classifications are valid", () => {
const classifications = ["new_issue", "regression", "unignored"] as const;
for (const classification of classifications) {
const payload = buildPayload(classification, createMockError());
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe(classification);
}
}
});
test("error details are preserved", () => {
const error = createMockError({
fingerprint: "fp_custom_123",
errorType: "CustomError",
errorMessage: "Custom error message",
sampleStackTrace: "CustomError: at line 42",
taskIdentifier: "my-custom-task",
occurrenceCount: 999,
});
const payload = buildPayload("new_issue", error);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.fingerprint).toBe("fp_custom_123");
expect(parsed.object.error.type).toBe("CustomError");
expect(parsed.object.error.message).toBe("Custom error message");
expect(parsed.object.error.stackTrace).toBe("CustomError: at line 42");
expect(parsed.object.error.taskIdentifier).toBe("my-custom-task");
expect(parsed.object.error.occurrenceCount).toBe(999);
}
});
test("empty stack trace becomes undefined", () => {
const error = createMockError({ sampleStackTrace: "" });
const payload = buildPayload("new_issue", error);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.stackTrace).toBeUndefined();
}
});
});
+147 -2
View File
@@ -94,8 +94,8 @@ export function getErrorGroups(ch: ClickhouseReader, settings?: ClickHouseSettin
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
GROUP BY error_fingerprint, task_identifier
HAVING max(last_seen) >= now() - INTERVAL {days: Int64} DAY
ORDER BY last_seen DESC
HAVING toInt64(last_seen) >= toInt64(toUnixTimestamp(now() - INTERVAL {days: Int64} DAY)) * 1000
ORDER BY toInt64(last_seen) DESC
LIMIT {limit: Int64}
OFFSET {offset: Int64}
`,
@@ -314,3 +314,148 @@ export function createErrorOccurrencesQueryBuilder(
settings
);
}
export const ErrorOccurrencesByVersionQueryResult = z.object({
error_fingerprint: z.string(),
task_version: z.string(),
bucket_epoch: z.number(),
count: z.number(),
});
export type ErrorOccurrencesByVersionQueryResult = z.infer<
typeof ErrorOccurrencesByVersionQueryResult
>;
/**
* Creates a query builder for bucketed error occurrence counts grouped by task_version.
* Used for stacked-by-version activity charts on the error detail page.
*/
export function createErrorOccurrencesByVersionQueryBuilder(
ch: ClickhouseReader,
intervalExpr: string,
settings?: ClickHouseSettings
): ClickhouseQueryBuilder<ErrorOccurrencesByVersionQueryResult> {
return new ClickhouseQueryBuilder(
"getErrorOccurrencesByVersion",
`
SELECT
error_fingerprint,
task_version,
toUnixTimestamp(toStartOfInterval(minute, ${intervalExpr})) as bucket_epoch,
sum(count) as count
FROM trigger_dev.error_occurrences_v1
`,
ch,
ErrorOccurrencesByVersionQueryResult,
settings
);
}
// ---------------------------------------------------------------------------
// Alert evaluator active errors since a timestamp
// ---------------------------------------------------------------------------
export const ActiveErrorsSinceQueryResult = z.object({
environment_id: z.string(),
task_identifier: z.string(),
error_fingerprint: z.string(),
error_type: z.string(),
error_message: z.string(),
sample_stack_trace: z.string(),
first_seen: z.string(),
last_seen: z.string(),
occurrence_count: z.number(),
});
export type ActiveErrorsSinceQueryResult = z.infer<typeof ActiveErrorsSinceQueryResult>;
/**
* Query builder for fetching all errors active since a given timestamp.
* Returns errors with last_seen > scheduledAt, grouped by env/task/fingerprint.
* Used by the error alert evaluator to find new issues, regressions, and un-ignored errors.
*/
export function getActiveErrorsSinceQueryBuilder(
ch: ClickhouseReader,
settings?: ClickHouseSettings
) {
return ch.queryBuilder({
name: "getActiveErrorsSince",
baseQuery: `
SELECT
environment_id,
task_identifier,
error_fingerprint,
any(error_type) as error_type,
any(error_message) as error_message,
any(sample_stack_trace) as sample_stack_trace,
toString(toUnixTimestamp64Milli(min(first_seen))) as first_seen,
toString(toUnixTimestamp64Milli(max(last_seen))) as last_seen,
toUInt64(sumMerge(occurrence_count)) as occurrence_count
FROM trigger_dev.errors_v1
`,
schema: ActiveErrorsSinceQueryResult,
settings,
});
}
export const OccurrenceCountsSinceQueryResult = z.object({
environment_id: z.string(),
task_identifier: z.string(),
error_fingerprint: z.string(),
occurrences_since: z.number(),
});
export type OccurrenceCountsSinceQueryResult = z.infer<typeof OccurrenceCountsSinceQueryResult>;
/**
* Query builder for occurrence counts since a given timestamp, grouped by error.
* Used by the alert evaluator to check ignore thresholds.
*/
export function getOccurrenceCountsSinceQueryBuilder(
ch: ClickhouseReader,
settings?: ClickHouseSettings
) {
return ch.queryBuilder({
name: "getOccurrenceCountsSince",
baseQuery: `
SELECT
environment_id,
task_identifier,
error_fingerprint,
sum(count) as occurrences_since
FROM trigger_dev.error_occurrences_v1
`,
schema: OccurrenceCountsSinceQueryResult,
settings,
});
}
// ---------------------------------------------------------------------------
// Alert evaluator helpers occurrence rate & count since timestamp
// ---------------------------------------------------------------------------
export const ErrorOccurrenceTotalCountResult = z.object({
total_count: z.number(),
});
export type ErrorOccurrenceTotalCountResult = z.infer<typeof ErrorOccurrenceTotalCountResult>;
/**
* Query builder for summing occurrences since a given timestamp.
* Used by the alert evaluator to check total-count-based ignore thresholds.
*/
export function getOccurrenceCountSinceQueryBuilder(
ch: ClickhouseReader,
settings?: ClickHouseSettings
) {
return ch.queryBuilder({
name: "getOccurrenceCountSince",
baseQuery: `
SELECT
sum(count) as total_count
FROM trigger_dev.error_occurrences_v1
`,
schema: ErrorOccurrenceTotalCountResult,
settings,
});
}
@@ -40,7 +40,11 @@ import {
getErrorHourlyOccurrences,
getErrorOccurrencesListQueryBuilder,
createErrorOccurrencesQueryBuilder,
createErrorOccurrencesByVersionQueryBuilder,
getErrorAffectedVersionsQueryBuilder,
getOccurrenceCountSinceQueryBuilder,
getActiveErrorsSinceQueryBuilder,
getOccurrenceCountsSinceQueryBuilder,
} from "./errors.js";
export { msToClickHouseInterval } from "./intervals.js";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
@@ -273,6 +277,11 @@ export class ClickHouse {
occurrencesListQueryBuilder: getErrorOccurrencesListQueryBuilder(this.reader),
createOccurrencesQueryBuilder: (intervalExpr: string) =>
createErrorOccurrencesQueryBuilder(this.reader, intervalExpr),
createOccurrencesByVersionQueryBuilder: (intervalExpr: string) =>
createErrorOccurrencesByVersionQueryBuilder(this.reader, intervalExpr),
occurrenceCountSinceQueryBuilder: getOccurrenceCountSinceQueryBuilder(this.reader),
activeErrorsSinceQueryBuilder: getActiveErrorsSinceQueryBuilder(this.reader),
occurrenceCountsSinceQueryBuilder: getOccurrenceCountsSinceQueryBuilder(this.reader),
};
}
}
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "public"."ErrorGroupStatus" AS ENUM ('UNRESOLVED', 'RESOLVED', 'IGNORED');
-- AlterEnum
ALTER TYPE "public"."ProjectAlertType" ADD VALUE IF NOT EXISTS 'ERROR_GROUP';
-- CreateTable
CREATE TABLE
"public"."ErrorGroupState" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"environmentId" TEXT,
"taskIdentifier" TEXT NOT NULL,
"errorFingerprint" TEXT NOT NULL,
"status" "public"."ErrorGroupStatus" NOT NULL DEFAULT 'UNRESOLVED',
"ignoredUntil" TIMESTAMP(3),
"ignoredUntilOccurrenceRate" INTEGER,
"ignoredUntilTotalOccurrences" INTEGER,
"ignoredAtOccurrenceCount" BIGINT,
"ignoredAt" TIMESTAMP(3),
"ignoredReason" TEXT,
"ignoredByUserId" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolvedInVersion" TEXT,
"resolvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ErrorGroupState_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ErrorGroupState_environmentId_taskIdentifier_errorFingerpri_key" ON "public"."ErrorGroupState" (
"environmentId",
"taskIdentifier",
"errorFingerprint"
);
-- CreateIndex
CREATE INDEX "ErrorGroupState_environmentId_status_idx" ON "public"."ErrorGroupState" ("environmentId", "status");
-- AddForeignKey
ALTER TABLE "public"."ErrorGroupState" ADD CONSTRAINT "ErrorGroupState_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ErrorGroupState" ADD CONSTRAINT "ErrorGroupState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ErrorGroupState" ADD CONSTRAINT "ErrorGroupState_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "public"."RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "public"."ProjectAlertChannel"
ADD COLUMN "errorAlertConfig" JSONB;
@@ -61,11 +61,10 @@ model User {
backupCodes MfaBackupCode[]
bulkActions BulkActionGroup[]
impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
platformNotifications PlatformNotification[]
platformNotificationInteractions PlatformNotificationInteraction[]
}
@@ -233,7 +232,8 @@ model Organization {
metricsDashboards MetricsDashboard[]
prompts Prompt[]
platformNotifications PlatformNotification[]
platformNotifications PlatformNotification[]
errorGroupStates ErrorGroupState[]
}
model OrgMember {
@@ -353,6 +353,7 @@ model RuntimeEnvironment {
BulkActionGroup BulkActionGroup[]
customerQueries CustomerQuery[]
prompts Prompt[]
errorGroupStates ErrorGroupState[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -426,8 +427,8 @@ model Project {
metricsDashboards MetricsDashboard[]
llmModels LlmModel[]
prompts Prompt[]
platformNotifications PlatformNotification[]
errorGroupStates ErrorGroupState[]
}
enum ProjectVersion {
@@ -2116,6 +2117,8 @@ model ProjectAlertChannel {
alertTypes ProjectAlertType[]
environmentTypes RuntimeEnvironmentType[] @default([STAGING, PRODUCTION])
errorAlertConfig Json?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
@@ -2170,6 +2173,7 @@ enum ProjectAlertType {
TASK_RUN_ATTEMPT
DEPLOYMENT_FAILURE
DEPLOYMENT_SUCCESS
ERROR_GROUP
}
enum ProjectAlertStatus {
@@ -2839,3 +2843,83 @@ model PlatformNotificationInteraction {
@@unique([notificationId, userId])
}
enum ErrorGroupStatus {
UNRESOLVED
RESOLVED
IGNORED
}
/**
* Error group state is used to track when a user has interacted with an error (ignored/resolved)
* The actual error data is in ClickHouse.
*/
model ErrorGroupState {
id String @id @default(cuid())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
/**
* You can ignore/resolve an error across all environments, or specific ones
*/
environment RuntimeEnvironment? @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
environmentId String?
taskIdentifier String
errorFingerprint String
status ErrorGroupStatus @default(UNRESOLVED)
/**
* Error is ignored until this date
*/
ignoredUntil DateTime?
/**
* Error is ignored until this occurrence rate
*/
ignoredUntilOccurrenceRate Int?
/**
* Error is ignored until this total occurrences
*/
ignoredUntilTotalOccurrences Int?
/// Total occurrence count at the time the error was ignored (from ClickHouse).
/// Used with ignoredUntilTotalOccurrences to compute occurrences since ignoring.
ignoredAtOccurrenceCount BigInt?
/**
* Error was ignored at this date
*/
ignoredAt DateTime?
/**
* Reason for ignoring the error
*/
ignoredReason String?
/**
* User who ignored the error
*/
ignoredByUserId String?
/**
* Error was resolved at this date
*/
resolvedAt DateTime?
/**
* Error was resolved in this version
*/
resolvedInVersion String?
/**
* User who resolved the error
*/
resolvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([environmentId, taskIdentifier, errorFingerprint])
@@index([environmentId, status])
}
@@ -0,0 +1,114 @@
import {
Body,
CodeBlock,
Container,
Head,
Html,
Link,
Preview,
Text,
dracula,
} from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles";
import React from "react";
export const AlertErrorGroupEmailSchema = z.object({
email: z.literal("alert-error-group"),
classification: z.enum(["new_issue", "regression", "unignored"]),
taskIdentifier: z.string(),
environment: z.string(),
error: z.object({
message: z.string(),
type: z.string().optional(),
stackTrace: z.string().optional(),
}),
occurrenceCount: z.number(),
errorLink: z.string().url(),
organization: z.string(),
project: z.string(),
});
type AlertErrorGroupEmailProps = z.infer<typeof AlertErrorGroupEmailSchema>;
const classificationLabels: Record<string, string> = {
new_issue: "New error",
regression: "Regression",
unignored: "Error resurfaced",
};
const previewDefaults: AlertErrorGroupEmailProps = {
email: "alert-error-group",
classification: "new_issue",
taskIdentifier: "my-task",
environment: "Production",
error: {
message: "Cannot read property 'foo' of undefined",
type: "TypeError",
stackTrace: "TypeError: Cannot read property 'foo' of undefined\n at Object.<anonymous>",
},
occurrenceCount: 42,
errorLink: "https://trigger.dev",
organization: "my-organization",
project: "my-project",
};
export default function Email(props: AlertErrorGroupEmailProps) {
const {
classification,
taskIdentifier,
environment,
error,
occurrenceCount,
errorLink,
organization,
project,
} = {
...previewDefaults,
...props,
};
const label = classificationLabels[classification] ?? "Error alert";
return (
<Html>
<Head />
<Preview>
{`${organization}: [${label}] ${error.type ?? "Error"} in ${taskIdentifier} (${environment})`}
</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>
{label}: {error.type ?? "Error"} in {taskIdentifier}
</Text>
<Text style={paragraphTight}>Organization: {organization}</Text>
<Text style={paragraphTight}>Project: {project}</Text>
<Text style={paragraphTight}>Task: {taskIdentifier}</Text>
<Text style={paragraphTight}>Environment: {environment}</Text>
<Text style={paragraphTight}>Occurrences: {occurrenceCount}</Text>
<Text style={paragraphLight}>{error.message}</Text>
{error.stackTrace && (
<CodeBlock code={error.stackTrace} theme={dracula} lineNumbers language="log" />
)}
<Link
href={errorLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "50px",
}}
>
Investigate this error
</Link>
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
<Footer />
</Container>
</Body>
</Html>
);
}
+16
View File
@@ -2,6 +2,9 @@ import { ReactElement } from "react";
import { z } from "zod";
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
import AlertErrorGroupEmail, {
AlertErrorGroupEmailSchema,
} from "../emails/alert-error-group";
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
import { setGlobalBasePath } from "../emails/components/BasePath";
import AlertDeploymentFailureEmail, {
@@ -31,6 +34,7 @@ export const DeliverEmailSchema = z
InviteEmailSchema,
AlertRunEmailSchema,
AlertAttemptEmailSchema,
AlertErrorGroupEmailSchema,
AlertDeploymentFailureEmailSchema,
AlertDeploymentSuccessEmailSchema,
MfaEnabledEmailSchema,
@@ -114,6 +118,18 @@ export class EmailClient {
component: <AlertRunFailureEmail {...data} />,
};
}
case "alert-error-group": {
const classLabel =
data.classification === "new_issue"
? "New error"
: data.classification === "regression"
? "Regression"
: "Error resurfaced";
return {
subject: `[${data.organization}] ${classLabel}: ${data.error.type ?? "Error"} in ${data.taskIdentifier} [${data.environment}]`,
component: <AlertErrorGroupEmail {...data} />,
};
}
case "alert-deployment-failure": {
return {
subject: `[${data.organization}] Deployment ${data.version} [${data.environment}] failed: ${data.error.name}`,
+63
View File
@@ -190,6 +190,62 @@ export type AlertWebhookDeploymentSuccessObject = z.infer<
>;
export type AlertWebhookDeploymentFailedObject = z.infer<typeof AlertWebhookDeploymentFailedObject>;
/** Represents an error group alert webhook payload */
export const AlertWebhookErrorGroupObject = z.object({
/** Classification of the error alert */
classification: z.enum(["new_issue", "regression", "unignored"]),
/** Error information */
error: z.object({
/** Error fingerprint identifier */
fingerprint: z.string(),
/** Error type */
type: z.string(),
/** Error message */
message: z.string(),
/** Sample stack trace */
stackTrace: z.string().optional(),
/** When the error was first seen */
firstSeen: z.coerce.date(),
/** When the error was last seen */
lastSeen: z.coerce.date(),
/** Number of occurrences */
occurrenceCount: z.number(),
/** Task identifier where the error occurred */
taskIdentifier: z.string(),
}),
/** Environment information */
environment: z.object({
/** Environment ID */
id: z.string(),
/** Environment name */
name: z.string(),
}),
/** Organization information */
organization: z.object({
/** Organization ID */
id: z.string(),
/** Organization slug */
slug: z.string(),
/** Organization name */
name: z.string(),
}),
/** Project information */
project: z.object({
/** Project ID */
id: z.string(),
/** Project reference */
ref: z.string(),
/** Project slug */
slug: z.string(),
/** Project name */
name: z.string(),
}),
/** URL to view the error in the dashboard */
dashboardUrl: z.string(),
});
export type AlertWebhookErrorGroupObject = z.infer<typeof AlertWebhookErrorGroupObject>;
/** Common properties for all webhooks */
const commonProperties = {
/** Webhook ID */
@@ -220,9 +276,16 @@ export const Webhook = z.discriminatedUnion("type", [
type: z.literal("alert.deployment.failed"),
object: AlertWebhookDeploymentFailedObject,
}),
/** Error group alert webhook */
z.object({
...commonProperties,
type: z.literal("alert.error"),
object: AlertWebhookErrorGroupObject,
}),
]);
export type Webhook = z.infer<typeof Webhook>;
export type RunFailedWebhook = Extract<Webhook, { type: "alert.run.failed" }>;
export type DeploymentSuccessWebhook = Extract<Webhook, { type: "alert.deployment.success" }>;
export type DeploymentFailedWebhook = Extract<Webhook, { type: "alert.deployment.failed" }>;
export type ErrorWebhook = Extract<Webhook, { type: "alert.error" }>;