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:
@@ -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",
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+15
-1
@@ -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
-12
@@ -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);
|
||||
|
||||
+588
-147
@@ -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">
|
||||
|
||||
+297
-28
@@ -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]" />
|
||||
))}
|
||||
|
||||
+48
@@ -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 })
|
||||
);
|
||||
}
|
||||
+198
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+140
-123
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" }>;
|
||||
|
||||
Reference in New Issue
Block a user