diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx new file mode 100644 index 00000000000..9b2fa3c2028 --- /dev/null +++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx @@ -0,0 +1,332 @@ +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 } from "@remix-run/react"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { Fragment, useRef, useState } from "react"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout, variantClasses } from "~/components/primitives/Callout"; +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 { cn } from "~/utils/cn"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; + +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; +}; + +export function ConfigureErrorAlerts({ + emails: existingEmails, + webhooks: existingWebhooks, + slackChannel: existingSlackChannel, + slack, + emailAlertsEnabled, + connectToSlackHref, +}: ConfigureErrorAlertsProps) { + const fetcher = useFetcher(); + const location = useOptimisticLocation(); + const isSubmitting = fetcher.state !== "idle"; + + const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState( + 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 emailFieldValues = useRef( + existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""] + ); + + const webhookFieldValues = useRef( + 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 ( +
+
+ Configure alerts + +
+ +
+ +
+
+ You'll receive alerts when + +
  • An error is seen for the first time
  • +
  • A resolved error re-occurs
  • +
  • An ignored error re-occurs based on settings you configured
  • +
    +
    + + {/* Email section */} +
    + + + Email + + {emailAlertsEnabled ? ( + + {emailFields.map((emailField, index) => ( + + { + 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)); + } + }} + /> + {emailField.error} + + ))} + + ) : ( + + Email integration is not available. Please contact your organization + administrator. + + )} +
    + + {/* Slack section */} +
    + + + Slack + + + {slack.status === "READY" ? ( + <> + + {selectedSlackChannel && selectedSlackChannel.is_private && ( + + To receive alerts in the{" "} + {selectedSlackChannel.name}{" "} + channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in + Slack and type:{" "} + /invite @Trigger.dev. + + )} + + + ) : slack.status === "NOT_CONFIGURED" ? ( + connectToSlackHref ? ( + + + Connect to Slack + + + ) : ( + + Slack is not connected. Connect Slack from the{" "} + Alerts page to enable + Slack notifications. + + ) + ) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? ( + connectToSlackHref ? ( +
    + + The Slack integration in your workspace has been revoked or has expired. + Please re-connect your Slack workspace. + + + + Connect to Slack + + +
    + ) : ( + + The Slack integration in your workspace has been revoked or expired. Please + re-connect from the{" "} + Alerts page. + + ) + ) : slack.status === "FAILED_FETCHING_CHANNELS" ? ( + + Failed loading channels from Slack. Please try again later. + + ) : ( + + Slack integration is not available. Please contact your organization + administrator. + + )} +
    +
    + + {/* Webhook section */} +
    + + + Webhook + + + {webhookFields.map((webhookField, index) => ( + + { + 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)); + } + }} + /> + {webhookField.error} + + ))} + We'll issue POST requests to these URLs with a JSON payload. + +
    + + {form.error} +
    +
    +
    + +
    + +
    +
    + ); +} + +function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) { + return ( +
    + {is_private ? : } + {name} +
    + ); +} diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index e0b58ed717c..635489400ac 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -20,8 +20,18 @@ export function MainBody({ children }: { children: React.ReactNode }) { } /** This container should be placed around the content on a page */ -export function PageContainer({ children }: { children: React.ReactNode }) { - return
    {children}
    ; +export function PageContainer({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
    + {children} +
    + ); } export function PageBody({ diff --git a/apps/webapp/app/components/logs/LogsVersionFilter.tsx b/apps/webapp/app/components/logs/LogsVersionFilter.tsx new file mode 100644 index 00000000000..4cc10545060 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsVersionFilter.tsx @@ -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 ( + + {(search, setSearch) => ( + + Versions + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); + } + + return ( + + {(search, setSearch) => ( + }> + del(["versions", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 63ba99f6d7d..6a99c4bcd2e 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -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 ( @@ -245,8 +253,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: { diff --git a/apps/webapp/app/components/primitives/UnorderedList.tsx b/apps/webapp/app/components/primitives/UnorderedList.tsx new file mode 100644 index 00000000000..e65dfe6673f --- /dev/null +++ b/apps/webapp/app/components/primitives/UnorderedList.tsx @@ -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; + +export function UnorderedList({ + variant = "base", + className, + spacing = false, + children, + ...props +}: UnorderedListProps) { + const v = listVariants[variant]; + return ( +
      li]:flex [&>li]:items-baseline [&>li]:before:shrink-0 [&>li]:before:content-['•']", + v.text, + v.items, + spacing && v.spacing, + className + )} + {...props} + > + {children} +
    + ); +} diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index 9a366c9789d..3b2a2c6a3c1 100644 --- a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx @@ -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["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} /> )} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f643209b8cb..dc3657b42a9 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1216,7 +1216,7 @@ function AppliedMachinesFilter() { ); } -function VersionsDropdown({ +export function VersionsDropdown({ trigger, clearSearchValue, searchValue, diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts index d2ab0be1d1a..dbcb672ad7d 100644 --- a/apps/webapp/app/models/projectAlert.server.ts +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -32,3 +32,9 @@ export const ProjectAlertSlackStorage = z.object({ }); export type ProjectAlertSlackStorage = z.infer; + +export const ErrorAlertConfig = z.object({ + evaluationIntervalMs: z.number().min(60_000).default(300_000), +}); + +export type ErrorAlertConfig = z.infer; diff --git a/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts new file mode 100644 index 00000000000..2fcf8653454 --- /dev/null +++ b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts @@ -0,0 +1,72 @@ +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>; + +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": { + 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, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 024ac1e95ea..6a21bc2361c 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -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>; 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,39 @@ export class ErrorGroupPresenter extends BasePresenter { defaultPeriod: "7d", }); - const [summary, affectedVersions, runList] = await Promise.all([ + const [summary, affectedVersions, runList, stateRow] = await Promise.all([ this.getSummary(organizationId, projectId, environmentId, fingerprint), 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, 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 +176,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 +185,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>; + 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 +208,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 +229,27 @@ export class ErrorGroupPresenter extends BasePresenter { buckets.push(epoch); } - const byBucket = new Map(); + // Collect distinct versions and index results by (epoch, version) + const versionSet = new Set(); + const byBucketVersion = new Map(); 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 = { 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( @@ -268,6 +321,63 @@ export class ErrorGroupPresenter extends BasePresenter { return sortVersionsDescending(versions).slice(0, 5); } + private async getState( + environmentId: string, + fingerprint: string + ): Promise { + const row = await this.replica.errorGroupState.findFirst({ + where: { + environmentId, + 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.findUnique({ + 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 +385,7 @@ export class ErrorGroupPresenter extends BasePresenter { userId?: string; projectId: string; fingerprint: string; + versions?: string[]; pageSize: number; from?: number; to?: number; @@ -289,6 +400,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, diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 89832b28340..a8d3d6ab9f8 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -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,7 +166,9 @@ export class ErrorsListPresenter extends BasePresenter { const hasFilters = (tasks !== undefined && tasks.length > 0) || + (versions !== undefined && versions.length > 0) || (search !== undefined && search !== "") || + (statuses !== undefined && statuses.length > 0) || !time.isDefault; const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); @@ -189,6 +201,10 @@ 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 }); + } + queryBuilder.groupBy("error_fingerprint, task_identifier"); // Text search via HAVING (operates on aggregated values) @@ -254,15 +270,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,9 +286,18 @@ 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, }; }); + if (statuses && statuses.length > 0) { + transformedErrorGroups = transformedErrorGroups.filter((g) => + statuses.includes(g.status as ErrorGroupStatus) + ); + } + return { errorGroups: transformedErrorGroups, pagination: { @@ -282,6 +306,8 @@ export class ErrorsListPresenter extends BasePresenter { }, filters: { tasks, + versions, + statuses, search, period: time, from: effectiveFrom, @@ -367,6 +393,51 @@ export class ErrorsListPresenter extends BasePresenter { return { data }; } + /** + * 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(); + 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. */ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 1bedd30d0f9..d93e7640d39 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -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, @@ -556,7 +568,7 @@ export function alertTypeTitle(alertType: ProjectAlertType): string { case "DEPLOYMENT_SUCCESS": return "Deployment success"; default: { - assertNever(alertType); + throw new Error(`Unknown alertType: ${alertType}`); } } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 0ff8594fa36..ef3fa368051 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -1,8 +1,11 @@ -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, Form, useFetcher, useNavigation, useSubmit } from "@remix-run/react"; +import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { parse } from "@conform-to/zod"; +import { z } from "zod"; 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,20 +17,22 @@ 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 { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody } from "~/components/layout/AppLayout"; -import { Suspense, useMemo } from "react"; +import { Suspense, useMemo, 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 { formatDistanceToNow, isPast } from "date-fns"; import { formatNumberCompact } from "~/utils/numberFormatter"; import * as Property from "~/components/primitives/PropertyTable"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; @@ -37,15 +42,32 @@ import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCom 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 { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; +import { getSeriesColor } from "~/components/code/chartColors"; +import { + Popover, + PopoverArrowTrigger, + PopoverContent, + PopoverMenuItem, +} from "~/components/primitives/Popover"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server"; export const meta: MetaFunction = ({ data }) => { return [ @@ -55,6 +77,107 @@ export const meta: MetaFunction = ({ data }) => { ]; }; +const actionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("resolve"), + taskIdentifier: z.string(), + resolvedInVersion: z.string().optional(), + }), + z.object({ + action: z.literal("ignore"), + taskIdentifier: z.string(), + duration: z.coerce.number().optional(), + occurrenceRate: z.coerce.number().optional(), + totalOccurrences: z.coerce.number().optional(), + reason: z.string().optional(), + }), + z.object({ + action: z.literal("unresolve"), + taskIdentifier: z.string(), + }), +]); + +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("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) { + 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 +205,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 +217,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, projectId: project.id, fingerprint, + versions: versions.length > 0 ? versions : undefined, period, from, to, @@ -115,9 +240,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 +275,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 ( <> @@ -163,6 +298,11 @@ export default function Page() { }} title={{ErrorId.toFriendlyId(fingerprint)}} /> + + + Configure alerts + + @@ -232,7 +372,7 @@ function ErrorGroupDetail({ envParam: string; fingerprint: string; }) { - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -252,11 +392,13 @@ 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), }; @@ -265,9 +407,16 @@ function ErrorGroupDetail({
    {/* Error Summary */}
    -
    - {errorGroup.errorMessage} - {formatNumberCompact(errorGroup.count)} total occurrences +
    +
    + {errorGroup.errorMessage} + {formatNumberCompact(errorGroup.count)} total occurrences +
    +
    @@ -314,23 +463,25 @@ function ErrorGroupDetail({ )}
    + +
    {/* Activity chart */}
    -
    +
    +
    }> }> - {(result) => - result.data.length > 0 ? ( - - ) : ( - - ) - } + {(result) => { + if (result.data.length > 0 && result.versions.length > 0) { + return ; + } + return ; + }}
    @@ -369,10 +520,10 @@ function ErrorGroupDetail({ {runList ? ( 0} filters={{ tasks: [], - versions: [], + versions: selectedVersions, statuses: [], from: undefined, to: undefined, @@ -392,14 +543,309 @@ function ErrorGroupDetail({ ); } -const activityChartConfig: ChartConfig = { - count: { - label: "Occurrences", - color: "#6366F1", - }, -}; +const STATUS_BADGE_STYLES = { + UNRESOLVED: "bg-error/10 text-error", + RESOLVED: "bg-success/10 text-success", + IGNORED: "bg-text-dimmed/10 text-text-dimmed", +} as const; + +const STATUS_LABELS = { + UNRESOLVED: "Unresolved", + RESOLVED: "Resolved", + IGNORED: "Ignored", +} as const; + +function StatusBadge({ status }: { status: ErrorGroupState["status"] }) { + return ( + + {STATUS_LABELS[status]} + + ); +} + +function IgnoredDetails({ + state, + totalOccurrences, +}: { + state: ErrorGroupState; + totalOccurrences: number; +}) { + 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 ( +
    +
    + + {ignoredForever ? "Ignored permanently" : "Ignored with conditions"} + + {state.ignoredByUserDisplayName && ( + by {state.ignoredByUserDisplayName} + )} + {state.ignoredAt && ( + + + + )} +
    + + {state.ignoredReason && ( +
    + Reason: {state.ignoredReason} +
    + )} + + {hasConditions && ( +
    + Will unignore when: +
      + {state.ignoredUntil && ( +
    • + Time expires:{" "} + + + + {isPast(state.ignoredUntil) && (expired)} +
    • + )} + {state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && ( +
    • + Occurrence rate exceeds{" "} + {state.ignoredUntilOccurrenceRate}/min +
    • + )} + {state.ignoredUntilTotalOccurrences !== null && + state.ignoredUntilTotalOccurrences > 0 && ( +
    • + Total occurrences exceed{" "} + + {state.ignoredUntilTotalOccurrences.toLocaleString()} + + {occurrencesSinceIgnore !== null && ( + + ({occurrencesSinceIgnore.toLocaleString()} since ignored) + + )} +
    • + )} +
    +
    + )} +
    + ); +} + +function ErrorGroupActionButtons({ + state, + taskIdentifier, + fingerprint, +}: { + state: ErrorGroupState; + taskIdentifier: string; + fingerprint: string; +}) { + const navigation = useNavigation(); + const submit = useSubmit(); + const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false); + const isSubmitting = navigation.state !== "idle"; + + return ( + <> +
    + + + {state.status === "UNRESOLVED" && ( + <> + + + Ignore + + + submit( + { taskIdentifier, action: "ignore", duration: String(60 * 60 * 1000) }, + { method: "post" } + ) + } + /> + + submit( + { + taskIdentifier, + action: "ignore", + duration: String(24 * 60 * 60 * 1000), + }, + { method: "post" } + ) + } + /> + submit({ taskIdentifier, action: "ignore" }, { method: "post" })} + /> + setCustomIgnoreOpen(true)} + /> + + + + )} + + {state.status === "RESOLVED" && ( + + )} + + {state.status === "IGNORED" && ( + + )} + + + + + + Custom ignore condition + + setCustomIgnoreOpen(false)} + /> + + + + ); +} + +function CustomIgnoreForm({ + taskIdentifier, + onClose, +}: { + taskIdentifier: string; + onClose: () => void; +}) { + const fetcher = useFetcher(); + const isSubmitting = fetcher.state !== "idle"; + + return ( + { + setTimeout(onClose, 100); + }} + > + + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + + + + +
    + ); +} + +function ActivityChart({ + activity, + versions, +}: { + activity: ErrorGroupActivity; + versions: ErrorGroupActivityVersions; +}) { + const chartConfig = useMemo(() => { + const cfg: ChartConfig = {}; + for (let i = 0; i < versions.length; i++) { + cfg[versions[i]] = { + label: versions[i], + color: getSeriesColor(i), + }; + } + return cfg; + }, [versions]); -function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { const data = useMemo( () => activity.map((d) => ({ @@ -453,13 +899,14 @@ function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { return ( { 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 +122,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,10 +176,36 @@ export default function Page() { envParam, } = useTypedLoaderData(); + const revalidator = useRevalidator(); + useInterval({ + interval: 60_000, + onLoad: false, + callback: useCallback(() => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, [revalidator]), + }); + + const location = useOptimisticLocation(); + const showAlerts = new URLSearchParams(location.search).has("alerts"); + const alertsHref = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("alerts", "true"); + return `?${params.toString()}`; + }, [location.search]); + return ( - <> +
    + + {!showAlerts && ( + + Configure alerts + + )} + @@ -222,7 +271,125 @@ export default function Page() { - +
    + ); +} + +const errorStatusOptions = [ + { value: "UNRESOLVED", label: "Unresolved" }, + { value: "RESOLVED", label: "Resolved" }, + { value: "IGNORED", label: "Ignored" }, +] as const; + +const statusIcon = ; +const statusShortcut = { key: "s" }; + +function StatusFilter() { + const { values, del } = useSearchParams(); + const selectedStatuses = values("status"); + + if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) { + return ( + + {(search, setSearch) => ( + + Status + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); + } + + return ( + + {(search, setSearch) => ( + }> + { + const opt = errorStatusOptions.find((o) => o.value === s); + return opt ? opt.label : s; + }) + )} + onRemove={() => del(["status", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function ErrorStatusDropdown({ + trigger, + clearSearchValue, + searchValue, + 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, + }); + }; + + const filtered = useMemo(() => { + return errorStatusOptions.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item) => ( + + {item.label} + + ))} + + + ); } @@ -238,7 +405,9 @@ function FiltersBar({ 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") || @@ -249,7 +418,9 @@ function FiltersBar({
    {list ? ( <> + + ) : ( <> + + {hasFilters && ( @@ -319,6 +492,7 @@ function ErrorsList({ ID + Status Task Error Occurrences @@ -373,6 +547,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,9 +561,12 @@ function ErrorGroupRow({ {errorGroup.fingerprint.slice(-8)} + + + {errorGroup.taskIdentifier} - {errorMessage} + {errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}…` : errorMessage} {errorGroup.count.toLocaleString()} @@ -413,6 +593,27 @@ function ErrorGroupRow({ ); } +const LIST_STATUS_STYLES = { + UNRESOLVED: "text-error", + RESOLVED: "text-success", + IGNORED: "text-text-dimmed", +} as const; + +const LIST_STATUS_LABELS = { + UNRESOLVED: "Unresolved", + RESOLVED: "Resolved", + IGNORED: "Ignored", +} as const; + +function ListStatusBadge({ status }: { status: string }) { + const s = (status as keyof typeof LIST_STATUS_STYLES) ?? "UNRESOLVED"; + return ( + + {LIST_STATUS_LABELS[s] ?? "Unresolved"} + + ); +} + function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) { const maxCount = Math.max(...activity.map((d) => d.count)); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts new file mode 100644 index 00000000000..cb9ba373bc5 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts @@ -0,0 +1,47 @@ +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, + }, + }); + + 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 }) + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx index f6723ddebaa..0b55f329c0b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx @@ -1,10 +1,176 @@ +import { parse } from "@conform-to/zod"; import { Outlet } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { PageContainer } from "~/components/layout/AppLayout"; +import { + ConfigureErrorAlerts, + ErrorAlertsFormSchema, +} from "~/components/errors/ConfigureErrorAlerts"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +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 { EnvironmentParamSchema, v3ErrorsConnectToSlackPath } from "~/utils/pathBuilder"; +import { + type CreateAlertChannelOptions, + CreateAlertChannelService, +} from "~/v3/services/alerts/createAlertChannel.server"; +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, + }); + + return typedjson({ + alertData, + projectRef: project.externalRef, + projectId: project.id, + environmentType: environment.type, + connectToSlackHref, + }); +}; + +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 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(); + + 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 (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 channelsToDelete = existingChannels.filter( + (ch) => + !processedChannelIds.has(ch.id) && + 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 } = useTypedLoaderData(); + const { has } = useSearchParams(); + const showAlerts = has("alerts") ?? false; + return ( - - + + + + + + {showAlerts && ( + <> + + + + + + )} + ); } diff --git a/apps/webapp/app/routes/storybook.unordered-list/route.tsx b/apps/webapp/app/routes/storybook.unordered-list/route.tsx new file mode 100644 index 00000000000..b17bb2dda11 --- /dev/null +++ b/apps/webapp/app/routes/storybook.unordered-list/route.tsx @@ -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 ( +
    + {variantGroups.map((group) => ( +
    + {group.label} + {group.variants.map((variant) => ( +
    + {variant} + This is a paragraph before the list. + + {sampleItems.map((item) => ( +
  • {item}
  • + ))} +
    + This is a paragraph after the list. +
    + ))} +
    + ))} +
    + ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 83d455c2a55..bcaee62d6b0 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -136,6 +136,10 @@ const stories: Story[] = [ name: "Typography", slug: "typography", }, + { + name: "Unordered list", + slug: "unordered-list", + }, { name: "Usage", slug: "usage", diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 0db25808129..6352da230aa 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -540,6 +540,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, diff --git a/apps/webapp/app/v3/alertsWorker.server.ts b/apps/webapp/app/v3/alertsWorker.server.ts index 46670887a75..693b16b738a 100644 --- a/apps/webapp/app/v3/alertsWorker.server.ts +++ b/apps/webapp/app/v3/alertsWorker.server.ts @@ -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); + }, }, }); diff --git a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts index b2bbb423983..c87218f2bfc 100644 --- a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts +++ b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts @@ -7,6 +7,7 @@ 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, @@ -70,6 +71,12 @@ export class CreateAlertChannelService extends BaseService { environmentTypes, }, }); + + if (options.alertTypes.includes("ERROR_GROUP")) { + await this.#scheduleErrorAlertEvaluation(project.id); + } + + return updated; } const alertChannel = await this._prisma.projectAlertChannel.create({ @@ -87,9 +94,24 @@ export class CreateAlertChannelService extends BaseService { }, }); + if (options.alertTypes.includes("ERROR_GROUP")) { + await this.#scheduleErrorAlertEvaluation(project.id); + } + return alertChannel; } + async #scheduleErrorAlertEvaluation(projectId: string): Promise { + await alertsWorker.enqueue({ + id: `evaluateErrorAlerts:${projectId}`, + job: "v3.evaluateErrorAlerts", + payload: { + projectId, + scheduledAt: Date.now(), + }, + }); + } + async #createProperties(channel: CreateAlertChannelOptions["channel"]) { switch (channel.type) { case "EMAIL": diff --git a/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts new file mode 100644 index 00000000000..e7540aaae32 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts @@ -0,0 +1,383 @@ +import { + type ChatPostMessageArguments, + ErrorCode, + type WebAPIHTTPError, + type WebAPIPlatformError, + type WebAPIRateLimitedError, + type WebAPIRequestError, +} 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"; + +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 { + 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 { + 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 { + 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 label = this.#classificationLabel(payload.classification); + const errorType = payload.error.errorType || "Error"; + const task = payload.error.taskIdentifier; + const envName = payload.error.environmentName; + + const emoji = + payload.classification === "new_issue" + ? ":rotating_light:" + : payload.classification === "regression" + ? ":warning:" + : ":bell:"; + + await this.#postSlackMessage(integration, { + channel: slackProperties.data.channelId, + text: `${label}: ${errorType} in ${task} [${envName}]`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `${emoji} *${label}* in *${task}* [${envName}]`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: this.#wrapInCodeBlock( + payload.error.sampleStackTrace || payload.error.errorMessage + ), + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `> *${task}* | ${envName} | ${channel.project.name}\n> ${payload.error.occurrenceCount} occurrences | ${this.#formatTimestamp(new Date(Number(payload.error.lastSeen)))}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Investigate" }, + url: errorLink, + }, + ], + }, + ], + }); + } + + 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 { + const webhookProperties = ProjectAlertWebhookProperties.safeParse(channel.properties); + if (!webhookProperties.success) { + logger.error("[DeliverErrorGroupAlert] Failed to parse webhook properties", { + issues: webhookProperties.error.issues, + }); + return; + } + + const webhookPayload = { + type: "alert.error_group" as const, + classification: payload.classification, + error: { + fingerprint: payload.error.fingerprint, + type: payload.error.errorType, + message: payload.error.errorMessage, + stackTrace: payload.error.sampleStackTrace || undefined, + firstSeen: payload.error.firstSeen, + lastSeen: payload.error.lastSeen, + occurrenceCount: payload.error.occurrenceCount, + taskIdentifier: payload.error.taskIdentifier, + }, + environment: { + id: payload.error.environmentId, + name: payload.error.environmentName, + }, + organization: { + id: channel.project.organizationId, + slug: channel.project.organization.slug, + name: channel.project.organization.title, + }, + project: { + id: channel.project.id, + ref: 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; + } + } + + #wrapInCodeBlock(text: string, maxLength = 3000) { + const truncated = + text.length > maxLength - 10 + ? text.slice(0, maxLength - 10 - 50) + + "\n\ntruncated - check dashboard for complete error message" + : 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; +} diff --git a/apps/webapp/app/v3/services/alerts/errorAlertEvaluator.server.ts b/apps/webapp/app/v3/services/alerts/errorAlertEvaluator.server.ts new file mode 100644 index 00000000000..9c6fc2611f1 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/errorAlertEvaluator.server.ts @@ -0,0 +1,438 @@ +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 { + 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); + const environments = await this.resolveEnvironments(projectId, allEnvTypes); + + if (environments.length === 0) { + logger.info("[ErrorAlertEvaluator] No matching environments found", { projectId }); + await this.selfChain(projectId, nextScheduledAt, minIntervalMs); + 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(projectId, envIds, scheduledAt); + + if (activeErrors.length === 0) { + await this.selfChain(projectId, nextScheduledAt, minIntervalMs); + return; + } + + const states = await this.getErrorGroupStates(projectId, activeErrors, envIds); + const stateMap = this.buildStateMap(states); + + const occurrenceCounts = await this.getOccurrenceCountsSince(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, + }); + } + } + + const stateUpdates = alertableErrors.filter( + (a) => a.classification === "regression" || a.classification === "unignored" + ); + await this.updateErrorGroupStates(stateUpdates, stateMap); + + for (const alertable of alertableErrors) { + const envChannels = channelsByEnvId.get(alertable.error.environment_id) ?? []; + for (const channel of envChannels) { + await alertsWorker.enqueue({ + 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, + }, + }, + }); + } + } + + 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 + ), + }); + + 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 { + 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(); + 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 { + 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 { + const result = new Map(); + 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( + projectId: string, + envIds: string[], + scheduledAt: number + ): Promise { + const qb = this._clickhouse.errors.activeErrorsSinceQueryBuilder(); + 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( + projectId: string, + activeErrors: ActiveErrorsSinceQueryResult[], + envIds: string[] + ): Promise { + const fingerprints = [...new Set(activeErrors.map((e) => e.error_fingerprint))]; + if (fingerprints.length === 0) return []; + + return this._replica.errorGroupState.findMany({ + where: { + projectId, + errorFingerprint: { in: fingerprints }, + environmentId: { in: envIds }, + }, + }); + } + + private buildStateMap(states: ErrorGroupState[]): Map { + const map = new Map(); + for (const s of states) { + map.set(`${s.environmentId}:${s.taskIdentifier}:${s.errorFingerprint}`, s); + } + return map; + } + + private async getOccurrenceCountsSince( + 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("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 { + const map = new Map(); + 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 + ): Promise { + 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) continue; + + 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, + }, + }); + } + } + + private async selfChain( + projectId: string, + nextScheduledAt: number, + intervalMs: number + ): Promise { + await alertsWorker.enqueue({ + id: `evaluateErrorAlerts:${projectId}`, + job: "v3.evaluateErrorAlerts", + payload: { + projectId, + scheduledAt: nextScheduledAt, + }, + availableAt: new Date(nextScheduledAt + intervalMs), + }); + } +} diff --git a/apps/webapp/app/v3/services/errorGroupActions.server.ts b/apps/webapp/app/v3/services/errorGroupActions.server.ts new file mode 100644 index 00000000000..c026efe2aba --- /dev/null +++ b/apps/webapp/app/v3/services/errorGroupActions.server.ts @@ -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", + }, + }); + } +} diff --git a/internal-packages/clickhouse/src/errors.ts b/internal-packages/clickhouse/src/errors.ts index c93efbcaf1f..4b13ce18c80 100644 --- a/internal-packages/clickhouse/src/errors.ts +++ b/internal-packages/clickhouse/src/errors.ts @@ -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 { + 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; + +/** + * 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; + +/** + * 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; + +/** + * 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, + }); +} diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 18e52483627..5d3e945480d 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -35,7 +35,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"; @@ -259,6 +263,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), }; } } diff --git a/internal-packages/database/prisma/migrations/20260306102053_error_group_state/migration.sql b/internal-packages/database/prisma/migrations/20260306102053_error_group_state/migration.sql new file mode 100644 index 00000000000..28595d98bf9 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260306102053_error_group_state/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "public"."ErrorGroupStatus" AS ENUM ('UNRESOLVED', 'RESOLVED', 'IGNORED'); + +-- AlterEnum +ALTER TYPE "public"."ProjectAlertType" ADD VALUE '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, + "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 INDEX "ErrorGroupState_status_idx" ON "public"."ErrorGroupState" ("status"); + +-- CreateIndex +CREATE INDEX "ErrorGroupState_ignoredUntil_idx" ON "public"."ErrorGroupState" ("ignoredUntil"); + +-- CreateIndex +CREATE UNIQUE INDEX "ErrorGroupState_environmentId_taskIdentifier_errorFingerpri_key" ON "public"."ErrorGroupState" ( + "environmentId", + "taskIdentifier", + "errorFingerprint" +); + +-- 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; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20260308181657_add_error_alert_config_to_project_alert_channel/migration.sql b/internal-packages/database/prisma/migrations/20260308181657_add_error_alert_config_to_project_alert_channel/migration.sql new file mode 100644 index 00000000000..09b0eacdca3 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260308181657_add_error_alert_config_to_project_alert_channel/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."ProjectAlertChannel" ADD COLUMN "errorAlertConfig" JSONB; diff --git a/internal-packages/database/prisma/migrations/20260320115950_add_ignored_at_occurrence_count_to_error_group_state/migration.sql b/internal-packages/database/prisma/migrations/20260320115950_add_ignored_at_occurrence_count_to_error_group_state/migration.sql new file mode 100644 index 00000000000..d153b165ef1 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260320115950_add_ignored_at_occurrence_count_to_error_group_state/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."ErrorGroupState" ADD COLUMN "ignoredAtOccurrenceCount" BIGINT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 9e91fc70f14..f28df9fe72e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -228,6 +228,7 @@ model Organization { githubAppInstallations GithubAppInstallation[] customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + errorGroupStates ErrorGroupState[] } model OrgMember { @@ -346,6 +347,7 @@ model RuntimeEnvironment { waitpointTags WaitpointTag[] BulkActionGroup BulkActionGroup[] customerQueries CustomerQuery[] + errorGroupStates ErrorGroupState[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -418,6 +420,7 @@ model Project { taskScheduleInstances TaskScheduleInstance[] metricsDashboards MetricsDashboard[] llmModels LlmModel[] + errorGroupStates ErrorGroupState[] } enum ProjectVersion { @@ -783,7 +786,6 @@ model TaskRun { /// Store the stream keys that are being used by the run realtimeStreams String[] @default([]) - @@unique([oneTimeUseToken]) @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) // Finding child runs @@ -2019,6 +2021,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 @@ -2073,6 +2077,7 @@ enum ProjectAlertType { TASK_RUN_ATTEMPT DEPLOYMENT_FAILURE DEPLOYMENT_SUCCESS + ERROR_GROUP } enum ProjectAlertStatus { @@ -2634,3 +2639,84 @@ model LlmPrice { @@unique([modelId, usageType, pricingTierId]) @@map("llm_prices") } + +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([status]) + @@index([ignoredUntil]) +} diff --git a/internal-packages/emails/emails/alert-error-group.tsx b/internal-packages/emails/emails/alert-error-group.tsx new file mode 100644 index 00000000000..f584f06edba --- /dev/null +++ b/internal-packages/emails/emails/alert-error-group.tsx @@ -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; + +const classificationLabels: Record = { + 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.", + }, + 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 ( + + + + {`${organization}: [${label}] ${error.type ?? "Error"} in ${taskIdentifier} (${environment})`} + + + + + {label}: {error.type ?? "Error"} in {taskIdentifier} + + Organization: {organization} + Project: {project} + Task: {taskIdentifier} + Environment: {environment} + Occurrences: {occurrenceCount} + + {error.message} + {error.stackTrace && ( + + )} + + Investigate this error + + + Trigger.dev +