Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/generated/api-version.ts
Original file line number Diff line number Diff line change
@@ -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";
62 changes: 62 additions & 0 deletions apps/api/src/services/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
117 changes: 80 additions & 37 deletions apps/api/src/services/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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,
};
}
Loading