Files
triggerdotdev--trigger.dev/apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts
T
2026-07-13 13:32:57 +08:00

166 lines
5.3 KiB
TypeScript

import { type ErrorGroupListItem } from "@trigger.dev/core/v3";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import {
type ErrorGroupStatus,
type Project,
type RuntimeEnvironment,
} from "@trigger.dev/database";
import { z } from "zod";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { CoercedDate } from "~/utils/zod";
import { ErrorsListPresenter, type ErrorsListOptions } from "./ErrorsListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
// API status (lowercase) <-> DB ErrorGroupState.status (uppercase).
const API_STATUS_TO_DB: Record<string, ErrorGroupStatus> = {
unresolved: "UNRESOLVED",
resolved: "RESOLVED",
ignored: "IGNORED",
};
const DB_STATUS_TO_API: Record<ErrorGroupStatus, ErrorGroupListItem["status"]> = {
UNRESOLVED: "unresolved",
RESOLVED: "resolved",
IGNORED: "ignored",
};
export const ApiErrorListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
"filter[taskIdentifier]": z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
"filter[version]": z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
"filter[status]": z
.string()
.optional()
.transform((value, ctx) => {
if (!value) {
return undefined;
}
const statuses = value.split(",");
// hasOwnProperty, not `in`: `in` walks the prototype chain, so
// `filter[status]=toString` would pass and map to a function.
const invalid = statuses.filter(
(status) => !Object.prototype.hasOwnProperty.call(API_STATUS_TO_DB, status)
);
if (invalid.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid status values: ${invalid.join(
", "
)}. Allowed: unresolved, resolved, ignored.`,
});
return z.NEVER;
}
return Array.from(new Set(statuses.map((status) => API_STATUS_TO_DB[status])));
}),
"filter[search]": z.string().max(1000).optional(),
"filter[period]": z.string().optional(),
"filter[from]": CoercedDate,
"filter[to]": CoercedDate,
});
type ApiErrorListSearchParams = z.infer<typeof ApiErrorListSearchParams>;
export class ApiErrorListPresenter extends BasePresenter {
public async call(
project: Pick<Project, "id" | "organizationId">,
environment: Pick<RuntimeEnvironment, "id" | "organizationId">,
searchParams: ApiErrorListSearchParams
): Promise<{
data: ErrorGroupListItem[];
pagination: { next?: string; previous?: string };
}> {
return this.trace("call", async () => {
const options: ErrorsListOptions = {
projectId: project.id,
defaultPeriod: "1d",
};
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
if (searchParams["filter[taskIdentifier]"]) {
options.tasks = searchParams["filter[taskIdentifier]"];
}
if (searchParams["filter[version]"]) {
options.versions = searchParams["filter[version]"];
}
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"];
}
if (searchParams["filter[search]"]) {
options.search = searchParams["filter[search]"];
}
if (searchParams["filter[period]"]) {
options.period = searchParams["filter[period]"];
}
if (searchParams["filter[from]"]) {
options.from = searchParams["filter[from]"].getTime();
}
if (searchParams["filter[to]"]) {
options.to = searchParams["filter[to]"].getTime();
}
const organizationId = environment.organizationId;
const plan = await getCurrentPlan(organizationId);
options.retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
// The errors data lives in the "logs" ClickHouse client, matching the
// dashboard list loader.
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"logs"
);
const presenter = new ErrorsListPresenter(this._replica, clickhouse);
const result = await presenter.call(organizationId, environment.id, options);
return {
data: result.errorGroups.map((group) => ({
id: ErrorId.toFriendlyId(group.fingerprint),
fingerprint: group.fingerprint,
taskIdentifier: group.taskIdentifier,
errorType: group.errorType,
errorMessage: group.errorMessage,
status: DB_STATUS_TO_API[group.status],
count: group.count,
firstSeen: group.firstSeen,
lastSeen: group.lastSeen,
resolvedAt: group.resolvedAt,
ignoredUntil: group.ignoredUntil,
})),
pagination: result.pagination,
};
});
}
}