520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
/**
|
||
* Sentry-as-product integration.
|
||
*
|
||
* Provisions a Sentry project per Vibn project under the shared
|
||
* `vibnai` Sentry org, then makes its DSN + auth token available
|
||
* to any Coolify app deployed for that project. Real-user errors
|
||
* from the user's deployed app land in Sentry; AI tools (Stage 3)
|
||
* read the issue feed back into chat (Stage 4).
|
||
*
|
||
* Design choices:
|
||
* - **Idempotent.** Looks up existing Sentry projects by slug
|
||
* before creating, so reruns are safe.
|
||
* - **Shared org token.** The same `SENTRY_AUTH_TOKEN` we use to
|
||
* upload source maps for vibn-frontend is reused across every
|
||
* user project — it has org-write scope. This means we DON'T
|
||
* need per-project auth tokens, just per-project DSNs.
|
||
* - **Slug convention.** `vibn-{workspace}-{projectSlug}`. Avoids
|
||
* collisions across workspaces and stays under Sentry's
|
||
* 50-char project slug limit. Clamped if longer.
|
||
* - **Soft failure.** If Sentry provisioning fails (rate limit,
|
||
* network, token revoked) we log and continue. The Vibn
|
||
* project still works without Sentry; we'll lazily retry on
|
||
* the next deploy.
|
||
*
|
||
* See SENTRY_AS_PRODUCT.md for the full proposal context.
|
||
*/
|
||
|
||
import { query } from '@/lib/db-postgres';
|
||
import { upsertApplicationEnv } from '@/lib/coolify';
|
||
|
||
const SENTRY_API_BASE = 'https://de.sentry.io/api/0';
|
||
const SENTRY_ORG_SLUG = 'vibnai';
|
||
const SENTRY_TEAM_SLUG = 'vibnai'; // Default team name matches org slug for personal/single-team setups.
|
||
|
||
export interface SentryProjectInfo {
|
||
/** Sentry project slug, e.g. `vibn-mark-account-checkout-app`. */
|
||
slug: string;
|
||
/** Public DSN for runtime error capture. NEXT_PUBLIC_SENTRY_DSN. */
|
||
dsn: string;
|
||
/** When this row was created/updated. */
|
||
provisionedAt: string;
|
||
}
|
||
|
||
export interface ProvisionInput {
|
||
projectId: string;
|
||
/** Vibn workspace slug, e.g. "mark-account". */
|
||
workspaceSlug: string;
|
||
/** Vibn project slug, e.g. "checkout-app". */
|
||
projectSlug: string;
|
||
/** Display name shown in the Sentry UI. */
|
||
projectName: string;
|
||
}
|
||
|
||
/**
|
||
* Returns the Sentry project for a given Vibn project, creating
|
||
* it if missing. Persists the result to fs_projects.data.sentry
|
||
* so subsequent calls hit the cache instead of Sentry's API.
|
||
*
|
||
* Returns null if Sentry provisioning fails — caller should log
|
||
* and continue.
|
||
*/
|
||
export async function ensureSentryProject(
|
||
input: ProvisionInput,
|
||
): Promise<SentryProjectInfo | null> {
|
||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||
if (!authToken) {
|
||
console.warn('[sentry] SENTRY_AUTH_TOKEN missing — skipping provisioning');
|
||
return null;
|
||
}
|
||
|
||
// 1. Fast path: already provisioned for this Vibn project.
|
||
const cached = await loadSentryFromProject(input.projectId);
|
||
if (cached) return cached;
|
||
|
||
// 2. Build the deterministic slug. Sentry caps at 50 chars and
|
||
// only allows lowercase a–z, 0–9, dashes.
|
||
const slug = buildSentrySlug(input.workspaceSlug, input.projectSlug);
|
||
|
||
try {
|
||
// 3. Try to look up existing Sentry project (handles the case
|
||
// where fs_projects was wiped but Sentry still has the project).
|
||
const existing = await fetchSentryProject(slug, authToken);
|
||
if (existing) {
|
||
const info = await materialize(input.projectId, existing, authToken);
|
||
if (info) return info;
|
||
}
|
||
|
||
// 4. Create fresh.
|
||
const created = await createSentryProject({
|
||
slug,
|
||
name: input.projectName.slice(0, 50),
|
||
authToken,
|
||
});
|
||
if (!created) return null;
|
||
|
||
return await materialize(input.projectId, created, authToken);
|
||
} catch (err) {
|
||
console.error('[sentry] ensureSentryProject failed', err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets the standard Sentry env vars on a Coolify application
|
||
* so that its next build inlines the DSN and uploads source maps.
|
||
* Idempotent — safe to call on every apps.create. Soft-fails on
|
||
* any per-key error so a single failed upsert doesn't block deploy.
|
||
*
|
||
* Env vars set:
|
||
* - NEXT_PUBLIC_SENTRY_DSN — public DSN for runtime capture
|
||
* - SENTRY_AUTH_TOKEN — shared org token for source map upload
|
||
*
|
||
* Both are marked Coolify-default (is_buildtime=true is_runtime=true);
|
||
* the public DSN must be present at build time so Next.js inlines
|
||
* it into the client bundle.
|
||
*/
|
||
export async function applySentryEnvToCoolifyApp(
|
||
coolifyAppUuid: string,
|
||
projectId: string,
|
||
): Promise<void> {
|
||
const sentry = await loadSentryFromProject(projectId);
|
||
if (!sentry) return; // Project not yet provisioned in Sentry — Stage 1 will retry.
|
||
|
||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||
if (!authToken) return;
|
||
|
||
const envs: Array<[string, string]> = [
|
||
['NEXT_PUBLIC_SENTRY_DSN', sentry.dsn],
|
||
['SENTRY_AUTH_TOKEN', authToken],
|
||
];
|
||
|
||
for (const [key, value] of envs) {
|
||
try {
|
||
await upsertApplicationEnv(coolifyAppUuid, { key, value });
|
||
} catch (err) {
|
||
console.warn(`[sentry] upsert ${key} on ${coolifyAppUuid} failed`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the Sentry project info for a Vibn project if already
|
||
* provisioned, else null. Read-only — does NOT call Sentry's API.
|
||
*/
|
||
export async function loadSentryFromProject(
|
||
projectId: string,
|
||
): Promise<SentryProjectInfo | null> {
|
||
const rows = await query<{ data: any }>(
|
||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||
[projectId],
|
||
);
|
||
const sentry = rows[0]?.data?.sentry;
|
||
if (sentry?.slug && sentry?.dsn && sentry?.provisionedAt) {
|
||
return sentry as SentryProjectInfo;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// Issue feed — Stage 3 MCP tools read these
|
||
// ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface SentryIssueSummary {
|
||
id: string;
|
||
title: string;
|
||
/** "error" | "fatal" | "warning" | "info" | "debug" */
|
||
level: string;
|
||
/** Total event count (lifetime). */
|
||
count: number;
|
||
/** ISO of most recent occurrence. */
|
||
lastSeen: string;
|
||
/** ISO of first occurrence. */
|
||
firstSeen: string;
|
||
/** "unresolved" | "resolved" | "ignored" — we filter to unresolved. */
|
||
status: string;
|
||
/** Best-effort culprit string from Sentry, e.g. "GET /api/checkout". */
|
||
culprit: string | null;
|
||
/** Direct URL to the issue in Sentry's UI. */
|
||
permalink: string | null;
|
||
}
|
||
|
||
export interface SentryEventDetail {
|
||
/** Sentry event ID (the canonical "fingerprint" of this occurrence). */
|
||
eventId: string;
|
||
/** Most recent event's render of the stack trace (top frames first). */
|
||
stackFrames: Array<{
|
||
function: string | null;
|
||
filename: string | null;
|
||
lineno: number | null;
|
||
colno: number | null;
|
||
/** Source code surrounding the line if Sentry has source maps. */
|
||
contextLine: string | null;
|
||
}>;
|
||
/** User-context tag (email/id/username/ip), if Sentry captured one. */
|
||
user: { email?: string; id?: string; username?: string; ipAddress?: string } | null;
|
||
/** Request method + URL if this was an HTTP-facing error. */
|
||
request: { method?: string; url?: string } | null;
|
||
/** Truncated breadcrumbs (last 20) — clicks, fetches, navigations. */
|
||
breadcrumbs: Array<{
|
||
type: string | null;
|
||
category: string | null;
|
||
message: string | null;
|
||
timestamp: string | null;
|
||
}>;
|
||
/** Direct URL to a Session Replay if the user had one recorded. */
|
||
replayUrl: string | null;
|
||
}
|
||
|
||
/**
|
||
* List recent unresolved issues for a Vibn project's Sentry project.
|
||
* Returns [] if Sentry isn't provisioned yet — caller should treat
|
||
* that as "no errors", which is functionally correct (no Sentry =
|
||
* no error capture path).
|
||
*/
|
||
export async function listRecentSentryIssues(
|
||
projectId: string,
|
||
options?: { limit?: number; sinceHours?: number },
|
||
): Promise<SentryIssueSummary[]> {
|
||
const sentry = await loadSentryFromProject(projectId);
|
||
if (!sentry) return [];
|
||
|
||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||
if (!authToken) return [];
|
||
|
||
const limit = clampLimit(options?.limit ?? 10);
|
||
const sinceHours = options?.sinceHours ?? 24;
|
||
const statsPeriod = `${sinceHours}h`;
|
||
|
||
const url = new URL(
|
||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${sentry.slug}/issues/`,
|
||
);
|
||
url.searchParams.set('limit', String(limit));
|
||
url.searchParams.set('query', 'is:unresolved');
|
||
url.searchParams.set('statsPeriod', statsPeriod);
|
||
url.searchParams.set('sort', 'date');
|
||
|
||
const res = await fetch(url, {
|
||
headers: { Authorization: `Bearer ${authToken}` },
|
||
});
|
||
if (!res.ok) {
|
||
console.warn(
|
||
`[sentry] listRecentSentryIssues ${res.status}: ${await res.text()}`,
|
||
);
|
||
return [];
|
||
}
|
||
const raw = (await res.json()) as Array<Record<string, any>>;
|
||
return raw.map((i) => ({
|
||
id: String(i.id),
|
||
title: String(i.title ?? '(untitled)'),
|
||
level: String(i.level ?? 'error'),
|
||
count: Number(i.count ?? 0),
|
||
lastSeen: String(i.lastSeen ?? ''),
|
||
firstSeen: String(i.firstSeen ?? ''),
|
||
status: String(i.status ?? 'unresolved'),
|
||
culprit: i.culprit ? String(i.culprit) : null,
|
||
permalink: i.permalink ? String(i.permalink) : null,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Fetch the latest event for an issue, with stack trace,
|
||
* breadcrumbs, user, request, and Session Replay link if any.
|
||
* Returns null if the issue isn't found or Sentry isn't ready.
|
||
*/
|
||
export async function getSentryIssueDetail(
|
||
projectId: string,
|
||
issueId: string,
|
||
): Promise<SentryEventDetail | null> {
|
||
const sentry = await loadSentryFromProject(projectId);
|
||
if (!sentry) return null;
|
||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||
if (!authToken) return null;
|
||
|
||
const res = await fetch(
|
||
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/events/latest/`,
|
||
{
|
||
headers: { Authorization: `Bearer ${authToken}` },
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
console.warn(
|
||
`[sentry] getSentryIssueDetail ${res.status}: ${await res.text()}`,
|
||
);
|
||
return null;
|
||
}
|
||
const e = (await res.json()) as Record<string, any>;
|
||
|
||
// Sentry stores frames in a few different places depending on
|
||
// platform. The exception entry has the most reliable shape.
|
||
const exceptionEntry = (e.entries || []).find(
|
||
(entry: any) => entry?.type === 'exception',
|
||
);
|
||
const firstException = exceptionEntry?.data?.values?.[0];
|
||
const frames =
|
||
(firstException?.stacktrace?.frames as Array<Record<string, any>> | undefined) ?? [];
|
||
// Sentry returns frames in oldest-first order; reverse so top of
|
||
// stack (the line that actually threw) is first — which is what
|
||
// every developer scans first.
|
||
const stackFrames = [...frames].reverse().slice(0, 12).map((f) => ({
|
||
function: f.function ?? null,
|
||
filename: f.filename ?? null,
|
||
lineno: typeof f.lineno === 'number' ? f.lineno : null,
|
||
colno: typeof f.colno === 'number' ? f.colno : null,
|
||
contextLine: f.contextLine ?? null,
|
||
}));
|
||
|
||
const breadcrumbsEntry = (e.entries || []).find(
|
||
(entry: any) => entry?.type === 'breadcrumbs',
|
||
);
|
||
const breadcrumbs =
|
||
((breadcrumbsEntry?.data?.values as Array<Record<string, any>> | undefined) ?? [])
|
||
.slice(-20)
|
||
.map((b) => ({
|
||
type: b.type ?? null,
|
||
category: b.category ?? null,
|
||
message: b.message ?? null,
|
||
timestamp: b.timestamp ?? null,
|
||
}));
|
||
|
||
const requestEntry = (e.entries || []).find(
|
||
(entry: any) => entry?.type === 'request',
|
||
);
|
||
const request = requestEntry?.data
|
||
? {
|
||
method: requestEntry.data.method ?? undefined,
|
||
url: requestEntry.data.url ?? undefined,
|
||
}
|
||
: null;
|
||
|
||
// Session Replay: Sentry attaches a `replayId` tag on events that
|
||
// have one. Build the canonical UI URL.
|
||
const replayId = (e.tags as Array<Record<string, any>> | undefined)?.find(
|
||
(t) => t.key === 'replayId',
|
||
)?.value;
|
||
const replayUrl = replayId
|
||
? `https://${SENTRY_ORG_SLUG}.sentry.io/replays/${replayId}/`
|
||
: null;
|
||
|
||
return {
|
||
eventId: String(e.eventID ?? e.id ?? ''),
|
||
stackFrames,
|
||
user: e.user ? sanitizeUser(e.user) : null,
|
||
request,
|
||
breadcrumbs,
|
||
replayUrl,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Mark an issue resolved. Used by the AI after it ships a fix and
|
||
* verifies the bug no longer fires (e.g. via tests, log watch, or
|
||
* explicit user confirmation).
|
||
*/
|
||
export async function resolveSentryIssue(
|
||
projectId: string,
|
||
issueId: string,
|
||
): Promise<boolean> {
|
||
const sentry = await loadSentryFromProject(projectId);
|
||
if (!sentry) return false;
|
||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||
if (!authToken) return false;
|
||
|
||
const res = await fetch(
|
||
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/`,
|
||
{
|
||
method: 'PUT',
|
||
headers: {
|
||
Authorization: `Bearer ${authToken}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ status: 'resolved' }),
|
||
},
|
||
);
|
||
return res.ok;
|
||
}
|
||
|
||
function clampLimit(n: number): number {
|
||
if (!Number.isFinite(n)) return 10;
|
||
return Math.max(1, Math.min(50, Math.floor(n)));
|
||
}
|
||
|
||
function sanitizeUser(u: Record<string, any>): SentryEventDetail['user'] {
|
||
return {
|
||
email: u.email ?? undefined,
|
||
id: u.id ?? undefined,
|
||
username: u.username ?? undefined,
|
||
ipAddress: u.ip_address ?? u.ipAddress ?? undefined,
|
||
};
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// Internal helpers
|
||
// ──────────────────────────────────────────────────────────────────
|
||
|
||
function buildSentrySlug(workspaceSlug: string, projectSlug: string): string {
|
||
const raw = `vibn-${workspaceSlug}-${projectSlug}`
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-]+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
// Sentry: 50 char max. Truncate from the end (preserves the
|
||
// `vibn-{workspace}` prefix which keeps slugs distinguishable
|
||
// across workspaces).
|
||
return raw.slice(0, 50);
|
||
}
|
||
|
||
interface SentryApiProject {
|
||
slug: string;
|
||
name: string;
|
||
id: string;
|
||
}
|
||
|
||
async function fetchSentryProject(
|
||
slug: string,
|
||
authToken: string,
|
||
): Promise<SentryApiProject | null> {
|
||
const res = await fetch(
|
||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/`,
|
||
{
|
||
headers: { Authorization: `Bearer ${authToken}` },
|
||
},
|
||
);
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
`Sentry GET project failed: ${res.status} ${await res.text()}`,
|
||
);
|
||
}
|
||
return (await res.json()) as SentryApiProject;
|
||
}
|
||
|
||
async function createSentryProject(input: {
|
||
slug: string;
|
||
name: string;
|
||
authToken: string;
|
||
}): Promise<SentryApiProject | null> {
|
||
const res = await fetch(
|
||
`${SENTRY_API_BASE}/teams/${SENTRY_ORG_SLUG}/${SENTRY_TEAM_SLUG}/projects/`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${input.authToken}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
name: input.name,
|
||
slug: input.slug,
|
||
platform: 'javascript-nextjs', // Most common case; doesn't gate which SDK actually sends events.
|
||
}),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
// 409 = slug already exists in another team; we tried the GET
|
||
// path first, so this is genuinely a race or a slug conflict.
|
||
if (res.status === 409) {
|
||
console.warn(`[sentry] slug ${input.slug} taken — retry GET`);
|
||
return await fetchSentryProject(input.slug, input.authToken);
|
||
}
|
||
throw new Error(`Sentry POST project failed: ${res.status} ${text}`);
|
||
}
|
||
return (await res.json()) as SentryApiProject;
|
||
}
|
||
|
||
interface SentryClientKey {
|
||
dsn: { public: string };
|
||
isActive: boolean;
|
||
}
|
||
|
||
async function fetchProjectDsn(
|
||
slug: string,
|
||
authToken: string,
|
||
): Promise<string | null> {
|
||
const res = await fetch(
|
||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/keys/`,
|
||
{
|
||
headers: { Authorization: `Bearer ${authToken}` },
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
`Sentry GET keys failed: ${res.status} ${await res.text()}`,
|
||
);
|
||
}
|
||
const keys = (await res.json()) as SentryClientKey[];
|
||
// Sentry auto-creates a "Default" key on project creation; pick
|
||
// the first active one. Multiple keys exist in pro setups but
|
||
// we always want the live one.
|
||
const active = keys.find((k) => k.isActive) ?? keys[0];
|
||
return active?.dsn?.public ?? null;
|
||
}
|
||
|
||
async function materialize(
|
||
projectId: string,
|
||
apiProj: SentryApiProject,
|
||
authToken: string,
|
||
): Promise<SentryProjectInfo | null> {
|
||
const dsn = await fetchProjectDsn(apiProj.slug, authToken);
|
||
if (!dsn) {
|
||
console.error(`[sentry] no DSN returned for ${apiProj.slug}`);
|
||
return null;
|
||
}
|
||
|
||
const info: SentryProjectInfo = {
|
||
slug: apiProj.slug,
|
||
dsn,
|
||
provisionedAt: new Date().toISOString(),
|
||
};
|
||
|
||
await query(
|
||
`UPDATE fs_projects
|
||
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{sentry}', $2::jsonb, true),
|
||
updated_at = NOW()
|
||
WHERE id = $1`,
|
||
[projectId, JSON.stringify(info)],
|
||
);
|
||
|
||
return info;
|
||
}
|