5052d895b3
## 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.
148 lines
4.4 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|
|
}
|