/**
* Error capture schema — OWNED BY THE ERROR CAPTURE FEATURE.
*
* Everything exported here is re-exported from ./schema.ts via `export *`, so
* these tables join the app's Drizzle schema namespace and are reachable as
* `schema.
` throughout the server. Keep all schema changes additive
* (never drop/rename/retype existing columns) per the storing-data rules.
*
* Two tables, Sentry-style:
* - `error_issues` — the grouped issue (one row per fingerprint per owner
* scope). Ownable + shareable so an org-scoped analytics key surfaces its
* issues to the whole org via `accessFilter`, mirroring session_recordings.
* - `error_events` — individual occurrences. High-volume, owner-scoped like
* `analytics_events` (plain owner columns, not `ownableColumns()`), always
* read behind an issue the caller already has access to, and pruned to a
* bounded retention per issue so occurrences can't grow unbounded.
*/
import {
createSharesTable,
index,
integer,
now,
ownableColumns,
table,
text,
uniqueIndex,
} from "@agent-native/core/db/schema";
/**
* Grouped error issues. A stable `fingerprint` (error type + top meaningful
* stack frame, or message when there's no usable stack) collapses many
* occurrences into one triageable issue.
*/
export const errorIssues = table(
"error_issues",
{
id: text("id").primaryKey(),
/** Stable grouping key; unique per owner scope. */
fingerprint: text("fingerprint").notNull(),
/** Error class/name, e.g. "TypeError" or "Error" (or "Message"). */
type: text("type").notNull().default("Error"),
/** Human-readable issue title (type + first line of the message). */
title: text("title").notNull(),
/** Best-effort culprit — the top in-app frame ("fn (file:line)"). */
culprit: text("culprit"),
level: text("level", {
enum: ["fatal", "error", "warning", "info", "debug"],
})
.notNull()
.default("error"),
status: text("status", {
enum: ["unresolved", "resolved", "ignored"],
})
.notNull()
.default("unresolved"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
/** Lifetime occurrence count (monotonic; not affected by retention). */
eventCount: integer("event_count").notNull().default(0),
/** Approximate distinct users, recomputed over retained occurrences. */
usersAffected: integer("users_affected").notNull().default(0),
/** id of a representative occurrence for the detail view. */
sampleEventId: text("sample_event_id"),
/** Most recent linked session_recordings.id (sr_...), if any. */
lastSessionRecordingId: text("last_session_recording_id"),
/** Optional triage owner (email). */
assignee: text("assignee"),
/** Denormalized product dimensions for filtering/display. */
app: text("app"),
template: text("template"),
createdAt: text("created_at").notNull().default(now()),
updatedAt: text("updated_at").notNull().default(now()),
...ownableColumns(),
},
(issue) => ({
scopeFingerprintUnique: uniqueIndex(
"error_issues_scope_fingerprint_idx",
).on(issue.ownerEmail, issue.orgId, issue.fingerprint),
scopeLastSeenIdx: index("error_issues_scope_last_seen_idx").on(
issue.ownerEmail,
issue.orgId,
issue.lastSeenAt,
),
scopeStatusIdx: index("error_issues_scope_status_idx").on(
issue.orgId,
issue.ownerEmail,
issue.status,
issue.lastSeenAt,
),
}),
);
export const errorIssueShares = createSharesTable("error_issue_shares");
/**
* Individual error occurrences (events). Owner-scoped like analytics_events.
* Always read behind an issue whose access the caller already resolved through
* `accessFilter`, and additionally filtered by owner scope for defense in
* depth. Pruned to a bounded retention per issue at ingest time.
*/
export const errorEvents = table(
"error_events",
{
id: text("id").primaryKey(),
issueId: text("issue_id").notNull(),
fingerprint: text("fingerprint").notNull(),
type: text("type").notNull().default("Error"),
message: text("message").notNull().default(""),
culprit: text("culprit"),
level: text("level", {
enum: ["fatal", "error", "warning", "info", "debug"],
})
.notNull()
.default("error"),
/** Normalized stack frames as JSON (ParsedStackFrame[]). */
stack: text("stack").notNull().default("[]"),
/** Bounded raw stack string kept for display fidelity. */
rawStack: text("raw_stack"),
handled: integer("handled", { mode: "boolean" }).notNull().default(true),
url: text("url"),
userId: text("user_id"),
anonymousId: text("anonymous_id"),
userKey: text("user_key"),
sessionId: text("session_id"),
/** Client replay id (localStorage) reported by the SDK. */
clientRecordingId: text("client_recording_id"),
/** Resolved session_recordings.id (sr_...) when a replay exists. */
sessionRecordingId: text("session_recording_id"),
release: text("release"),
environment: text("environment"),
tags: text("tags").notNull().default("{}"),
extra: text("extra").notNull().default("{}"),
breadcrumbs: text("breadcrumbs").notNull().default("[]"),
occurredAt: text("occurred_at").notNull(),
createdAt: text("created_at").notNull().default(now()),
ownerEmail: text("owner_email").notNull().default("local@localhost"),
orgId: text("org_id"),
},
(event) => ({
issueOccurredIdx: index("error_events_issue_occurred_idx").on(
event.issueId,
event.occurredAt,
),
scopeOccurredIdx: index("error_events_scope_occurred_idx").on(
event.ownerEmail,
event.orgId,
event.occurredAt,
),
}),
);