diff --git a/CHANGELOG.md b/CHANGELOG.md index 31610622..9389d1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve --- +## [1.17.1] - 2026-07-19 + +### Fixed + +- **Concurrent error-group create race** — `findOrCreateErrorGroup` now handles Prisma P2002 on unique `(project_id, fingerprint)` by re-fetching the existing group and incrementing occurrences (`isNew: false`), so concurrent `POST /ingest/error` no longer 500s ([#599](https://github.com/Telemetry-Tracker/telemetry-tracker/pull/599)) + +--- + ## [1.17.0] - 2026-07-19 ### Added diff --git a/apps/api/src/generated/api-version.ts b/apps/api/src/generated/api-version.ts index 45773dfd..173a9c43 100644 --- a/apps/api/src/generated/api-version.ts +++ b/apps/api/src/generated/api-version.ts @@ -1,2 +1,2 @@ /** Generated at build time from CHANGELOG (/Users/unjica/Documents/GitHub/telemetry-tracker/CHANGELOG.md). Do not edit manually. */ -export const API_VERSION = "1.17.0"; +export const API_VERSION = "1.17.1"; diff --git a/apps/api/src/services/errors.test.ts b/apps/api/src/services/errors.test.ts index 24835365..698eb63a 100644 --- a/apps/api/src/services/errors.test.ts +++ b/apps/api/src/services/errors.test.ts @@ -60,4 +60,66 @@ describe("findOrCreateErrorGroup", () => { }) ); }); + + it("on create race (P2002), re-fetches existing group and returns isNew false", async () => { + prisma.errorGroup.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "eg-winner", + message: "boom", + app: "web", + environment: "production", + }); + prisma.errorGroup.create.mockRejectedValueOnce( + Object.assign(new Error("Unique constraint failed"), { code: "P2002" }) + ); + prisma.errorGroup.update.mockResolvedValueOnce({}); + + const result = await findOrCreateErrorGroup(prisma as never, { + projectId: "p1", + fingerprint: "fp", + message: "boom", + top_stack: "at foo", + app: "web", + environment: "production", + release: "1.2.0", + }); + + expect(result).toEqual({ + group: { + id: "eg-winner", + message: "boom", + app: "web", + environment: "production", + }, + isNew: false, + }); + expect(prisma.errorGroup.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "eg-winner" }, + data: expect.objectContaining({ + occurrences: { increment: 1 }, + release: "1.2.0", + }), + }) + ); + }); + + it("re-throws non-P2002 create errors", async () => { + prisma.errorGroup.findUnique.mockResolvedValueOnce(null); + prisma.errorGroup.create.mockRejectedValueOnce( + Object.assign(new Error("db down"), { code: "P1001" }) + ); + + await expect( + findOrCreateErrorGroup(prisma as never, { + projectId: "p1", + fingerprint: "fp", + message: "boom", + top_stack: "at foo", + app: "web", + }) + ).rejects.toMatchObject({ code: "P1001" }); + expect(prisma.errorGroup.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/services/errors.ts b/apps/api/src/services/errors.ts index 008fafd0..a91c90f7 100644 --- a/apps/api/src/services/errors.ts +++ b/apps/api/src/services/errors.ts @@ -5,6 +5,54 @@ export function computeFingerprint(message: string, stack?: string): string { return `${message}\n${firstLine}`; } +function isUniqueViolation(e: unknown): boolean { + return ( + typeof e === "object" && + e !== null && + "code" in e && + (e as { code: string }).code === "P2002" + ); +} + +type ErrorGroupRow = { + id: string; + message: string; + app: string; + environment: string | null; +}; + +async function touchExistingErrorGroup( + prisma: PrismaClient, + existing: ErrorGroupRow, + data: { + environment?: string | null; + release?: string | null; + platform?: string | null; + } +): Promise<{ group: ErrorGroupRow; isNew: false }> { + await prisma.errorGroup.update({ + where: { id: existing.id }, + data: { + occurrences: { increment: 1 }, + last_seen: new Date(), + ...(data.environment != null && data.environment !== "" + ? { environment: data.environment } + : {}), + ...(data.release != null && data.release !== "" ? { release: data.release } : {}), + ...(data.platform != null && data.platform !== "" ? { platform: data.platform } : {}), + }, + }); + return { + group: { + id: existing.id, + message: existing.message, + app: existing.app, + environment: existing.environment, + }, + isNew: false, + }; +} + export async function findOrCreateErrorGroup( prisma: PrismaClient, data: { @@ -17,7 +65,7 @@ export async function findOrCreateErrorGroup( release?: string | null; platform?: string | null; } -): Promise<{ group: { id: string; message: string; app: string; environment: string | null }; isNew: boolean }> { +): Promise<{ group: ErrorGroupRow; isNew: boolean }> { const existing = await prisma.errorGroup.findUnique({ where: { project_id_fingerprint: { @@ -27,48 +75,43 @@ export async function findOrCreateErrorGroup( }, }); if (existing) { - await prisma.errorGroup.update({ - where: { id: existing.id }, + return touchExistingErrorGroup(prisma, existing, data); + } + try { + const created = await prisma.errorGroup.create({ data: { - occurrences: { increment: 1 }, - last_seen: new Date(), - ...(data.environment != null && data.environment !== "" - ? { environment: data.environment } - : {}), - ...(data.release != null && data.release !== "" ? { release: data.release } : {}), - ...(data.platform != null && data.platform !== "" ? { platform: data.platform } : {}), + project_id: data.projectId, + fingerprint: data.fingerprint, + message: data.message, + top_stack: data.top_stack, + app: data.app, + environment: data.environment ?? null, + release: data.release ?? null, + platform: data.platform ?? null, + occurrences: 1, }, }); return { group: { - id: existing.id, - message: existing.message, - app: existing.app, - environment: existing.environment, + id: created.id, + message: created.message, + app: created.app, + environment: created.environment, }, - isNew: false, + isNew: true, }; + } catch (e) { + // Concurrent ingest: another request created the same (project_id, fingerprint). + if (!isUniqueViolation(e)) throw e; + const raced = await prisma.errorGroup.findUnique({ + where: { + project_id_fingerprint: { + project_id: data.projectId, + fingerprint: data.fingerprint, + }, + }, + }); + if (!raced) throw e; + return touchExistingErrorGroup(prisma, raced, data); } - const created = await prisma.errorGroup.create({ - data: { - project_id: data.projectId, - fingerprint: data.fingerprint, - message: data.message, - top_stack: data.top_stack, - app: data.app, - environment: data.environment ?? null, - release: data.release ?? null, - platform: data.platform ?? null, - occurrences: 1, - }, - }); - return { - group: { - id: created.id, - message: created.message, - app: created.app, - environment: created.environment, - }, - isNew: true, - }; }