Files
Eric Allam 5052d895b3 feat(webapp,core): add a public HTTP API for errors (#4005)
## Summary

Adds an environment-scoped HTTP API over the Errors feature, mirroring
the runs API. Task-run failures are grouped by a fingerprint into "error
groups," and this exposes everything you can do with them in the
dashboard:

- `GET /api/v1/errors` lists error groups, with
`filter[taskIdentifier]`, `filter[version]`, `filter[status]`
(`unresolved`/`resolved`/`ignored`), `filter[search]`, a time range, and
cursor pagination.
- `GET /api/v1/errors/{errorId}` retrieves a single group (summary,
lifecycle state, affected versions).
- `POST /api/v1/errors/{errorId}/{resolve,ignore,unresolve}` changes its
state.
- `GET /api/v1/runs?filter[error]={errorId}` lists the runs behind a
group.

Request and response schemas are exported from `@trigger.dev/core/v3` so
the SDK can reuse them, and all endpoints are documented in the API
reference (OpenAPI). `errorId` is the `error_<fingerprint>` friendly id.

## Attribution

State changes record who made them. A plain environment API key has no
user, so `resolvedBy`/`ignoredByUserId` stay null. When the caller uses
an environment JWT obtained by exchanging a personal access token or a
delegated user token at `POST /api/v1/projects/:ref/:env/jwt`, that
exchange now stamps an `act` delegation claim, and the write endpoints
read `act.sub` to attribute the change to the acting user. This is the
first endpoint to consume the `act` claim, so two small pieces of
plumbing ride along: the exchange stamps `act` for personal-access-token
subjects too (it was delegated-token-only), and the public-JWT
bearer-auth path surfaces `act.sub` to the handler.

Built on the delegated-token work in #3997.
2026-06-21 09:29:13 +01:00

148 lines
4.4 KiB
TypeScript

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: {
// Nullable: a resolve via an env API key has no acting user, so
// `resolvedBy` stays null. The dashboard always passes a userId; the
// API passes the `act.sub` user from a PAT/UAT-exchanged JWT, else null.
userId?: string | null;
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 ?? 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: "RESOLVED",
resolvedAt: now,
resolvedInVersion: params.resolvedInVersion ?? null,
resolvedBy: params.userId ?? null,
},
});
}
async ignoreError(
identifier: ErrorGroupIdentifier,
params: {
userId?: string | null;
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 ?? null,
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",
},
});
}
}