@secondlayer/shared 13.3.0 → 13.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/src/archive/range-digest.d.ts +50 -0
  2. package/dist/src/archive/semantic-digest-builder.d.ts +50 -0
  3. package/dist/src/coverage/index.d.ts +50 -0
  4. package/dist/src/db/index.d.ts +54 -1
  5. package/dist/src/db/index.js +4 -1
  6. package/dist/src/db/index.js.map +3 -3
  7. package/dist/src/db/queries/chain-reorgs.d.ts +50 -0
  8. package/dist/src/db/queries/chain-reorgs.js +4 -1
  9. package/dist/src/db/queries/chain-reorgs.js.map +3 -3
  10. package/dist/src/db/queries/contracts.d.ts +50 -0
  11. package/dist/src/db/queries/instance.d.ts +50 -0
  12. package/dist/src/db/queries/integrity.d.ts +50 -0
  13. package/dist/src/db/queries/subgraph-gaps.d.ts +50 -0
  14. package/dist/src/db/queries/subgraph-operations.d.ts +50 -0
  15. package/dist/src/db/queries/subgraphs.d.ts +50 -0
  16. package/dist/src/db/queries/sync-scope.d.ts +50 -0
  17. package/dist/src/db/queries/webhooks.d.ts +50 -0
  18. package/dist/src/db/schema.d.ts +51 -1
  19. package/dist/src/index.d.ts +169 -7
  20. package/dist/src/index.js +239 -87
  21. package/dist/src/index.js.map +6 -4
  22. package/dist/src/insights/index.d.ts +1540 -0
  23. package/dist/src/insights/index.js +2284 -0
  24. package/dist/src/insights/index.js.map +29 -0
  25. package/dist/src/insights/thresholds.d.ts +56 -0
  26. package/dist/src/insights/thresholds.js +86 -0
  27. package/dist/src/insights/thresholds.js.map +10 -0
  28. package/dist/src/leader.d.ts +3 -1
  29. package/dist/src/leader.js +3 -1
  30. package/dist/src/leader.js.map +3 -3
  31. package/dist/src/node/local-client.d.ts +50 -0
  32. package/dist/src/queue/listener.js +4 -1
  33. package/dist/src/queue/listener.js.map +3 -3
  34. package/dist/src/schemas/index.d.ts +95 -7
  35. package/dist/src/schemas/index.js +180 -87
  36. package/dist/src/schemas/index.js.map +4 -3
  37. package/dist/src/schemas/insights.d.ts +89 -0
  38. package/dist/src/schemas/insights.js +98 -0
  39. package/dist/src/schemas/insights.js.map +10 -0
  40. package/migrations/0169_subgraph_health_history.ts +54 -0
  41. package/migrations/0170_insights.ts +63 -0
  42. package/package.json +13 -1
@@ -0,0 +1,2284 @@
1
+ // src/schemas/insights.ts
2
+ import { z } from "zod";
3
+ var INSIGHT_SEVERITIES = [
4
+ "losing",
5
+ "will_fail",
6
+ "costs",
7
+ "recovered"
8
+ ];
9
+ var InsightSeveritySchema = z.enum(INSIGHT_SEVERITIES);
10
+ var InsightResourceKindSchema = z.enum(["webhook", "subgraph", "account"]);
11
+ var InsightEvidenceSchema = z.discriminatedUnion("kind", [
12
+ z.object({
13
+ kind: z.literal("series"),
14
+ title: z.string(),
15
+ series: z.array(z.object({
16
+ name: z.string(),
17
+ role: z.enum(["a", "context", "mark"]),
18
+ points: z.array(z.number())
19
+ })),
20
+ labels: z.array(z.tuple([z.number(), z.string()])),
21
+ unit: z.string().optional()
22
+ }),
23
+ z.object({
24
+ kind: z.literal("table"),
25
+ title: z.string(),
26
+ columns: z.array(z.string()),
27
+ rows: z.array(z.array(z.union([z.string(), z.number()]))),
28
+ markRow: z.number().optional()
29
+ }),
30
+ z.object({
31
+ kind: z.literal("shapes"),
32
+ title: z.string(),
33
+ shapes: z.array(z.object({
34
+ code: z.string(),
35
+ body: z.string(),
36
+ count: z.number(),
37
+ isNew: z.boolean()
38
+ }))
39
+ }),
40
+ z.object({
41
+ kind: z.literal("timeline"),
42
+ title: z.string(),
43
+ steps: z.array(z.object({
44
+ what: z.string(),
45
+ at: z.string(),
46
+ note: z.string().optional(),
47
+ failed: z.boolean()
48
+ }))
49
+ })
50
+ ]);
51
+ var InsightSchema = z.object({
52
+ id: z.string(),
53
+ rule: z.string(),
54
+ severity: InsightSeveritySchema,
55
+ impact: z.number(),
56
+ resource: z.object({
57
+ kind: InsightResourceKindSchema,
58
+ id: z.string(),
59
+ name: z.string()
60
+ }),
61
+ line: z.string(),
62
+ meta: z.string().optional(),
63
+ lead: z.string(),
64
+ body: z.string(),
65
+ evidence: z.array(InsightEvidenceSchema),
66
+ handoff: z.object({
67
+ prompt: z.string(),
68
+ commands: z.array(z.string())
69
+ }),
70
+ how: z.object({
71
+ detector: z.string(),
72
+ facts: z.array(z.tuple([z.string(), z.string()])),
73
+ label: z.object({ cause: z.string(), confidence: z.number() }).optional()
74
+ }),
75
+ createdAt: z.string(),
76
+ resolvedAt: z.string().nullable().optional()
77
+ });
78
+ var InsightListResponseSchema = z.object({
79
+ data: z.array(InsightSchema)
80
+ });
81
+ var SEVERITY_ORDER = {
82
+ losing: 0,
83
+ will_fail: 1,
84
+ costs: 2,
85
+ recovered: 3
86
+ };
87
+
88
+ // src/insights/rank.ts
89
+ function rankInsights(insights) {
90
+ return [...insights].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || b.impact - a.impact || Date.parse(b.createdAt) - Date.parse(a.createdAt));
91
+ }
92
+ // src/insights/thresholds.ts
93
+ var RATE_LIMITED_MIN_ATTEMPTS = 10;
94
+ var RATE_LIMITED_MIN_RATIO = 0.5;
95
+ var DOWN_MIN_CONSECUTIVE = 5;
96
+ var REJECTS_MIN_CONSECUTIVE = 5;
97
+ var SLOW_WINDOW = 20;
98
+ var SLOW_MIN_SAMPLES = 5;
99
+ var SLOW_TIMEOUT_RATIO = 0.5;
100
+ var LAG_WINDOW = 20;
101
+ var LAG_MIN_SAMPLES = 5;
102
+ var LAG_THRESHOLD_MS = 60000;
103
+ var LOGIN_WALL_MIN_CONSECUTIVE = 20;
104
+ var LOGIN_WALL_PRIOR_JSON_RATIO = 0.8;
105
+ var LOGIN_WALL_MIN_PRIOR = 10;
106
+ var SLOWING_DAYS = 14;
107
+ var SLOWING_MIN_RISING_DAYS = 10;
108
+ var SLOWING_MIN_R2 = 0.8;
109
+ var TLS_WARN_DAYS = 14;
110
+ var DEAD_LETTER_WARN_AGE_DAYS = 83;
111
+ var DEAD_LETTER_DELETE_AGE_DAYS = 90;
112
+ var TEST_ENDPOINT_MIN_AGE_DAYS = 7;
113
+ var HANDLER_ERRORS_MIN_24H = 10;
114
+ var SUBGRAPH_LAG_BLOCKS = 20;
115
+ var SUBGRAPH_LAG_SNAPSHOTS = 3;
116
+ var SUBGRAPH_LAG_LOSING_BLOCKS = 500;
117
+ var PAGE_CAP_ROWS = 1000;
118
+ var DUPLICATE_READS_MIN_PAGES = 3;
119
+ var DUPLICATE_READS_WINDOW_MS = 3600000;
120
+ var DUPLICATE_READS_LOOKBACK_MS = 24 * 3600000;
121
+ var POLLING_MIN_DURATION_MS = 3600000;
122
+ var POLLING_MIN_REQUESTS = 5;
123
+ var POLLING_LOOKBACK_MS = 6 * 3600000;
124
+ var POLLING_MAX_STDDEV_RATIO = 0.2;
125
+ var POLLING_MIN_EMPTY_RATIO = 0.8;
126
+ var SERVER_ERRORS_MIN_5XX = 3;
127
+ var BACKFILL_DAY_SHARE = 0.25;
128
+ var BACKFILL_FULL_PAGE_RATIO = 0.9;
129
+ var BACKFILL_MIN_DAY_ROWS = 1e5;
130
+ var CREDITS_RUNWAY_WARN_DAYS = 7;
131
+ var RECOVERED_VISIBLE_MS = 24 * 3600000;
132
+ var CLEAR_STREAK_TO_RESOLVE = 2;
133
+ // src/insights/prompt.ts
134
+ function handoffPrompt(parts) {
135
+ const sentence = (text) => {
136
+ const trimmed = text.trim().replace(/[.\s]+$/, "");
137
+ return trimmed.length > 0 ? `${trimmed}.` : "";
138
+ };
139
+ const [first = "", ...rest] = parts.observed;
140
+ const sentences = [
141
+ sentence(`${parts.subject} ${first}`),
142
+ ...rest.map(sentence),
143
+ sentence(parts.lookAt),
144
+ parts.followUp ? sentence(parts.followUp) : ""
145
+ ];
146
+ return sentences.filter((s) => s.length > 0).join(" ");
147
+ }
148
+ function plainSnippet(text, max = 80) {
149
+ if (!text)
150
+ return "";
151
+ const flat = text.replace(/<[^>]*>/g, " ").replace(/=>/g, "->").replace(/[`{}$\\]/g, "").replace(/\s+/g, " ").trim();
152
+ return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}...` : flat;
153
+ }
154
+ function utcTime(date) {
155
+ return `${date.toISOString().slice(11, 16)} UTC`;
156
+ }
157
+ function utcDateTime(date) {
158
+ const month = date.toLocaleString("en-US", {
159
+ month: "short",
160
+ timeZone: "UTC"
161
+ });
162
+ return `${month} ${date.getUTCDate()} ${utcTime(date)}`;
163
+ }
164
+ function utcDay(date) {
165
+ const month = date.toLocaleString("en-US", {
166
+ month: "short",
167
+ timeZone: "UTC"
168
+ });
169
+ return `${month} ${date.getUTCDate()}`;
170
+ }
171
+ function plural(n, one, many = `${one}s`) {
172
+ return `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
173
+ }
174
+ function hostOf(url) {
175
+ try {
176
+ return new URL(url).host;
177
+ } catch {
178
+ return url;
179
+ }
180
+ }
181
+ // src/insights/store.ts
182
+ import { sql as sql2 } from "kysely";
183
+
184
+ // src/db/jsonb.ts
185
+ import { sql } from "kysely";
186
+ function jsonb(value) {
187
+ const escaped = JSON.stringify(value, (_k, v) => typeof v === "bigint" ? v.toString() : v).replace(/'/g, "''");
188
+ return sql`${sql.raw(`'${escaped}'::jsonb`)}`;
189
+ }
190
+ function parseJsonb(value) {
191
+ if (typeof value === "string") {
192
+ try {
193
+ return JSON.parse(value);
194
+ } catch {
195
+ return value;
196
+ }
197
+ }
198
+ return value ?? {};
199
+ }
200
+
201
+ // src/insights/store.ts
202
+ var NIL_UUID = "00000000-0000-0000-0000-000000000000";
203
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
204
+ function scopeWhere(scope, column = sql2.ref("account_id")) {
205
+ return scope.accountId === null ? sql2`${column} IS NULL` : sql2`${column} = ${scope.accountId}`;
206
+ }
207
+ function toPayload(insight) {
208
+ return {
209
+ resource: insight.resource,
210
+ line: insight.line,
211
+ ...insight.meta !== undefined ? { meta: insight.meta } : {},
212
+ lead: insight.lead,
213
+ body: insight.body,
214
+ evidence: insight.evidence,
215
+ handoff: insight.handoff,
216
+ how: insight.how
217
+ };
218
+ }
219
+ async function recordEvaluation(db, scope, fired, evaluatedKeys, now = new Date) {
220
+ await db.transaction().execute(async (trx) => {
221
+ for (const insight of fired) {
222
+ await sql2`
223
+ INSERT INTO insights
224
+ (account_id, resource_kind, resource_id, rule, severity, impact,
225
+ payload, first_seen_at, last_seen_at)
226
+ VALUES
227
+ (${scope.accountId}, ${insight.resource.kind}, ${insight.resource.id},
228
+ ${insight.rule}, ${insight.severity}, ${insight.impact},
229
+ ${jsonb(toPayload(insight))}, ${now}, ${now})
230
+ ON CONFLICT
231
+ (coalesce(account_id, ${sql2.lit(NIL_UUID)}::uuid), resource_kind, resource_id, rule)
232
+ WHERE resolved_at IS NULL
233
+ DO UPDATE SET
234
+ payload = EXCLUDED.payload,
235
+ severity = EXCLUDED.severity,
236
+ impact = EXCLUDED.impact,
237
+ last_seen_at = EXCLUDED.last_seen_at,
238
+ clear_streak = 0
239
+ `.execute(trx);
240
+ }
241
+ const firedKeys = new Set(fired.map((i) => `${i.resource.kind}\x00${i.resource.id}\x00${i.rule}`));
242
+ for (const key of evaluatedKeys) {
243
+ if (firedKeys.has(`${key.kind}\x00${key.id}\x00${key.rule}`))
244
+ continue;
245
+ await sql2`
246
+ UPDATE insights
247
+ SET clear_streak = clear_streak + 1,
248
+ resolved_at = CASE
249
+ WHEN clear_streak + 1 >= ${CLEAR_STREAK_TO_RESOLVE}
250
+ THEN ${now}::timestamptz
251
+ ELSE NULL
252
+ END
253
+ WHERE ${scopeWhere(scope)}
254
+ AND resource_kind = ${key.kind}
255
+ AND resource_id = ${key.id}
256
+ AND rule = ${key.rule}
257
+ AND resolved_at IS NULL
258
+ `.execute(trx);
259
+ }
260
+ });
261
+ }
262
+ async function listOpenResources(db, scope) {
263
+ const { rows } = await sql2`
264
+ SELECT resource_kind, resource_id, rule FROM insights
265
+ WHERE ${scopeWhere(scope)} AND resolved_at IS NULL
266
+ `.execute(db);
267
+ return rows.map((r) => ({
268
+ kind: r.resource_kind,
269
+ id: r.resource_id,
270
+ rule: r.rule
271
+ }));
272
+ }
273
+ async function listInsights(db, scope, now = new Date, filter = {}) {
274
+ const since = new Date(now.getTime() - RECOVERED_VISIBLE_MS);
275
+ const { rows } = await sql2`
276
+ SELECT i.id, i.rule, i.severity, i.impact, i.payload, i.first_seen_at, i.resolved_at
277
+ FROM insights i
278
+ WHERE ${scopeWhere(scope, sql2.ref("i.account_id"))}
279
+ AND i.dismissed_at IS NULL
280
+ AND (i.resolved_at IS NULL OR i.resolved_at > ${since})
281
+ ${filter.kind ? sql2`AND i.resource_kind = ${filter.kind}` : sql2``}
282
+ ${filter.id ? sql2`AND i.resource_id = ${filter.id}` : sql2``}
283
+ AND NOT EXISTS (
284
+ SELECT 1 FROM insight_mutes m
285
+ WHERE ${scopeWhere(scope, sql2.ref("m.account_id"))}
286
+ AND m.resource_kind = i.resource_kind
287
+ AND m.resource_id = i.resource_id
288
+ AND m.rule = i.rule
289
+ )
290
+ `.execute(db);
291
+ return rankInsights(rows.map((row) => {
292
+ const payload = parseJsonb(row.payload);
293
+ return {
294
+ id: row.id,
295
+ rule: row.rule,
296
+ severity: row.resolved_at ? "recovered" : row.severity,
297
+ impact: Number(row.impact),
298
+ ...payload,
299
+ createdAt: row.first_seen_at.toISOString(),
300
+ resolvedAt: row.resolved_at ? row.resolved_at.toISOString() : null
301
+ };
302
+ }));
303
+ }
304
+ async function dismissInsight(db, scope, id, now = new Date) {
305
+ if (!UUID_RE.test(id))
306
+ return false;
307
+ const result = await sql2`
308
+ UPDATE insights SET dismissed_at = ${now}
309
+ WHERE id = ${id}::uuid AND ${scopeWhere(scope)}
310
+ `.execute(db);
311
+ return Number(result.numAffectedRows ?? 0) > 0;
312
+ }
313
+ async function muteRule(db, scope, id) {
314
+ if (!UUID_RE.test(id))
315
+ return false;
316
+ const result = await sql2`
317
+ INSERT INTO insight_mutes (account_id, resource_kind, resource_id, rule)
318
+ SELECT account_id, resource_kind, resource_id, rule FROM insights
319
+ WHERE id = ${id}::uuid AND ${scopeWhere(scope)}
320
+ ON CONFLICT (coalesce(account_id, ${sql2.lit(NIL_UUID)}::uuid), resource_kind, resource_id, rule)
321
+ DO NOTHING
322
+ `.execute(db);
323
+ if (Number(result.numAffectedRows ?? 0) > 0)
324
+ return true;
325
+ return existsInScope(db, scope, id);
326
+ }
327
+ async function unmuteRule(db, scope, id) {
328
+ if (!await existsInScope(db, scope, id))
329
+ return false;
330
+ await sql2`
331
+ DELETE FROM insight_mutes m
332
+ USING insights i
333
+ WHERE i.id = ${id}::uuid
334
+ AND ${scopeWhere(scope, sql2.ref("i.account_id"))}
335
+ AND ${scopeWhere(scope, sql2.ref("m.account_id"))}
336
+ AND m.resource_kind = i.resource_kind
337
+ AND m.resource_id = i.resource_id
338
+ AND m.rule = i.rule
339
+ `.execute(db);
340
+ return true;
341
+ }
342
+ async function existsInScope(db, scope, id) {
343
+ if (!UUID_RE.test(id))
344
+ return false;
345
+ const { rows } = await sql2`
346
+ SELECT id FROM insights WHERE id = ${id}::uuid AND ${scopeWhere(scope)}
347
+ `.execute(db);
348
+ return rows.length > 0;
349
+ }
350
+ async function purgeResolved(db, scope, olderThanDays = 30, now = new Date) {
351
+ const cutoff = new Date(now.getTime() - olderThanDays * 86400000);
352
+ await sql2`
353
+ DELETE FROM insights
354
+ WHERE ${scopeWhere(scope)} AND resolved_at IS NOT NULL AND resolved_at < ${cutoff}
355
+ `.execute(db);
356
+ }
357
+ // src/env.ts
358
+ import { z as z2 } from "zod";
359
+ var networksSchema = z2.string().transform((val) => {
360
+ const networks = val.split(",").map((n) => n.trim()).filter(Boolean);
361
+ const valid = ["mainnet", "testnet"];
362
+ for (const n of networks) {
363
+ if (!valid.includes(n)) {
364
+ throw new Error(`Invalid network: ${n}. Must be one of: ${valid.join(", ")}`);
365
+ }
366
+ }
367
+ return networks;
368
+ });
369
+ var envSchema = z2.object({
370
+ DATABASE_URL: z2.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z2.string().url().optional()),
371
+ SOURCE_DATABASE_URL: z2.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z2.string().url().optional()),
372
+ TARGET_DATABASE_URL: z2.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z2.string().url().optional()),
373
+ NETWORK: z2.enum(["mainnet", "testnet"]).optional(),
374
+ NETWORKS: networksSchema.optional(),
375
+ LOG_LEVEL: z2.enum(["debug", "info", "warn", "error"]).default("info"),
376
+ NODE_ENV: z2.enum(["development", "production", "test"]).default("development")
377
+ });
378
+ var cachedEnv = null;
379
+ function getEnv() {
380
+ if (cachedEnv) {
381
+ return cachedEnv;
382
+ }
383
+ const result = envSchema.safeParse(process.env);
384
+ if (!result.success) {
385
+ console.error("❌ Invalid environment configuration:");
386
+ console.error(z2.treeifyError(result.error));
387
+ throw new Error("Invalid environment configuration");
388
+ }
389
+ let enabledNetworks;
390
+ if (result.data.NETWORKS && result.data.NETWORKS.length > 0) {
391
+ enabledNetworks = result.data.NETWORKS;
392
+ } else if (result.data.NETWORK) {
393
+ enabledNetworks = [result.data.NETWORK];
394
+ } else {
395
+ enabledNetworks = ["mainnet"];
396
+ }
397
+ cachedEnv = { ...result.data, enabledNetworks };
398
+ return cachedEnv;
399
+ }
400
+ function isProductionEnv() {
401
+ return process.env["NODE_ENV"] === "production";
402
+ }
403
+ function isPox4DecoderEnabled(env = process.env) {
404
+ return env.POX4_DECODER_ENABLED !== "false";
405
+ }
406
+ function isPox5DecoderEnabled(env = process.env) {
407
+ return env.POX5_DECODER_ENABLED !== "false";
408
+ }
409
+ function isSbtcDecoderEnabled(env = process.env) {
410
+ return env.SBTC_DECODER_ENABLED !== "false";
411
+ }
412
+ function isBnsDecoderEnabled(env = process.env) {
413
+ return env.BNS_DECODER_ENABLED === "true";
414
+ }
415
+ // src/logger.ts
416
+ var LOG_LEVELS = {
417
+ debug: 0,
418
+ info: 1,
419
+ warn: 2,
420
+ error: 3
421
+ };
422
+
423
+ class Logger {
424
+ _level;
425
+ _isProduction;
426
+ _initialized = false;
427
+ init() {
428
+ if (this._initialized)
429
+ return;
430
+ this._initialized = true;
431
+ try {
432
+ const env = getEnv();
433
+ this._level = env.LOG_LEVEL;
434
+ this._isProduction = env.NODE_ENV === "production";
435
+ } catch {
436
+ this._level = "info";
437
+ this._isProduction = false;
438
+ }
439
+ }
440
+ get level() {
441
+ this.init();
442
+ return this._level;
443
+ }
444
+ get isProduction() {
445
+ this.init();
446
+ return this._isProduction;
447
+ }
448
+ shouldLog(level) {
449
+ return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
450
+ }
451
+ formatMessage(level, message, meta) {
452
+ const timestamp = new Date().toISOString();
453
+ if (this.isProduction) {
454
+ return JSON.stringify({
455
+ timestamp,
456
+ level,
457
+ message,
458
+ ...meta
459
+ });
460
+ }
461
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
462
+ return `[${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
463
+ }
464
+ debug(message, meta) {
465
+ if (this.shouldLog("debug")) {
466
+ console.debug(this.formatMessage("debug", message, meta));
467
+ }
468
+ }
469
+ info(message, meta) {
470
+ if (this.shouldLog("info")) {
471
+ console.info(this.formatMessage("info", message, meta));
472
+ }
473
+ }
474
+ warn(message, meta) {
475
+ if (this.shouldLog("warn")) {
476
+ console.warn(this.formatMessage("warn", message, meta));
477
+ }
478
+ }
479
+ error(message, meta) {
480
+ if (this.shouldLog("error")) {
481
+ console.error(this.formatMessage("error", message, meta));
482
+ }
483
+ }
484
+ }
485
+ var logger = new Logger;
486
+
487
+ // src/classify/confidence.ts
488
+ var clamp01 = (n) => Math.min(1, Math.max(0, n));
489
+ function choiceConfidence(probabilities) {
490
+ const ps = Object.values(probabilities);
491
+ const k = ps.length;
492
+ if (k <= 1)
493
+ return 1;
494
+ const pmax = Math.max(...ps);
495
+ return clamp01((pmax - 1 / k) / (1 - 1 / k));
496
+ }
497
+ function scoreConfidence(probabilities, levels) {
498
+ if (levels <= 1)
499
+ return 1;
500
+ let mode = 0;
501
+ let best = -1;
502
+ for (let i = 0;i < levels; i++) {
503
+ const p = probabilities[String(i)] ?? 0;
504
+ if (p > best) {
505
+ best = p;
506
+ mode = i;
507
+ }
508
+ }
509
+ let e = 0;
510
+ let d = 0;
511
+ for (let i = 0;i < levels; i++) {
512
+ e += (probabilities[String(i)] ?? 0) * Math.abs(i - mode);
513
+ d += Math.abs(i - (levels - 1) / 2);
514
+ }
515
+ d /= levels;
516
+ return clamp01(1 - e / d);
517
+ }
518
+ function booleanConfidence(p) {
519
+ return Math.abs(2 * p - 1);
520
+ }
521
+ function normalizeAnswer(question, raw) {
522
+ if (raw.type === "refusal")
523
+ return { type: "refusal" };
524
+ if (raw.type === "boolean" && question.type === "boolean") {
525
+ return {
526
+ type: "boolean",
527
+ probability: raw.probability,
528
+ confidence: booleanConfidence(raw.probability)
529
+ };
530
+ }
531
+ if (raw.type === "choice" && question.type === "choice") {
532
+ const probabilities = raw.probabilities ?? {};
533
+ return {
534
+ type: "choice",
535
+ choice: raw.choice,
536
+ probabilities,
537
+ confidence: raw.probabilities ? choiceConfidence(probabilities) : 0
538
+ };
539
+ }
540
+ if (raw.type === "score" && question.type === "score") {
541
+ const probabilities = raw.probabilities ?? {};
542
+ return {
543
+ type: "score",
544
+ score: raw.score,
545
+ probabilities,
546
+ confidence: raw.probabilities ? scoreConfidence(probabilities, question.criteria.length) : 0
547
+ };
548
+ }
549
+ return { type: "refusal" };
550
+ }
551
+
552
+ // src/classify/types.ts
553
+ class ClassifierInputError extends Error {
554
+ constructor(message) {
555
+ super(message);
556
+ this.name = "ClassifierInputError";
557
+ }
558
+ }
559
+
560
+ // src/classify/limits.ts
561
+ var MAX_QUESTIONS = 64;
562
+ var MIN_CHOICE = 2;
563
+ var MAX_CHOICE = 255;
564
+ var MIN_SCORE = 2;
565
+ var MAX_SCORE = 10;
566
+ var QUESTION_ID = /^[A-Za-z0-9_.-]{1,100}$/;
567
+ var MAX_STATE_CHARS = 24000;
568
+ function validateQuestions(questions) {
569
+ const ids = Object.keys(questions);
570
+ if (ids.length === 0)
571
+ throw new ClassifierInputError("no questions");
572
+ if (ids.length > MAX_QUESTIONS) {
573
+ throw new ClassifierInputError(`more than ${MAX_QUESTIONS} questions`);
574
+ }
575
+ for (const id of ids) {
576
+ if (!QUESTION_ID.test(id)) {
577
+ throw new ClassifierInputError(`invalid question id: ${id}`);
578
+ }
579
+ const q = questions[id];
580
+ if (q.type === "choice") {
581
+ const n = Object.keys(q.criteria).length;
582
+ if (n < MIN_CHOICE || n > MAX_CHOICE) {
583
+ throw new ClassifierInputError(`question ${id}: choice needs ${MIN_CHOICE}-${MAX_CHOICE} options`);
584
+ }
585
+ } else if (q.type === "score") {
586
+ const n = q.criteria.length;
587
+ if (n < MIN_SCORE || n > MAX_SCORE) {
588
+ throw new ClassifierInputError(`question ${id}: score needs ${MIN_SCORE}-${MAX_SCORE} levels`);
589
+ }
590
+ } else if (q.type !== "boolean") {
591
+ throw new ClassifierInputError(`question ${id}: unknown type`);
592
+ }
593
+ }
594
+ }
595
+ function stateSize(state) {
596
+ return typeof state === "string" ? state.length : JSON.stringify(state).length;
597
+ }
598
+
599
+ // src/classify/system-one.ts
600
+ function toWireState(state) {
601
+ const parts = state.map((p) => {
602
+ if (p.type === "text")
603
+ return p.text;
604
+ if (p.type === "json")
605
+ return p.value;
606
+ throw new Error("file state unsupported");
607
+ });
608
+ return parts.length === 1 ? parts[0] : parts;
609
+ }
610
+ function renormalize(p) {
611
+ const sum = Object.values(p).reduce((a, b) => a + b, 0);
612
+ if (!(sum > 0))
613
+ return p;
614
+ return Object.fromEntries(Object.entries(p).map(([k, v]) => [k, v / sum]));
615
+ }
616
+ function mapAnswer(a) {
617
+ if (a.type === "noul" && typeof a.noul === "number") {
618
+ return { type: "boolean", probability: a.noul };
619
+ }
620
+ if (a.type === "choice" && typeof a.choice === "string") {
621
+ return {
622
+ type: "choice",
623
+ choice: a.choice,
624
+ ...a.probabilities && { probabilities: renormalize(a.probabilities) }
625
+ };
626
+ }
627
+ if (a.type === "score" && typeof a.score === "number") {
628
+ if (!a.probabilities)
629
+ return { type: "score", score: a.score };
630
+ const probabilities = renormalize(a.probabilities);
631
+ const score = Object.entries(probabilities).reduce((s, [level, p]) => s + Number(level) * p, 0);
632
+ return { type: "score", score, probabilities };
633
+ }
634
+ return { type: "refusal" };
635
+ }
636
+ function systemOneModel(opts) {
637
+ const doFetch = opts.fetchImpl ?? fetch;
638
+ return {
639
+ specificationVersion: "v4",
640
+ provider: `secondlayer.${opts.provider}`,
641
+ modelId: opts.modelId,
642
+ supportedQuestionTypes: ["choice", "score", "boolean"],
643
+ async doDecide({ state, questions, abortSignal }) {
644
+ const wireQuestions = Object.fromEntries(Object.entries(questions).map(([id, q]) => [
645
+ id,
646
+ q.type === "boolean" ? { ...q, type: "noul" } : q
647
+ ]));
648
+ const res = await doFetch(opts.url, {
649
+ method: "POST",
650
+ headers: {
651
+ "content-type": "application/json",
652
+ ...opts.apiKey && { authorization: `Bearer ${opts.apiKey}` }
653
+ },
654
+ body: JSON.stringify({
655
+ model: opts.modelId,
656
+ state: toWireState(state),
657
+ questions: wireQuestions
658
+ }),
659
+ signal: abortSignal
660
+ });
661
+ if (!res.ok)
662
+ throw new Error(`${opts.provider} ${res.status}`);
663
+ const json = await res.json();
664
+ const body = json.result ?? json;
665
+ const answers = Object.fromEntries(Object.entries(body.answers ?? {}).map(([id, a]) => [id, mapAnswer(a)]));
666
+ return {
667
+ answers,
668
+ warnings: [],
669
+ usage: {
670
+ inputTokens: body.usage?.input_tokens,
671
+ outputTokens: body.usage?.output_tokens
672
+ },
673
+ response: { modelId: body.model ?? opts.modelId }
674
+ };
675
+ }
676
+ };
677
+ }
678
+
679
+ // src/classify/providers.ts
680
+ var DEFAULT_MODEL = {
681
+ jev: "typesafe-ai/jev",
682
+ kev: "kev-latest",
683
+ clef: "clef"
684
+ };
685
+ var REQUIRED_ENV = {
686
+ jev: ["AI_GATEWAY_API_KEY"],
687
+ kev: ["KEV_URL"],
688
+ clef: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"]
689
+ };
690
+ var envModel = (env) => env.CLASSIFIER_MODEL?.trim() || undefined;
691
+ function resolveProvider(env = process.env) {
692
+ const rules = { name: "rules", modelId: "rules" };
693
+ const requested = env.CLASSIFIER?.trim();
694
+ let name;
695
+ if (!requested) {
696
+ name = env.AI_GATEWAY_API_KEY ? "jev" : "rules";
697
+ } else if (requested === "jev" || requested === "kev" || requested === "clef" || requested === "rules") {
698
+ name = requested;
699
+ } else {
700
+ logger.warn(`classifier: unknown CLASSIFIER=${requested}; using rules`);
701
+ return rules;
702
+ }
703
+ if (name === "rules")
704
+ return rules;
705
+ const missing = REQUIRED_ENV[name].find((v) => !env[v]);
706
+ if (missing) {
707
+ logger.warn(`classifier: CLASSIFIER=${name} missing ${missing}; using rules`);
708
+ return rules;
709
+ }
710
+ return { name, modelId: envModel(env) ?? DEFAULT_MODEL[name] };
711
+ }
712
+ function buildModel(name, modelId, env = process.env, fetchImpl) {
713
+ if (name === "jev")
714
+ return modelId;
715
+ if (name === "kev") {
716
+ return systemOneModel({
717
+ provider: "kev",
718
+ url: `${(env.KEV_URL ?? "").replace(/\/$/, "")}/v1/systemone`,
719
+ modelId,
720
+ apiKey: env.KEV_API_KEY,
721
+ fetchImpl
722
+ });
723
+ }
724
+ return systemOneModel({
725
+ provider: "clef",
726
+ url: `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/ai/run/@cf/cloudflare/${modelId}`,
727
+ modelId,
728
+ apiKey: env.CLOUDFLARE_API_TOKEN,
729
+ fetchImpl
730
+ });
731
+ }
732
+
733
+ // src/classify/rules.ts
734
+ function answerFor(question, value) {
735
+ if (value === undefined)
736
+ return { type: "refusal" };
737
+ if (question.type === "choice") {
738
+ if (typeof value !== "string" || !(value in question.criteria)) {
739
+ return { type: "refusal" };
740
+ }
741
+ const probabilities = Object.fromEntries(Object.keys(question.criteria).map((k) => [k, k === value ? 1 : 0]));
742
+ return { type: "choice", choice: value, probabilities, confidence: 1 };
743
+ }
744
+ if (question.type === "score") {
745
+ const n = question.criteria.length;
746
+ if (typeof value !== "number" || !Number.isInteger(value)) {
747
+ return { type: "refusal" };
748
+ }
749
+ if (value < 0 || value >= n)
750
+ return { type: "refusal" };
751
+ const probabilities = Object.fromEntries(Array.from({ length: n }, (_, i) => [String(i), i === value ? 1 : 0]));
752
+ return { type: "score", score: value, probabilities, confidence: 1 };
753
+ }
754
+ if (typeof value !== "boolean")
755
+ return { type: "refusal" };
756
+ return { type: "boolean", probability: value ? 1 : 0, confidence: 1 };
757
+ }
758
+ function runRules(questions, fn, state) {
759
+ const values = fn(state) ?? {};
760
+ const out = {};
761
+ for (const id of Object.keys(questions)) {
762
+ out[id] = answerFor(questions[id], values[id]);
763
+ }
764
+ return out;
765
+ }
766
+
767
+ // src/classify/classify.ts
768
+ async function classify(input) {
769
+ validateQuestions(input.questions);
770
+ const env = input.env ?? process.env;
771
+ const resolved = resolveProvider(env);
772
+ const name = input.provider ?? resolved.name;
773
+ if (name === "rules") {
774
+ if (!input.rules)
775
+ return null;
776
+ return {
777
+ provider: "rules",
778
+ modelId: "rules",
779
+ answers: runRules(input.questions, input.rules, input.state)
780
+ };
781
+ }
782
+ const modelId = input.modelId ?? (input.provider ? undefined : envModel(env)) ?? DEFAULT_MODEL[name];
783
+ if (stateSize(input.state) > MAX_STATE_CHARS) {
784
+ logger.warn(`classifier: state over ${MAX_STATE_CHARS} chars; skipped`);
785
+ return null;
786
+ }
787
+ try {
788
+ const { experimental_decide: decide } = await import("ai");
789
+ const r = await decide({
790
+ model: buildModel(name, modelId, env, input.fetchImpl),
791
+ state: input.state,
792
+ questions: input.questions,
793
+ maxRetries: 1,
794
+ abortSignal: AbortSignal.timeout(input.timeoutMs ?? 5000)
795
+ });
796
+ const answers = {};
797
+ for (const id of Object.keys(input.questions)) {
798
+ answers[id] = normalizeAnswer(input.questions[id], r.answers[id] ?? { type: "refusal" });
799
+ }
800
+ return {
801
+ provider: name,
802
+ modelId: r.response.modelId ?? modelId,
803
+ answers
804
+ };
805
+ } catch (err) {
806
+ logger.warn(`classifier: ${name} failed: ${err instanceof Error ? err.name : "error"}`);
807
+ return null;
808
+ }
809
+ }
810
+ // src/insights/label.ts
811
+ var CAUSE_CONFIDENCE = 0.85;
812
+ var LABEL_TIMEOUT_MS = 5000;
813
+ var CACHE_TTL_MS = 6 * 3600000;
814
+ var CAUSE_QUESTIONS = {
815
+ cause: {
816
+ type: "choice",
817
+ instructions: "Which cause best explains this webhook's change?",
818
+ criteria: {
819
+ receiver_down: "receiver unreachable: 5xx from a proxy, connection refused, timeouts",
820
+ receiver_rate_limiting: "receiver answers 429 or slows under load",
821
+ receiver_rejects_payload: "4xx other than 429: auth, schema, signature",
822
+ receiver_slow: "2xx but response time well above baseline",
823
+ tls_or_dns: "certificate or name resolution errors",
824
+ chain_quiet: "matching events dropped as much as deliveries",
825
+ config_change: "change began right after the webhook's settings changed",
826
+ our_side: "delivery service or chain source is behind (cursor lag)",
827
+ unknown: "none of these fit the evidence"
828
+ }
829
+ }
830
+ };
831
+ var defaultCache = new Map;
832
+ function cacheKey(insight) {
833
+ const codes = insight.evidence.flatMap((e) => e.kind === "shapes" ? e.shapes.map((s) => s.code) : []).sort().join(",");
834
+ return `${insight.resource.kind}:${insight.resource.id}:${insight.rule}:${codes}`;
835
+ }
836
+ function renderState(insight) {
837
+ return [
838
+ `${insight.resource.kind} ${insight.resource.name}`,
839
+ `rule: ${insight.rule}`,
840
+ `detector: ${insight.how.detector}`,
841
+ ...insight.how.facts.map(([k, v]) => `${k}: ${v}`)
842
+ ].join(`
843
+ `);
844
+ }
845
+ async function maybeLabel(insight, deps = {}) {
846
+ const env = deps.env ?? process.env;
847
+ if (env.INSIGHTS_MODEL_LABELS !== "on")
848
+ return insight;
849
+ if (!insight.rule.startsWith("receiver_"))
850
+ return insight;
851
+ const now = deps.now ?? Date.now();
852
+ const cache = deps.cache ?? defaultCache;
853
+ const key = cacheKey(insight);
854
+ const hit = cache.get(key);
855
+ let outcome;
856
+ if (hit && now - hit.at < CACHE_TTL_MS) {
857
+ outcome = hit.outcome;
858
+ } else {
859
+ const result = await (deps.classifyFn ?? classify)({
860
+ state: renderState(insight),
861
+ questions: CAUSE_QUESTIONS,
862
+ timeoutMs: LABEL_TIMEOUT_MS,
863
+ env
864
+ }).catch((err) => {
865
+ logger.warn("insights.label_failed", { error: String(err) });
866
+ return null;
867
+ });
868
+ if (!result)
869
+ return insight;
870
+ const answer = result.answers.cause;
871
+ outcome = answer.type === "choice" && answer.confidence >= CAUSE_CONFIDENCE && answer.choice !== "unknown" ? { cause: answer.choice, confidence: answer.confidence } : null;
872
+ cache.set(key, { at: now, outcome });
873
+ }
874
+ if (!outcome)
875
+ return insight;
876
+ return { ...insight, how: { ...insight.how, label: outcome } };
877
+ }
878
+ // src/insights/subgraph-rules.ts
879
+ import { sql as sql3 } from "kysely";
880
+
881
+ // src/subgraphs/health.ts
882
+ var HOUR_MS = 3600000;
883
+ function positiveDeltas(values) {
884
+ let total = 0;
885
+ let prev = null;
886
+ for (const value of values) {
887
+ if (value === null)
888
+ continue;
889
+ if (prev !== null && value > prev)
890
+ total += value - prev;
891
+ prev = value;
892
+ }
893
+ return total;
894
+ }
895
+ function toNumber(value) {
896
+ if (value === null)
897
+ return null;
898
+ const n = Number(value);
899
+ return Number.isFinite(n) ? n : null;
900
+ }
901
+ function bucketHourly(snapshots, hours, now = Date.now()) {
902
+ const sorted = snapshots.map((s) => ({ ...s, at: new Date(s.captured_at).getTime() })).sort((a, b) => a.at - b.at);
903
+ const currentHourStart = Math.floor(now / HOUR_MS) * HOUR_MS;
904
+ const out = [];
905
+ for (let i = hours - 1;i >= 0; i--) {
906
+ const start = currentHourStart - i * HOUR_MS;
907
+ const end = start + HOUR_MS;
908
+ const inHour = sorted.filter((s) => s.at >= start && s.at < end);
909
+ if (inHour.length === 0) {
910
+ out.push({
911
+ hour: new Date(start).toISOString(),
912
+ lagBlocks: null,
913
+ rowsWritten: 0,
914
+ errors: 0,
915
+ eventsHandled: 0
916
+ });
917
+ continue;
918
+ }
919
+ const before = sorted.filter((s) => s.at < start).at(-1);
920
+ const series = before ? [before, ...inHour] : inHour;
921
+ const lags = inHour.map((s) => {
922
+ const tip = toNumber(s.chain_tip);
923
+ const cursor = toNumber(s.last_processed_block);
924
+ return tip === null || cursor === null ? null : Math.max(0, tip - cursor);
925
+ }).filter((lag) => lag !== null);
926
+ out.push({
927
+ hour: new Date(start).toISOString(),
928
+ lagBlocks: lags.length > 0 ? Math.max(...lags) : null,
929
+ rowsWritten: positiveDeltas(series.map((s) => toNumber(s.rows_written_total))),
930
+ errors: positiveDeltas(series.map((s) => toNumber(s.total_errors))),
931
+ eventsHandled: positiveDeltas(series.map((s) => toNumber(s.total_processed)))
932
+ });
933
+ }
934
+ return out;
935
+ }
936
+
937
+ // src/insights/subgraph-rules.ts
938
+ var HOUR_MS2 = 3600000;
939
+ var ERROR_GROUPS_SHOWN = 3;
940
+ function subject(name) {
941
+ return `My Secondlayer subgraph "${name}"`;
942
+ }
943
+ function resourceOf(input) {
944
+ return {
945
+ kind: "subgraph",
946
+ id: input.subgraph.name,
947
+ name: input.subgraph.name
948
+ };
949
+ }
950
+ var handlerErrors = {
951
+ id: "subgraph_handler_errors",
952
+ skip: (input) => input.snapshots.length === 0,
953
+ evaluate(input) {
954
+ const hours = bucketHourly(input.snapshots, 24, input.now.getTime());
955
+ const errors = hours.reduce((sum, h) => sum + h.errors, 0);
956
+ if (errors < HANDLER_ERRORS_MIN_24H)
957
+ return null;
958
+ const { subgraph } = input;
959
+ const top = input.errorGroups.slice(0, ERROR_GROUPS_SHOWN);
960
+ const leader = top[0];
961
+ const eventTypes = [
962
+ ...new Set(top.map((g) => g.eventType).filter((t) => !!t))
963
+ ];
964
+ const leaderText = leader ? plainSnippet(leader.message, 120) : "";
965
+ return {
966
+ rule: "subgraph_handler_errors",
967
+ severity: "losing",
968
+ impact: errors,
969
+ resource: resourceOf(input),
970
+ line: `${subgraph.name} threw ${plural(errors, "handler error")} today`,
971
+ meta: "last 24 hours",
972
+ lead: "Your handler is failing on some events.",
973
+ body: [
974
+ `${plural(errors, "event")} failed inside your handler in the last 24 hours, and those rows were not written.`,
975
+ leader ? `The most common error is: ${leaderText}${eventTypes.length > 0 ? ` (event type ${eventTypes[0]})` : ""}.` : ""
976
+ ].filter(Boolean).join(" "),
977
+ evidence: top.length > 0 ? [
978
+ {
979
+ kind: "table",
980
+ title: "Most common errors",
981
+ columns: ["Error", "Event type", "Seen"],
982
+ rows: top.map((g) => [
983
+ plainSnippet(g.message, 120),
984
+ g.eventType ?? g.sourceName,
985
+ g.count
986
+ ])
987
+ }
988
+ ] : [],
989
+ handoff: {
990
+ prompt: handoffPrompt({
991
+ subject: subject(subgraph.name),
992
+ observed: [
993
+ `threw ${plural(errors, "handler error")} in the last 24 hours`,
994
+ leader ? `The most common error is: ${leaderText}${eventTypes[0] ? ` on event type ${eventTypes[0]}` : ""}, seen ${plural(leader.count, "time")} in total` : ""
995
+ ],
996
+ lookAt: eventTypes.length > 0 ? `Check how the handler deals with ${eventTypes.join(" and ")} events` : "Check what the failing events have in common",
997
+ followUp: `Then run sl subgraphs gaps ${subgraph.name}`
998
+ }),
999
+ commands: [`sl subgraphs gaps ${subgraph.name}`]
1000
+ },
1001
+ how: {
1002
+ detector: `${HANDLER_ERRORS_MIN_24H} or more handler errors in the last 24 hours of health history`,
1003
+ facts: [
1004
+ ["errors in 24 hours", String(errors)],
1005
+ [
1006
+ "event types in top errors",
1007
+ eventTypes.join(", ") || "none recorded"
1008
+ ],
1009
+ ["group counts", "all-time per group, not limited to 24 hours"]
1010
+ ]
1011
+ }
1012
+ };
1013
+ }
1014
+ };
1015
+ var lag = {
1016
+ id: "subgraph_lag",
1017
+ skip: (input) => lagSamples(input).length < SUBGRAPH_LAG_SNAPSHOTS,
1018
+ evaluate(input) {
1019
+ if (input.subgraph.status !== "active")
1020
+ return null;
1021
+ const newest = lagSamples(input).slice(0, SUBGRAPH_LAG_SNAPSHOTS);
1022
+ if (newest.length < SUBGRAPH_LAG_SNAPSHOTS)
1023
+ return null;
1024
+ if (!newest.every((s) => s.blocks > SUBGRAPH_LAG_BLOCKS))
1025
+ return null;
1026
+ const current = newest[0]?.blocks ?? 0;
1027
+ const since = newest[newest.length - 1]?.at ?? input.now;
1028
+ const minutes = Math.max(1, Math.round((input.now.getTime() - since.getTime()) / 60000));
1029
+ const { subgraph } = input;
1030
+ const hours = bucketHourly(input.snapshots, 24, input.now.getTime());
1031
+ return {
1032
+ rule: "subgraph_lag",
1033
+ severity: current > SUBGRAPH_LAG_LOSING_BLOCKS ? "losing" : "will_fail",
1034
+ impact: current,
1035
+ resource: resourceOf(input),
1036
+ line: `${subgraph.name} is ${plural(current, "block")} behind`,
1037
+ meta: `for about ${plural(minutes, "minute")}`,
1038
+ lead: "Your subgraph is falling behind the chain.",
1039
+ body: `It has trailed the chain tip by more than ${SUBGRAPH_LAG_BLOCKS} blocks on its last ${SUBGRAPH_LAG_SNAPSHOTS} checks, and is ${plural(current, "block")} behind now. Rows are late until it catches up.`,
1040
+ evidence: [
1041
+ {
1042
+ kind: "series",
1043
+ title: "Blocks behind the chain, per hour",
1044
+ series: [
1045
+ {
1046
+ name: "Blocks behind",
1047
+ role: "a",
1048
+ points: hours.map((h) => h.lagBlocks ?? 0)
1049
+ }
1050
+ ],
1051
+ labels: [
1052
+ [0, "24h ago"],
1053
+ [hours.length - 1, "now"]
1054
+ ],
1055
+ unit: "blocks"
1056
+ }
1057
+ ],
1058
+ handoff: {
1059
+ prompt: handoffPrompt({
1060
+ subject: subject(subgraph.name),
1061
+ observed: [
1062
+ `has been more than ${SUBGRAPH_LAG_BLOCKS} blocks behind the chain for about ${plural(minutes, "minute")}`,
1063
+ `It is ${plural(current, "block")} behind right now`
1064
+ ],
1065
+ lookAt: "Check whether the handler got slower, a recent deploy changed how much work each block needs, or the subgraph is processing a very large range",
1066
+ followUp: `Then run sl subgraphs status ${subgraph.name}`
1067
+ }),
1068
+ commands: [`sl subgraphs status ${subgraph.name}`]
1069
+ },
1070
+ how: {
1071
+ detector: `more than ${SUBGRAPH_LAG_BLOCKS} blocks behind the tip on ${SUBGRAPH_LAG_SNAPSHOTS} consecutive health snapshots, and not reindexing`,
1072
+ facts: [
1073
+ ["blocks behind now", String(current)],
1074
+ ["snapshots over the line", String(newest.length)],
1075
+ ["behind since about", `${since.toISOString().slice(11, 16)} UTC`]
1076
+ ]
1077
+ }
1078
+ };
1079
+ }
1080
+ };
1081
+ function lagSamples(input) {
1082
+ return input.snapshots.map((s) => ({
1083
+ at: new Date(s.captured_at),
1084
+ tip: s.chain_tip === null ? null : Number(s.chain_tip),
1085
+ cursor: s.last_processed_block === null ? null : Number(s.last_processed_block)
1086
+ })).filter((s) => s.tip !== null && s.cursor !== null).sort((a, b) => b.at.getTime() - a.at.getTime()).map((s) => ({ at: s.at, blocks: Math.max(0, s.tip - s.cursor) }));
1087
+ }
1088
+ var SUBGRAPH_RULES = [
1089
+ handlerErrors,
1090
+ lag
1091
+ ];
1092
+ async function loadSubgraphInputs(db, now) {
1093
+ const subgraphs = await db.selectFrom("subgraphs").select(["id", "name", "status"]).execute();
1094
+ if (subgraphs.length === 0)
1095
+ return [];
1096
+ const since = new Date(now.getTime() - 25 * HOUR_MS2);
1097
+ const snapshots = await db.selectFrom("subgraph_health_snapshots").select([
1098
+ "subgraph_id",
1099
+ "captured_at",
1100
+ "chain_tip",
1101
+ "last_processed_block",
1102
+ "rows_written_total",
1103
+ "total_errors",
1104
+ "total_processed"
1105
+ ]).where("captured_at", ">=", since).orderBy("captured_at", "asc").execute();
1106
+ const groups = await sql3`
1107
+ SELECT subgraph_name, message, event_type, source_name, count, last_seen
1108
+ FROM subgraph_error_groups
1109
+ WHERE last_seen > ${new Date(now.getTime() - 24 * HOUR_MS2)}
1110
+ ORDER BY count DESC
1111
+ `.execute(db);
1112
+ return subgraphs.map((subgraph) => ({
1113
+ now,
1114
+ subgraph,
1115
+ snapshots: snapshots.filter((s) => s.subgraph_id === subgraph.id),
1116
+ errorGroups: groups.rows.filter((g) => g.subgraph_name === subgraph.name).slice(0, ERROR_GROUPS_SHOWN).map((g) => ({
1117
+ message: g.message,
1118
+ eventType: g.event_type,
1119
+ sourceName: g.source_name,
1120
+ count: Number(g.count),
1121
+ lastSeen: g.last_seen
1122
+ }))
1123
+ }));
1124
+ }
1125
+
1126
+ // src/insights/webhook-rules.ts
1127
+ import { sql as sql4 } from "kysely";
1128
+ var MINUTE_MS = 60000;
1129
+ var DAY_MS = 86400000;
1130
+ var BUCKET_MS = 10 * MINUTE_MS;
1131
+ var BUCKETS = 36;
1132
+ var SHAPE_BODY_MAX = 80;
1133
+ var SHAPES_MAX = 4;
1134
+ var ATTEMPT_WINDOW = 300;
1135
+ var ATTEMPT_LOOKBACK_DAYS = 8;
1136
+ var PRIOR_WINDOW_MS = 7 * DAY_MS;
1137
+ function consecutiveFromStart(rows, predicate) {
1138
+ const out = [];
1139
+ for (const row of rows) {
1140
+ if (!predicate(row))
1141
+ break;
1142
+ out.push(row);
1143
+ }
1144
+ return out;
1145
+ }
1146
+ var is2xx = (d) => d.statusCode !== null && d.statusCode >= 200 && d.statusCode < 300;
1147
+ var isDown = (d) => d.statusCode === null || d.statusCode >= 500;
1148
+ var isReject = (d) => d.statusCode !== null && d.statusCode >= 400 && d.statusCode < 500 && d.statusCode !== 429;
1149
+ var isHtml = (d) => (d.contentType ?? "").toLowerCase().startsWith("text/html");
1150
+ var isJson = (d) => (d.contentType ?? "").toLowerCase().startsWith("application/json");
1151
+ function codeLabel(statusCode) {
1152
+ return statusCode === null ? "no response" : String(statusCode);
1153
+ }
1154
+ function shapeText(d) {
1155
+ return (d.body ?? d.errorMessage ?? "").replace(/\s+/g, " ").trim().slice(0, SHAPE_BODY_MAX);
1156
+ }
1157
+ function shapeKey(d) {
1158
+ return `${codeLabel(d.statusCode)}\x00${shapeText(d)}`;
1159
+ }
1160
+ function buildShapes(affected, earlier, now) {
1161
+ const seenBefore = new Set(earlier.filter((d) => now.getTime() - d.dispatchedAt.getTime() <= PRIOR_WINDOW_MS).map(shapeKey));
1162
+ const counts = new Map;
1163
+ for (const d of affected) {
1164
+ const key = shapeKey(d);
1165
+ const entry = counts.get(key);
1166
+ if (entry)
1167
+ entry.count++;
1168
+ else
1169
+ counts.set(key, { sample: d, count: 1 });
1170
+ }
1171
+ return [...counts.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, SHAPES_MAX).map(([key, { sample, count }]) => ({
1172
+ code: codeLabel(sample.statusCode),
1173
+ body: shapeText(sample),
1174
+ count,
1175
+ isNew: !seenBefore.has(key)
1176
+ }));
1177
+ }
1178
+ function subject2(name) {
1179
+ return `My Secondlayer webhook "${name}"`;
1180
+ }
1181
+ function mostCommonCode(rows) {
1182
+ const counts = new Map;
1183
+ for (const r of rows)
1184
+ counts.set(r.statusCode, (counts.get(r.statusCode) ?? 0) + 1);
1185
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
1186
+ }
1187
+ function breakdown(rows) {
1188
+ const counts = new Map;
1189
+ for (const r of rows) {
1190
+ const key = codeLabel(r.statusCode);
1191
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1192
+ }
1193
+ return [...counts.entries()].map(([k, n]) => `${n}x ${k}`).join(", ");
1194
+ }
1195
+ function attemptsLast24h(input) {
1196
+ const since = input.now.getTime() - DAY_MS;
1197
+ return input.attempts.filter((a) => a.dispatchedAt.getTime() >= since).length;
1198
+ }
1199
+ function resourceOf2(input) {
1200
+ return {
1201
+ kind: "webhook",
1202
+ id: input.webhook.id,
1203
+ name: input.webhook.name
1204
+ };
1205
+ }
1206
+ function throughputEvidence(input) {
1207
+ const { startMs, matched, delivered } = input.throughput;
1208
+ const at = (i) => utcTime(new Date(startMs + i * BUCKET_MS)).slice(0, 5);
1209
+ return {
1210
+ kind: "series",
1211
+ title: "Matched and delivered, every 10 minutes",
1212
+ series: [
1213
+ { name: "Matched", role: "context", points: matched },
1214
+ { name: "Delivered", role: "a", points: delivered }
1215
+ ],
1216
+ labels: [
1217
+ [0, at(0)],
1218
+ [Math.floor(BUCKETS / 2), at(Math.floor(BUCKETS / 2))],
1219
+ [BUCKETS - 1, "now"]
1220
+ ],
1221
+ unit: "events"
1222
+ };
1223
+ }
1224
+ function matchedPerHour(input) {
1225
+ const total = input.throughput.matched.reduce((a, b) => a + b, 0);
1226
+ return Math.round(total / (BUCKETS / 6));
1227
+ }
1228
+ function levenshtein(a, b) {
1229
+ const prev = Array.from({ length: b.length + 1 }, (_, j) => j);
1230
+ for (let i = 1;i <= a.length; i++) {
1231
+ let diagonal = prev[0] ?? 0;
1232
+ prev[0] = i;
1233
+ for (let j = 1;j <= b.length; j++) {
1234
+ const above = prev[j] ?? 0;
1235
+ prev[j] = Math.min(above + 1, (prev[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
1236
+ diagonal = above;
1237
+ }
1238
+ }
1239
+ return prev[b.length] ?? 0;
1240
+ }
1241
+ function canonicalJson(value) {
1242
+ if (Array.isArray(value))
1243
+ return `[${value.map(canonicalJson).join(",")}]`;
1244
+ if (value && typeof value === "object") {
1245
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
1246
+ return `{${entries.join(",")}}`;
1247
+ }
1248
+ return JSON.stringify(value) ?? "null";
1249
+ }
1250
+ var receiverDown = {
1251
+ id: "receiver_down",
1252
+ evaluate(input) {
1253
+ const streak = consecutiveFromStart(input.attempts, isDown);
1254
+ if (streak.length < DOWN_MIN_CONSECUTIVE)
1255
+ return null;
1256
+ const { webhook } = input;
1257
+ const since = streak[streak.length - 1]?.dispatchedAt ?? input.now;
1258
+ const host = hostOf(webhook.url);
1259
+ const code = mostCommonCode(streak);
1260
+ const server = streak.find((d) => d.server)?.server ?? null;
1261
+ const answer = code === null ? "did not answer" : `returns HTTP ${code}${server ? ` from ${plainSnippet(server, 30)}` : ""}`;
1262
+ const hourly = matchedPerHour(input);
1263
+ const queued = input.pending;
1264
+ const flowing = hourly > 0 ? `Chain matches are normal (about ${hourly} an hour), so the problem is the receiver.` : "";
1265
+ const shapes = buildShapes(streak, input.attempts.slice(streak.length), input.now);
1266
+ return {
1267
+ rule: "receiver_down",
1268
+ severity: "losing",
1269
+ impact: queued > 0 ? queued : streak.length,
1270
+ resource: resourceOf2(input),
1271
+ line: `${webhook.name} has failed ${streak.length} deliveries in a row`,
1272
+ meta: `since ${utcTime(since)}`,
1273
+ lead: "Your receiver is down, not us.",
1274
+ body: [
1275
+ `${host} ${answer} on every delivery since ${utcTime(since)}.`,
1276
+ queued > 0 ? `${plural(queued, "delivery")} queued to retry.` : "",
1277
+ flowing
1278
+ ].filter(Boolean).join(" "),
1279
+ evidence: [
1280
+ throughputEvidence(input),
1281
+ { kind: "shapes", title: "What the receiver sent back", shapes }
1282
+ ],
1283
+ handoff: {
1284
+ prompt: handoffPrompt({
1285
+ subject: subject2(webhook.name),
1286
+ observed: [
1287
+ `has been failing since ${utcTime(since)}`,
1288
+ `The receiver ${host} ${answer} on every delivery`,
1289
+ queued > 0 ? `${plural(queued, "delivery")} queued to retry` : "",
1290
+ flowing.replace(/\.$/, "")
1291
+ ],
1292
+ lookAt: `Check the service behind ${host}`,
1293
+ followUp: `Then run sl webhooks doctor ${webhook.name}`
1294
+ }),
1295
+ commands: [
1296
+ `sl webhooks doctor ${webhook.name}`,
1297
+ `sl webhooks dead ${webhook.name}`
1298
+ ]
1299
+ },
1300
+ how: {
1301
+ detector: `the newest ${DOWN_MIN_CONSECUTIVE} or more delivery attempts are 5xx or got no response`,
1302
+ facts: [
1303
+ ["consecutive failures", String(streak.length)],
1304
+ ["breakdown", breakdown(streak)],
1305
+ ["first failure", utcDateTime(since)],
1306
+ [
1307
+ "last success",
1308
+ webhook.lastSuccessAt ? utcDateTime(webhook.lastSuccessAt) : "never"
1309
+ ],
1310
+ ["queued retries", String(queued)]
1311
+ ]
1312
+ }
1313
+ };
1314
+ }
1315
+ };
1316
+ var receiverRejects = {
1317
+ id: "receiver_rejects",
1318
+ evaluate(input) {
1319
+ const streak = consecutiveFromStart(input.attempts, isReject);
1320
+ if (streak.length < REJECTS_MIN_CONSECUTIVE)
1321
+ return null;
1322
+ const { webhook } = input;
1323
+ const host = hostOf(webhook.url);
1324
+ const code = mostCommonCode(streak);
1325
+ const since = streak[streak.length - 1]?.dispatchedAt ?? input.now;
1326
+ const text = plainSnippet(streak.find((d) => d.body || d.errorMessage)?.body ?? streak.find((d) => d.errorMessage)?.errorMessage, 100);
1327
+ return {
1328
+ rule: "receiver_rejects",
1329
+ severity: "losing",
1330
+ impact: input.pending > 0 ? input.pending : streak.length,
1331
+ resource: resourceOf2(input),
1332
+ line: `${webhook.name} is being rejected by ${host}`,
1333
+ meta: `${streak.length} in a row, HTTP ${code}`,
1334
+ lead: "Your receiver is refusing our requests.",
1335
+ body: [
1336
+ `${host} answered HTTP ${code} to the last ${streak.length} deliveries, since ${utcTime(since)}.`,
1337
+ text ? `It said: ${text}.` : "",
1338
+ "It is reachable, so this is usually its auth or signature check."
1339
+ ].filter(Boolean).join(" "),
1340
+ evidence: [
1341
+ {
1342
+ kind: "shapes",
1343
+ title: "What the receiver sent back",
1344
+ shapes: buildShapes(streak, input.attempts.slice(streak.length), input.now)
1345
+ }
1346
+ ],
1347
+ handoff: {
1348
+ prompt: handoffPrompt({
1349
+ subject: subject2(webhook.name),
1350
+ observed: [
1351
+ `has been rejected since ${utcTime(since)}`,
1352
+ `The receiver ${host} answered HTTP ${code} to ${plural(streak.length, "delivery")} in a row`,
1353
+ text ? `The response said: ${text}` : ""
1354
+ ],
1355
+ lookAt: `Check how ${host} authenticates or verifies signatures on incoming webhook requests`,
1356
+ followUp: `Then run sl webhooks test ${webhook.name}`
1357
+ }),
1358
+ commands: [`sl webhooks test ${webhook.name}`]
1359
+ },
1360
+ how: {
1361
+ detector: `the newest ${REJECTS_MIN_CONSECUTIVE} or more delivery attempts are 4xx other than 429`,
1362
+ facts: [
1363
+ ["consecutive rejects", String(streak.length)],
1364
+ ["breakdown", breakdown(streak)],
1365
+ ["first reject", utcDateTime(since)]
1366
+ ]
1367
+ }
1368
+ };
1369
+ }
1370
+ };
1371
+ var receiverRateLimited = {
1372
+ id: "receiver_rate_limited",
1373
+ evaluate(input) {
1374
+ const window = input.attempts.slice(0, 100);
1375
+ if (window.length < RATE_LIMITED_MIN_ATTEMPTS)
1376
+ return null;
1377
+ const limited = window.filter((d) => d.statusCode === 429);
1378
+ if (limited.length / window.length < RATE_LIMITED_MIN_RATIO)
1379
+ return null;
1380
+ const { webhook } = input;
1381
+ const host = hostOf(webhook.url);
1382
+ const retryAfter = limited.find((d) => d.retryAfter && /^\d+$/.test(d.retryAfter))?.retryAfter ?? null;
1383
+ const percent = Math.round(limited.length / window.length * 100);
1384
+ return {
1385
+ rule: "receiver_rate_limited",
1386
+ severity: "will_fail",
1387
+ impact: limited.length,
1388
+ resource: resourceOf2(input),
1389
+ line: `${host} is rate limiting ${webhook.name}`,
1390
+ meta: `${limited.length} of the last ${window.length} attempts`,
1391
+ lead: "Your receiver is telling us to slow down.",
1392
+ body: [
1393
+ `${percent}% of recent deliveries got HTTP 429.`,
1394
+ retryAfter ? `It asked us to wait ${retryAfter} seconds.` : "",
1395
+ "We retry, so nothing is lost yet, but a long streak ends in dead letters."
1396
+ ].filter(Boolean).join(" "),
1397
+ evidence: [
1398
+ {
1399
+ kind: "shapes",
1400
+ title: "What the receiver sent back",
1401
+ shapes: buildShapes(window, input.attempts.slice(window.length), input.now)
1402
+ }
1403
+ ],
1404
+ handoff: {
1405
+ prompt: handoffPrompt({
1406
+ subject: subject2(webhook.name),
1407
+ observed: [
1408
+ `is being rate limited by ${host}`,
1409
+ `${limited.length} of the last ${window.length} delivery attempts got HTTP 429`,
1410
+ retryAfter ? `The receiver asked for ${retryAfter} seconds between retries` : ""
1411
+ ],
1412
+ lookAt: `Check the rate limit on ${host} and whether this webhook should have its own allowance`,
1413
+ followUp: `Then run sl webhooks deliveries ${webhook.name}`
1414
+ }),
1415
+ commands: [`sl webhooks deliveries ${webhook.name}`]
1416
+ },
1417
+ how: {
1418
+ detector: `at least ${RATE_LIMITED_MIN_ATTEMPTS} recent attempts and ${Math.round(RATE_LIMITED_MIN_RATIO * 100)}% or more are HTTP 429`,
1419
+ facts: [
1420
+ ["429 responses", `${limited.length} of ${window.length}`],
1421
+ ["latest Retry-After", retryAfter ? `${retryAfter}s` : "not sent"]
1422
+ ]
1423
+ }
1424
+ };
1425
+ }
1426
+ };
1427
+ var receiverLoginWall = {
1428
+ id: "receiver_login_wall",
1429
+ evaluate(input) {
1430
+ const streak = consecutiveFromStart(input.attempts, (d) => is2xx(d) && isHtml(d));
1431
+ if (streak.length < LOGIN_WALL_MIN_CONSECUTIVE)
1432
+ return null;
1433
+ const start = streak[streak.length - 1]?.dispatchedAt ?? input.now;
1434
+ const prior = input.attempts.slice(streak.length).filter((d) => is2xx(d) && start.getTime() - d.dispatchedAt.getTime() <= PRIOR_WINDOW_MS);
1435
+ if (prior.length < LOGIN_WALL_MIN_PRIOR)
1436
+ return null;
1437
+ const json = prior.filter(isJson);
1438
+ if (json.length / prior.length < LOGIN_WALL_PRIOR_JSON_RATIO)
1439
+ return null;
1440
+ const { webhook } = input;
1441
+ const host = hostOf(webhook.url);
1442
+ const heights = streak.map((d) => d.blockHeight).filter((h) => h !== null);
1443
+ const from = heights.length > 0 ? Math.min(...heights) : null;
1444
+ const to = heights.length > 0 ? Math.max(...heights) : null;
1445
+ const htmlSample = streak.find((d) => d.body);
1446
+ const jsonSample = json.find((d) => d.body);
1447
+ return {
1448
+ rule: "receiver_login_wall",
1449
+ severity: "losing",
1450
+ impact: streak.length,
1451
+ resource: resourceOf2(input),
1452
+ line: `${host} now answers ${webhook.name} with a web page`,
1453
+ meta: `${streak.length} deliveries since ${utcTime(start)}`,
1454
+ lead: "Your receiver says OK, but sends back a web page.",
1455
+ body: [
1456
+ `Since ${utcTime(start)} every delivery got HTTP 200 with an HTML page instead of JSON.`,
1457
+ "That is usually a login page or proxy screen in front of the endpoint, so the events are not reaching your handler."
1458
+ ].join(" "),
1459
+ evidence: [
1460
+ {
1461
+ kind: "shapes",
1462
+ title: "Response before and after",
1463
+ shapes: [
1464
+ {
1465
+ code: "200",
1466
+ body: jsonSample ? shapeText(jsonSample) : "application/json",
1467
+ count: json.length,
1468
+ isNew: false
1469
+ },
1470
+ {
1471
+ code: "200",
1472
+ body: htmlSample ? shapeText(htmlSample) : "text/html",
1473
+ count: streak.length,
1474
+ isNew: true
1475
+ }
1476
+ ]
1477
+ }
1478
+ ],
1479
+ handoff: {
1480
+ prompt: handoffPrompt({
1481
+ subject: subject2(webhook.name),
1482
+ observed: [
1483
+ `has been answered with a web page since ${utcTime(start)}`,
1484
+ `${host} returns HTTP 200 with text/html on ${streak.length} deliveries in a row, where it used to return JSON`,
1485
+ `${json.length} of ${prior.length} successful deliveries in the week before were JSON`
1486
+ ],
1487
+ lookAt: `Check whether a login page, access policy or proxy rule was put in front of ${host}`,
1488
+ followUp: from !== null && to !== null ? `Once it is fixed, replay the missed blocks with sl webhooks replay ${webhook.name} --from-block ${from} --to-block ${to} -y` : undefined
1489
+ }),
1490
+ commands: from !== null && to !== null ? [
1491
+ `sl webhooks replay ${webhook.name} --from-block ${from} --to-block ${to} -y`
1492
+ ] : []
1493
+ },
1494
+ how: {
1495
+ detector: `the newest ${LOGIN_WALL_MIN_CONSECUTIVE} or more successful responses are text/html while ${Math.round(LOGIN_WALL_PRIOR_JSON_RATIO * 100)}% or more of the week before were JSON`,
1496
+ facts: [
1497
+ ["html responses in a row", String(streak.length)],
1498
+ ["first html response", utcDateTime(start)],
1499
+ ["json share before", `${json.length} of ${prior.length}`],
1500
+ ...from !== null && to !== null ? [["affected blocks", `${from} to ${to}`]] : []
1501
+ ]
1502
+ }
1503
+ };
1504
+ }
1505
+ };
1506
+ function fitLine(points) {
1507
+ const n = points.length;
1508
+ const meanX = points.reduce((s, p) => s + p.x, 0) / n;
1509
+ const meanY = points.reduce((s, p) => s + p.y, 0) / n;
1510
+ let sxx = 0;
1511
+ let sxy = 0;
1512
+ let syy = 0;
1513
+ for (const p of points) {
1514
+ sxx += (p.x - meanX) ** 2;
1515
+ sxy += (p.x - meanX) * (p.y - meanY);
1516
+ syy += (p.y - meanY) ** 2;
1517
+ }
1518
+ const slope = sxx === 0 ? 0 : sxy / sxx;
1519
+ const intercept = meanY - slope * meanX;
1520
+ const r2 = sxx === 0 || syy === 0 ? 0 : sxy * sxy / (sxx * syy);
1521
+ return { slope, intercept, r2 };
1522
+ }
1523
+ var receiverSlowing = {
1524
+ id: "receiver_slowing",
1525
+ evaluate(input) {
1526
+ const days = input.dailyP95;
1527
+ if (days.length < SLOWING_DAYS)
1528
+ return null;
1529
+ let rising = 0;
1530
+ for (let i = 1;i < days.length; i++) {
1531
+ const prev = days[i - 1]?.p95Ms;
1532
+ const cur = days[i]?.p95Ms;
1533
+ if (prev != null && cur != null && cur > prev)
1534
+ rising++;
1535
+ }
1536
+ if (rising < SLOWING_MIN_RISING_DAYS)
1537
+ return null;
1538
+ const points = days.flatMap((d, x) => d.p95Ms === null ? [] : [{ x, y: d.p95Ms }]);
1539
+ if (points.length < SLOWING_MIN_RISING_DAYS)
1540
+ return null;
1541
+ const fit = fitLine(points);
1542
+ if (fit.slope <= 0 || fit.r2 < SLOWING_MIN_R2)
1543
+ return null;
1544
+ const timeout = input.webhook.timeoutMs;
1545
+ const lastX = days.length - 1;
1546
+ const nowFit = fit.intercept + fit.slope * lastX;
1547
+ const laterFit = fit.intercept + fit.slope * (lastX + SLOWING_DAYS);
1548
+ if (!(nowFit < timeout && laterFit >= timeout))
1549
+ return null;
1550
+ const daysLeft = Math.max(1, Math.round((timeout - nowFit) / fit.slope));
1551
+ const { webhook } = input;
1552
+ const host = hostOf(webhook.url);
1553
+ const first = days.find((d) => d.p95Ms !== null)?.p95Ms ?? 0;
1554
+ const last = [...days].reverse().find((d) => d.p95Ms !== null)?.p95Ms ?? 0;
1555
+ return {
1556
+ rule: "receiver_slowing",
1557
+ severity: "will_fail",
1558
+ impact: attemptsLast24h(input),
1559
+ resource: resourceOf2(input),
1560
+ line: `${host} is getting slower for ${webhook.name}`,
1561
+ meta: `may hit the ${timeout / 1000}s timeout in about ${plural(daysLeft, "day")}`,
1562
+ lead: "Your receiver is slowing down, and not by chance.",
1563
+ body: `The slowest 5% of deliveries went from ${Math.round(first)} ms to ${Math.round(last)} ms over ${days.length} days, rising on ${rising} of them. At this pace they reach the ${timeout} ms timeout in about ${plural(daysLeft, "day")}, and then they start failing.`,
1564
+ evidence: [
1565
+ {
1566
+ kind: "series",
1567
+ title: "Slowest 5% of response times, per day",
1568
+ series: [
1569
+ {
1570
+ name: "p95",
1571
+ role: "a",
1572
+ points: days.map((d) => Math.round(d.p95Ms ?? 0))
1573
+ },
1574
+ {
1575
+ name: "Timeout",
1576
+ role: "context",
1577
+ points: days.map(() => timeout)
1578
+ }
1579
+ ],
1580
+ labels: [
1581
+ [0, days[0]?.day.slice(5) ?? ""],
1582
+ [lastX, days[lastX]?.day.slice(5) ?? ""]
1583
+ ],
1584
+ unit: "ms"
1585
+ }
1586
+ ],
1587
+ handoff: {
1588
+ prompt: handoffPrompt({
1589
+ subject: subject2(webhook.name),
1590
+ observed: [
1591
+ `is getting slower: its receiver ${host} took ${Math.round(first)} ms at the slow end ${days.length} days ago and ${Math.round(last)} ms now`,
1592
+ `The delivery timeout is ${timeout} ms, which the trend reaches in about ${plural(daysLeft, "day")}`
1593
+ ],
1594
+ lookAt: `Check what changed on ${host} over the last two weeks, such as data growth, a slower dependency or a heavier handler`,
1595
+ followUp: `Then run sl webhooks deliveries ${webhook.name}`
1596
+ }),
1597
+ commands: [`sl webhooks deliveries ${webhook.name}`]
1598
+ },
1599
+ how: {
1600
+ detector: `daily p95 response time rose on ${SLOWING_MIN_RISING_DAYS} or more of ${SLOWING_DAYS} days, a straight-line fit has r2 of ${SLOWING_MIN_R2} or more, and it reaches the timeout within ${SLOWING_DAYS} days`,
1601
+ facts: [
1602
+ ["rising days", `${rising} of ${days.length}`],
1603
+ ["trend", `+${Math.round(fit.slope)} ms a day`],
1604
+ ["fit r2", fit.r2.toFixed(2)],
1605
+ ["timeout", `${timeout} ms`],
1606
+ ["days until timeout", String(daysLeft)]
1607
+ ]
1608
+ }
1609
+ };
1610
+ }
1611
+ };
1612
+ var tlsExpiring = {
1613
+ id: "tls_expiring",
1614
+ evaluate(input) {
1615
+ const expires = input.webhook.receiverCertExpiresAt;
1616
+ if (!expires)
1617
+ return null;
1618
+ const msLeft = expires.getTime() - input.now.getTime();
1619
+ if (msLeft >= TLS_WARN_DAYS * DAY_MS)
1620
+ return null;
1621
+ const { webhook } = input;
1622
+ const host = hostOf(webhook.url);
1623
+ const daysLeft = Math.floor(msLeft / DAY_MS);
1624
+ const expired = msLeft <= 0;
1625
+ return {
1626
+ rule: "tls_expiring",
1627
+ severity: "will_fail",
1628
+ impact: attemptsLast24h(input),
1629
+ resource: resourceOf2(input),
1630
+ line: expired ? `The certificate for ${host} has expired` : `The certificate for ${host} expires soon`,
1631
+ meta: expired ? `expired ${utcDateTime(expires)}` : `in ${plural(daysLeft, "day")}`,
1632
+ lead: expired ? "Your receiver's certificate has expired." : "Your receiver's certificate is about to expire.",
1633
+ body: expired ? `Deliveries to ${host} fail the TLS check until the certificate is renewed.` : `After ${utcDateTime(expires)} deliveries to ${host} fail the TLS check until the certificate is renewed.`,
1634
+ evidence: [
1635
+ {
1636
+ kind: "table",
1637
+ title: "Receiver certificate",
1638
+ columns: ["Host", "Expires", "Days left", "Last checked"],
1639
+ rows: [
1640
+ [
1641
+ host,
1642
+ utcDateTime(expires),
1643
+ daysLeft,
1644
+ input.webhook.receiverCertCheckedAt ? utcDateTime(input.webhook.receiverCertCheckedAt) : "unknown"
1645
+ ]
1646
+ ]
1647
+ }
1648
+ ],
1649
+ handoff: {
1650
+ prompt: handoffPrompt({
1651
+ subject: subject2(webhook.name),
1652
+ observed: [
1653
+ expired ? `points at ${host}, whose TLS certificate expired on ${utcDateTime(expires)}` : `points at ${host}, whose TLS certificate expires on ${utcDateTime(expires)}`
1654
+ ],
1655
+ lookAt: `Check why the certificate on ${host} is not renewing automatically`
1656
+ }),
1657
+ commands: []
1658
+ },
1659
+ how: {
1660
+ detector: `the receiver certificate expires in less than ${TLS_WARN_DAYS} days`,
1661
+ facts: [
1662
+ ["expires", utcDateTime(expires)],
1663
+ ["days left", String(daysLeft)]
1664
+ ]
1665
+ }
1666
+ };
1667
+ }
1668
+ };
1669
+ function literalContractCalls(triggers) {
1670
+ if (!Array.isArray(triggers))
1671
+ return [];
1672
+ const out = [];
1673
+ for (const t of triggers) {
1674
+ if (!t || typeof t !== "object")
1675
+ continue;
1676
+ const { type, contractId, functionName } = t;
1677
+ if (type !== "contract_call")
1678
+ continue;
1679
+ if (typeof contractId !== "string" || typeof functionName !== "string")
1680
+ continue;
1681
+ if (contractId.includes("*") || functionName.includes("*"))
1682
+ continue;
1683
+ out.push({ contractId, functionName });
1684
+ }
1685
+ return out;
1686
+ }
1687
+ function contractIdsToCheck(triggers) {
1688
+ return [...new Set(literalContractCalls(triggers).map((t) => t.contractId))];
1689
+ }
1690
+ var filterCantMatch = {
1691
+ id: "filter_cant_match",
1692
+ skip: (input) => contractIdsToCheck(input.webhook.triggers).length > 0 && !input.abiAvailable,
1693
+ evaluate(input) {
1694
+ const offending = literalContractCalls(input.webhook.triggers).filter((t) => {
1695
+ const names = input.abi[t.contractId];
1696
+ return names !== undefined && !names.includes(t.functionName);
1697
+ });
1698
+ const first = offending[0];
1699
+ if (!first)
1700
+ return null;
1701
+ const names = input.abi[first.contractId] ?? [];
1702
+ const ranked = [...names].sort((a, b) => levenshtein(a, first.functionName) - levenshtein(b, first.functionName));
1703
+ const closest = ranked[0];
1704
+ const { webhook } = input;
1705
+ return {
1706
+ rule: "filter_cant_match",
1707
+ severity: "will_fail",
1708
+ impact: offending.length,
1709
+ resource: resourceOf2(input),
1710
+ line: `${webhook.name} can never match a call`,
1711
+ meta: `${first.contractId} has no ${first.functionName}`,
1712
+ lead: "This filter waits for something that does not exist.",
1713
+ body: [
1714
+ `${first.contractId} has no public function named ${first.functionName}, so no event will ever match.`,
1715
+ closest ? `The closest is ${closest}.` : ""
1716
+ ].filter(Boolean).join(" "),
1717
+ evidence: [
1718
+ {
1719
+ kind: "table",
1720
+ title: "Public functions on the contract",
1721
+ columns: ["Function", "In contract"],
1722
+ rows: [
1723
+ [first.functionName, "not found"],
1724
+ ...ranked.slice(0, 6).map((n) => [n, "yes"])
1725
+ ],
1726
+ markRow: 0
1727
+ }
1728
+ ],
1729
+ handoff: {
1730
+ prompt: handoffPrompt({
1731
+ subject: subject2(webhook.name),
1732
+ observed: [
1733
+ `waits for calls to ${first.functionName} on ${first.contractId}`,
1734
+ `That contract has no public method with that name${closest ? `, and the closest name is ${closest}` : ""}`,
1735
+ "The webhook has never matched and cannot"
1736
+ ],
1737
+ lookAt: "Check the spelling in the trigger and whether the contract was redeployed under another name",
1738
+ followUp: `Create the corrected webhook first, then run sl webhooks delete ${webhook.name}`
1739
+ }),
1740
+ commands: [`sl webhooks delete ${webhook.name}`]
1741
+ },
1742
+ how: {
1743
+ detector: "a contract_call trigger names a literal contract and method, the contract exists, and its ABI has no method with that name",
1744
+ facts: [
1745
+ ["contract", first.contractId],
1746
+ ["method in trigger", first.functionName],
1747
+ ["methods on contract", String(names.length)],
1748
+ ["triggers that cannot match", String(offending.length)]
1749
+ ]
1750
+ }
1751
+ };
1752
+ }
1753
+ };
1754
+ var deadLettersExpiring = {
1755
+ id: "dead_letters_expiring",
1756
+ evaluate(input) {
1757
+ const { dead, webhook } = input;
1758
+ if (dead.count === 0 || !dead.oldestAt)
1759
+ return null;
1760
+ const deleteAt = new Date(dead.oldestAt.getTime() + DEAD_LETTER_DELETE_AGE_DAYS * DAY_MS);
1761
+ return {
1762
+ rule: "dead_letters_expiring",
1763
+ severity: "will_fail",
1764
+ impact: dead.count,
1765
+ resource: resourceOf2(input),
1766
+ line: `${plural(dead.count, "failed event")} on ${webhook.name} will be deleted`,
1767
+ meta: `from ${utcDateTime(deleteAt)}`,
1768
+ lead: "Failed events are kept for 90 days, and these are almost there.",
1769
+ body: `${plural(dead.count, "event")} that never reached your receiver are older than ${DEAD_LETTER_WARN_AGE_DAYS} days. The oldest is deleted on ${utcDateTime(deleteAt)}. Send them again before then if you still want them.`,
1770
+ evidence: [
1771
+ {
1772
+ kind: "table",
1773
+ title: "Failed events near deletion",
1774
+ columns: ["Failed events", "Oldest failure", "Deleted from"],
1775
+ rows: [
1776
+ [dead.count, utcDateTime(dead.oldestAt), utcDateTime(deleteAt)]
1777
+ ]
1778
+ }
1779
+ ],
1780
+ handoff: {
1781
+ prompt: handoffPrompt({
1782
+ subject: subject2(webhook.name),
1783
+ observed: [
1784
+ `has ${plural(dead.count, "failed event")} older than ${DEAD_LETTER_WARN_AGE_DAYS} days`,
1785
+ `The oldest failed on ${utcDateTime(dead.oldestAt)} and is deleted from ${utcDateTime(deleteAt)}`
1786
+ ],
1787
+ lookAt: "Decide whether those events still matter, and confirm the receiver can handle them now",
1788
+ followUp: `Then run sl webhooks dead ${webhook.name}${dead.oldestOutboxId ? ` and sl webhooks requeue ${webhook.name} ${dead.oldestOutboxId}` : ""}`
1789
+ }),
1790
+ commands: [
1791
+ `sl webhooks dead ${webhook.name}`,
1792
+ ...dead.oldestOutboxId ? [`sl webhooks requeue ${webhook.name} ${dead.oldestOutboxId}`] : []
1793
+ ]
1794
+ },
1795
+ how: {
1796
+ detector: `failed outbox rows older than ${DEAD_LETTER_WARN_AGE_DAYS} days exist (deleted at ${DEAD_LETTER_DELETE_AGE_DAYS})`,
1797
+ facts: [
1798
+ ["failed events past the line", String(dead.count)],
1799
+ ["oldest failure", utcDateTime(dead.oldestAt)],
1800
+ ["oldest is deleted", utcDateTime(deleteAt)]
1801
+ ]
1802
+ }
1803
+ };
1804
+ }
1805
+ };
1806
+ var duplicateWebhook = {
1807
+ id: "duplicate_webhook",
1808
+ evaluate(input) {
1809
+ const { webhook, siblings } = input;
1810
+ const mine = canonicalJson(webhook.triggers);
1811
+ const twin = siblings.find((s) => s.url === webhook.url && canonicalJson(s.triggers) === mine);
1812
+ if (!twin)
1813
+ return null;
1814
+ const newer = webhook.createdAt.getTime() !== twin.createdAt.getTime() ? webhook.createdAt.getTime() > twin.createdAt.getTime() : webhook.id > twin.id;
1815
+ if (!newer)
1816
+ return null;
1817
+ return {
1818
+ rule: "duplicate_webhook",
1819
+ severity: "costs",
1820
+ impact: input.deliveries30d,
1821
+ resource: resourceOf2(input),
1822
+ line: `${webhook.name} duplicates ${twin.name}`,
1823
+ meta: "same URL and same filters",
1824
+ lead: "Two webhooks do the same job.",
1825
+ body: `${webhook.name} and ${twin.name} send the same events to ${hostOf(webhook.url)}. Your receiver gets each event twice, and each delivery counts.`,
1826
+ evidence: [
1827
+ {
1828
+ kind: "table",
1829
+ title: "Matching webhooks",
1830
+ columns: ["Webhook", "Created"],
1831
+ rows: [
1832
+ [twin.name, utcDateTime(twin.createdAt)],
1833
+ [webhook.name, utcDateTime(webhook.createdAt)]
1834
+ ],
1835
+ markRow: 1
1836
+ }
1837
+ ],
1838
+ handoff: {
1839
+ prompt: handoffPrompt({
1840
+ subject: subject2(webhook.name),
1841
+ observed: [
1842
+ `duplicates the webhook "${twin.name}"`,
1843
+ `Both send the same filters to ${webhook.url}, so each event is delivered twice`
1844
+ ],
1845
+ lookAt: `Check that nothing relies on both, then remove the newer one, ${webhook.name}`
1846
+ }),
1847
+ commands: [`sl webhooks delete ${webhook.name}`]
1848
+ },
1849
+ how: {
1850
+ detector: "two non-paused webhooks share a URL and identical triggers",
1851
+ facts: [
1852
+ ["older webhook", twin.name],
1853
+ ["newer webhook", webhook.name],
1854
+ ["deliveries in the last 30 days", String(input.deliveries30d)]
1855
+ ]
1856
+ }
1857
+ };
1858
+ }
1859
+ };
1860
+ var TEST_HOST_SUFFIXES = [".ngrok.io", ".ngrok-free.app", ".pipedream.net"];
1861
+ var TEST_HOSTS = new Set(["postman-echo.com", "webhook.site"]);
1862
+ function isTestHost(host) {
1863
+ const name = host.toLowerCase().split(":")[0] ?? "";
1864
+ return TEST_HOSTS.has(name) || name.startsWith("requestbin.") || TEST_HOST_SUFFIXES.some((suffix) => name.endsWith(suffix));
1865
+ }
1866
+ var testEndpoint = {
1867
+ id: "test_endpoint",
1868
+ evaluate(input) {
1869
+ const { webhook } = input;
1870
+ const host = hostOf(webhook.url);
1871
+ if (!isTestHost(host))
1872
+ return null;
1873
+ const ageMs = input.now.getTime() - webhook.createdAt.getTime();
1874
+ if (ageMs <= TEST_ENDPOINT_MIN_AGE_DAYS * DAY_MS)
1875
+ return null;
1876
+ const ageDays = Math.floor(ageMs / DAY_MS);
1877
+ return {
1878
+ rule: "test_endpoint",
1879
+ severity: "costs",
1880
+ impact: input.deliveries30d,
1881
+ resource: resourceOf2(input),
1882
+ line: `${webhook.name} still sends to a test endpoint`,
1883
+ meta: host,
1884
+ lead: "This webhook points at a throwaway test URL.",
1885
+ body: `${host} is a public testing service, and ${webhook.name} has been sending to it for ${plural(ageDays, "day")}. Real events are leaving your account, and each delivery counts.`,
1886
+ evidence: [
1887
+ {
1888
+ kind: "table",
1889
+ title: "Test endpoint",
1890
+ columns: ["Host", "Age", "Deliveries, last 30 days"],
1891
+ rows: [[host, `${ageDays} days`, input.deliveries30d]]
1892
+ }
1893
+ ],
1894
+ handoff: {
1895
+ prompt: handoffPrompt({
1896
+ subject: subject2(webhook.name),
1897
+ observed: [
1898
+ `has sent to the test service ${host} for ${plural(ageDays, "day")}`,
1899
+ `${plural(input.deliveries30d, "delivery")} in the last 30 days`
1900
+ ],
1901
+ lookAt: "Decide whether this should point at a real receiver, or be paused or removed"
1902
+ }),
1903
+ commands: [
1904
+ `sl webhooks pause ${webhook.name}`,
1905
+ `sl webhooks delete ${webhook.name}`
1906
+ ]
1907
+ },
1908
+ how: {
1909
+ detector: `the receiver host is a known test service and the webhook is older than ${TEST_ENDPOINT_MIN_AGE_DAYS} days`,
1910
+ facts: [
1911
+ ["host", host],
1912
+ ["age", `${ageDays} days`],
1913
+ ["deliveries in the last 30 days", String(input.deliveries30d)]
1914
+ ]
1915
+ }
1916
+ };
1917
+ }
1918
+ };
1919
+ var WEBHOOK_RULES = [
1920
+ receiverDown,
1921
+ receiverRejects,
1922
+ receiverRateLimited,
1923
+ receiverLoginWall,
1924
+ receiverSlowing,
1925
+ tlsExpiring,
1926
+ filterCantMatch,
1927
+ deadLettersExpiring,
1928
+ duplicateWebhook,
1929
+ testEndpoint
1930
+ ];
1931
+ var ABI_TTL_MS = 24 * 3600000;
1932
+ function createAbiCache() {
1933
+ const entries = new Map;
1934
+ return {
1935
+ get(contractId, now) {
1936
+ const hit = entries.get(contractId);
1937
+ return hit && now - hit.at < ABI_TTL_MS ? hit.names : undefined;
1938
+ },
1939
+ set(contractId, names, now) {
1940
+ entries.set(contractId, { names, at: now });
1941
+ }
1942
+ };
1943
+ }
1944
+ async function fetchFunctionNames(contractId, baseUrl, apiKey, deps) {
1945
+ try {
1946
+ const res = await deps.fetchImpl(`${baseUrl.replace(/\/$/, "")}/v1/contracts/${encodeURIComponent(contractId)}?include=abi`, {
1947
+ headers: { Authorization: `Bearer ${apiKey}` },
1948
+ signal: AbortSignal.timeout(5000)
1949
+ });
1950
+ if (res.status === 404)
1951
+ return null;
1952
+ if (!res.ok)
1953
+ return "unavailable";
1954
+ const body = await res.json();
1955
+ const functions = body.contract?.abi?.functions;
1956
+ if (!Array.isArray(functions))
1957
+ return "unavailable";
1958
+ return functions.map((f) => f.name).filter((n) => typeof n === "string");
1959
+ } catch {
1960
+ return "unavailable";
1961
+ }
1962
+ }
1963
+ var num = (v) => v === null ? null : Number(v);
1964
+ async function loadWebhookInputs(db, now, abiDeps) {
1965
+ const nowMs = now.getTime();
1966
+ const webhooks = await db.selectFrom("webhooks").select([
1967
+ "id",
1968
+ "name",
1969
+ "status",
1970
+ "url",
1971
+ "triggers",
1972
+ "timeout_ms",
1973
+ "created_at",
1974
+ "last_success_at",
1975
+ "receiver_cert_expires_at",
1976
+ "receiver_cert_checked_at"
1977
+ ]).execute();
1978
+ if (webhooks.length === 0)
1979
+ return [];
1980
+ const lookback = new Date(nowMs - ATTEMPT_LOOKBACK_DAYS * DAY_MS);
1981
+ const attemptRows = await sql4`
1982
+ SELECT webhook_id, outbox_id, status_code, duration_ms, dispatched_at,
1983
+ error_message, body, content_type, server, retry_after, block_height
1984
+ FROM (
1985
+ SELECT d.webhook_id, d.outbox_id, d.status_code, d.duration_ms,
1986
+ d.dispatched_at, d.error_message,
1987
+ left(d.response_body, 200) AS body,
1988
+ d.response_headers ->> 'content-type' AS content_type,
1989
+ d.response_headers ->> 'server' AS server,
1990
+ d.response_headers ->> 'retry-after' AS retry_after,
1991
+ o.block_height,
1992
+ row_number() OVER (PARTITION BY d.webhook_id ORDER BY d.dispatched_at DESC) AS rn
1993
+ FROM webhook_deliveries d
1994
+ LEFT JOIN webhook_outbox o ON o.id = d.outbox_id
1995
+ WHERE d.dispatched_at > ${lookback}
1996
+ ) t
1997
+ WHERE rn <= ${ATTEMPT_WINDOW}
1998
+ ORDER BY webhook_id, dispatched_at DESC
1999
+ `.execute(db);
2000
+ const startMs = Math.floor(nowMs / BUCKET_MS) * BUCKET_MS - (BUCKETS - 1) * BUCKET_MS;
2001
+ const start = new Date(startMs);
2002
+ const matchedRows = await sql4`
2003
+ SELECT webhook_id,
2004
+ floor(extract(epoch FROM (created_at - ${start}::timestamptz)) / 600)::int AS b,
2005
+ count(*) AS n
2006
+ FROM webhook_outbox
2007
+ WHERE created_at >= ${start} AND is_replay = false
2008
+ GROUP BY 1, 2
2009
+ `.execute(db);
2010
+ const deliveredRows = await sql4`
2011
+ SELECT webhook_id,
2012
+ floor(extract(epoch FROM (delivered_at - ${start}::timestamptz)) / 600)::int AS b,
2013
+ count(*) AS n
2014
+ FROM webhook_outbox
2015
+ WHERE delivered_at >= ${start} AND status = 'delivered'
2016
+ GROUP BY 1, 2
2017
+ `.execute(db);
2018
+ const pendingRows = await sql4`
2019
+ SELECT webhook_id, count(*) AS n FROM webhook_outbox
2020
+ WHERE status = 'pending' GROUP BY 1
2021
+ `.execute(db);
2022
+ const dayStart = Math.floor(nowMs / DAY_MS) * DAY_MS - (SLOWING_DAYS - 1) * DAY_MS;
2023
+ const p95Rows = await sql4`
2024
+ SELECT webhook_id,
2025
+ to_char(dispatched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
2026
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95
2027
+ FROM webhook_deliveries
2028
+ WHERE dispatched_at >= ${new Date(dayStart)} AND duration_ms IS NOT NULL
2029
+ GROUP BY 1, 2
2030
+ `.execute(db);
2031
+ const warnBefore = new Date(nowMs - DEAD_LETTER_WARN_AGE_DAYS * DAY_MS);
2032
+ const deadRows = await sql4`
2033
+ SELECT webhook_id, count(*) AS n,
2034
+ min(coalesce(failed_at, created_at)) AS oldest,
2035
+ (array_agg(id ORDER BY coalesce(failed_at, created_at)))[1] AS oldest_id
2036
+ FROM webhook_outbox
2037
+ WHERE status = 'dead' AND coalesce(failed_at, created_at) < ${warnBefore}
2038
+ GROUP BY 1
2039
+ `.execute(db);
2040
+ const deliveryCountRows = await sql4`
2041
+ SELECT webhook_id, count(*) AS n FROM webhook_deliveries
2042
+ WHERE dispatched_at > ${new Date(nowMs - 30 * DAY_MS)} GROUP BY 1
2043
+ `.execute(db);
2044
+ const attemptsBy = new Map;
2045
+ for (const r of attemptRows.rows) {
2046
+ const list = attemptsBy.get(r.webhook_id) ?? [];
2047
+ list.push({
2048
+ outboxId: r.outbox_id,
2049
+ statusCode: r.status_code,
2050
+ durationMs: r.duration_ms,
2051
+ dispatchedAt: r.dispatched_at,
2052
+ errorMessage: r.error_message,
2053
+ body: r.body,
2054
+ contentType: r.content_type,
2055
+ server: r.server,
2056
+ retryAfter: r.retry_after,
2057
+ blockHeight: num(r.block_height)
2058
+ });
2059
+ attemptsBy.set(r.webhook_id, list);
2060
+ }
2061
+ const series = (rows) => {
2062
+ const by = new Map;
2063
+ for (const r of rows) {
2064
+ if (r.b < 0 || r.b >= BUCKETS)
2065
+ continue;
2066
+ const arr = by.get(r.webhook_id) ?? new Array(BUCKETS).fill(0);
2067
+ arr[r.b] = Number(r.n);
2068
+ by.set(r.webhook_id, arr);
2069
+ }
2070
+ return by;
2071
+ };
2072
+ const matchedBy = series(matchedRows.rows);
2073
+ const deliveredBy = series(deliveredRows.rows);
2074
+ const pendingBy = new Map(pendingRows.rows.map((r) => [r.webhook_id, Number(r.n)]));
2075
+ const countBy = new Map(deliveryCountRows.rows.map((r) => [r.webhook_id, Number(r.n)]));
2076
+ const deadBy = new Map(deadRows.rows.map((r) => [r.webhook_id, r]));
2077
+ const p95By = new Map;
2078
+ for (const r of p95Rows.rows) {
2079
+ const m = p95By.get(r.webhook_id) ?? new Map;
2080
+ m.set(r.day, Number(r.p95));
2081
+ p95By.set(r.webhook_id, m);
2082
+ }
2083
+ const days = Array.from({ length: SLOWING_DAYS }, (_, i) => new Date(dayStart + i * DAY_MS).toISOString().slice(0, 10));
2084
+ const baseUrl = abiDeps.env.STREAMS_API_URL?.trim();
2085
+ const apiKey = abiDeps.env.INDEX_INTERNAL_API_KEY?.trim();
2086
+ const abiEnabled = Boolean(baseUrl && apiKey);
2087
+ const abiBy = new Map;
2088
+ if (abiEnabled && baseUrl && apiKey) {
2089
+ const wanted = new Set(webhooks.filter((w) => w.status !== "paused").flatMap((w) => contractIdsToCheck(w.triggers)));
2090
+ for (const id of wanted) {
2091
+ const cached = abiDeps.cache.get(id, nowMs);
2092
+ if (cached) {
2093
+ abiBy.set(id, cached);
2094
+ continue;
2095
+ }
2096
+ const names = await fetchFunctionNames(id, baseUrl, apiKey, abiDeps);
2097
+ if (Array.isArray(names))
2098
+ abiDeps.cache.set(id, names, nowMs);
2099
+ abiBy.set(id, names);
2100
+ }
2101
+ }
2102
+ const live = webhooks.filter((w) => w.status !== "paused");
2103
+ return webhooks.map((w) => {
2104
+ const dead = deadBy.get(w.id);
2105
+ const ids = contractIdsToCheck(w.triggers);
2106
+ const abi = {};
2107
+ let abiAvailable = abiEnabled;
2108
+ for (const id of ids) {
2109
+ const found = abiBy.get(id);
2110
+ if (Array.isArray(found))
2111
+ abi[id] = found;
2112
+ else if (found === "unavailable")
2113
+ abiAvailable = false;
2114
+ }
2115
+ const p95 = p95By.get(w.id);
2116
+ return {
2117
+ now,
2118
+ webhook: {
2119
+ id: w.id,
2120
+ name: w.name,
2121
+ status: w.status,
2122
+ url: w.url,
2123
+ triggers: w.triggers,
2124
+ timeoutMs: w.timeout_ms,
2125
+ createdAt: w.created_at,
2126
+ lastSuccessAt: w.last_success_at,
2127
+ receiverCertExpiresAt: w.receiver_cert_expires_at,
2128
+ receiverCertCheckedAt: w.receiver_cert_checked_at
2129
+ },
2130
+ attempts: attemptsBy.get(w.id) ?? [],
2131
+ pending: pendingBy.get(w.id) ?? 0,
2132
+ throughput: {
2133
+ startMs,
2134
+ matched: matchedBy.get(w.id) ?? new Array(BUCKETS).fill(0),
2135
+ delivered: deliveredBy.get(w.id) ?? new Array(BUCKETS).fill(0)
2136
+ },
2137
+ dailyP95: days.map((day) => ({ day, p95Ms: p95?.get(day) ?? null })),
2138
+ dead: {
2139
+ count: dead ? Number(dead.n) : 0,
2140
+ oldestAt: dead?.oldest ?? null,
2141
+ oldestOutboxId: dead?.oldest_id ?? null
2142
+ },
2143
+ deliveries30d: countBy.get(w.id) ?? 0,
2144
+ siblings: live.filter((o) => o.id !== w.id).map((o) => ({
2145
+ id: o.id,
2146
+ name: o.name,
2147
+ url: o.url,
2148
+ triggers: o.triggers,
2149
+ createdAt: o.created_at
2150
+ })),
2151
+ abi,
2152
+ abiAvailable
2153
+ };
2154
+ });
2155
+ }
2156
+
2157
+ // src/insights/evaluate.ts
2158
+ function runRules2(rules, input, resource, opts = {}) {
2159
+ const fired = [];
2160
+ const evaluated = [];
2161
+ for (const rule of rules) {
2162
+ try {
2163
+ if (rule.skip?.(input))
2164
+ continue;
2165
+ const key = { kind: resource.kind, id: resource.id, rule: rule.id };
2166
+ const result = opts.active === false ? null : rule.evaluate(input);
2167
+ evaluated.push(key);
2168
+ if (result)
2169
+ fired.push(result);
2170
+ } catch (err) {
2171
+ logger.warn("insights.rule_failed", {
2172
+ rule: rule.id,
2173
+ error: String(err)
2174
+ });
2175
+ }
2176
+ }
2177
+ return { fired, evaluated };
2178
+ }
2179
+ var defaultAbiCache = createAbiCache();
2180
+ async function evaluateTenantInsights(db, deps = {}) {
2181
+ const now = deps.now ?? new Date;
2182
+ const env = deps.env ?? process.env;
2183
+ const scope = { accountId: null };
2184
+ const webhookInputs = await loadWebhookInputs(db, now, {
2185
+ env,
2186
+ fetchImpl: deps.fetchImpl ?? fetch,
2187
+ cache: deps.abiCache ?? defaultAbiCache
2188
+ });
2189
+ const subgraphInputs = await loadSubgraphInputs(db, now);
2190
+ const fired = [];
2191
+ const evaluated = [];
2192
+ for (const input of webhookInputs) {
2193
+ const result = runRules2(WEBHOOK_RULES, input, { kind: "webhook", id: input.webhook.id }, { active: input.webhook.status !== "paused" });
2194
+ fired.push(...result.fired);
2195
+ evaluated.push(...result.evaluated);
2196
+ }
2197
+ for (const input of subgraphInputs) {
2198
+ const result = runRules2(SUBGRAPH_RULES, input, {
2199
+ kind: "subgraph",
2200
+ id: input.subgraph.name
2201
+ });
2202
+ fired.push(...result.fired);
2203
+ evaluated.push(...result.evaluated);
2204
+ }
2205
+ const live = new Set([
2206
+ ...webhookInputs.map((i) => `webhook\x00${i.webhook.id}`),
2207
+ ...subgraphInputs.map((i) => `subgraph\x00${i.subgraph.name}`)
2208
+ ]);
2209
+ for (const open of await listOpenResources(db, scope)) {
2210
+ if (!live.has(`${open.kind}\x00${open.id}`))
2211
+ evaluated.push(open);
2212
+ }
2213
+ const labelled = await Promise.all(fired.map((insight) => maybeLabel(insight, {
2214
+ classifyFn: deps.classifyFn,
2215
+ cache: deps.cache,
2216
+ env,
2217
+ now: now.getTime()
2218
+ })));
2219
+ await recordEvaluation(db, scope, labelled, evaluated, now);
2220
+ await purgeResolved(db, scope, 30, now);
2221
+ }
2222
+ export {
2223
+ BACKFILL_DAY_SHARE,
2224
+ BACKFILL_FULL_PAGE_RATIO,
2225
+ BACKFILL_MIN_DAY_ROWS,
2226
+ CLEAR_STREAK_TO_RESOLVE,
2227
+ CREDITS_RUNWAY_WARN_DAYS,
2228
+ DEAD_LETTER_DELETE_AGE_DAYS,
2229
+ DEAD_LETTER_WARN_AGE_DAYS,
2230
+ DOWN_MIN_CONSECUTIVE,
2231
+ DUPLICATE_READS_LOOKBACK_MS,
2232
+ DUPLICATE_READS_MIN_PAGES,
2233
+ DUPLICATE_READS_WINDOW_MS,
2234
+ HANDLER_ERRORS_MIN_24H,
2235
+ LAG_MIN_SAMPLES,
2236
+ LAG_THRESHOLD_MS,
2237
+ LAG_WINDOW,
2238
+ LOGIN_WALL_MIN_CONSECUTIVE,
2239
+ LOGIN_WALL_MIN_PRIOR,
2240
+ LOGIN_WALL_PRIOR_JSON_RATIO,
2241
+ PAGE_CAP_ROWS,
2242
+ POLLING_LOOKBACK_MS,
2243
+ POLLING_MAX_STDDEV_RATIO,
2244
+ POLLING_MIN_DURATION_MS,
2245
+ POLLING_MIN_EMPTY_RATIO,
2246
+ POLLING_MIN_REQUESTS,
2247
+ RATE_LIMITED_MIN_ATTEMPTS,
2248
+ RATE_LIMITED_MIN_RATIO,
2249
+ RECOVERED_VISIBLE_MS,
2250
+ REJECTS_MIN_CONSECUTIVE,
2251
+ SERVER_ERRORS_MIN_5XX,
2252
+ SLOWING_DAYS,
2253
+ SLOWING_MIN_R2,
2254
+ SLOWING_MIN_RISING_DAYS,
2255
+ SLOW_MIN_SAMPLES,
2256
+ SLOW_TIMEOUT_RATIO,
2257
+ SLOW_WINDOW,
2258
+ SUBGRAPH_LAG_BLOCKS,
2259
+ SUBGRAPH_LAG_LOSING_BLOCKS,
2260
+ SUBGRAPH_LAG_SNAPSHOTS,
2261
+ TEST_ENDPOINT_MIN_AGE_DAYS,
2262
+ TLS_WARN_DAYS,
2263
+ dismissInsight,
2264
+ evaluateTenantInsights,
2265
+ handoffPrompt,
2266
+ hostOf,
2267
+ listInsights,
2268
+ listOpenResources,
2269
+ maybeLabel,
2270
+ muteRule,
2271
+ plainSnippet,
2272
+ plural,
2273
+ purgeResolved,
2274
+ rankInsights,
2275
+ recordEvaluation,
2276
+ runRules2 as runRules,
2277
+ unmuteRule,
2278
+ utcDateTime,
2279
+ utcDay,
2280
+ utcTime
2281
+ };
2282
+
2283
+ //# debugId=85A02E469680D94664756E2164756E21
2284
+ //# sourceMappingURL=index.js.map