Download superlog/apps/api/src/index.ts from smlflg/Experimente: direct link, hf CLI and curl.
- Browser
- Download file 106 kB
-
https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/index.ts
- Command line
-
hf download hf://smlflg/Experimente/superlog/apps/api/src/index.ts
-
curl -L -o index.ts https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/index.ts
106 kB
| import "./env.js"; | |
| import "./net.js"; | |
| import { createClient } from "@clickhouse/client"; | |
| import { serve } from "@hono/node-server"; | |
| import { SpanStatusCode, trace } from "@opentelemetry/api"; | |
| import { | |
| DEFAULT_AGENT_RUN_PROVIDER, | |
| type Issue, | |
| confirmResolutionProposal, | |
| createIncidentLifecycle, | |
| db, | |
| dismissResolutionProposal, | |
| isAgentRunProvider, | |
| listAccessibleGithubInstallsForProject, | |
| mintApiKey, | |
| resolveIncident, | |
| runMigrations, | |
| schema, | |
| sendLoopsWelcomeFlow, | |
| upsertLoopsContact, | |
| } from "@superlog/db"; | |
| import { fingerprint, fingerprintLog } from "@superlog/fingerprint"; | |
| import { Autumn, AutumnError } from "autumn-js"; | |
| import { and, asc, count, desc, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; | |
| import { Hono } from "hono"; | |
| import type { Context } from "hono"; | |
| import { cors } from "hono/cors"; | |
| import { HTTPException } from "hono/http-exception"; | |
| import { nanoid } from "nanoid"; | |
| import { mountAlerts } from "./alerts.js"; | |
| import { auth } from "./auth.js"; | |
| import { shouldRunMigrationsOnBoot } from "./boot-migrations.js"; | |
| import { mountDashboards } from "./dashboards.js"; | |
| import { mountFeedbackAuthed, mountFeedbackPublic } from "./feedback.js"; | |
| import { type GatewayVars, mountGateway } from "./gateway.js"; | |
| import { prBaseBranchExists } from "./github-branches.js"; | |
| import { | |
| closeAgentPullRequestOnGithub, | |
| listProjectRepoBranches, | |
| mergeGithubPullRequest, | |
| mountGithubAuthed, | |
| mountGithubAuthorOAuth, | |
| mountGithubPublic, | |
| } from "./github.js"; | |
| import { createApiHttpObservabilityMiddleware } from "./http-observability.js"; | |
| import { mountImpersonation } from "./impersonation.js"; | |
| import { buildIncidentListItem, shouldInlineIncidentListStats } from "./incidents/list.js"; | |
| import { getPrDeliveryRetryEligibility } from "./incidents/pr-retry.js"; | |
| import { buildIncidentPullRequestViews } from "./incidents/pr-view.js"; | |
| import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js"; | |
| import { | |
| buildIncidentStatsFromActivityRows, | |
| buildIncidentStatsFromIssues, | |
| buildIncidentStatsPairs, | |
| buildIncidentStatsWithFallback, | |
| spanSampleKey, | |
| } from "./incidents/stats.js"; | |
| import { mountLinearAuthed, mountLinearPublic } from "./linear.js"; | |
| import { logger } from "./logger.js"; | |
| import { mountManagementApi, mountOrgKeyManagementAuthed } from "./management.js"; | |
| import { | |
| METRIC_AGGREGATIONS, | |
| type MetricAggregation, | |
| type ResourceAttrFilter, | |
| type SeriesSource, | |
| countSeries, | |
| getTraceDetail, | |
| listAttributeKeys, | |
| listAttributeValues, | |
| listIssueFilterAttributeKeys, | |
| listIssueFilterAttributeValues, | |
| listMetricNames, | |
| listServices, | |
| metricSeries, | |
| pickStep, | |
| previewIssueFilterMatches, | |
| queryLogs, | |
| queryMetrics, | |
| queryTraces, | |
| queryTracesAggregated, | |
| } from "./mcp/clickhouse.js"; | |
| import { mountMcpAuthed, mountMcpPublic } from "./mcp/index.js"; | |
| import { resolveActiveOrgContext, resolveMaybeActiveOrgContext } from "./org-context.js"; | |
| import { mountSettingsAuthed } from "./settings.js"; | |
| import { normalizeSignupIntentKeyHash, normalizeSignupIntentKeyPrefix } from "./signup-intents.js"; | |
| import { mountSlackAuthed, mountSlackPublic } from "./slack.js"; | |
| import { sourceMapObjectStoreFromEnv } from "./sourcemaps.js"; | |
| import { userIsStaff } from "./staff.js"; | |
| import { symbolicateIssueSample, symbolicateTelemetrySample } from "./symbolication.js"; | |
| import { buildSystemCapabilities } from "./system-capabilities.js"; | |
| import { mountWebhooks } from "./webhooks.js"; | |
| const PORT = Number(process.env.PORT ?? 4100); | |
| const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173"; | |
| const WEB_CORS_ORIGINS = Array.from( | |
| new Set([WEB_ORIGIN, "http://localhost:5173", "http://127.0.0.1:5173"]), | |
| ); | |
| const ch = createClient({ | |
| url: process.env.CLICKHOUSE_URL ?? "http://localhost:8123", | |
| database: process.env.CLICKHOUSE_DB ?? "superlog", | |
| username: process.env.CLICKHOUSE_USER ?? "default", | |
| password: process.env.CLICKHOUSE_PASSWORD ?? "", | |
| // idle_socket_ttl must stay below CH server's keep_alive_timeout (3s default) | |
| // so we recycle before the server closes; request_timeout short-circuits | |
| // stale sockets that slipped through instead of hanging 30s. | |
| request_timeout: 10_000, | |
| keep_alive: { enabled: true, idle_socket_ttl: 2_500 }, | |
| }); | |
| const tracer = trace.getTracer("@superlog/api"); | |
| const SIGNUP_INTENT_TTL_MS = 30 * 60 * 1000; | |
| type Vars = { | |
| userId: string; | |
| orgId: string | null; | |
| impersonating?: boolean; | |
| } & Partial<GatewayVars>; | |
| const app = new Hono<{ Variables: Vars }>(); | |
| const incidentLifecycle = createIncidentLifecycle(db); | |
| const sourceMapObjectStore = sourceMapObjectStoreFromEnv(process.env); | |
| app.use( | |
| "/api/*", | |
| cors({ | |
| origin: WEB_CORS_ORIGINS, | |
| credentials: true, | |
| allowHeaders: [ | |
| "authorization", | |
| "content-type", | |
| "traceparent", | |
| "tracestate", | |
| "x-superlog-signup-source", | |
| ], | |
| allowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], | |
| }), | |
| ); | |
| app.use("/api/*", createApiHttpObservabilityMiddleware()); | |
| app.use( | |
| "/mcp", | |
| cors({ | |
| origin: "*", | |
| allowHeaders: ["authorization", "content-type", "mcp-session-id", "mcp-protocol-version"], | |
| allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], | |
| exposeHeaders: ["mcp-session-id", "www-authenticate"], | |
| }), | |
| ); | |
| app.use( | |
| "/oauth/*", | |
| cors({ | |
| origin: "*", | |
| allowHeaders: ["content-type"], | |
| allowMethods: ["GET", "POST", "OPTIONS"], | |
| }), | |
| ); | |
| app.use( | |
| "/.well-known/*", | |
| cors({ | |
| origin: "*", | |
| allowMethods: ["GET", "OPTIONS"], | |
| }), | |
| ); | |
| app.use( | |
| "/activate/*", | |
| cors({ | |
| origin: WEB_CORS_ORIGINS, | |
| credentials: true, | |
| allowHeaders: ["authorization", "content-type", "traceparent", "tracestate"], | |
| allowMethods: ["POST", "OPTIONS"], | |
| }), | |
| ); | |
| // /feedback/pr/* is the anonymous PR-link surface — the web app posts to | |
| // it cross-origin from /feedback/pr/:owner/:repo/:num. Credentials are | |
| // included so a signed-in user is still attributed to their account. | |
| app.use( | |
| "/feedback/*", | |
| cors({ | |
| origin: WEB_CORS_ORIGINS, | |
| credentials: true, | |
| allowHeaders: ["content-type", "traceparent", "tracestate"], | |
| allowMethods: ["POST", "OPTIONS"], | |
| }), | |
| ); | |
| app.get("/health", (c) => c.json({ ok: true })); | |
| // Tells the web client which social providers have credentials configured | |
| // in this environment so it can hide the buttons that would 503 on click. | |
| app.get("/api/auth-providers", (c) => | |
| c.json({ | |
| google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET), | |
| github: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET), | |
| }), | |
| ); | |
| app.get("/api/system/capabilities", (c) => c.json(buildSystemCapabilities())); | |
| app.post("/api/signup-intents", async (c) => { | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| keyHash?: unknown; | |
| keyPrefix?: unknown; | |
| returnTo?: unknown; | |
| }; | |
| const keyHash = normalizeSignupIntentKeyHash(body.keyHash); | |
| const keyPrefix = normalizeSignupIntentKeyPrefix(body.keyPrefix); | |
| if (!keyHash || !keyPrefix) { | |
| throw new HTTPException(400, { message: "valid keyHash and keyPrefix required" }); | |
| } | |
| const returnTo = | |
| typeof body.returnTo === "string" && body.returnTo.length <= 1024 ? body.returnTo : null; | |
| const intentId = `sui_${nanoid(24)}`; | |
| const expiresAt = new Date(Date.now() + SIGNUP_INTENT_TTL_MS); | |
| await db.insert(schema.signupIntents).values({ | |
| id: intentId, | |
| keyHash, | |
| keyPrefix, | |
| returnTo, | |
| expiresAt, | |
| }); | |
| const signupUrl = new URL(`${WEB_ORIGIN}/signup`); | |
| signupUrl.searchParams.set("from", "skill"); | |
| signupUrl.searchParams.set("intent", intentId); | |
| return c.json({ | |
| id: intentId, | |
| signupUrl: signupUrl.toString(), | |
| expiresAt: expiresAt.toISOString(), | |
| }); | |
| }); | |
| // Better Auth handles its own routes under /api/auth/*. Mount before the | |
| // session middleware so sign-in/sign-up/oauth-callback endpoints don't trip | |
| // the unauthenticated guard. | |
| app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); | |
| mountGateway(app, ch); | |
| mountGithubPublic(app); | |
| mountGithubAuthorOAuth(app); | |
| mountLinearPublic(app); | |
| mountMcpPublic(app, ch); | |
| mountSlackPublic(app); | |
| mountFeedbackPublic(app); | |
| // Management API (org-key auth, /api/v1/*) registers its own middleware | |
| // before the session middleware below — the session middleware skips | |
| // /api/v1/* and /api/auth/*. | |
| mountManagementApi(app, { ch }); | |
| app.use("/api/*", async (c, next) => { | |
| if (c.req.path.startsWith("/api/v1/")) return next(); | |
| if (c.req.path.startsWith("/api/auth/")) return next(); | |
| if (c.req.path === "/api/auth-providers") return next(); | |
| const session = await auth.api.getSession({ headers: c.req.raw.headers }); | |
| if (!session) return c.json({ error: "unauthenticated" }, 401); | |
| c.set("userId", session.user.id); | |
| c.set("orgId", session.session.activeOrganizationId ?? null); | |
| // Better Auth's admin plugin sets impersonatedBy on the session row when an | |
| // admin is acting as another user; surface it on `c.var` so /api/me can | |
| // expose a boolean without the web client having to inspect raw session. | |
| c.set("impersonating", typeof session.session.impersonatedBy === "string"); | |
| await next(); | |
| }); | |
| mountMcpAuthed(app); | |
| mountGithubAuthed(app); | |
| mountLinearAuthed(app); | |
| mountSlackAuthed(app); | |
| mountSettingsAuthed(app); | |
| mountOrgKeyManagementAuthed(app); | |
| mountDashboards(app); | |
| mountAlerts(app, { ch }); | |
| mountWebhooks(app); | |
| mountFeedbackAuthed(app); | |
| mountImpersonation(app); | |
| const ALLOWED_SIGNUP_SOURCES = new Set(["skill", "web", "mcp", "github", "cli"]); | |
| app.get("/api/me", async (c) => { | |
| const ctx = await resolveMaybeActiveOrgContext({ | |
| userId: c.var.userId, | |
| preferredOrgId: c.var.orgId, | |
| }); | |
| const { user } = ctx; | |
| // Whether billing hard-blocks (ingest 402 / investigation cap) are enforced. | |
| // Metering runs regardless; this only gates blocking, so the web can avoid | |
| // showing an "Ingest paused" bar when nothing is actually being blocked. | |
| const billingEnforcement = process.env.BILLING_ENFORCEMENT_ENABLED === "true"; | |
| // Pre-org users (just signed up, haven't created their first org yet) get a | |
| // null org/project so the web client can route them to the create-org step | |
| // in the onboarding wizard. | |
| if (!ctx.org) { | |
| return c.json({ | |
| user: { | |
| id: user.id, | |
| email: user.email, | |
| name: user.name, | |
| isStaff: userIsStaff(user.role), | |
| impersonating: c.var.impersonating === true, | |
| }, | |
| org: null, | |
| project: null, | |
| billingEnforcement, | |
| }); | |
| } | |
| const { org, project } = ctx; | |
| if (!org.signupSource) { | |
| const headerSource = c.req.header("x-superlog-signup-source")?.trim().toLowerCase(); | |
| if (headerSource && ALLOWED_SIGNUP_SOURCES.has(headerSource)) { | |
| const updated = await db | |
| .update(schema.orgs) | |
| .set({ signupSource: headerSource }) | |
| .where(and(eq(schema.orgs.id, org.id), isNull(schema.orgs.signupSource))) | |
| .returning({ signupSource: schema.orgs.signupSource }); | |
| if (updated[0]?.signupSource) org.signupSource = updated[0].signupSource; | |
| } | |
| } | |
| void upsertLoopsContact({ | |
| user, | |
| org, | |
| project, | |
| signupSource: org.signupSource, | |
| appUrl: WEB_ORIGIN, | |
| }).catch((err) => { | |
| logger.warn({ err, userId: user.id, orgId: org.id }, "loops contact sync failed"); | |
| }); | |
| const accessibleInstalls = await listAccessibleGithubInstallsForProject(project.id); | |
| const githubSetupNeeded = accessibleInstalls.length === 0 && !org.githubSetupSkippedAt; | |
| // `hasIngested` decides whether OnboardingGate shows the install wizard. Reading | |
| // it from api_keys.last_used_at (set by proxy/src/index.ts on every successful | |
| // auth) avoids 6 ClickHouse count() queries per page load — the previous gate | |
| // derived this from /stats and was firing on every navigation forever. | |
| const ingestedKey = await db.query.apiKeys.findFirst({ | |
| columns: { id: true }, | |
| where: and(eq(schema.apiKeys.projectId, project.id), isNotNull(schema.apiKeys.lastUsedAt)), | |
| }); | |
| const hasIngested = ingestedKey !== undefined; | |
| return c.json({ | |
| user: { | |
| id: user.id, | |
| email: user.email, | |
| name: user.name, | |
| isStaff: userIsStaff(user.role), | |
| impersonating: c.var.impersonating === true, | |
| }, | |
| org: { id: org.id, name: org.name, slug: org.slug, githubSetupNeeded }, | |
| project: { id: project.id, name: project.name, slug: project.slug, hasIngested }, | |
| billingEnforcement, | |
| }); | |
| }); | |
| app.put("/api/me/active-project", async (c) => { | |
| const { user, org } = await resolveActiveOrgContext({ | |
| userId: c.var.userId, | |
| preferredOrgId: c.var.orgId, | |
| }); | |
| const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown }; | |
| const projectId = typeof body.projectId === "string" ? body.projectId : null; | |
| if (!projectId) throw new HTTPException(400, { message: "projectId required" }); | |
| const project = await db.query.projects.findFirst({ | |
| where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)), | |
| }); | |
| if (!project) throw new HTTPException(404, { message: "project not found in active org" }); | |
| await db | |
| .update(schema.users) | |
| .set({ activeProjectId: project.id }) | |
| .where(eq(schema.users.id, user.id)); | |
| return c.json({ project: { id: project.id, name: project.name, slug: project.slug } }); | |
| }); | |
| const ORG_NAME_MAX = 80; | |
| function slugifyOrgName(value: string): string { | |
| return value | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-+|-+$/g, "") | |
| .slice(0, 40); | |
| } | |
| async function uniqueOrgSlug(client: Pick<typeof db, "query">, base: string): Promise<string> { | |
| const seed = base || "org"; | |
| let candidate = seed; | |
| for (let attempt = 0; attempt < 20; attempt += 1) { | |
| const existing = await client.query.orgs.findFirst({ where: eq(schema.orgs.slug, candidate) }); | |
| if (!existing) return candidate; | |
| candidate = `${seed.slice(0, 32)}-${nanoid(6).toLowerCase()}`; | |
| } | |
| return `${seed.slice(0, 20)}-${nanoid(12).toLowerCase()}`; | |
| } | |
| // First-org creation. Called by the onboarding wizard's create-org step for | |
| // users that signed up but don't have a membership yet. Idempotent on retry: | |
| // if the user already has an org, returns it instead of creating a duplicate. | |
| app.post("/api/me/orgs", async (c) => { | |
| const body = (await c.req.json().catch(() => ({}))) as { name?: unknown }; | |
| const rawName = typeof body.name === "string" ? body.name.trim() : ""; | |
| if (!rawName) throw new HTTPException(400, { message: "name required" }); | |
| if (rawName.length > ORG_NAME_MAX) { | |
| throw new HTTPException(400, { message: `name must be ${ORG_NAME_MAX} chars or fewer` }); | |
| } | |
| const userId = c.var.userId; | |
| type WelcomePayload = { | |
| user: { id: string; email: string }; | |
| org: typeof schema.orgs.$inferSelect; | |
| project: typeof schema.projects.$inferSelect; | |
| }; | |
| const result = await db.transaction( | |
| async ( | |
| tx, | |
| ): Promise<{ | |
| payload: { | |
| org: { id: string; name: string; slug: string }; | |
| project: { id: string; name: string; slug: string }; | |
| }; | |
| welcome: WelcomePayload | null; | |
| }> => { | |
| // Serializes first-org creation for a user. Without this lock, two | |
| // concurrent create-org requests can both see no membership and create two | |
| // organizations before either membership row exists. | |
| const [user] = await tx | |
| .select({ id: schema.users.id, email: schema.users.email }) | |
| .from(schema.users) | |
| .where(eq(schema.users.id, userId)) | |
| .for("update"); | |
| if (!user) throw new HTTPException(404, { message: "user not found" }); | |
| const existingMembership = await tx.query.orgMembers.findFirst({ | |
| where: eq(schema.orgMembers.userId, userId), | |
| }); | |
| if (existingMembership) { | |
| const org = await tx.query.orgs.findFirst({ | |
| where: eq(schema.orgs.id, existingMembership.orgId), | |
| }); | |
| if (!org) | |
| throw new HTTPException(500, { message: "org membership references missing org" }); | |
| let project = await tx.query.projects.findFirst({ | |
| where: eq(schema.projects.orgId, org.id), | |
| }); | |
| if (!project) { | |
| const [createdProject] = await tx | |
| .insert(schema.projects) | |
| .values({ orgId: org.id, name: "Default", slug: "default" }) | |
| .returning(); | |
| if (!createdProject) { | |
| throw new HTTPException(500, { message: "failed to create default project" }); | |
| } | |
| project = createdProject; | |
| await tx | |
| .insert(schema.projectAutomationSettings) | |
| .values({ projectId: project.id }) | |
| .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId }); | |
| } | |
| return { | |
| payload: { | |
| org: { id: org.id, name: org.name, slug: org.slug }, | |
| project: { id: project.id, name: project.name, slug: project.slug }, | |
| }, | |
| welcome: null, | |
| }; | |
| } | |
| const slug = await uniqueOrgSlug(tx, slugifyOrgName(rawName)); | |
| const [org] = await tx.insert(schema.orgs).values({ name: rawName, slug }).returning(); | |
| if (!org) throw new HTTPException(500, { message: "failed to create org" }); | |
| await tx | |
| .insert(schema.orgMembers) | |
| .values({ orgId: org.id, userId, role: "owner" }) | |
| .onConflictDoNothing({ target: [schema.orgMembers.orgId, schema.orgMembers.userId] }); | |
| const [project] = await tx | |
| .insert(schema.projects) | |
| .values({ orgId: org.id, name: "Default", slug: "default" }) | |
| .returning(); | |
| if (!project) throw new HTTPException(500, { message: "failed to create default project" }); | |
| await tx | |
| .insert(schema.projectAutomationSettings) | |
| .values({ projectId: project.id }) | |
| .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId }); | |
| // Promote the new org to active on every session this user has open, so the | |
| // next /api/me call returns it without requiring a sign-out/sign-in round trip. | |
| await tx | |
| .update(schema.sessions) | |
| .set({ activeOrganizationId: org.id }) | |
| .where( | |
| and(eq(schema.sessions.userId, userId), isNull(schema.sessions.activeOrganizationId)), | |
| ); | |
| return { | |
| payload: { | |
| org: { id: org.id, name: org.name, slug: org.slug }, | |
| project: { id: project.id, name: project.name, slug: project.slug }, | |
| }, | |
| welcome: { user, org, project }, | |
| }; | |
| }, | |
| ); | |
| if (result.welcome) { | |
| void sendLoopsWelcomeFlow({ | |
| user: result.welcome.user, | |
| org: result.welcome.org, | |
| project: result.welcome.project, | |
| signupSource: result.welcome.org.signupSource, | |
| appUrl: WEB_ORIGIN, | |
| }).catch((err) => { | |
| logger.warn({ err, userId, orgId: result.welcome?.org.id }, "loops welcome flow failed"); | |
| }); | |
| } | |
| return c.json(result.payload); | |
| }); | |
| app.post("/api/signup-intents/:intentId/claim", async (c) => { | |
| const intentId = c.req.param("intentId"); | |
| const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown }; | |
| const projectId = typeof body.projectId === "string" ? body.projectId : null; | |
| if (!projectId) throw new HTTPException(400, { message: "projectId required" }); | |
| const { user, org } = await resolveActiveOrgContext({ | |
| userId: c.var.userId, | |
| preferredOrgId: c.var.orgId, | |
| }); | |
| const project = await db.query.projects.findFirst({ | |
| where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)), | |
| }); | |
| if (!project) throw new HTTPException(404, { message: "project not found in active org" }); | |
| const claimed = await db.transaction(async (tx) => { | |
| const intent = await tx.query.signupIntents.findFirst({ | |
| where: eq(schema.signupIntents.id, intentId), | |
| }); | |
| if (!intent) throw new HTTPException(404, { message: "signup intent not found" }); | |
| if (intent.expiresAt < new Date()) { | |
| logger.warn( | |
| { | |
| intent_id: intentId, | |
| project_id: projectId, | |
| user_id: c.var.userId, | |
| expires_at: intent.expiresAt.toISOString(), | |
| age_ms: Date.now() - intent.expiresAt.getTime(), | |
| }, | |
| "signup intent expired", | |
| ); | |
| throw new HTTPException(410, { message: "signup intent expired" }); | |
| } | |
| if (intent.consumedAt) { | |
| if (intent.claimedProjectId === project.id) { | |
| return { | |
| id: null, | |
| keyPrefix: intent.keyPrefix, | |
| returnTo: intent.returnTo, | |
| alreadyClaimed: true, | |
| }; | |
| } | |
| throw new HTTPException(409, { message: "signup intent already claimed" }); | |
| } | |
| const existingKey = await tx.query.apiKeys.findFirst({ | |
| where: eq(schema.apiKeys.keyHash, intent.keyHash), | |
| }); | |
| if (existingKey && existingKey.projectId !== project.id) { | |
| throw new HTTPException(409, { | |
| message: "signup intent key already belongs to another project", | |
| }); | |
| } | |
| const [key] = existingKey | |
| ? [existingKey] | |
| : await tx | |
| .insert(schema.apiKeys) | |
| .values({ | |
| projectId: project.id, | |
| name: "Skill onboarding", | |
| keyHash: intent.keyHash, | |
| keyPrefix: intent.keyPrefix, | |
| }) | |
| .returning(); | |
| if (!key) throw new Error("failed to claim signup intent key"); | |
| await tx | |
| .update(schema.signupIntents) | |
| .set({ | |
| consumedAt: new Date(), | |
| claimedProjectId: project.id, | |
| claimedByUserId: user.id, | |
| }) | |
| .where(eq(schema.signupIntents.id, intent.id)); | |
| return { | |
| id: key.id, | |
| keyPrefix: key.keyPrefix, | |
| returnTo: intent.returnTo, | |
| alreadyClaimed: false, | |
| }; | |
| }); | |
| return c.json(claimed); | |
| }); | |
| app.post("/api/projects/:projectId/keys", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| return tracer.startActiveSpan("apikey.create", async (span) => { | |
| span.setAttribute("tenant.project_id", projectId); | |
| try { | |
| const body = (await c.req.json().catch(() => ({}))) as { name?: string }; | |
| const name = body.name?.trim() || "new key"; | |
| span.setAttribute("apikey.name", name); | |
| const minted = await mintApiKey({ projectId, name }); | |
| span.setAttribute("apikey.prefix", minted.keyPrefix); | |
| span.setAttribute("apikey.id", minted.id); | |
| return c.json({ | |
| id: minted.id, | |
| name: minted.name, | |
| keyPrefix: minted.keyPrefix, | |
| createdAt: minted.createdAt, | |
| plaintext: minted.plaintext, | |
| }); | |
| } catch (err) { | |
| span.recordException(err as Error); | |
| span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); | |
| throw err; | |
| } finally { | |
| span.end(); | |
| } | |
| }); | |
| }); | |
| app.get("/api/projects/:projectId/keys", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const rows = await db.query.apiKeys.findMany({ | |
| where: eq(schema.apiKeys.projectId, projectId), | |
| }); | |
| return c.json( | |
| rows.map((k) => ({ | |
| id: k.id, | |
| name: k.name, | |
| keyPrefix: k.keyPrefix, | |
| lastUsedAt: k.lastUsedAt, | |
| revokedAt: k.revokedAt, | |
| createdAt: k.createdAt, | |
| })), | |
| ); | |
| }); | |
| app.delete("/api/projects/:projectId/keys/:keyId", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const keyId = c.req.param("keyId"); | |
| await requireProjectAccess(c, projectId); | |
| await db | |
| .update(schema.apiKeys) | |
| .set({ revokedAt: new Date() }) | |
| .where(and(eq(schema.apiKeys.id, keyId), eq(schema.apiKeys.projectId, projectId))); | |
| return c.json({ ok: true }); | |
| }); | |
| // Reports whether the user has connected at least one MCP client to this | |
| // project. Used by the dashboard's setup-todos to hide the "Install the MCP | |
| // server" card once a token exists. Counts non-revoked tokens whose refresh | |
| // is still valid (or never expires) — clients with only stale access tokens | |
| // will still re-auth, so we don't gate on `accessExpiresAt`. | |
| app.get("/api/projects/:projectId/mcp-status", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const rows = await db | |
| .select({ id: schema.mcpOauthTokens.id }) | |
| .from(schema.mcpOauthTokens) | |
| .where( | |
| and(eq(schema.mcpOauthTokens.projectId, projectId), isNull(schema.mcpOauthTokens.revokedAt)), | |
| ) | |
| .limit(1); | |
| return c.json({ connected: rows.length > 0 }); | |
| }); | |
| app.get("/api/projects/:projectId/stats", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const [traces, logs, metrics, issueRows] = await Promise.all([ | |
| chCount("otel_traces", "Timestamp", projectId), | |
| chCount("otel_logs", "Timestamp", projectId), | |
| chMetricsCount(projectId), | |
| db.select({ c: count() }).from(schema.issues).where(eq(schema.issues.projectId, projectId)), | |
| ]); | |
| return c.json({ | |
| window: "1h", | |
| traces, | |
| logs, | |
| metrics, | |
| issues: Number(issueRows[0]?.c ?? 0), | |
| }); | |
| }); | |
| // The Autumn SDK throws a typed AutumnError carrying the HTTP `statusCode` and | |
| // raw `body` — use it directly (no casts) and match the specific 409 | |
| // "plan_already_attached" conflict code in the body. | |
| function isPlanAlreadyAttached(err: unknown): boolean { | |
| return ( | |
| err instanceof AutumnError && | |
| err.statusCode === 409 && | |
| err.body.includes("plan_already_attached") | |
| ); | |
| } | |
| // Immediate "switch to Free" — per Autumn's guidance, attach the Free plan now | |
| // (planSchedule "immediate") and carry usage over, rather than cancelling. Free | |
| // is the auto_enable default, so attaching it when already on Free returns a | |
| // "plan_already_attached" conflict, which we treat as a no-op success. Per-org | |
| // (org = Autumn customer); the carry-over closes the toggle-to-reset loophole. | |
| app.post("/api/me/billing/cancel", async (c) => { | |
| const ctx = await resolveMaybeActiveOrgContext({ | |
| userId: c.var.userId, | |
| preferredOrgId: c.var.orgId, | |
| }); | |
| if (!ctx.org) throw new HTTPException(400, { message: "no active org" }); | |
| const key = process.env.AUTUMN_SECRET_KEY; | |
| if (!key) throw new HTTPException(400, { message: "billing is not configured" }); | |
| const orgId = ctx.org.id; | |
| // "Switch to Free" — per Autumn's guidance, attach the Free plan immediately | |
| // and carry usage over, rather than cancelling the paid plan. When a paid | |
| // plan is active this is a real downgrade: planSchedule:"immediate" applies it | |
| // now and carryOverUsages preserves the org's metered usage (spans / logs / | |
| // metric points / investigation credits) so a maxed cap can't be reset by | |
| // toggling paid↔Free. (Cancelling instead spins up a fresh Free entitlement | |
| // with usage 0 — the reset we were fighting; and billing.update has no | |
| // carry-over option.) If the org is ALREADY on Free (Free is the auto_enable | |
| // default), the attach 409s "plan_already_attached" — that's a no-op success. | |
| const autumn = new Autumn({ secretKey: key }); | |
| try { | |
| await autumn.billing.attach({ | |
| customerId: orgId, | |
| planId: "free", | |
| planSchedule: "immediate", | |
| carryOverUsages: { enabled: true }, | |
| }); | |
| } catch (err) { | |
| // Only the "already on Free" conflict is a no-op success; surface any other | |
| // error so a genuine failure isn't reported as a successful switch. | |
| if (isPlanAlreadyAttached(err)) { | |
| return c.json({ ok: true }); | |
| } | |
| throw err; | |
| } | |
| return c.json({ ok: true }); | |
| }); | |
| app.get("/api/projects/:projectId/explore/attribute-keys", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const { since, until } = parseRangeQuery(c); | |
| const rows = await listAttributeKeys( | |
| ch, | |
| projectId, | |
| { since, until }, | |
| parseExploreAttributeSource(c), | |
| ); | |
| return c.json(rows); | |
| }); | |
| app.get("/api/projects/:projectId/explore/attribute-values", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const key = c.req.query("key"); | |
| if (!key) throw new HTTPException(400, { message: "key is required" }); | |
| const { since, until } = parseRangeQuery(c); | |
| const rows = await listAttributeValues( | |
| ch, | |
| projectId, | |
| key, | |
| { since, until }, | |
| 200, | |
| parseExploreAttributeSource(c), | |
| ); | |
| return c.json(rows); | |
| }); | |
| app.get("/api/projects/:projectId/explore/services", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const { since, until } = parseRangeQuery(c); | |
| const services = await listServices(ch, projectId, { since, until }); | |
| return c.json(services); | |
| }); | |
| app.post("/api/projects/:projectId/explore/logs", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| return tracer.startActiveSpan("explore.query_logs", async (span) => { | |
| span.setAttribute("tenant.project_id", projectId); | |
| try { | |
| const body = (await c.req.json().catch(() => ({}))) as ExploreListBody; | |
| const limit = clampLimit(body.limit, 100, 500); | |
| span.setAttribute("explore.limit", limit); | |
| if (body.service) span.setAttribute("explore.service", body.service); | |
| if (body.severity) span.setAttribute("explore.severity", body.severity); | |
| if (body.search) span.setAttribute("explore.has_search", true); | |
| const rows = await queryLogs(ch, projectId, { | |
| range: body.range, | |
| service: body.service, | |
| severity: body.severity, | |
| search: body.search, | |
| resourceAttrs: normalizeAttrs(body.resourceAttrs), | |
| limit, | |
| }); | |
| span.setAttribute("explore.row_count", rows.length); | |
| return c.json(rows); | |
| } catch (err) { | |
| span.recordException(err as Error); | |
| span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); | |
| throw err; | |
| } finally { | |
| span.end(); | |
| } | |
| }); | |
| }); | |
| app.post("/api/projects/:projectId/explore/traces", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as ExploreListBody; | |
| const limit = clampLimit(body.limit, 100, 500); | |
| const rows = await queryTraces(ch, projectId, { | |
| range: body.range, | |
| service: body.service, | |
| spanName: body.spanName, | |
| statusCode: body.statusCode, | |
| minDurationMs: body.minDurationMs, | |
| resourceAttrs: normalizeAttrs(body.resourceAttrs), | |
| limit, | |
| }); | |
| return c.json(rows); | |
| }); | |
| app.post("/api/projects/:projectId/explore/traces-aggregated", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as ExploreListBody; | |
| const limit = clampLimit(body.limit, 100, 500); | |
| const rows = await queryTracesAggregated(ch, projectId, { | |
| range: body.range, | |
| service: body.service, | |
| spanName: body.spanName, | |
| statusCode: body.statusCode, | |
| minDurationMs: body.minDurationMs, | |
| resourceAttrs: normalizeAttrs(body.resourceAttrs), | |
| limit, | |
| }); | |
| return c.json(rows); | |
| }); | |
| app.get("/api/projects/:projectId/explore/traces/:traceId", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const traceId = c.req.param("traceId"); | |
| await requireProjectAccess(c, projectId); | |
| if (!/^[a-fA-F0-9]{1,64}$/.test(traceId)) { | |
| throw new HTTPException(400, { message: "invalid trace id" }); | |
| } | |
| const detail = await getTraceDetail(ch, projectId, traceId); | |
| return c.json(detail); | |
| }); | |
| app.post("/api/projects/:projectId/explore/series", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as ExploreSeriesBody; | |
| const source: SeriesSource = body.source === "traces" ? "traces" : "logs"; | |
| const filter = body.filter ?? {}; | |
| const rangeSeconds = estimateRangeSeconds(filter.range); | |
| const step = pickStep(rangeSeconds); | |
| const rows = await countSeries( | |
| ch, | |
| projectId, | |
| source, | |
| { | |
| range: filter.range, | |
| service: filter.service, | |
| resourceAttrs: normalizeAttrs(filter.resourceAttrs), | |
| search: filter.search, | |
| severity: filter.severity, | |
| spanName: filter.spanName, | |
| statusCode: filter.statusCode, | |
| minDurationMs: filter.minDurationMs, | |
| }, | |
| body.groupBy || undefined, | |
| step, | |
| ); | |
| return c.json({ step: `${step.n} ${step.unit}`, rows }); | |
| }); | |
| app.get("/api/projects/:projectId/explore/metric-names", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const { since, until } = parseRangeQuery(c); | |
| const names = await listMetricNames(ch, projectId, { since, until }); | |
| return c.json(names); | |
| }); | |
| app.post("/api/projects/:projectId/explore/metric-series", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as MetricSeriesBody; | |
| const metricName = typeof body.metricName === "string" ? body.metricName : ""; | |
| const filter = body.filter ?? {}; | |
| const rangeSeconds = estimateRangeSeconds(filter.range); | |
| const step = pickStep(rangeSeconds); | |
| const aggregation = | |
| typeof body.aggregation === "string" && | |
| (METRIC_AGGREGATIONS as readonly string[]).includes(body.aggregation) | |
| ? (body.aggregation as MetricAggregation) | |
| : undefined; | |
| const rows = await metricSeries( | |
| ch, | |
| projectId, | |
| metricName, | |
| { | |
| range: filter.range, | |
| service: filter.service, | |
| resourceAttrs: normalizeAttrs(filter.resourceAttrs), | |
| }, | |
| body.groupBy || undefined, | |
| step, | |
| aggregation, | |
| ); | |
| return c.json({ step: `${step.n} ${step.unit}`, rows }); | |
| }); | |
| app.post("/api/projects/:projectId/explore/metrics", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as ExploreListBody & { metricName?: string }; | |
| const limit = clampLimit(body.limit, 100, 500); | |
| const rows = await queryMetrics(ch, projectId, { | |
| metricName: typeof body.metricName === "string" ? body.metricName : undefined, | |
| range: body.range, | |
| service: body.service, | |
| resourceAttrs: normalizeAttrs(body.resourceAttrs), | |
| limit, | |
| }); | |
| return c.json(rows); | |
| }); | |
| type MetricSeriesBody = { | |
| metricName?: string; | |
| groupBy?: string; | |
| aggregation?: string; | |
| filter?: Omit<ExploreListBody, "limit">; | |
| }; | |
| type ExploreListBody = { | |
| range?: { since?: string; until?: string }; | |
| service?: string; | |
| resourceAttrs?: ResourceAttrFilter[]; | |
| search?: string; | |
| severity?: string; | |
| spanName?: string; | |
| statusCode?: string; | |
| minDurationMs?: number; | |
| limit?: number; | |
| }; | |
| type ExploreSeriesBody = { | |
| source?: SeriesSource; | |
| groupBy?: string; | |
| filter?: Omit<ExploreListBody, "limit">; | |
| }; | |
| function parseRangeQuery(c: Context<{ Variables: Vars }>): { since?: string; until?: string } { | |
| const since = c.req.query("since"); | |
| const until = c.req.query("until"); | |
| return { since, until }; | |
| } | |
| function parseExploreAttributeSource( | |
| c: Context<{ Variables: Vars }>, | |
| ): SeriesSource | "metrics" | undefined { | |
| const source = c.req.query("source"); | |
| if (source === "logs" || source === "traces" || source === "metrics") return source; | |
| return undefined; | |
| } | |
| function clampLimit(n: number | undefined, fallback: number, max: number): number { | |
| const v = Math.floor(Number(n ?? fallback)); | |
| if (!Number.isFinite(v) || v <= 0) return fallback; | |
| return Math.min(v, max); | |
| } | |
| function isIncidentStatus(value: string): value is schema.IncidentStatus { | |
| return ( | |
| value === "open" || value === "resolved" || value === "autoresolved_noise" || value === "merged" | |
| ); | |
| } | |
| function normalizeAttrs(attrs: ResourceAttrFilter[] | undefined): ResourceAttrFilter[] | undefined { | |
| if (!Array.isArray(attrs)) return undefined; | |
| const out = attrs.filter( | |
| (a): a is ResourceAttrFilter => | |
| !!a && | |
| typeof a.key === "string" && | |
| a.key.length > 0 && | |
| typeof a.value === "string" && | |
| (a.op === undefined || a.op === "eq" || a.op === "neq" || a.op === "not_contains"), | |
| ); | |
| return out.length > 0 ? out : undefined; | |
| } | |
| function estimateRangeSeconds(range: { since?: string; until?: string } | undefined): number { | |
| if (!range?.since || !range?.until) return 3600; | |
| const s = Date.parse(range.since); | |
| const u = Date.parse(range.until); | |
| if (!Number.isFinite(s) || !Number.isFinite(u) || u <= s) return 3600; | |
| return (u - s) / 1000; | |
| } | |
| async function chCount(table: string, tsColumn: string, projectId: string): Promise<number> { | |
| const r = await ch.query({ | |
| query: ` | |
| SELECT count() AS c | |
| FROM ${table} | |
| WHERE ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND ${tsColumn} > now() - INTERVAL 1 HOUR | |
| `, | |
| query_params: { projectId }, | |
| format: "JSONEachRow", | |
| }); | |
| const rows = (await r.json()) as { c: string | number }[]; | |
| return Number(rows[0]?.c ?? 0); | |
| } | |
| async function chMetricsCount(projectId: string): Promise<number> { | |
| return tracer.startActiveSpan("metrics.aggregate_counts", async (span) => { | |
| span.setAttribute("tenant.project_id", projectId); | |
| try { | |
| const tables = [ | |
| "otel_metrics_gauge", | |
| "otel_metrics_sum", | |
| "otel_metrics_histogram", | |
| "otel_metrics_summary", | |
| "otel_metrics_exp_histogram", | |
| ]; | |
| span.setAttribute("metrics.table_count", tables.length); | |
| const counts = await Promise.all(tables.map((t) => chCount(t, "TimeUnix", projectId))); | |
| const total = counts.reduce((a, b) => a + b, 0); | |
| span.setAttribute("metrics.total", total); | |
| return total; | |
| } catch (err) { | |
| span.recordException(err as Error); | |
| span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); | |
| throw err; | |
| } finally { | |
| span.end(); | |
| } | |
| }); | |
| } | |
| async function requireProjectAccess( | |
| c: Context<{ Variables: Vars }>, | |
| projectId: string, | |
| ): Promise<void> { | |
| return tracer.startActiveSpan("project.authorize", async (span) => { | |
| span.setAttribute("tenant.project_id", projectId); | |
| span.setAttribute("superlog.user_id", c.var.userId); | |
| if (c.var.orgId) span.setAttribute("superlog.org_id_hint", c.var.orgId); | |
| try { | |
| const project = await db.query.projects.findFirst({ | |
| where: eq(schema.projects.id, projectId), | |
| }); | |
| if (!project) throw new HTTPException(404, { message: "project not found" }); | |
| const ctx = await resolveActiveOrgContext({ | |
| userId: c.var.userId, | |
| preferredOrgId: c.var.orgId, | |
| }); | |
| if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" }); | |
| span.setAttribute("superlog.org_id", ctx.org.id); | |
| } catch (err) { | |
| span.recordException(err as Error); | |
| span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); | |
| throw err; | |
| } finally { | |
| span.end(); | |
| } | |
| }); | |
| } | |
| async function getProjectAutomation(projectId: string): Promise<{ | |
| autoInvestigateIssuesEnabled: boolean; | |
| agentRunProvider: string; | |
| maxRuntimeMinutes: number; | |
| maxHumanResumeCount: number; | |
| customInstructions: string; | |
| agentRunEnabled: boolean; | |
| linearTicketPolicy: schema.LinearTicketPolicy; | |
| linearTicketInstructions: schema.LinearTicketInstruction[]; | |
| prPolicy: schema.PrPolicy; | |
| prBaseBranch: string | null; | |
| autoMergeFixPrs: schema.AutoMergePolicy; | |
| autoMergeMethod: schema.AutoMergeMethod; | |
| issueFilterConfig: schema.IssueFilterConfig; | |
| }> { | |
| const row = await db.query.projectAutomationSettings.findFirst({ | |
| where: eq(schema.projectAutomationSettings.projectId, projectId), | |
| }); | |
| return { | |
| autoInvestigateIssuesEnabled: row?.autoInvestigateIssuesEnabled ?? true, | |
| agentRunProvider: row?.agentRunProvider ?? DEFAULT_AGENT_RUN_PROVIDER, | |
| maxRuntimeMinutes: row?.maxRuntimeMinutes ?? 90, | |
| maxHumanResumeCount: row?.maxHumanResumeCount ?? 3, | |
| customInstructions: row?.customInstructions ?? "", | |
| agentRunEnabled: row?.agentRunEnabled ?? true, | |
| linearTicketPolicy: row?.linearTicketPolicy ?? "on_ready_to_pr", | |
| linearTicketInstructions: row?.linearTicketInstructions ?? [], | |
| prPolicy: row?.prPolicy ?? "on_ready_to_pr", | |
| prBaseBranch: schema.normalizePrBaseBranch(row?.prBaseBranch), | |
| autoMergeFixPrs: row?.autoMergeFixPrs ?? "never", | |
| autoMergeMethod: row?.autoMergeMethod ?? "squash", | |
| issueFilterConfig: row?.issueFilterConfig ?? schema.EMPTY_ISSUE_FILTER_CONFIG, | |
| }; | |
| } | |
| function sanitizeClauseList(input: unknown): schema.IssueFilterClause[] { | |
| if (!Array.isArray(input)) return []; | |
| const out: schema.IssueFilterClause[] = []; | |
| const seen = new Set<string>(); | |
| for (const item of input) { | |
| if (!item || typeof item !== "object") continue; | |
| const key = | |
| typeof (item as { key?: unknown }).key === "string" | |
| ? (item as { key: string }).key.trim() | |
| : ""; | |
| const value = | |
| typeof (item as { value?: unknown }).value === "string" | |
| ? (item as { value: string }).value.trim() | |
| : ""; | |
| if (!key || !value) continue; | |
| const dedupe = `${key.toLowerCase()}=${value}`; | |
| if (seen.has(dedupe)) continue; | |
| seen.add(dedupe); | |
| out.push({ key: key.slice(0, 200), value: value.slice(0, 400) }); | |
| if (out.length >= 20) break; | |
| } | |
| return out; | |
| } | |
| function sanitizeIssueFilterConfig( | |
| input: unknown, | |
| fallback: schema.IssueFilterConfig, | |
| ): schema.IssueFilterConfig { | |
| if (!input || typeof input !== "object") return fallback; | |
| const o = input as Partial<Record<keyof schema.IssueFilterConfig, unknown>>; | |
| return { | |
| includeLogs: sanitizeClauseList(o.includeLogs), | |
| includeSpans: sanitizeClauseList(o.includeSpans), | |
| excludeLogs: sanitizeClauseList(o.excludeLogs), | |
| excludeSpans: sanitizeClauseList(o.excludeSpans), | |
| }; | |
| } | |
| async function getProjectIncident(projectId: string, incidentId: string) { | |
| return db.query.incidents.findFirst({ | |
| where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)), | |
| }); | |
| } | |
| async function getLatestAgentRun(incidentId: string) { | |
| return db.query.agentRuns.findFirst({ | |
| where: eq(schema.agentRuns.incidentId, incidentId), | |
| orderBy: [desc(schema.agentRuns.createdAt)], | |
| }); | |
| } | |
| app.get("/api/projects/:projectId/issues", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const silencedParam = c.req.query("silenced") ?? "active"; | |
| const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200); | |
| const projectFilter = eq(schema.issues.projectId, projectId); | |
| const where = | |
| silencedParam === "all" | |
| ? projectFilter | |
| : silencedParam === "silenced" | |
| ? and(projectFilter, isNotNull(schema.issues.silencedAt)) | |
| : and(projectFilter, isNull(schema.issues.silencedAt)); | |
| const rows = await db.query.issues.findMany({ | |
| where, | |
| orderBy: [desc(schema.issues.lastSeen)], | |
| limit, | |
| }); | |
| return c.json(rows); | |
| }); | |
| app.get("/api/projects/:projectId/issues/:issueId", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const issueId = c.req.param("issueId"); | |
| await requireProjectAccess(c, projectId); | |
| const issue = await db.query.issues.findFirst({ | |
| where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)), | |
| }); | |
| if (!issue) throw new HTTPException(404, { message: "issue not found" }); | |
| const symbolication = await symbolicateIssueSample({ | |
| database: db, | |
| objectReader: sourceMapObjectStore, | |
| projectId, | |
| sample: issue.lastSample, | |
| }).catch((err) => { | |
| logger.warn({ err, projectId, issueId }, "failed to symbolicate issue sample"); | |
| return null; | |
| }); | |
| return c.json({ ...issue, symbolication }); | |
| }); | |
| app.post("/api/projects/:projectId/issues/lookup", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| kind?: "log" | "span"; | |
| service?: string | null; | |
| severity?: string | null; | |
| body?: string | null; | |
| exceptionType?: string | null; | |
| exceptionMessage?: string | null; | |
| stacktrace?: string | null; | |
| }; | |
| const kind = body.kind === "span" ? "span" : "log"; | |
| const fp = | |
| kind === "span" | |
| ? fingerprint({ | |
| type: body.exceptionType || body.severity || "Error", | |
| stacktrace: body.stacktrace ?? null, | |
| message: body.exceptionMessage ?? body.body ?? null, | |
| }) | |
| : fingerprintLog({ | |
| service: body.service ?? "", | |
| severity: body.severity ?? "", | |
| body: body.body ?? "", | |
| exceptionType: body.exceptionType ?? null, | |
| stacktrace: body.stacktrace ?? null, | |
| }); | |
| const issue = await db.query.issues.findFirst({ | |
| where: and(eq(schema.issues.projectId, projectId), eq(schema.issues.fingerprint, fp.hash)), | |
| }); | |
| return c.json({ issue: issue ?? null }); | |
| }); | |
| app.post("/api/projects/:projectId/symbolication/log", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| stacktrace?: string | null; | |
| logAttrs?: Record<string, string> | null; | |
| resourceAttrs?: Record<string, string> | null; | |
| }; | |
| const symbolication = await symbolicateTelemetrySample({ | |
| database: db, | |
| objectReader: sourceMapObjectStore, | |
| projectId, | |
| sample: { | |
| stacktrace: body.stacktrace ?? null, | |
| logAttrs: body.logAttrs ?? null, | |
| resourceAttrs: body.resourceAttrs ?? null, | |
| }, | |
| }).catch((err) => { | |
| logger.warn({ err, projectId }, "failed to symbolicate log sample"); | |
| return null; | |
| }); | |
| return c.json({ symbolication }); | |
| }); | |
| app.get("/api/projects/:projectId/issues/:issueId/agent-run", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const issueId = c.req.param("issueId"); | |
| await requireProjectAccess(c, projectId); | |
| const issue = await db.query.issues.findFirst({ | |
| where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)), | |
| }); | |
| if (!issue) throw new HTTPException(404, { message: "issue not found" }); | |
| const link = await db.query.incidentIssues.findFirst({ | |
| where: eq(schema.incidentIssues.issueId, issueId), | |
| }); | |
| if (!link) return c.json({ incident: null, agentRun: null, events: [] }); | |
| const incident = await getProjectIncident(projectId, link.incidentId); | |
| if (!incident) return c.json({ incident: null, agentRun: null, events: [] }); | |
| const agentRun = await getLatestAgentRun(incident.id); | |
| if (!agentRun) return c.json({ incident, agentRun: null, events: [] }); | |
| const events = await loadIncidentTimeline(incident.id, agentRun.id); | |
| return c.json({ incident, agentRun, events }); | |
| }); | |
| app.patch("/api/projects/:projectId/incidents/:incidentId", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as { status?: string }; | |
| const status = body.status; | |
| if (status !== "open" && status !== "resolved") { | |
| throw new HTTPException(400, { message: "status must be 'open' or 'resolved'" }); | |
| } | |
| const existing = await db.query.incidents.findFirst({ | |
| where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)), | |
| }); | |
| if (!existing) throw new HTTPException(404, { message: "incident not found" }); | |
| if (status === "resolved") { | |
| // Route the dashboard's mark-resolved through the shared helper so the | |
| // resolved_* columns are populated exactly like every other resolve path. | |
| const { resolved } = await resolveIncident({ | |
| incidentId, | |
| kind: "dashboard_manual", | |
| reasonCode: "dashboard_manual", | |
| reasonText: `Resolved from the dashboard by user ${c.var.userId}.`, | |
| resolvedByUserId: c.var.userId, | |
| }); | |
| if (resolved) { | |
| await runResolvedIncidentSideEffectsForIncident({ | |
| incidentId, | |
| closePullRequest: (pr) => | |
| closeAgentPullRequestOnGithub({ | |
| installationId: pr.githubInstallationId, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| }), | |
| }); | |
| } | |
| } else { | |
| await incidentLifecycle.reopenManually({ | |
| incident: existing, | |
| actor: { userId: c.var.userId }, | |
| summary: "Incident reopened from the dashboard.", | |
| detail: { reason: "dashboard_manual" }, | |
| }); | |
| } | |
| const updated = await db.query.incidents.findFirst({ | |
| where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)), | |
| }); | |
| return c.json(updated); | |
| }); | |
| // Confirm/dismiss a pending resolution proposal from the dashboard. The | |
| // proposalId in the path is the source of truth — projectId/incidentId | |
| // are validated for access control and to make the URL self-documenting. | |
| // Both routes route through the same shared helper the Slack handler | |
| // uses, so confirmed/dismissed proposals from either surface look | |
| // identical in the audit log. | |
| async function decideResolutionProposal( | |
| c: Context<{ Variables: Vars }>, | |
| decision: "confirm" | "dismiss", | |
| ): Promise<Response> { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| const proposalId = c.req.param("proposalId"); | |
| if (!projectId || !incidentId || !proposalId) { | |
| throw new HTTPException(400, { message: "missing path parameter" }); | |
| } | |
| await requireProjectAccess(c, projectId); | |
| const proposal = await db.query.incidentResolutionProposals.findFirst({ | |
| where: eq(schema.incidentResolutionProposals.id, proposalId), | |
| }); | |
| if (!proposal || proposal.incidentId !== incidentId) { | |
| throw new HTTPException(404, { message: "proposal not found" }); | |
| } | |
| // Make sure the proposal belongs to this project — defence in depth on | |
| // the project-access check above (the incident's projectId is the | |
| // authoritative one). | |
| const incident = await db.query.incidents.findFirst({ | |
| where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)), | |
| }); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| // Look up the user's name for the attribution phrase. Skip if the user | |
| // row is gone (e.g. just-deleted account); the helper falls back to the | |
| // user id. | |
| const user = await db.query.users.findFirst({ | |
| where: eq(schema.users.id, c.var.userId), | |
| columns: { id: true, name: true, email: true }, | |
| }); | |
| const displayName = user?.name?.trim() || user?.email || null; | |
| const actor = { userId: c.var.userId, displayName }; | |
| const result = | |
| decision === "confirm" | |
| ? await confirmResolutionProposal({ proposalId, actor }) | |
| : await dismissResolutionProposal({ proposalId, actor }); | |
| if (!result.ok) { | |
| // already_confirmed / already_dismissed land as 409 so the dashboard | |
| // can refresh and show the up-to-date state without treating it as a | |
| // hard error. | |
| if (result.reason?.startsWith("already_")) { | |
| throw new HTTPException(409, { message: result.reason }); | |
| } | |
| throw new HTTPException(400, { message: result.reason ?? "decision failed" }); | |
| } | |
| if (decision === "confirm" && result.incidentId) { | |
| await runResolvedIncidentSideEffectsForIncident({ | |
| incidentId: result.incidentId, | |
| closePullRequest: (pr) => | |
| closeAgentPullRequestOnGithub({ | |
| installationId: pr.githubInstallationId, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| }), | |
| }); | |
| } | |
| return c.json({ ok: true, incidentId, proposalId, decision }); | |
| } | |
| app.post( | |
| "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/confirm", | |
| (c) => decideResolutionProposal(c, "confirm"), | |
| ); | |
| app.post( | |
| "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/dismiss", | |
| (c) => decideResolutionProposal(c, "dismiss"), | |
| ); | |
| app.post("/api/projects/:projectId/issues/:issueId/silence", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const issueId = c.req.param("issueId"); | |
| await requireProjectAccess(c, projectId); | |
| return tracer.startActiveSpan("issue.silence", async (span) => { | |
| span.setAttribute("tenant.project_id", projectId); | |
| span.setAttribute("issue.id", issueId); | |
| try { | |
| const updated = await db | |
| .update(schema.issues) | |
| .set({ silencedAt: new Date() }) | |
| .where( | |
| and( | |
| eq(schema.issues.id, issueId), | |
| eq(schema.issues.projectId, projectId), | |
| isNull(schema.issues.silencedAt), | |
| ), | |
| ) | |
| .returning(); | |
| if (!updated[0]) { | |
| span.setAttribute("issue.silence.applied", false); | |
| const existing = await db.query.issues.findFirst({ | |
| where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)), | |
| }); | |
| if (!existing) throw new HTTPException(404, { message: "issue not found" }); | |
| return c.json(existing); | |
| } | |
| span.setAttribute("issue.silence.applied", true); | |
| return c.json(updated[0]); | |
| } catch (err) { | |
| span.recordException(err as Error); | |
| span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); | |
| throw err; | |
| } finally { | |
| span.end(); | |
| } | |
| }); | |
| }); | |
| app.post("/api/projects/:projectId/issues/:issueId/unsilence", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const issueId = c.req.param("issueId"); | |
| await requireProjectAccess(c, projectId); | |
| const existing = await db.query.issues.findFirst({ | |
| where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)), | |
| }); | |
| if (!existing) throw new HTTPException(404, { message: "issue not found" }); | |
| if (!existing.silencedAt) return c.json(existing); | |
| const conflict = await db.query.issues.findFirst({ | |
| where: and( | |
| eq(schema.issues.projectId, projectId), | |
| eq(schema.issues.fingerprint, existing.fingerprint), | |
| isNull(schema.issues.silencedAt), | |
| ), | |
| }); | |
| if (conflict) { | |
| throw new HTTPException(409, { | |
| message: "another active issue already exists for this fingerprint", | |
| }); | |
| } | |
| const updated = await db | |
| .update(schema.issues) | |
| .set({ silencedAt: null }) | |
| .where(and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId))) | |
| .returning(); | |
| return c.json(updated[0] ?? existing); | |
| }); | |
| app.get("/api/projects/:projectId/automation", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const current = await getProjectAutomation(projectId); | |
| return c.json({ projectId, ...current }); | |
| }); | |
| // Picker support for the Issue filter editor — keys/values/preview are drawn | |
| // from ERROR events only (logs SeverityNumber>=17, traces with exception | |
| // event) so the UI matches the population the worker filter applies to. | |
| const ISSUE_FILTER_RANGE = "now() - INTERVAL 24 HOUR"; | |
| app.get("/api/projects/:projectId/issue-filter/attribute-keys", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const rows = await listIssueFilterAttributeKeys(ch, projectId, { | |
| since: ISSUE_FILTER_RANGE, | |
| }); | |
| return c.json(rows); | |
| }); | |
| app.get("/api/projects/:projectId/issue-filter/attribute-values", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const key = c.req.query("key"); | |
| if (!key) throw new HTTPException(400, { message: "key is required" }); | |
| const rows = await listIssueFilterAttributeValues(ch, projectId, key, { | |
| since: ISSUE_FILTER_RANGE, | |
| }); | |
| return c.json(rows); | |
| }); | |
| // POST because the filter is now a structured object with 4 lists, which | |
| // doesn't fit cleanly in a query string. | |
| app.post("/api/projects/:projectId/issue-filter/preview", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| config?: unknown; | |
| }; | |
| const config = sanitizeIssueFilterConfig(body.config, schema.EMPTY_ISSUE_FILTER_CONFIG); | |
| const events = await previewIssueFilterMatches( | |
| ch, | |
| projectId, | |
| config, | |
| { since: ISSUE_FILTER_RANGE }, | |
| 10, | |
| ); | |
| return c.json({ events }); | |
| }); | |
| const VALID_AGENT_POLICIES: ReadonlySet<string> = new Set(["never", "on_ready_to_pr", "always"]); | |
| const VALID_AUTO_MERGE_POLICIES: ReadonlySet<string> = new Set([ | |
| "never", | |
| "when_checks_pass", | |
| "immediately", | |
| ]); | |
| const VALID_AUTO_MERGE_METHODS: ReadonlySet<string> = new Set(["squash", "merge", "rebase"]); | |
| const MAX_INSTRUCTIONS_LEN = 8000; | |
| function parsePrBaseBranch(input: unknown, current: string | null): string | null { | |
| if (input === undefined) return current; | |
| if (input === null) return null; | |
| if (typeof input !== "string") return current; | |
| const branch = schema.normalizePrBaseBranch(input); | |
| if (branch && !schema.isValidPrBaseBranch(branch)) { | |
| throw new HTTPException(400, { | |
| message: | |
| "prBaseBranch must be a valid Git branch name, or blank to use the repository default", | |
| }); | |
| } | |
| return branch; | |
| } | |
| // Branches the agent could target for PRs: the union across the project's | |
| // enabled GitHub repos. Powers the strict PR-target-branch picker in Settings. | |
| app.get("/api/projects/:projectId/github/branches", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const result = await listProjectRepoBranches(projectId); | |
| if (result.errored) { | |
| return c.json({ error: "failed to load branches from GitHub" }, 502); | |
| } | |
| return c.json({ branches: result.branches }); | |
| }); | |
| app.patch("/api/projects/:projectId/automation", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const current = await getProjectAutomation(projectId); | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| autoInvestigateIssuesEnabled?: unknown; | |
| agentRunProvider?: unknown; | |
| maxRuntimeMinutes?: unknown; | |
| maxHumanResumeCount?: unknown; | |
| customInstructions?: unknown; | |
| agentRunEnabled?: unknown; | |
| linearTicketPolicy?: unknown; | |
| linearTicketInstructions?: unknown; | |
| prPolicy?: unknown; | |
| prBaseBranch?: unknown; | |
| autoMergeFixPrs?: unknown; | |
| autoMergeMethod?: unknown; | |
| issueFilterConfig?: unknown; | |
| }; | |
| const autoInvestigateIssuesEnabled = | |
| typeof body.autoInvestigateIssuesEnabled === "boolean" | |
| ? body.autoInvestigateIssuesEnabled | |
| : current.autoInvestigateIssuesEnabled; | |
| const agentRunProvider = | |
| typeof body.agentRunProvider === "string" ? body.agentRunProvider : current.agentRunProvider; | |
| const maxRuntimeMinutes = | |
| typeof body.maxRuntimeMinutes === "number" | |
| ? Math.floor(body.maxRuntimeMinutes) | |
| : current.maxRuntimeMinutes; | |
| const maxHumanResumeCount = | |
| typeof body.maxHumanResumeCount === "number" | |
| ? Math.floor(body.maxHumanResumeCount) | |
| : current.maxHumanResumeCount; | |
| const customInstructions = | |
| typeof body.customInstructions === "string" | |
| ? body.customInstructions.slice(0, MAX_INSTRUCTIONS_LEN) | |
| : current.customInstructions; | |
| const agentRunEnabled = | |
| typeof body.agentRunEnabled === "boolean" ? body.agentRunEnabled : current.agentRunEnabled; | |
| const linearTicketPolicy: schema.LinearTicketPolicy = | |
| typeof body.linearTicketPolicy === "string" && VALID_AGENT_POLICIES.has(body.linearTicketPolicy) | |
| ? (body.linearTicketPolicy as schema.LinearTicketPolicy) | |
| : current.linearTicketPolicy; | |
| const linearTicketInstructions: schema.LinearTicketInstruction[] = (() => { | |
| if (!Array.isArray(body.linearTicketInstructions)) return current.linearTicketInstructions; | |
| const validated = body.linearTicketInstructions | |
| .filter( | |
| (item): item is { id: string; title: string; text: string } => | |
| item !== null && | |
| typeof item === "object" && | |
| typeof item.id === "string" && | |
| typeof item.title === "string" && | |
| typeof item.text === "string", | |
| ) | |
| .slice(0, 20) | |
| .map((item) => ({ | |
| id: item.id, | |
| title: item.title.slice(0, 200), | |
| text: item.text.slice(0, 2000), | |
| })); | |
| return validated; | |
| })(); | |
| const prPolicy: schema.PrPolicy = | |
| typeof body.prPolicy === "string" && VALID_AGENT_POLICIES.has(body.prPolicy) | |
| ? (body.prPolicy as schema.PrPolicy) | |
| : current.prPolicy; | |
| const prBaseBranch = parsePrBaseBranch(body.prBaseBranch, current.prBaseBranch); | |
| const autoMergeFixPrs: schema.AutoMergePolicy = | |
| typeof body.autoMergeFixPrs === "string" && VALID_AUTO_MERGE_POLICIES.has(body.autoMergeFixPrs) | |
| ? (body.autoMergeFixPrs as schema.AutoMergePolicy) | |
| : current.autoMergeFixPrs; | |
| const autoMergeMethod: schema.AutoMergeMethod = | |
| typeof body.autoMergeMethod === "string" && VALID_AUTO_MERGE_METHODS.has(body.autoMergeMethod) | |
| ? (body.autoMergeMethod as schema.AutoMergeMethod) | |
| : current.autoMergeMethod; | |
| const issueFilterConfig = | |
| body.issueFilterConfig !== undefined | |
| ? sanitizeIssueFilterConfig(body.issueFilterConfig, current.issueFilterConfig) | |
| : current.issueFilterConfig; | |
| if (!isAgentRunProvider(agentRunProvider)) { | |
| throw new HTTPException(400, { | |
| message: "agentRunProvider must be one of: community, anthropic, disabled", | |
| }); | |
| } | |
| if (!Number.isFinite(maxRuntimeMinutes) || maxRuntimeMinutes < 1 || maxRuntimeMinutes > 720) { | |
| throw new HTTPException(400, { message: "maxRuntimeMinutes must be between 1 and 720" }); | |
| } | |
| if ( | |
| !Number.isFinite(maxHumanResumeCount) || | |
| maxHumanResumeCount < 0 || | |
| maxHumanResumeCount > 10 | |
| ) { | |
| throw new HTTPException(400, { message: "maxHumanResumeCount must be between 0 and 10" }); | |
| } | |
| // When the target branch changed to a non-blank value, confirm it actually | |
| // exists in one of the project's repos. Skipped when GitHub can't be reached | |
| // (errored) so a transient API failure can't lock the user out of saving | |
| // unrelated settings — the worker still falls back to the repo default at PR | |
| // time if the branch later disappears. | |
| if (prBaseBranch && prBaseBranch !== current.prBaseBranch) { | |
| const { branches, errored } = await listProjectRepoBranches(projectId); | |
| if (!errored && !prBaseBranchExists(prBaseBranch, branches)) { | |
| throw new HTTPException(400, { | |
| message: `Branch "${prBaseBranch}" was not found in this project's connected repositories.`, | |
| }); | |
| } | |
| } | |
| const values = { | |
| projectId, | |
| autoInvestigateIssuesEnabled, | |
| agentRunProvider, | |
| maxRuntimeMinutes, | |
| maxHumanResumeCount, | |
| customInstructions, | |
| agentRunEnabled, | |
| linearTicketPolicy, | |
| linearTicketInstructions, | |
| prPolicy, | |
| prBaseBranch, | |
| autoMergeFixPrs, | |
| autoMergeMethod, | |
| issueFilterConfig, | |
| updatedAt: new Date(), | |
| }; | |
| const updated = await db | |
| .insert(schema.projectAutomationSettings) | |
| .values(values) | |
| .onConflictDoUpdate({ | |
| target: schema.projectAutomationSettings.projectId, | |
| set: { | |
| autoInvestigateIssuesEnabled, | |
| agentRunProvider, | |
| maxRuntimeMinutes, | |
| maxHumanResumeCount, | |
| customInstructions, | |
| agentRunEnabled, | |
| linearTicketPolicy, | |
| linearTicketInstructions, | |
| prPolicy, | |
| prBaseBranch, | |
| autoMergeFixPrs, | |
| autoMergeMethod, | |
| issueFilterConfig, | |
| updatedAt: new Date(), | |
| }, | |
| }) | |
| .returning(); | |
| return c.json(updated[0] ?? values); | |
| }); | |
| app.get("/api/projects/:projectId/incidents", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200); | |
| const status = c.req.query("status"); | |
| const incidentStatus = status && status !== "all" && isIncidentStatus(status) ? status : null; | |
| if (status && status !== "all" && !incidentStatus) { | |
| throw new HTTPException(400, { message: "invalid incident status" }); | |
| } | |
| // Default view hides agent-classified noise. Pass ?status=all to include it, | |
| // or ?status=autoresolved_noise to inspect just the noise pile. | |
| const baseFilter = eq(schema.incidents.projectId, projectId); | |
| let where = !status | |
| ? and(baseFilter, ne(schema.incidents.status, "autoresolved_noise")) | |
| : baseFilter; | |
| if (incidentStatus) { | |
| where = and(baseFilter, eq(schema.incidents.status, incidentStatus)); | |
| } | |
| const incidents = await db.query.incidents.findMany({ | |
| where, | |
| orderBy: [desc(schema.incidents.lastSeen)], | |
| limit, | |
| }); | |
| const [agentRuns, pendingProposals] = await Promise.all([ | |
| Promise.all(incidents.map((incident) => getLatestAgentRun(incident.id))), | |
| loadPendingResolutionProposals(incidents.map((i) => i.id)), | |
| ]); | |
| // Graph stats hit ClickHouse and can be slow on large projects. Keep the list | |
| // itself Postgres-only by default; callers that still need the legacy inline | |
| // shape can opt in with ?includeStats=1 while the web UI lazy-loads rows. | |
| const inlineStats = shouldInlineIncidentListStats(c.req.query("includeStats")); | |
| const statsByIncidentId = inlineStats | |
| ? await loadIncidentsBucketStats( | |
| projectId, | |
| incidents.map((i) => i.id), | |
| ) | |
| : new Map<string, IncidentBucketStats>(); | |
| const emptyStats: IncidentBucketStats = { | |
| buckets: buildBucketsFromMap(new Map(), INCIDENT_STATS_WINDOW_DAYS), | |
| impactedUsers: 0, | |
| impactedUsersAvailable: false, | |
| impactedUsersCapped: false, | |
| }; | |
| return c.json( | |
| incidents.map((incident, index) => | |
| buildIncidentListItem({ | |
| incident, | |
| agentRun: agentRuns[index] ?? null, | |
| pendingResolutionProposal: pendingProposals.get(incident.id) ?? null, | |
| stats: inlineStats | |
| ? { | |
| windowDays: INCIDENT_STATS_WINDOW_DAYS, | |
| ...(statsByIncidentId.get(incident.id) ?? emptyStats), | |
| } | |
| : undefined, | |
| }), | |
| ), | |
| ); | |
| }); | |
| type IncidentBucketStats = { | |
| buckets: { day: string; count: number }[]; | |
| impactedUsers: number; | |
| impactedUsersAvailable: boolean; | |
| impactedUsersCapped: boolean; | |
| }; | |
| // Bulk version of the per-incident stats query, used by the incidents list. | |
| // Two ClickHouse round-trips: one returns (svc, et, day, count) tuples for | |
| // the sparkline, the other returns (svc, et) → distinct user.id arrays. | |
| // We then map each tuple back to whichever incident's issues had that | |
| // (svc, et) pair, summing counts and Set-unioning users per-incident. | |
| // Same approximation as the single-incident endpoint — see | |
| // `fetchIncidentTimeseriesPairs`. | |
| async function loadIncidentsBucketStats( | |
| projectId: string, | |
| incidentIds: string[], | |
| ): Promise<Map<string, IncidentBucketStats>> { | |
| if (incidentIds.length === 0) return new Map(); | |
| const links = await db.query.incidentIssues.findMany({ | |
| where: inArray(schema.incidentIssues.incidentId, incidentIds), | |
| }); | |
| if (links.length === 0) return new Map(); | |
| const issues = await db.query.issues.findMany({ | |
| where: inArray( | |
| schema.issues.id, | |
| links.map((l) => l.issueId), | |
| ), | |
| }); | |
| if (issues.length === 0) return new Map(); | |
| const issueById = new Map(issues.map((i) => [i.id, i] as const)); | |
| // incidentId -> list of (kind, svc, et) it cares about | |
| const incidentPairs = new Map<string, { kind: "span" | "log"; svc: string; et: string }[]>(); | |
| for (const link of links) { | |
| const issue = issueById.get(link.issueId); | |
| if (!issue) continue; | |
| const arr = incidentPairs.get(link.incidentId) ?? []; | |
| arr.push({ | |
| kind: issue.kind === "log" ? "log" : "span", | |
| svc: issue.service ?? "", | |
| et: issue.exceptionType ?? "", | |
| }); | |
| incidentPairs.set(link.incidentId, arr); | |
| } | |
| // Union all pairs into the CH query (one round-trip). | |
| const spanServices: string[] = []; | |
| const spanExcTypes: string[] = []; | |
| const logServices: string[] = []; | |
| const logExcTypes: string[] = []; | |
| const seenSpan = new Set<string>(); | |
| const seenLog = new Set<string>(); | |
| for (const pairs of incidentPairs.values()) { | |
| for (const p of pairs) { | |
| const key = pairKey2(p.svc, p.et); | |
| if (p.kind === "log") { | |
| if (!seenLog.has(key)) { | |
| seenLog.add(key); | |
| logServices.push(p.svc); | |
| logExcTypes.push(p.et); | |
| } | |
| } else { | |
| if (!seenSpan.has(key)) { | |
| seenSpan.add(key); | |
| spanServices.push(p.svc); | |
| spanExcTypes.push(p.et); | |
| } | |
| } | |
| } | |
| } | |
| // Delimiter unlikely to appear in service / exception_type — keeps the | |
| // composite map key unambiguous so ("ab","c") and ("a","bc") don't collide. | |
| const tupleKey = (kind: string, svc: string, et: string) => `${kind}${svc}${et}`; | |
| const [rows, userRows] = await Promise.all([ | |
| fetchIncidentTimeseriesPairs({ | |
| projectId, | |
| spanServices, | |
| spanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| windowDays: INCIDENT_STATS_WINDOW_DAYS, | |
| }), | |
| fetchIncidentUserIdsByPair({ | |
| projectId, | |
| spanServices, | |
| spanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| windowDays: INCIDENT_STATS_WINDOW_DAYS, | |
| }), | |
| ]); | |
| const tupleCounts = new Map<string, Map<string, number>>(); | |
| for (const r of rows) { | |
| const key = tupleKey(r.kind, r.svc, r.et); | |
| let byDay = tupleCounts.get(key); | |
| if (!byDay) { | |
| byDay = new Map(); | |
| tupleCounts.set(key, byDay); | |
| } | |
| byDay.set(r.day, (byDay.get(r.day) ?? 0) + r.count); | |
| } | |
| const tupleUsers = new Map<string, { users: string[]; capped: boolean }>(); | |
| for (const r of userRows) { | |
| tupleUsers.set(tupleKey(r.kind, r.svc, r.et), { | |
| users: r.users, | |
| // groupUniqArray returns up to N rows; if we got exactly N we likely | |
| // truncated. A false positive here just shows "200+" once — better | |
| // than under-reporting a genuinely viral incident. | |
| capped: r.users.length >= INCIDENT_USERS_CAP, | |
| }); | |
| } | |
| const out = new Map<string, IncidentBucketStats>(); | |
| for (const [incidentId, pairs] of incidentPairs) { | |
| const merged = new Map<string, number>(); | |
| const userUnion = new Set<string>(); | |
| let capped = false; | |
| // A single incident may have two issues whose (kind, svc, et) tuples are | |
| // equal; dedupe so we don't double-count counts or users. | |
| const tupleKeys = new Set(pairs.map((p) => tupleKey(p.kind, p.svc, p.et))); | |
| for (const key of tupleKeys) { | |
| const byDay = tupleCounts.get(key); | |
| if (byDay) for (const [day, c] of byDay) merged.set(day, (merged.get(day) ?? 0) + c); | |
| const tu = tupleUsers.get(key); | |
| if (tu) { | |
| for (const uid of tu.users) userUnion.add(uid); | |
| if (tu.capped) capped = true; | |
| } | |
| } | |
| out.set(incidentId, { | |
| buckets: buildBucketsFromMap(merged, INCIDENT_STATS_WINDOW_DAYS), | |
| impactedUsers: userUnion.size, | |
| impactedUsersAvailable: userUnion.size > 0, | |
| impactedUsersCapped: capped, | |
| }); | |
| } | |
| return out; | |
| } | |
| app.get("/api/projects/:projectId/incidents/:incidentId", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const [links, agentRuns] = await Promise.all([ | |
| db.query.incidentIssues.findMany({ | |
| where: eq(schema.incidentIssues.incidentId, incidentId), | |
| orderBy: [desc(schema.incidentIssues.createdAt)], | |
| }), | |
| db.query.agentRuns.findMany({ | |
| where: eq(schema.agentRuns.incidentId, incident.id), | |
| orderBy: [desc(schema.agentRuns.createdAt)], | |
| }), | |
| ]); | |
| const issues = | |
| links.length > 0 | |
| ? await db.query.issues.findMany({ | |
| where: inArray( | |
| schema.issues.id, | |
| links.map((link) => link.issueId), | |
| ), | |
| orderBy: [desc(schema.issues.lastSeen)], | |
| }) | |
| : []; | |
| // Timeline is the incident's own — events from incident_events scoped to | |
| // this incident (and any of its agent runs), plus PR/Linear ticket events. | |
| // Findings (root cause, severity, etc.) on `incident` are the primary | |
| // source of truth; the timeline + agent-run history ride along on the | |
| // same GET so the detail panel renders in one round trip. | |
| const latestAgentRun = agentRuns[0] ?? null; | |
| const [timeline, pendingProposalMap] = await Promise.all([ | |
| latestAgentRun ? loadIncidentTimeline(incident.id, latestAgentRun.id) : Promise.resolve([]), | |
| loadPendingResolutionProposals([incident.id]), | |
| ]); | |
| return c.json({ | |
| incident, | |
| issues, | |
| agentRun: latestAgentRun, | |
| agentRuns, | |
| timeline, | |
| pendingResolutionProposal: pendingProposalMap.get(incident.id) ?? null, | |
| }); | |
| }); | |
| app.get("/api/projects/:projectId/incidents/:incidentId/pull-requests", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const [prs, agentRuns] = await Promise.all([ | |
| db.query.agentPullRequests.findMany({ | |
| where: eq(schema.agentPullRequests.incidentId, incidentId), | |
| orderBy: [desc(schema.agentPullRequests.createdAt)], | |
| }), | |
| db.query.agentRuns.findMany({ | |
| where: eq(schema.agentRuns.incidentId, incidentId), | |
| }), | |
| ]); | |
| return c.json(buildIncidentPullRequestViews(prs, agentRuns)); | |
| }); | |
| const VALID_MANUAL_MERGE_METHODS = new Set(["squash", "merge", "rebase"]); | |
| app.post("/api/projects/:projectId/incidents/:incidentId/pull-requests/:prId/merge", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| const prId = c.req.param("prId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const pr = await db.query.agentPullRequests.findFirst({ | |
| where: and( | |
| eq(schema.agentPullRequests.id, prId), | |
| eq(schema.agentPullRequests.incidentId, incidentId), | |
| ), | |
| }); | |
| if (!pr) throw new HTTPException(404, { message: "pull request not found" }); | |
| if (pr.state !== "open") { | |
| throw new HTTPException(409, { message: `pull request is already ${pr.state}` }); | |
| } | |
| const installation = await db.query.githubInstallations.findFirst({ | |
| where: eq(schema.githubInstallations.id, pr.installationId), | |
| }); | |
| if (!installation || installation.revokedAt) { | |
| throw new HTTPException(409, { message: "github installation is unavailable" }); | |
| } | |
| const body = (await c.req.json().catch(() => ({}))) as { method?: unknown }; | |
| const method = | |
| typeof body.method === "string" && VALID_MANUAL_MERGE_METHODS.has(body.method) | |
| ? (body.method as "squash" | "merge" | "rebase") | |
| : "squash"; | |
| const merged = await mergeGithubPullRequest({ | |
| installationId: installation.installationId, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| method, | |
| }); | |
| const now = new Date(); | |
| const [updatedPr] = await db | |
| .update(schema.agentPullRequests) | |
| .set({ | |
| state: "merged", | |
| mergedAt: now, | |
| closedAt: now, | |
| headSha: merged.sha ?? pr.headSha, | |
| lastSyncedAt: now, | |
| updatedAt: now, | |
| }) | |
| .where(eq(schema.agentPullRequests.id, pr.id)) | |
| .returning(); | |
| await db | |
| .insert(schema.agentPrEvents) | |
| .values({ | |
| agentPrId: pr.id, | |
| kind: "pr_merged", | |
| summary: `PR #${pr.prNumber} merged from dashboard`, | |
| payload: { method, sha: merged.sha, prUrl: pr.url, repoFullName: pr.repoFullName }, | |
| providerEventId: `dashboard_merge:${pr.id}`, | |
| occurredAt: now, | |
| }) | |
| .onConflictDoNothing(); | |
| await resolveIncident({ | |
| incidentId: pr.incidentId, | |
| kind: "agent_pr_merged", | |
| reasonCode: "agent_pr_merged", | |
| reasonText: `Resolved because agent PR #${pr.prNumber} (${pr.repoFullName}) was merged.`, | |
| agentRunId: pr.agentRunId, | |
| eventSummary: `Incident resolved because PR #${pr.prNumber} was merged.`, | |
| eventDetail: { | |
| agentPrId: pr.id, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| prUrl: pr.url, | |
| mergedByLogin: null, | |
| }, | |
| eventDedupeKey: `incident_resolved:agent_pr:${pr.id}`, | |
| resolvedAt: now, | |
| }); | |
| const agentRun = await db.query.agentRuns.findFirst({ | |
| where: eq(schema.agentRuns.id, pr.agentRunId), | |
| }); | |
| return c.json({ | |
| ok: true, | |
| sha: merged.sha, | |
| pullRequest: updatedPr | |
| ? (buildIncidentPullRequestViews([updatedPr], agentRun ? [agentRun] : [])[0] ?? null) | |
| : null, | |
| }); | |
| }); | |
| const INCIDENT_STATS_WINDOW_DAYS = 14; | |
| // Composite-key delimiter for (kind, service, exception_type) tuples. ASCII SOH | |
| // is never a legal char in any of those fields, so it guarantees ("ab","c") and | |
| // ("a","bc") get distinct keys. | |
| const PAIR_KEY_SEP = ""; | |
| const pairKey2 = (svc: string, et: string) => `${svc}${PAIR_KEY_SEP}${et}`; | |
| // Backfills the last N days as zero so the bar chart always shows a continuous timeline. | |
| function buildBucketsFromMap( | |
| countsByDay: Map<string, number>, | |
| windowDays: number, | |
| ): { day: string; count: number }[] { | |
| const buckets: { day: string; count: number }[] = []; | |
| const today = new Date(); | |
| today.setUTCHours(0, 0, 0, 0); | |
| for (let i = windowDays - 1; i >= 0; i--) { | |
| const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000); | |
| const key = d.toISOString().slice(0, 10); | |
| buckets.push({ day: key, count: countsByDay.get(key) ?? 0 }); | |
| } | |
| return buckets; | |
| } | |
| // Per-incident user-id sample cap. groupUniqArray(N) limits the response payload | |
| // so a viral incident doesn't blow up the list endpoint; the per-incident count | |
| // displayed in the UI is exact up to this cap, and "200+" thereafter. | |
| const INCIDENT_USERS_CAP = 200; | |
| // Bulk fetch distinct user IDs per (kind, service, exception_type) across a project. | |
| // Caller maps the returned tuples back to incidents and Set-unions per-incident. | |
| async function fetchIncidentUserIdsByPair(args: { | |
| projectId: string; | |
| spanServices: string[]; | |
| spanExcTypes: string[]; | |
| logServices: string[]; | |
| logExcTypes: string[]; | |
| windowDays: number; | |
| }): Promise<{ kind: "span" | "log"; svc: string; et: string; users: string[] }[]> { | |
| const { projectId, spanServices, spanExcTypes, logServices, logExcTypes, windowDays } = args; | |
| if (spanServices.length === 0 && logServices.length === 0) return []; | |
| const spanWhere = | |
| spanServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({spanServices:Array(String)}, {spanExcTypes:Array(String)})`; | |
| const logWhere = | |
| logServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`; | |
| const query = ` | |
| SELECT 'span' AS kind, | |
| coalesce(ServiceName, '') AS svc, | |
| coalesce(event_attrs['exception.type'], '') AS et, | |
| groupUniqArray(${INCIDENT_USERS_CAP})(nullIf(coalesce(SpanAttributes['user.id'], ResourceAttributes['user.id']), '')) AS users | |
| FROM ( | |
| SELECT Timestamp, ServiceName, SpanAttributes, ResourceAttributes, Events.Name, Events.Attributes | |
| FROM otel_traces | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND has({spanServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND has(Events.Name, 'exception') | |
| ) | |
| ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs | |
| WHERE event_name = 'exception' | |
| AND ${spanWhere} | |
| GROUP BY svc, et | |
| UNION ALL | |
| SELECT 'log' AS kind, | |
| coalesce(ServiceName, '') AS svc, | |
| coalesce(LogAttributes['exception.type'], '') AS et, | |
| groupUniqArray(${INCIDENT_USERS_CAP})(nullIf(coalesce(LogAttributes['user.id'], ResourceAttributes['user.id']), '')) AS users | |
| FROM otel_logs | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND SeverityNumber >= 17 | |
| AND has({logServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND ${logWhere} | |
| GROUP BY svc, et | |
| `; | |
| const res = await ch.query({ | |
| query, | |
| query_params: { | |
| projectId, | |
| days: windowDays, | |
| spanServices, | |
| spanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| }, | |
| format: "JSONEachRow", | |
| }); | |
| const rows = (await res.json()) as { | |
| kind: "span" | "log"; | |
| svc: string; | |
| et: string; | |
| users: string[]; | |
| }[]; | |
| return rows; | |
| } | |
| // Bulk fetch per-(service, exception_type, day) counts for spans + logs across a project. | |
| // Caller maps the returned tuples back to incidents. The incident → telemetry link is | |
| // approximate: fingerprints live in pg only, so we filter by the fingerprint *inputs* | |
| // (exception.type, optionally service). This over-counts slightly when two distinct | |
| // stacktraces share an exception type in the same service — fine for a sparkline. | |
| async function fetchIncidentTimeseriesPairs(args: { | |
| projectId: string; | |
| spanServices: string[]; | |
| spanExcTypes: string[]; | |
| logServices: string[]; | |
| logExcTypes: string[]; | |
| windowDays: number; | |
| }): Promise<{ svc: string; et: string; day: string; count: number; kind: "span" | "log" }[]> { | |
| const { projectId, spanServices, spanExcTypes, logServices, logExcTypes, windowDays } = args; | |
| if (spanServices.length === 0 && logServices.length === 0) return []; | |
| const spanWhere = | |
| spanServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({spanServices:Array(String)}, {spanExcTypes:Array(String)})`; | |
| const logWhere = | |
| logServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`; | |
| const query = ` | |
| SELECT 'span' AS kind, | |
| coalesce(ServiceName, '') AS svc, | |
| coalesce(event_attrs['exception.type'], '') AS et, | |
| toString(toDate(Timestamp)) AS day, | |
| count() AS c | |
| FROM ( | |
| SELECT Timestamp, ServiceName, Events.Name, Events.Attributes | |
| FROM otel_traces | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND has({spanServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND has(Events.Name, 'exception') | |
| ) | |
| ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs | |
| WHERE event_name = 'exception' | |
| AND ${spanWhere} | |
| GROUP BY svc, et, day | |
| UNION ALL | |
| SELECT 'log' AS kind, | |
| coalesce(ServiceName, '') AS svc, | |
| coalesce(LogAttributes['exception.type'], '') AS et, | |
| toString(toDate(Timestamp)) AS day, | |
| count() AS c | |
| FROM otel_logs | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND SeverityNumber >= 17 | |
| AND has({logServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND ${logWhere} | |
| GROUP BY svc, et, day | |
| `; | |
| const res = await ch.query({ | |
| query, | |
| query_params: { | |
| projectId, | |
| days: windowDays, | |
| spanServices, | |
| spanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| }, | |
| format: "JSONEachRow", | |
| }); | |
| const rows = (await res.json()) as { | |
| kind: "span" | "log"; | |
| svc: string; | |
| et: string; | |
| day: string; | |
| c: string | number; | |
| }[]; | |
| return rows.map((r) => ({ ...r, count: Number(r.c) })); | |
| } | |
| // Daily event counts + impacted-user count for an incident's underlying telemetry. | |
| app.get("/api/projects/:projectId/incidents/:incidentId/stats", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const windowDays = INCIDENT_STATS_WINDOW_DAYS; | |
| const empty = { | |
| windowDays, | |
| buckets: [] as { day: string; count: number }[], | |
| totalEvents: 0, | |
| impactedUsers: 0, | |
| impactedUsersAvailable: false, | |
| }; | |
| const links = await db.query.incidentIssues.findMany({ | |
| where: eq(schema.incidentIssues.incidentId, incidentId), | |
| }); | |
| if (links.length === 0) return c.json(empty); | |
| const issues = await db.query.issues.findMany({ | |
| where: inArray( | |
| schema.issues.id, | |
| links.map((l) => l.issueId), | |
| ), | |
| }); | |
| if (issues.length === 0) return c.json(empty); | |
| const fingerprints = [...new Set(issues.map((issue) => issue.fingerprint).filter(Boolean))]; | |
| const aggregateParams: Record<string, unknown> = { | |
| projectId, | |
| days: windowDays, | |
| fingerprints, | |
| }; | |
| const aggregateQuery = ` | |
| SELECT toString(day) AS bucket_day, sum(event_count) AS count | |
| FROM issue_activity_daily | |
| WHERE project_id = {projectId:String} | |
| AND fingerprint IN {fingerprints:Array(String)} | |
| AND day >= today() - ({days:UInt32} - 1) | |
| GROUP BY day | |
| ORDER BY day ASC | |
| `; | |
| const spanNamesBySample = await loadSpanNamesForIssueSamples(projectId, issues).catch((err) => { | |
| logger.warn( | |
| { err, projectId, incidentId }, | |
| "incident span-name lookup unavailable; using unscoped span stats", | |
| ); | |
| return new Map<string, string>(); | |
| }); | |
| const { | |
| namedSpanServices, | |
| namedSpanNames, | |
| namedSpanExcTypes, | |
| unnamedSpanServices, | |
| unnamedSpanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| } = buildIncidentStatsPairs(issues, spanNamesBySample); | |
| // Match spans against the incident's fingerprint inputs. When we can resolve | |
| // SpanName from the issue sample, include it so ClickHouse can use the | |
| // (ServiceName, SpanName, Timestamp) primary key instead of scanning every | |
| // exception span for a busy service. | |
| const namedSpanWhere = | |
| namedSpanServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(SpanName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({namedSpanServices:Array(String)}, {namedSpanNames:Array(String)}, {namedSpanExcTypes:Array(String)})`; | |
| const unnamedSpanWhere = | |
| unnamedSpanServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({unnamedSpanServices:Array(String)}, {unnamedSpanExcTypes:Array(String)})`; | |
| const spanWhere = `(${namedSpanWhere} OR ${unnamedSpanWhere})`; | |
| const logWhere = | |
| logServices.length === 0 | |
| ? "0" | |
| : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`; | |
| const params: Record<string, unknown> = { | |
| projectId, | |
| days: windowDays, | |
| namedSpanServices, | |
| namedSpanNames, | |
| namedSpanExcTypes, | |
| unnamedSpanServices, | |
| unnamedSpanExcTypes, | |
| logServices, | |
| logExcTypes, | |
| }; | |
| const fallbackStats = buildIncidentStatsFromIssues(issues, { windowDays }); | |
| const bucketsQuery = ` | |
| SELECT day, sum(c) AS c | |
| FROM ( | |
| SELECT toString(toDate(Timestamp)) AS day, count() AS c | |
| FROM ( | |
| SELECT Timestamp, ServiceName, SpanName, Events.Name, Events.Attributes | |
| FROM otel_traces | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND ( | |
| has({namedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| OR has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| ) | |
| AND ( | |
| has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| OR has({namedSpanNames:Array(String)}, coalesce(SpanName, '')) | |
| ) | |
| AND has(Events.Name, 'exception') | |
| ) | |
| ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs | |
| WHERE event_name = 'exception' | |
| AND ${spanWhere} | |
| GROUP BY day | |
| UNION ALL | |
| SELECT toString(toDate(Timestamp)) AS day, count() AS c | |
| FROM otel_logs | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND SeverityNumber >= 17 | |
| AND has({logServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND ${logWhere} | |
| GROUP BY day | |
| ) | |
| GROUP BY day | |
| ORDER BY day ASC | |
| `; | |
| const usersQuery = ` | |
| SELECT | |
| uniqExact(uid) AS users, | |
| countIf(uid IS NOT NULL) AS tagged_events | |
| FROM ( | |
| SELECT nullIf(coalesce(SpanAttributes['user.id'], ResourceAttributes['user.id']), '') AS uid | |
| FROM ( | |
| SELECT Timestamp, ServiceName, SpanName, SpanAttributes, ResourceAttributes, Events.Name, Events.Attributes | |
| FROM otel_traces | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND ( | |
| has({namedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| OR has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| ) | |
| AND ( | |
| has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, '')) | |
| OR has({namedSpanNames:Array(String)}, coalesce(SpanName, '')) | |
| ) | |
| AND has(Events.Name, 'exception') | |
| ) | |
| ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs | |
| WHERE event_name = 'exception' | |
| AND ${spanWhere} | |
| UNION ALL | |
| SELECT nullIf(coalesce(LogAttributes['user.id'], ResourceAttributes['user.id']), '') AS uid | |
| FROM otel_logs | |
| WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY | |
| AND ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND SeverityNumber >= 17 | |
| AND has({logServices:Array(String)}, coalesce(ServiceName, '')) | |
| AND ${logWhere} | |
| ) | |
| `; | |
| const stats = await buildIncidentStatsWithFallback({ | |
| fallback: fallbackStats, | |
| // Stay below the ClickHouse client's 10s request timeout so this endpoint | |
| // returns issue-derived activity instead of surfacing a 500 to the drawer. | |
| timeoutMs: 8_500, | |
| onTelemetryUnavailable: (reason, err) => { | |
| logger.warn( | |
| { err, reason, projectId, incidentId }, | |
| "incident telemetry stats unavailable; returning issue fallback", | |
| ); | |
| }, | |
| loadTelemetry: async (signal) => { | |
| try { | |
| const activityRes = await ch.query({ | |
| query: aggregateQuery, | |
| query_params: aggregateParams, | |
| format: "JSONEachRow", | |
| abort_signal: signal, | |
| }); | |
| const activityRows = (await activityRes.json()) as { | |
| bucket_day: string; | |
| count: string | number; | |
| }[]; | |
| return buildIncidentStatsFromActivityRows( | |
| activityRows.map((row) => ({ day: row.bucket_day, count: row.count })), | |
| { windowDays }, | |
| ); | |
| } catch (err) { | |
| logger.warn( | |
| { err, projectId, incidentId }, | |
| "incident fingerprint activity aggregate unavailable; falling back to raw telemetry stats", | |
| ); | |
| } | |
| const [bucketsRes, usersRes] = await Promise.all([ | |
| ch.query({ | |
| query: bucketsQuery, | |
| query_params: params, | |
| format: "JSONEachRow", | |
| abort_signal: signal, | |
| }), | |
| ch.query({ | |
| query: usersQuery, | |
| query_params: params, | |
| format: "JSONEachRow", | |
| abort_signal: signal, | |
| }), | |
| ]); | |
| const bucketRows = (await bucketsRes.json()) as { day: string; c: string | number }[]; | |
| const userRows = (await usersRes.json()) as { | |
| users: string | number; | |
| tagged_events: string | number; | |
| }[]; | |
| const countsByDay = new Map(bucketRows.map((r) => [r.day, Number(r.c)])); | |
| const buckets = buildBucketsFromMap(countsByDay, windowDays); | |
| const totalEvents = buckets.reduce((a, b) => a + b.count, 0); | |
| const taggedEvents = Number(userRows[0]?.tagged_events ?? 0); | |
| return { | |
| windowDays, | |
| buckets, | |
| totalEvents, | |
| impactedUsers: Number(userRows[0]?.users ?? 0), | |
| // false ⇒ no event in the window had a user.id attribute, so the UI can show a | |
| // "not instrumented" hint and link to the OTel onboarding skill. | |
| impactedUsersAvailable: taggedEvents > 0, | |
| }; | |
| }, | |
| }); | |
| return c.json(stats); | |
| }); | |
| async function loadSpanNamesForIssueSamples( | |
| projectId: string, | |
| issues: Pick<Issue, "kind" | "lastSample">[], | |
| ): Promise<Map<string, string>> { | |
| const samples: { traceId: string; spanId: string }[] = []; | |
| const seen = new Set<string>(); | |
| for (const issue of issues) { | |
| if (issue.kind === "log" || issue.lastSample?.spanName) continue; | |
| const traceId = issue.lastSample?.traceId; | |
| const spanId = issue.lastSample?.spanId; | |
| if (!traceId || !spanId) continue; | |
| const key = spanSampleKey(traceId, spanId); | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| samples.push({ traceId, spanId }); | |
| } | |
| if (samples.length === 0) return new Map(); | |
| const res = await ch.query({ | |
| query: ` | |
| SELECT | |
| TraceId AS trace_id, | |
| SpanId AS span_id, | |
| any(SpanName) AS span_name | |
| FROM otel_traces | |
| WHERE ResourceAttributes['superlog.project_id'] = {projectId:String} | |
| AND TraceId IN {traceIds:Array(String)} | |
| AND SpanId IN {spanIds:Array(String)} | |
| GROUP BY trace_id, span_id | |
| `, | |
| query_params: { | |
| projectId, | |
| traceIds: samples.map((s) => s.traceId), | |
| spanIds: samples.map((s) => s.spanId), | |
| }, | |
| format: "JSONEachRow", | |
| }); | |
| const rows = (await res.json()) as { trace_id: string; span_id: string; span_name: string }[]; | |
| return new Map( | |
| rows | |
| .filter((row) => row.span_name) | |
| .map((row) => [spanSampleKey(row.trace_id, row.span_id), row.span_name]), | |
| ); | |
| } | |
| // Pull the latest open (decision IS NULL) resolution proposal for each | |
| // incident id. Returns a Map keyed by incident id so the caller can | |
| // stitch it into the response without an N+1 query. Empty input → empty | |
| // map (no DB roundtrip). | |
| async function loadPendingResolutionProposals( | |
| incidentIds: string[], | |
| ): Promise<Map<string, PendingResolutionProposal>> { | |
| if (incidentIds.length === 0) return new Map(); | |
| const rows = await db.query.incidentResolutionProposals.findMany({ | |
| where: and( | |
| inArray(schema.incidentResolutionProposals.incidentId, incidentIds), | |
| isNull(schema.incidentResolutionProposals.decision), | |
| ), | |
| orderBy: [desc(schema.incidentResolutionProposals.proposedAt)], | |
| }); | |
| // One open proposal per incident is the invariant — the gating in the | |
| // sweep selector enforces it — but defensively pick the newest if for | |
| // some reason multiple exist (e.g. mid-migration data). | |
| const map = new Map<string, PendingResolutionProposal>(); | |
| for (const row of rows) { | |
| if (map.has(row.incidentId)) continue; | |
| map.set(row.incidentId, { | |
| id: row.id, | |
| sourceKind: row.sourceKind, | |
| confidence: row.confidence, | |
| proposedReasonCode: row.proposedReasonCode, | |
| proposedReasonText: row.proposedReasonText, | |
| proposedAt: row.proposedAt.toISOString(), | |
| }); | |
| } | |
| return map; | |
| } | |
| type PendingResolutionProposal = { | |
| id: string; | |
| sourceKind: string; | |
| confidence: schema.IncidentResolutionProposalConfidence; | |
| proposedReasonCode: string; | |
| proposedReasonText: string; | |
| proposedAt: string; | |
| }; | |
| // Back-compat: returns the same {agentRun, events} shape the web client | |
| // used pre-merge. The main `/incidents/:id` GET now folds this in. | |
| app.get("/api/projects/:projectId/incidents/:incidentId/agent-run", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const agentRun = await getLatestAgentRun(incident.id); | |
| // An incident may have lifecycle events (manual resolve from the | |
| // dashboard or Slack, sweep proposal confirmed) without ever having | |
| // had an agent run — pass a never-matches uuid so loadIncidentTimeline | |
| // returns only the incident-scoped lifecycle events. | |
| const events = await loadIncidentTimeline( | |
| incident.id, | |
| agentRun?.id ?? "00000000-0000-0000-0000-000000000000", | |
| ); | |
| return c.json({ agentRun: agentRun ?? null, events }); | |
| }); | |
| app.post("/api/projects/:projectId/incidents/:incidentId/agent-run/restart", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const latest = await getLatestAgentRun(incident.id); | |
| if (!latest) throw new HTTPException(404, { message: "agent run not found" }); | |
| const automation = await getProjectAutomation(projectId); | |
| if (!automation.agentRunEnabled) { | |
| throw new HTTPException(400, { message: "incident agent_runs are disabled" }); | |
| } | |
| const activeStates = [ | |
| "queued", | |
| "repo_discovery", | |
| "running", | |
| "awaiting_human", | |
| "pr_retry_queued", | |
| "blocked_no_github", | |
| ]; | |
| const now = new Date(); | |
| const agentRun = await db.transaction(async (tx) => { | |
| const superseded = await tx | |
| .update(schema.agentRuns) | |
| .set({ state: "superseded", completedAt: now, updatedAt: now }) | |
| .where( | |
| and( | |
| eq(schema.agentRuns.incidentId, incident.id), | |
| inArray(schema.agentRuns.state, activeStates), | |
| ), | |
| ) | |
| .returning({ id: schema.agentRuns.id }); | |
| if (superseded.length > 0) { | |
| await tx.insert(schema.incidentEvents).values( | |
| superseded.map((row) => ({ | |
| agentRunId: row.id, | |
| kind: "agent_run_superseded", | |
| summary: "Investigation superseded by a restart.", | |
| dedupeKey: `superseded:${row.id}:${now.getTime()}`, | |
| processedAt: now, | |
| })), | |
| ); | |
| } | |
| const [created] = await tx | |
| .insert(schema.agentRuns) | |
| .values({ | |
| incidentId: incident.id, | |
| runtime: automation.agentRunProvider, | |
| state: "queued", | |
| }) | |
| .returning(); | |
| if (!created) throw new Error("failed to restart agent run"); | |
| await tx.insert(schema.incidentEvents).values({ | |
| agentRunId: created.id, | |
| kind: "agent_run_restarted", | |
| summary: "Investigation restarted.", | |
| detail: { | |
| restartedFromAgentRunId: latest.id, | |
| restartedFromState: latest.state, | |
| }, | |
| dedupeKey: `restart:${created.id}`, | |
| processedAt: now, | |
| }); | |
| return created; | |
| }); | |
| return c.json(agentRun); | |
| }); | |
| app.post("/api/projects/:projectId/incidents/:incidentId/agent-run/retry-pr", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| const incidentId = c.req.param("incidentId"); | |
| await requireProjectAccess(c, projectId); | |
| const incident = await getProjectIncident(projectId, incidentId); | |
| if (!incident) throw new HTTPException(404, { message: "incident not found" }); | |
| const latest = await getLatestAgentRun(incident.id); | |
| if (!latest) throw new HTTPException(404, { message: "agent run not found" }); | |
| const eligibility = getPrDeliveryRetryEligibility(latest ?? null); | |
| if (!eligibility.canRetry) { | |
| throw new HTTPException(400, { message: eligibility.reason }); | |
| } | |
| const now = new Date(); | |
| const agentRun = await db.transaction(async (tx) => { | |
| const [updated] = await tx | |
| .update(schema.agentRuns) | |
| .set({ | |
| state: "pr_retry_queued", | |
| failureReason: null, | |
| completedAt: null, | |
| updatedAt: now, | |
| }) | |
| .where( | |
| and( | |
| eq(schema.agentRuns.id, latest.id), | |
| eq(schema.agentRuns.state, "failed"), | |
| eq(schema.agentRuns.failureReason, "pr_open_failed"), | |
| ), | |
| ) | |
| .returning(); | |
| if (!updated) throw new HTTPException(409, { message: "agent run is no longer retryable" }); | |
| await tx.insert(schema.incidentEvents).values({ | |
| agentRunId: updated.id, | |
| kind: "agent_run_pr_retry_queued", | |
| summary: "PR delivery retry queued.", | |
| detail: { | |
| retriedFromState: latest.state, | |
| selectedRepoFullName: latest.result?.pr?.selectedRepoFullName ?? null, | |
| branchName: latest.result?.pr?.branchName ?? null, | |
| }, | |
| dedupeKey: `pr-retry:${updated.id}:${now.getTime()}`, | |
| processedAt: now, | |
| }); | |
| return updated; | |
| }); | |
| return c.json(agentRun); | |
| }); | |
| type TimelineEvent = { | |
| id: string; | |
| agentRunId: string; | |
| kind: string; | |
| summary: string | null; | |
| detail: Record<string, unknown> | null; | |
| // The provider's own event id (Anthropic `sevt_…`). Tool_result events carry | |
| // the matching `toolUseId` in their detail; pairing logic in the UI keys on | |
| // this, not on `id` (which is a freshly-allocated row UUID). | |
| providerEventId: string | null; | |
| createdAt: string; | |
| source: "agent_run" | "agent_pr" | "agent_linear"; | |
| actor?: { | |
| name: string | null; | |
| avatarUrl: string | null; | |
| profileUrl: string | null; | |
| } | null; | |
| }; | |
| async function loadIncidentTimeline( | |
| incidentId: string, | |
| agentRunId: string, | |
| ): Promise<TimelineEvent[]> { | |
| const [agentRunEvents, prRows, linearTickets] = await Promise.all([ | |
| // Match events on either join key: events tied to this incident's | |
| // current agent run OR events keyed directly on the incident (manual | |
| // resolves, sweep proposal confirmations, etc., don't have an | |
| // agent_run_id to anchor to). | |
| db.query.incidentEvents.findMany({ | |
| where: or( | |
| eq(schema.incidentEvents.agentRunId, agentRunId), | |
| eq(schema.incidentEvents.incidentId, incidentId), | |
| ), | |
| orderBy: [asc(schema.incidentEvents.createdAt)], | |
| }), | |
| db.query.agentPullRequests.findMany({ | |
| where: eq(schema.agentPullRequests.incidentId, incidentId), | |
| }), | |
| db.query.agentLinearTickets.findMany({ | |
| where: eq(schema.agentLinearTickets.incidentId, incidentId), | |
| }), | |
| ]); | |
| const [prEventRows, linearEventRows] = await Promise.all([ | |
| prRows.length === 0 | |
| ? ([] as schema.AgentPrEvent[]) | |
| : db.query.agentPrEvents.findMany({ | |
| where: inArray( | |
| schema.agentPrEvents.agentPrId, | |
| prRows.map((r) => r.id), | |
| ), | |
| orderBy: [asc(schema.agentPrEvents.occurredAt)], | |
| }), | |
| linearTickets.length === 0 | |
| ? ([] as schema.AgentLinearTicketEvent[]) | |
| : db.query.agentLinearTicketEvents.findMany({ | |
| where: inArray( | |
| schema.agentLinearTicketEvents.agentLinearTicketId, | |
| linearTickets.map((r) => r.id), | |
| ), | |
| orderBy: [asc(schema.agentLinearTicketEvents.occurredAt)], | |
| }), | |
| ]); | |
| const prById = new Map(prRows.map((r) => [r.id, r] as const)); | |
| const linearById = new Map(linearTickets.map((r) => [r.id, r] as const)); | |
| const items: TimelineEvent[] = []; | |
| for (const ev of agentRunEvents) { | |
| items.push({ | |
| id: ev.id, | |
| // The row's own agent_run_id if it has one; fall back to the | |
| // active agent run id we were called with so consumers that key | |
| // off `agentRunId` still group events correctly. Lifecycle events | |
| // without any run context land with the caller-supplied value. | |
| agentRunId: ev.agentRunId ?? agentRunId, | |
| kind: ev.kind, | |
| summary: ev.summary, | |
| detail: ev.detail, | |
| providerEventId: ev.providerEventId, | |
| createdAt: ev.createdAt.toISOString(), | |
| source: "agent_run", | |
| actor: null, | |
| }); | |
| } | |
| for (const ev of prEventRows) { | |
| const pr = prById.get(ev.agentPrId); | |
| items.push({ | |
| id: ev.id, | |
| agentRunId, | |
| kind: ev.kind, | |
| summary: ev.summary, | |
| detail: { | |
| ...(ev.payload ?? {}), | |
| prUrl: pr?.url ?? null, | |
| prNumber: pr?.prNumber ?? null, | |
| repoFullName: pr?.repoFullName ?? null, | |
| }, | |
| providerEventId: ev.providerEventId, | |
| createdAt: ev.occurredAt.toISOString(), | |
| source: "agent_pr", | |
| actor: ev.actorLogin | |
| ? { | |
| name: ev.actorLogin, | |
| avatarUrl: ev.actorAvatarUrl, | |
| profileUrl: `https://github.com/${ev.actorLogin}`, | |
| } | |
| : null, | |
| }); | |
| } | |
| for (const ev of linearEventRows) { | |
| const ticket = linearById.get(ev.agentLinearTicketId); | |
| items.push({ | |
| id: ev.id, | |
| agentRunId, | |
| kind: ev.kind, | |
| summary: ev.summary, | |
| detail: { | |
| ...(ev.payload ?? {}), | |
| ticketUrl: ticket?.url ?? null, | |
| ticketIdentifier: ticket?.ticketIdentifier ?? null, | |
| }, | |
| providerEventId: ev.providerEventId, | |
| createdAt: ev.occurredAt.toISOString(), | |
| source: "agent_linear", | |
| actor: ev.actorName | |
| ? { name: ev.actorName, avatarUrl: ev.actorAvatarUrl, profileUrl: null } | |
| : null, | |
| }); | |
| } | |
| items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); | |
| return items; | |
| } | |
| if (shouldRunMigrationsOnBoot(process.env)) { | |
| await runMigrations(); | |
| logger.info({ scope: "db" }, "migrations applied"); | |
| } else { | |
| // Managed deploy: migrations are applied by a dedicated, gated step as a | |
| // schema-owner role before this service rolls out. The app role can't (and | |
| // shouldn't) run DDL on boot. | |
| logger.info({ scope: "db" }, "skipping boot migrations (RUN_MIGRATIONS_ON_BOOT=false)"); | |
| } | |
| const server = serve({ fetch: app.fetch, port: PORT }); | |
| // Same load-balancer keep-alive concern as the proxy: Node's default 5s | |
| // keepAliveTimeout lets it close idle sockets an upstream LB (typically ~60s | |
| // idle timeout) still pools, so reused connections RST and surface as a 502 | |
| // even though the app is healthy. Keep the keep-alive comfortably above the LB | |
| // idle timeout — a thin margin still leaks 502s under bursty connection reuse — | |
| // with headersTimeout above keepAliveTimeout per Node's required order. | |
| if ("keepAliveTimeout" in server) { | |
| server.keepAliveTimeout = 75_000; | |
| server.headersTimeout = 76_000; | |
| } | |
| logger.info({ port: PORT }, "superlog api listening"); | |