Files
vibn-frontend/lib/integrations/sentry.ts
Mark Henderson 9ddbe5b7d8 feat(sentry-as-product): auto-provision per-project + AI feedback loop
Implements all 4 stages from SENTRY_AS_PRODUCT.md:

Stage 1 — Auto-provision per-project Sentry:
- New module lib/integrations/sentry.ts with idempotent
  ensureSentryProject(): creates Sentry project under shared
  vibnai org, fetches DSN, persists to fs_projects.data.sentry.
- Wired into POST /api/projects/create (provision early so DSN is
  ready before first deploy) and into applyEnvsAndDeploy in MCP
  (lazy retry + env var injection on every apps.create).
- applySentryEnvToCoolifyApp upserts NEXT_PUBLIC_SENTRY_DSN +
  SENTRY_AUTH_TOKEN onto the Coolify app, so the very first build
  inlines the DSN into the client bundle and uploads source maps.

Stage 2 — Bake into scaffolds:
- New module lib/scaffold/sentry-snippets.ts exposes canonical
  Next.js + Vite+React snippets the AI copies verbatim (keeps
  outputs deterministic across chats).
- AI system prompt updated: explicit instructions to wire Sentry
  on every new app, env vars are guaranteed available, project
  Sentry slug comes from projects_get.
- projects.get MCP response now includes `sentry: {slug, dsn,
  provisionedAt}` so the AI can substitute the slug into
  withSentryConfig({ project: <slug> }).

Stage 3 — Expose error feed to the AI:
- Three new MCP tools registered:
    project_recent_errors  — list unresolved issues
    project_error_detail   — stack trace + breadcrumbs + replay url
    project_error_resolve  — mark resolved after a verified fix
- Tenant-safe: each tool re-checks projectId belongs to caller's
  workspace before talking to Sentry.

Stage 4 — Auto-surface at chat-turn start:
- chat/route.ts pulls listRecentSentryIssues for the active
  project (last 6h, count ≥ 2 to skip noise) and appends a
  [PROJECT HEALTH] block to the system prompt. AI decides
  whether to surface a one-liner; if user's message is about a
  broken thing, AI prefers Sentry stack trace over guessing.

End state: a Vibn user's deployed app crashes for a real user →
Sentry captures with source-mapped stack trace + Session Replay →
next AI chat turn the AI knows about it and can offer a fix
without the user pasting the error.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-01 12:52:17 -07:00

520 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 az, 09, 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;
}