Skip to content

Commit 82a5ea0

Browse files
committed
Error alerting
1 parent 5e44b5f commit 82a5ea0

File tree

18 files changed

+1888
-25
lines changed

18 files changed

+1888
-25
lines changed

apps/webapp/app/models/projectAlert.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ export const ProjectAlertSlackStorage = z.object({
3232
});
3333

3434
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
35+
36+
export const ErrorAlertConfig = z.object({
37+
evaluationIntervalMs: z.number().min(60_000).default(300_000),
38+
});
39+
40+
export type ErrorAlertConfig = z.infer<typeof ErrorAlertConfig>;

apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from "zod";
22
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
33
import { TimeGranularity } from "~/utils/timeGranularity";
44
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
5-
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
5+
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
66
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
77
import { type Direction, DirectionSchema } from "~/components/ListPagination";
88
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -61,6 +61,18 @@ function parseClickHouseDateTime(value: string): Date {
6161
return new Date(value.replace(" ", "T") + "Z");
6262
}
6363

64+
export type ErrorGroupState = {
65+
status: ErrorGroupStatus;
66+
resolvedAt: Date | null;
67+
resolvedInVersion: string | null;
68+
resolvedBy: string | null;
69+
ignoredUntil: Date | null;
70+
ignoredReason: string | null;
71+
ignoredByUserId: string | null;
72+
ignoredUntilOccurrenceRate: number | null;
73+
ignoredUntilTotalOccurrences: number | null;
74+
};
75+
6476
export type ErrorGroupSummary = {
6577
fingerprint: string;
6678
errorType: string;
@@ -70,6 +82,7 @@ export type ErrorGroupSummary = {
7082
firstSeen: Date;
7183
lastSeen: Date;
7284
affectedVersions: string[];
85+
state: ErrorGroupState;
7386
};
7487

7588
export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>;
@@ -114,7 +127,7 @@ export class ErrorGroupPresenter extends BasePresenter {
114127
defaultPeriod: "7d",
115128
});
116129

117-
const [summary, affectedVersions, runList] = await Promise.all([
130+
const [summary, affectedVersions, runList, stateRow] = await Promise.all([
118131
this.getSummary(organizationId, projectId, environmentId, fingerprint),
119132
this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint),
120133
this.getRunList(organizationId, environmentId, {
@@ -128,10 +141,22 @@ export class ErrorGroupPresenter extends BasePresenter {
128141
cursor,
129142
direction,
130143
}),
144+
this.getState(environmentId, fingerprint),
131145
]);
132146

133147
if (summary) {
134148
summary.affectedVersions = affectedVersions;
149+
summary.state = stateRow ?? {
150+
status: "UNRESOLVED",
151+
resolvedAt: null,
152+
resolvedInVersion: null,
153+
resolvedBy: null,
154+
ignoredUntil: null,
155+
ignoredReason: null,
156+
ignoredByUserId: null,
157+
ignoredUntilOccurrenceRate: null,
158+
ignoredUntilTotalOccurrences: null,
159+
};
135160
}
136161

137162
return {
@@ -290,6 +315,31 @@ export class ErrorGroupPresenter extends BasePresenter {
290315
return sortVersionsDescending(versions).slice(0, 5);
291316
}
292317

318+
private async getState(
319+
environmentId: string,
320+
fingerprint: string
321+
): Promise<ErrorGroupState | null> {
322+
const row = await this.replica.errorGroupState.findFirst({
323+
where: {
324+
environmentId,
325+
errorFingerprint: fingerprint,
326+
},
327+
select: {
328+
status: true,
329+
resolvedAt: true,
330+
resolvedInVersion: true,
331+
resolvedBy: true,
332+
ignoredUntil: true,
333+
ignoredReason: true,
334+
ignoredByUserId: true,
335+
ignoredUntilOccurrenceRate: true,
336+
ignoredUntilTotalOccurrences: true,
337+
},
338+
});
339+
340+
return row;
341+
}
342+
293343
private async getRunList(
294344
organizationId: string,
295345
environmentId: string,

apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const errorsListGranularity = new TimeGranularity([
99
{ max: "3 months", granularity: "1w" },
1010
{ max: "Infinity", granularity: "30d" },
1111
]);
12-
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
12+
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
1313
import { type Direction } from "~/components/ListPagination";
1414
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
1515
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -23,6 +23,7 @@ export type ErrorsListOptions = {
2323
// filters
2424
tasks?: string[];
2525
versions?: string[];
26+
status?: ErrorGroupStatus;
2627
period?: string;
2728
from?: number;
2829
to?: number;
@@ -41,6 +42,7 @@ export const ErrorsListOptionsSchema = z.object({
4142
projectId: z.string(),
4243
tasks: z.array(z.string()).optional(),
4344
versions: z.array(z.string()).optional(),
45+
status: z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"]).optional(),
4446
period: z.string().optional(),
4547
from: z.number().int().nonnegative().optional(),
4648
to: z.number().int().nonnegative().optional(),
@@ -90,7 +92,11 @@ function decodeCursor(cursor: string): ErrorGroupCursor | null {
9092
}
9193
}
9294

93-
function cursorFromRow(row: { occurrence_count: number; error_fingerprint: string; task_identifier: string }): string {
95+
function cursorFromRow(row: {
96+
occurrence_count: number;
97+
error_fingerprint: string;
98+
task_identifier: string;
99+
}): string {
94100
return encodeCursor({
95101
occurrenceCount: row.occurrence_count,
96102
fingerprint: row.error_fingerprint,
@@ -126,6 +132,7 @@ export class ErrorsListPresenter extends BasePresenter {
126132
projectId,
127133
tasks,
128134
versions,
135+
status,
129136
period,
130137
search,
131138
from,
@@ -161,6 +168,7 @@ export class ErrorsListPresenter extends BasePresenter {
161168
(tasks !== undefined && tasks.length > 0) ||
162169
(versions !== undefined && versions.length > 0) ||
163170
(search !== undefined && search !== "") ||
171+
status !== undefined ||
164172
!time.isDefault;
165173

166174
const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId);
@@ -262,15 +270,14 @@ export class ErrorsListPresenter extends BasePresenter {
262270

263271
// Fetch global first_seen / last_seen from the errors_v1 summary table
264272
const fingerprints = errorGroups.map((e) => e.error_fingerprint);
265-
const globalSummaryMap = await this.getGlobalSummary(
266-
organizationId,
267-
projectId,
268-
environmentId,
269-
fingerprints
270-
);
273+
const [globalSummaryMap, stateMap] = await Promise.all([
274+
this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints),
275+
this.getErrorGroupStates(environmentId, errorGroups),
276+
]);
271277

272-
const transformedErrorGroups = errorGroups.map((error) => {
278+
let transformedErrorGroups = errorGroups.map((error) => {
273279
const global = globalSummaryMap.get(error.error_fingerprint);
280+
const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`);
274281
return {
275282
errorType: error.error_type,
276283
errorMessage: error.error_message,
@@ -279,9 +286,16 @@ export class ErrorsListPresenter extends BasePresenter {
279286
firstSeen: global?.firstSeen ?? new Date(),
280287
lastSeen: global?.lastSeen ?? new Date(),
281288
count: error.occurrence_count,
289+
status: state?.status ?? "UNRESOLVED",
290+
resolvedAt: state?.resolvedAt ?? null,
291+
ignoredUntil: state?.ignoredUntil ?? null,
282292
};
283293
});
284294

295+
if (status) {
296+
transformedErrorGroups = transformedErrorGroups.filter((g) => g.status === status);
297+
}
298+
285299
return {
286300
errorGroups: transformedErrorGroups,
287301
pagination: {
@@ -291,6 +305,7 @@ export class ErrorsListPresenter extends BasePresenter {
291305
filters: {
292306
tasks,
293307
versions,
308+
status,
294309
search,
295310
period: time,
296311
from: effectiveFrom,
@@ -376,6 +391,51 @@ export class ErrorsListPresenter extends BasePresenter {
376391
return { data };
377392
}
378393

394+
/**
395+
* Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups.
396+
* Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`.
397+
*/
398+
private async getErrorGroupStates(
399+
environmentId: string,
400+
errorGroups: Array<{ task_identifier: string; error_fingerprint: string }>
401+
) {
402+
type StateValue = {
403+
status: ErrorGroupStatus;
404+
resolvedAt: Date | null;
405+
ignoredUntil: Date | null;
406+
};
407+
408+
const result = new Map<string, StateValue>();
409+
if (errorGroups.length === 0) return result;
410+
411+
const states = await this.replica.errorGroupState.findMany({
412+
where: {
413+
environmentId,
414+
OR: errorGroups.map((e) => ({
415+
taskIdentifier: e.task_identifier,
416+
errorFingerprint: e.error_fingerprint,
417+
})),
418+
},
419+
select: {
420+
taskIdentifier: true,
421+
errorFingerprint: true,
422+
status: true,
423+
resolvedAt: true,
424+
ignoredUntil: true,
425+
},
426+
});
427+
428+
for (const state of states) {
429+
result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, {
430+
status: state.status,
431+
resolvedAt: state.resolvedAt,
432+
ignoredUntil: state.ignoredUntil,
433+
});
434+
}
435+
436+
return result;
437+
}
438+
379439
/**
380440
* Fetches global first_seen / last_seen for a set of fingerprints from errors_v1.
381441
*/

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
v3NewProjectAlertPath,
6464
v3ProjectAlertsPath,
6565
} from "~/utils/pathBuilder";
66+
import { alertsWorker } from "~/v3/alertsWorker.server";
6667

6768
export const meta: MetaFunction = () => {
6869
return [
@@ -156,6 +157,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
156157
data: { enabled: true },
157158
});
158159

160+
if (alertChannel.alertTypes.includes("ERROR_GROUP")) {
161+
await alertsWorker.enqueue({
162+
id: `evaluateErrorAlerts:${project.id}`,
163+
job: "v3.evaluateErrorAlerts",
164+
payload: {
165+
projectId: project.id,
166+
scheduledAt: Date.now(),
167+
},
168+
});
169+
}
170+
159171
return redirectWithSuccessMessage(
160172
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
161173
request,
@@ -556,7 +568,7 @@ export function alertTypeTitle(alertType: ProjectAlertType): string {
556568
case "DEPLOYMENT_SUCCESS":
557569
return "Deployment success";
558570
default: {
559-
assertNever(alertType);
571+
throw new Error(`Unknown alertType: ${alertType}`);
560572
}
561573
}
562574
}

0 commit comments

Comments
 (0)