Blame · Line-by-line history
prod-signals.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| a6d8fd5 | 1 | /** |
| 2 | * Block K9 — Production + test signal ingestion. | |
| 3 | * | |
| 4 | * Crontech (prod runtime), Gatetest (test runner), Sentry (external APM), | |
| 5 | * and manual callers post per-commit error signals back into Gluecron so | |
| 6 | * commits / PRs can be annotated with real-world failure data and so the | |
| 7 | * autonomous agent loops (fix, heal_bot) have something concrete to chase. | |
| 8 | * | |
| 9 | * Shape follows the commit-statuses template: pure helpers at the top, | |
| 10 | * DB helpers under the divider. DB helpers are defensive — never throw, | |
| 11 | * errors route to console.error so the caller's primary flow survives a | |
| 12 | * bad DB hop. | |
| 13 | */ | |
| 14 | ||
| 15 | import { createHash } from "node:crypto"; | |
| 16 | import { and, desc, eq, gte, sql } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | ||
| 19 | // NOTE: `prodSignals` is declared in src/db/schema.ts by the main thread | |
| 20 | // (see the snippet delivered alongside this file). We resolve it lazily so | |
| 21 | // the pure helpers in this module remain importable even if a session | |
| 22 | // lands the code before the schema edit is merged. Once schema.ts exports | |
| 23 | // `prodSignals`, the runtime import resolves on first DB call. | |
| 24 | let _prodSignals: any; | |
| 25 | async function prodSignalsTable(): Promise<any> { | |
| 26 | if (_prodSignals) return _prodSignals; | |
| 27 | const schema = await import("../db/schema"); | |
| 28 | _prodSignals = (schema as any).prodSignals; | |
| 29 | if (!_prodSignals) { | |
| 30 | throw new Error( | |
| 31 | "prodSignals table not exported from db/schema.ts (Block K9). " + | |
| 32 | "See lib/prod-signals.ts header for the snippet to paste." | |
| 33 | ); | |
| 34 | } | |
| 35 | return _prodSignals; | |
| 36 | } | |
| 37 | // Re-export for convenience in route code that wants the symbol directly. | |
| 38 | export async function _getProdSignalsTable() { | |
| 39 | return prodSignalsTable(); | |
| 40 | } | |
| 41 | ||
| 42 | export type SignalSource = "crontech" | "gatetest" | "sentry" | "manual"; | |
| 43 | ||
| 44 | export const SIGNAL_SOURCES: SignalSource[] = [ | |
| 45 | "crontech", | |
| 46 | "gatetest", | |
| 47 | "sentry", | |
| 48 | "manual", | |
| 49 | ]; | |
| 50 | ||
| 51 | export type SignalKind = | |
| 52 | | "runtime_error" | |
| 53 | | "test_failure" | |
| 54 | | "deploy_failure" | |
| 55 | | "performance" | |
| 56 | | "security"; | |
| 57 | ||
| 58 | export type SignalSeverity = "info" | "warning" | "error" | "critical"; | |
| 59 | export type SignalStatus = "open" | "dismissed" | "resolved"; | |
| 60 | ||
| 61 | const MESSAGE_MAX = 4000; | |
| 62 | const STACK_MAX = 16_000; | |
| 63 | const FRAME_MAX = 512; | |
| 64 | const HASH_LEN = 16; | |
| 65 | ||
| 66 | /** Git short-sha / full-sha sanity check. Hex, 7–64. */ | |
| 67 | export function isValidSha(sha: string | null | undefined): boolean { | |
| 68 | if (!sha) return false; | |
| 69 | if (typeof sha !== "string") return false; | |
| 70 | if (sha.length < 7 || sha.length > 64) return false; | |
| 71 | return /^[a-f0-9]+$/i.test(sha); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Normalise an error message for grouping. Collapses whitespace, strips | |
| 76 | * volatile fragments (hex pointers, line/col tails, numeric-only tokens) | |
| 77 | * so two occurrences of the same bug from different runs hash identically. | |
| 78 | */ | |
| 79 | function normaliseMessage(msg: string): string { | |
| 80 | return msg | |
| 81 | .trim() | |
| 82 | .replace(/\s+/g, " ") | |
| 83 | // Strip hex pointers like 0x7fffabcd | |
| 84 | .replace(/0x[0-9a-f]+/gi, "0x_") | |
| 85 | // Strip trailing :line:col noise | |
| 86 | .replace(/:\d+:\d+\b/g, ":_:_") | |
| 87 | .slice(0, MESSAGE_MAX); | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Extract the top, user-meaningful stack frame. Skips blank lines and any | |
| 92 | * line containing `node_modules`. Returns "" on malformed / empty input. | |
| 93 | * Capped at FRAME_MAX chars. | |
| 94 | */ | |
| 95 | export function extractTopFrame(stackTrace: string | null | undefined): string { | |
| 96 | if (!stackTrace || typeof stackTrace !== "string") return ""; | |
| 97 | const lines = stackTrace.split(/\r?\n/); | |
| 98 | for (const raw of lines) { | |
| 99 | const line = raw.trim(); | |
| 100 | if (!line) continue; | |
| 101 | if (line.includes("node_modules")) continue; | |
| 102 | return line.slice(0, FRAME_MAX); | |
| 103 | } | |
| 104 | return ""; | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Stable 16-hex-char fingerprint of `message + " @ " + topFrame` used as | |
| 109 | * the grouping key for count-bumping. Deterministic across processes. | |
| 110 | */ | |
| 111 | export function hashError( | |
| 112 | message: string | null | undefined, | |
| 113 | topStackFrame: string | null | undefined | |
| 114 | ): string { | |
| 115 | const m = normaliseMessage(String(message ?? "")); | |
| 116 | const f = String(topStackFrame ?? "").trim().slice(0, FRAME_MAX); | |
| 117 | const h = createHash("sha256").update(`${m} @ ${f}`).digest("hex"); | |
| 118 | return h.slice(0, HASH_LEN); | |
| 119 | } | |
| 120 | ||
| 121 | /** Allow-list the source tag. Unknown / empty → "manual". */ | |
| 122 | export function sanitiseSource(source: unknown): SignalSource { | |
| 123 | if (typeof source !== "string") return "manual"; | |
| 124 | const s = source.trim().toLowerCase(); | |
| 125 | if ((SIGNAL_SOURCES as string[]).includes(s)) return s as SignalSource; | |
| 126 | return "manual"; | |
| 127 | } | |
| 128 | ||
| 129 | const VALID_KINDS: SignalKind[] = [ | |
| 130 | "runtime_error", | |
| 131 | "test_failure", | |
| 132 | "deploy_failure", | |
| 133 | "performance", | |
| 134 | "security", | |
| 135 | ]; | |
| 136 | ||
| 137 | export function sanitiseKind(kind: unknown): SignalKind { | |
| 138 | if (typeof kind !== "string") return "runtime_error"; | |
| 139 | const k = kind.trim().toLowerCase(); | |
| 140 | if ((VALID_KINDS as string[]).includes(k)) return k as SignalKind; | |
| 141 | return "runtime_error"; | |
| 142 | } | |
| 143 | ||
| 144 | const VALID_SEVERITIES: SignalSeverity[] = [ | |
| 145 | "info", | |
| 146 | "warning", | |
| 147 | "error", | |
| 148 | "critical", | |
| 149 | ]; | |
| 150 | ||
| 151 | export function sanitiseSeverity(sev: unknown): SignalSeverity { | |
| 152 | if (typeof sev !== "string") return "error"; | |
| 153 | const s = sev.trim().toLowerCase(); | |
| 154 | if ((VALID_SEVERITIES as string[]).includes(s)) return s as SignalSeverity; | |
| 155 | return "error"; | |
| 156 | } | |
| 157 | ||
| 158 | // ---------- DB helpers ---------- | |
| 159 | ||
| 160 | export interface RecordSignalInput { | |
| 161 | repositoryId: string; | |
| 162 | commitSha: string; | |
| 163 | source: SignalSource | string; | |
| 164 | kind: SignalKind | string; | |
| 165 | message: string; | |
| 166 | stackTrace?: string | null; | |
| 167 | deployId?: string | null; | |
| 168 | environment?: string | null; | |
| 169 | severity?: SignalSeverity | string | null; | |
| 170 | samplePayload?: string | null; | |
| 171 | } | |
| 172 | ||
| 173 | export interface RecordSignalResult { | |
| 174 | id: string; | |
| 175 | status: SignalStatus; | |
| 176 | count: number; | |
| 177 | bumped: boolean; | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Insert or bump-count a signal. Keyed on (repository_id, error_hash). | |
| 182 | * Repeated posts of the same bug increment `count` and push `last_seen` | |
| 183 | * forward without creating new rows — cheap idempotency for noisy | |
| 184 | * runtimes. Defensive: returns null on any DB failure. | |
| 185 | */ | |
| 186 | export async function recordSignal( | |
| 187 | input: RecordSignalInput | |
| 188 | ): Promise<RecordSignalResult | null> { | |
| 189 | try { | |
| 190 | if (!isValidSha(input.commitSha)) return null; | |
| 191 | const sha = input.commitSha.toLowerCase(); | |
| 192 | const source = sanitiseSource(input.source); | |
| 193 | const kind = sanitiseKind(input.kind); | |
| 194 | const severity = sanitiseSeverity(input.severity); | |
| 195 | const message = (input.message || "").slice(0, MESSAGE_MAX); | |
| 196 | const stackTrace = input.stackTrace | |
| 197 | ? input.stackTrace.slice(0, STACK_MAX) | |
| 198 | : null; | |
| 199 | const topFrame = extractTopFrame(stackTrace); | |
| 200 | const errorHash = hashError(message, topFrame); | |
| 201 | const prodSignals = await prodSignalsTable(); | |
| 202 | ||
| 203 | // Check for existing row with the same (repo, hash). | |
| 204 | const [existing] = await db | |
| 205 | .select() | |
| 206 | .from(prodSignals) | |
| 207 | .where( | |
| 208 | and( | |
| 209 | eq(prodSignals.repositoryId, input.repositoryId), | |
| 210 | eq(prodSignals.errorHash, errorHash) | |
| 211 | ) | |
| 212 | ) | |
| 213 | .limit(1); | |
| 214 | ||
| 215 | if (existing) { | |
| 216 | const [bumped] = await db | |
| 217 | .update(prodSignals) | |
| 218 | .set({ | |
| 219 | count: (existing.count || 0) + 1, | |
| 220 | lastSeen: new Date(), | |
| 221 | // Most recent commit wins — if the bug keeps happening on newer | |
| 222 | // shas the view should reflect that. | |
| 223 | commitSha: sha, | |
| 224 | }) | |
| 225 | .where(eq(prodSignals.id, existing.id)) | |
| 226 | .returning(); | |
| 227 | if (!bumped) return null; | |
| 228 | return { | |
| 229 | id: bumped.id, | |
| 230 | status: (bumped.status || "open") as SignalStatus, | |
| 231 | count: bumped.count || 1, | |
| 232 | bumped: true, | |
| 233 | }; | |
| 234 | } | |
| 235 | ||
| 236 | const [row] = await db | |
| 237 | .insert(prodSignals) | |
| 238 | .values({ | |
| 239 | repositoryId: input.repositoryId, | |
| 240 | commitSha: sha, | |
| 241 | errorHash, | |
| 242 | source, | |
| 243 | kind, | |
| 244 | severity, | |
| 245 | message, | |
| 246 | stackTrace, | |
| 247 | deployId: input.deployId || null, | |
| 248 | environment: input.environment || null, | |
| 249 | samplePayload: input.samplePayload || null, | |
| 250 | }) | |
| 251 | .returning(); | |
| 252 | if (!row) return null; | |
| 253 | return { | |
| 254 | id: row.id, | |
| 255 | status: (row.status || "open") as SignalStatus, | |
| 256 | count: row.count || 1, | |
| 257 | bumped: false, | |
| 258 | }; | |
| 259 | } catch (err) { | |
| 260 | console.error("[prod-signals] recordSignal:", err); | |
| 261 | return null; | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | export async function listSignalsForCommit( | |
| 266 | repositoryId: string, | |
| 267 | commitSha: string, | |
| 268 | limit = 50 | |
| 269 | ): Promise<any[]> { | |
| 270 | try { | |
| 271 | if (!isValidSha(commitSha)) return []; | |
| 272 | const prodSignals = await prodSignalsTable(); | |
| 273 | return await db | |
| 274 | .select() | |
| 275 | .from(prodSignals) | |
| 276 | .where( | |
| 277 | and( | |
| 278 | eq(prodSignals.repositoryId, repositoryId), | |
| 279 | eq(prodSignals.commitSha, commitSha.toLowerCase()) | |
| 280 | ) | |
| 281 | ) | |
| 282 | .orderBy(desc(prodSignals.lastSeen)) | |
| 283 | .limit(limit); | |
| 284 | } catch (err) { | |
| 285 | console.error("[prod-signals] listSignalsForCommit:", err); | |
| 286 | return []; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | export async function listOpenSignalsForRepo( | |
| 291 | repositoryId: string, | |
| 292 | limit = 100 | |
| 293 | ): Promise<any[]> { | |
| 294 | try { | |
| 295 | const prodSignals = await prodSignalsTable(); | |
| 296 | return await db | |
| 297 | .select() | |
| 298 | .from(prodSignals) | |
| 299 | .where( | |
| 300 | and( | |
| 301 | eq(prodSignals.repositoryId, repositoryId), | |
| 302 | eq(prodSignals.status, "open") | |
| 303 | ) | |
| 304 | ) | |
| 305 | .orderBy(desc(prodSignals.lastSeen)) | |
| 306 | .limit(limit); | |
| 307 | } catch (err) { | |
| 308 | console.error("[prod-signals] listOpenSignalsForRepo:", err); | |
| 309 | return []; | |
| 310 | } | |
| 311 | } | |
| 312 | ||
| 313 | /** | |
| 314 | * PR-scoped listing. Pragmatic v1: rather than shelling out to `git log | |
| 315 | * base..head`, we return signals in the repo created after the PR's | |
| 316 | * creation timestamp. Noisy-but-useful — the agent fix loop filters | |
| 317 | * further by commit_sha once it has the PR's commit list from git. | |
| 318 | */ | |
| 319 | export async function listSignalsForPr( | |
| 320 | repositoryId: string, | |
| 321 | _baseSha: string, | |
| 322 | _headSha: string, | |
| 323 | prCreatedAt: Date | null = null, | |
| 324 | limit = 50 | |
| 325 | ): Promise<any[]> { | |
| 326 | try { | |
| 327 | const since = prCreatedAt || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); | |
| 328 | const prodSignals = await prodSignalsTable(); | |
| 329 | return await db | |
| 330 | .select() | |
| 331 | .from(prodSignals) | |
| 332 | .where( | |
| 333 | and( | |
| 334 | eq(prodSignals.repositoryId, repositoryId), | |
| 335 | gte(prodSignals.createdAt, since) | |
| 336 | ) | |
| 337 | ) | |
| 338 | .orderBy(desc(prodSignals.lastSeen)) | |
| 339 | .limit(limit); | |
| 340 | } catch (err) { | |
| 341 | console.error("[prod-signals] listSignalsForPr:", err); | |
| 342 | return []; | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | export async function dismissSignal(id: string): Promise<boolean> { | |
| 347 | try { | |
| 348 | const prodSignals = await prodSignalsTable(); | |
| 349 | const [row] = await db | |
| 350 | .update(prodSignals) | |
| 351 | .set({ status: "dismissed" }) | |
| 352 | .where(eq(prodSignals.id, id)) | |
| 353 | .returning(); | |
| 354 | return !!row; | |
| 355 | } catch (err) { | |
| 356 | console.error("[prod-signals] dismissSignal:", err); | |
| 357 | return false; | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | export async function resolveSignal( | |
| 362 | id: string, | |
| 363 | resolvedByCommit?: string | null | |
| 364 | ): Promise<boolean> { | |
| 365 | try { | |
| 366 | const commit = | |
| 367 | resolvedByCommit && isValidSha(resolvedByCommit) | |
| 368 | ? resolvedByCommit.toLowerCase() | |
| 369 | : null; | |
| 370 | const prodSignals = await prodSignalsTable(); | |
| 371 | const [row] = await db | |
| 372 | .update(prodSignals) | |
| 373 | .set({ | |
| 374 | status: "resolved", | |
| 375 | resolvedAt: new Date(), | |
| 376 | resolvedByCommit: commit, | |
| 377 | }) | |
| 378 | .where(eq(prodSignals.id, id)) | |
| 379 | .returning(); | |
| 380 | return !!row; | |
| 381 | } catch (err) { | |
| 382 | console.error("[prod-signals] resolveSignal:", err); | |
| 383 | return false; | |
| 384 | } | |
| 385 | } | |
| 386 | ||
| 387 | /** Count total signals in a repo — for badges / nav. Defensive. */ | |
| 388 | export async function countOpenSignals(repositoryId: string): Promise<number> { | |
| 389 | try { | |
| 390 | const prodSignals = await prodSignalsTable(); | |
| 391 | const [r] = await db | |
| 392 | .select({ n: sql<number>`count(*)::int` }) | |
| 393 | .from(prodSignals) | |
| 394 | .where( | |
| 395 | and( | |
| 396 | eq(prodSignals.repositoryId, repositoryId), | |
| 397 | eq(prodSignals.status, "open") | |
| 398 | ) | |
| 399 | ); | |
| 400 | return Number(r?.n || 0); | |
| 401 | } catch { | |
| 402 | return 0; | |
| 403 | } | |
| 404 | } | |
| 405 | ||
| 406 | export const __internal = { | |
| 407 | MESSAGE_MAX, | |
| 408 | STACK_MAX, | |
| 409 | FRAME_MAX, | |
| 410 | HASH_LEN, | |
| 411 | normaliseMessage, | |
| 412 | }; |