@obsunified/collector 2.2.3 → 2.4.3

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 (58) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  3. package/dist/chunk-RASSTGNB.js.map +1 -0
  4. package/dist/index.d.ts +264 -62
  5. package/dist/index.js +3390 -2350
  6. package/dist/index.js.map +1 -1
  7. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
  8. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  9. package/package.json +4 -2
  10. package/src/__tests__/autosre-closed-loop.test.ts +110 -0
  11. package/src/__tests__/db-parity.integration.test.ts +961 -0
  12. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  13. package/src/framework/env.ts +113 -0
  14. package/src/index.ts +27 -4
  15. package/src/lib/ai-store/evaluations.test.ts +3 -3
  16. package/src/lib/alert-email.ts +104 -0
  17. package/src/lib/alerts-store.test.ts +116 -2
  18. package/src/lib/alerts-store.ts +421 -57
  19. package/src/lib/analyses-store.test.ts +6 -6
  20. package/src/lib/db-parity.ts +883 -0
  21. package/src/lib/eval-cases-store.test.ts +1 -1
  22. package/src/lib/eval-cases-store.ts +16 -4
  23. package/src/lib/evidence-references.ts +30 -22
  24. package/src/lib/metric-alerts.test.ts +769 -0
  25. package/src/lib/metric-alerts.ts +640 -0
  26. package/src/lib/metrics-store.ts +9 -2
  27. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  28. package/src/lib/stage6.test.ts +1 -1
  29. package/src/lib/store/trace-queries.ts +1 -1
  30. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  31. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  32. package/src/migrations/056_metric_point_series_received.sql +9 -0
  33. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  34. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  35. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  36. package/src/migrations/parity.test.ts +105 -3
  37. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  38. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  39. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  40. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  41. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  42. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  43. package/src/migrations-postgres/README.md +13 -0
  44. package/src/plugins/ai-receiver.ts +6 -1
  45. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  46. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  47. package/src/plugins/alerts-evaluator.test.ts +10 -2
  48. package/src/plugins/alerts-evaluator.ts +239 -40
  49. package/src/plugins/alerts-routes.ts +17 -2
  50. package/src/plugins/autosre-routes.ts +251 -140
  51. package/src/plugins/connected-routes/manifest.test.ts +114 -32
  52. package/src/plugins/connected-routes/manifest.ts +59 -76
  53. package/src/plugins/connected-routes.test.ts +10 -8
  54. package/src/plugins/connected-routes.ts +42 -12
  55. package/src/plugins/eval-cases-routes.ts +33 -10
  56. package/src/plugins/evidence-retrieval-routes.ts +60 -25
  57. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  58. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -1,5 +1,8 @@
1
1
  import type {
2
2
  AlertChannel,
3
+ AlertEmailChannel,
4
+ AlertEvaluationOutcome,
5
+ AlertQueryMetrics,
3
6
  AlertRule,
4
7
  AlertState,
5
8
  AlertWebhookChannel,
@@ -10,7 +13,16 @@ import {
10
13
  consoleLogger,
11
14
  type Logger,
12
15
  } from "../framework/logger";
13
- import { AlertsStore, compareValue } from "../lib/alerts-store";
16
+ import {
17
+ type AlertEmailTransport,
18
+ type AlertNotificationPayload,
19
+ formatAlertEmail,
20
+ } from "../lib/alert-email";
21
+ import {
22
+ type AlertRuleEvaluation,
23
+ AlertsStore,
24
+ compareValue,
25
+ } from "../lib/alerts-store";
14
26
  import {
15
27
  openOrDeduplicateIncident,
16
28
  resolveServiceIncidentTarget,
@@ -29,6 +41,8 @@ const DEFAULT_RULE_TIMEOUT_MS = 10_000;
29
41
  interface AlertEvaluatorStore {
30
42
  listEnabledRules(): Promise<AlertRule[]>;
31
43
  evaluateRule(rule: AlertRule): Promise<number>;
44
+ /** Preferred when present: also says whether a metrics rule had no data. */
45
+ evaluateRuleWithStatus?(rule: AlertRule): Promise<AlertRuleEvaluation>;
32
46
  getState(
33
47
  ruleId: string,
34
48
  projectId: string,
@@ -37,7 +51,18 @@ interface AlertEvaluatorStore {
37
51
  last_state_change: string;
38
52
  /** 0 while the transition's notification is owed; absent pre-049 (= delivered). */
39
53
  notified?: number;
54
+ /** Start of the current pending period (migration 055); null/absent when not pending. */
55
+ pending_since?: string | null;
56
+ /** Last evaluation that saw the pending condition hold (migration 058). */
57
+ pending_seen_at?: string | null;
40
58
  } | null>;
59
+ /** Record (`{ since, seenAt }`) or clear (null) the rule's pending period. */
60
+ setPending(
61
+ ruleId: string,
62
+ projectId: string,
63
+ pending: { since: string; seenAt: string } | null,
64
+ ruleCreatedAt: string,
65
+ ): Promise<void>;
41
66
  /** Flip the exact transition `(state, lastStateChange)` to delivered. */
42
67
  markNotified(
43
68
  ruleId: string,
@@ -61,34 +86,19 @@ interface AlertEvaluatorStore {
61
86
  value: number,
62
87
  state: AlertState,
63
88
  notified: boolean,
89
+ outcome?: AlertEvaluationOutcome,
90
+ /** With outcome `error`: why (the store sanitizes it). */
91
+ errorMessage?: string,
64
92
  ): Promise<void>;
65
93
  }
66
94
 
67
- interface WebhookPayload {
68
- rule: {
69
- id: string;
70
- name: string;
71
- signal: string;
72
- };
73
- value: number;
74
- threshold: number;
75
- comparison: string;
76
- state: AlertState;
77
- evaluatedAt: string;
78
- projectId: string;
79
- /**
80
- * RFC 0002 Stage 6 — populated when the rule is bound to an Analysis
81
- * (`rule.analysisId` set). The narrative is the gated, LLM-generated
82
- * sentence the dashboard panel was last showing — what's actually
83
- * happening, in plain English. Webhooks consuming this can put the
84
- * narrative in the Slack/PagerDuty body directly.
85
- */
86
- analysis?: {
87
- id: string;
88
- narrative: string | null;
89
- status: string | null;
90
- };
91
- }
95
+ /**
96
+ * Default `maxEvaluationGapMs`: two of the default 5-minute evaluation
97
+ * ticks. Hosts with another cadence pass their own (twice the interval).
98
+ */
99
+ export const DEFAULT_MAX_EVALUATION_GAP_MS = 2 * 5 * 60_000;
100
+
101
+ type WebhookPayload = AlertNotificationPayload;
92
102
 
93
103
  export interface WebhookDeliveryOptions {
94
104
  /** Permit targets on loopback / private / link-local hosts. */
@@ -99,6 +109,51 @@ export interface WebhookDeliveryOptions {
99
109
  * reach private networks from `fetch` and leave it unset.
100
110
  */
101
111
  hostLookup?: HostLookup;
112
+ /**
113
+ * SMTP transport for email channels (`env.ALERT_EMAIL`). Unset on hosts
114
+ * that cannot send mail: an email channel then fails delivery (logged,
115
+ * retried next tick) instead of being skipped.
116
+ */
117
+ email?: AlertEmailTransport;
118
+ }
119
+
120
+ async function fireEmail(
121
+ channel: AlertEmailChannel,
122
+ payload: WebhookPayload,
123
+ logger: Logger,
124
+ tracer?: ChildSpanRunner,
125
+ delivery: WebhookDeliveryOptions = {},
126
+ ): Promise<boolean> {
127
+ const transport = delivery.email;
128
+ if (!transport) {
129
+ logger.error(
130
+ "[alerts-evaluator] email channel has no SMTP transport on this collector",
131
+ { rule_id: payload.rule.id, recipients: channel.to.length },
132
+ );
133
+ return false;
134
+ }
135
+ const exec = async (
136
+ span: { setAttribute(k: string, v: unknown): void } | null,
137
+ ): Promise<boolean> => {
138
+ try {
139
+ await transport.send({ to: channel.to, ...formatAlertEmail(payload) });
140
+ if (span) span.setAttribute("alert.email.sent", true);
141
+ return true;
142
+ } catch (err) {
143
+ if (span) span.setAttribute("alert.email.sent", false);
144
+ logger.error("[alerts-evaluator] email delivery failed", {
145
+ rule_id: payload.rule.id,
146
+ recipients: channel.to.length,
147
+ error: err instanceof Error ? err.message : String(err),
148
+ });
149
+ return false;
150
+ }
151
+ };
152
+ if (!tracer) return exec(null);
153
+ return tracer("email.alert", async (span) => exec(span), {
154
+ "alert.state": payload.state,
155
+ "alert.email.recipients": channel.to.length,
156
+ });
102
157
  }
103
158
 
104
159
  async function fireWebhook(
@@ -191,9 +246,23 @@ async function fireChannels(
191
246
  ): Promise<boolean> {
192
247
  if (channels.length === 0) return true;
193
248
  const results = await Promise.all(
194
- channels
195
- .filter((ch): ch is AlertWebhookChannel => ch.type === "webhook")
196
- .map((ch) => fireWebhook(ch, payload, logger, tracer, delivery)),
249
+ channels.map((ch) => {
250
+ if (ch.type === "webhook")
251
+ return fireWebhook(
252
+ ch as AlertWebhookChannel,
253
+ payload,
254
+ logger,
255
+ tracer,
256
+ delivery,
257
+ );
258
+ if (ch.type === "email")
259
+ return fireEmail(ch, payload, logger, tracer, delivery);
260
+ logger.error("[alerts-evaluator] unknown channel type", {
261
+ rule_id: payload.rule.id,
262
+ type: String((ch as { type?: unknown }).type),
263
+ });
264
+ return Promise.resolve(false);
265
+ }),
197
266
  );
198
267
  return results.every(Boolean);
199
268
  }
@@ -235,6 +304,12 @@ async function mapWithConcurrency<T, R>(
235
304
  return results;
236
305
  }
237
306
 
307
+ /** Pending-hold settings for one batch (see `evaluateRuleBatch`). */
308
+ interface HoldOptions {
309
+ maxEvaluationGapMs: number;
310
+ holdStartedAt?: string;
311
+ }
312
+
238
313
  async function evaluateOneRule(
239
314
  store: AlertEvaluatorStore,
240
315
  rule: AlertRule,
@@ -242,20 +317,75 @@ async function evaluateOneRule(
242
317
  tracer: ChildSpanRunner | undefined,
243
318
  ruleTimeoutMs: number,
244
319
  delivery: WebhookDeliveryOptions,
320
+ hold: HoldOptions,
245
321
  onAlertFiring?: (rule: AlertRule) => Promise<void>,
246
322
  ): Promise<{ fired: number; resolved: number; redelivered: number }> {
247
323
  const none = { fired: 0, resolved: 0, redelivered: 0 };
324
+ let previousForError: AlertState | null = null;
248
325
  try {
249
- const value = await withTimeout(
250
- store.evaluateRule(rule),
326
+ const priorState = await store.getState(rule.id, rule.projectId);
327
+ const previous: AlertState = priorState?.current_state ?? "ok";
328
+ previousForError = previous;
329
+ const raw = await withTimeout<number | AlertRuleEvaluation>(
330
+ store.evaluateRuleWithStatus
331
+ ? store.evaluateRuleWithStatus(rule)
332
+ : store.evaluateRule(rule),
251
333
  ruleTimeoutMs,
252
334
  `rule evaluation timed out after ${ruleTimeoutMs}ms`,
253
335
  );
254
- const shouldFire = compareValue(value, rule.threshold, rule.comparison);
255
- const priorState = await store.getState(rule.id, rule.projectId);
256
- const previous: AlertState = priorState?.current_state ?? "ok";
257
- const next: AlertState = shouldFire ? "firing" : "ok";
336
+ const { value, noData } =
337
+ typeof raw === "number" ? { value: raw, noData: false } : raw;
258
338
  const now = new Date().toISOString();
339
+ const nowMs = Date.parse(now);
340
+ const keepOnNoData =
341
+ noData &&
342
+ rule.signal === "metrics" &&
343
+ (rule.query as AlertQueryMetrics).noData === "keep";
344
+ const matches =
345
+ !keepOnNoData && compareValue(value, rule.threshold, rule.comparison);
346
+
347
+ // Pending duration (Prometheus `for`): a rule that is not firing only
348
+ // fires once its condition has held on consecutive evaluations for
349
+ // `pendingSecs`. alert_state keeps when the hold started
350
+ // (pending_since) and when it was last seen (pending_seen_at). The
351
+ // hold is broken by an evaluation that does not match, by a gap
352
+ // between evaluations longer than `maxEvaluationGapMs` (missed ticks,
353
+ // a stopped collector), by a hold last seen before this collector
354
+ // process started (`holdStartedAt`), and by editing the rule.
355
+ // Resolution stays immediate.
356
+ const pendingSecs = rule.pendingSecs ?? 0;
357
+ const pendingSince = priorState?.pending_since ?? null;
358
+ const seenAt = priorState?.pending_seen_at ?? pendingSince;
359
+ const holdBroken =
360
+ pendingSince !== null &&
361
+ (seenAt === null ||
362
+ nowMs - Date.parse(seenAt) > hold.maxEvaluationGapMs ||
363
+ (hold.holdStartedAt !== undefined && seenAt < hold.holdStartedAt));
364
+ let pendingActive = pendingSince !== null;
365
+ let shouldFire = matches;
366
+ if (matches && previous === "ok" && pendingSecs > 0) {
367
+ const since = holdBroken || pendingSince === null ? now : pendingSince;
368
+ await store.setPending(
369
+ rule.id,
370
+ rule.projectId,
371
+ { since, seenAt: now },
372
+ rule.createdAt,
373
+ );
374
+ pendingActive = true;
375
+ shouldFire = nowMs - Date.parse(since) >= pendingSecs * 1000;
376
+ }
377
+ const next: AlertState = keepOnNoData
378
+ ? previous
379
+ : shouldFire
380
+ ? "firing"
381
+ : "ok";
382
+ // Cleared once the hold ended or turned into a firing transition.
383
+ const clearPending = async () => {
384
+ if (pendingActive && (!matches || next === "firing")) {
385
+ await store.setPending(rule.id, rule.projectId, null, rule.createdAt);
386
+ }
387
+ };
388
+ const outcome: AlertEvaluationOutcome = keepOnNoData ? "no_data" : "value";
259
389
 
260
390
  // Stage 6: when the rule is bound to an analysis, pull the
261
391
  // latest narrative + status so the webhook payload describes
@@ -301,6 +431,7 @@ async function evaluateOneRule(
301
431
  // transition; otherwise the same-state branch below retries on the
302
432
  // next tick without a second transition.
303
433
  await store.transitionState(rule.id, rule.projectId, next, now);
434
+ await clearPending();
304
435
  if (next === "firing" && onAlertFiring) {
305
436
  await onAlertFiring(rule).catch(() => undefined);
306
437
  }
@@ -311,7 +442,14 @@ async function evaluateOneRule(
311
442
  if (ok) {
312
443
  await store.markNotified(rule.id, rule.projectId, next, now);
313
444
  }
314
- await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
445
+ await store.recordEvaluation(
446
+ rule.id,
447
+ rule.projectId,
448
+ value,
449
+ next,
450
+ ok,
451
+ outcome,
452
+ );
315
453
  if (!ok) {
316
454
  logger.warn(
317
455
  "[alerts-evaluator] state recorded but delivery failed; will retry",
@@ -323,9 +461,13 @@ async function evaluateOneRule(
323
461
  : { fired: 0, resolved: 1, redelivered: 0 };
324
462
  }
325
463
 
464
+ await clearPending();
465
+
326
466
  // Same state. While the transition into it is still marked undelivered,
327
467
  // retry the notification; the state itself is already correct. A row
328
468
  // without the flag (schema before migration 049) counts as delivered.
469
+ // This also runs on a no-data tick: the owed notice is about a past
470
+ // transition.
329
471
  const owed = priorState !== null && priorState.notified === 0;
330
472
  if (owed) {
331
473
  const ok = await deliver(previous);
@@ -337,7 +479,14 @@ async function evaluateOneRule(
337
479
  priorState.last_state_change,
338
480
  );
339
481
  }
340
- await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
482
+ await store.recordEvaluation(
483
+ rule.id,
484
+ rule.projectId,
485
+ value,
486
+ next,
487
+ ok,
488
+ outcome,
489
+ );
341
490
  if (!ok) {
342
491
  logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
343
492
  rule_id: rule.id,
@@ -350,13 +499,36 @@ async function evaluateOneRule(
350
499
  }
351
500
 
352
501
  // Same state, already notified — write an evaluation, do not fire.
353
- await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
502
+ await store.recordEvaluation(
503
+ rule.id,
504
+ rule.projectId,
505
+ value,
506
+ next,
507
+ false,
508
+ outcome,
509
+ );
354
510
  } catch (err) {
511
+ const message = err instanceof Error ? err.message : String(err);
355
512
  logger.error("[alerts-evaluator] rule evaluation failed", {
356
513
  rule_id: rule.id,
357
514
  project_id: rule.projectId,
358
- error: err instanceof Error ? err.message : String(err),
515
+ error: message,
359
516
  });
517
+ // Leave a trace in the rule's history: an `error` evaluation in the
518
+ // unchanged state. Best effort; the database may be what failed.
519
+ if (previousForError !== null) {
520
+ await store
521
+ .recordEvaluation(
522
+ rule.id,
523
+ rule.projectId,
524
+ 0,
525
+ previousForError,
526
+ false,
527
+ "error",
528
+ message,
529
+ )
530
+ .catch(() => undefined);
531
+ }
360
532
  }
361
533
 
362
534
  return none;
@@ -372,7 +544,20 @@ export async function evaluateRuleBatch(
372
544
  ruleTimeoutMs?: number;
373
545
  allowPrivateWebhookTargets?: boolean;
374
546
  hostLookup?: HostLookup;
547
+ email?: AlertEmailTransport;
375
548
  onAlertFiring?: (rule: AlertRule) => Promise<void>;
549
+ /**
550
+ * Longest gap between two evaluations that still continues a pending
551
+ * hold. Default `DEFAULT_MAX_EVALUATION_GAP_MS` (two 5-minute ticks);
552
+ * pass twice the host's evaluation interval.
553
+ */
554
+ maxEvaluationGapMs?: number;
555
+ /**
556
+ * ISO start of this collector process. A hold last seen before it
557
+ * starts over, as Prometheus resets `for` on restart. Unset on hosts
558
+ * without a long-lived process (Workers), where the gap rule applies.
559
+ */
560
+ holdStartedAt?: string;
376
561
  },
377
562
  ): Promise<{
378
563
  evaluated: number;
@@ -385,6 +570,7 @@ export async function evaluateRuleBatch(
385
570
  const delivery: WebhookDeliveryOptions = {
386
571
  allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
387
572
  hostLookup: options?.hostLookup,
573
+ email: options?.email,
388
574
  };
389
575
  const concurrency = Math.max(
390
576
  1,
@@ -402,6 +588,11 @@ export async function evaluateRuleBatch(
402
588
  options?.tracer,
403
589
  ruleTimeoutMs,
404
590
  delivery,
591
+ {
592
+ maxEvaluationGapMs:
593
+ options?.maxEvaluationGapMs ?? DEFAULT_MAX_EVALUATION_GAP_MS,
594
+ holdStartedAt: options?.holdStartedAt,
595
+ },
405
596
  options?.onAlertFiring,
406
597
  ),
407
598
  );
@@ -421,6 +612,8 @@ export async function evaluateAllRules(
421
612
  concurrency?: number;
422
613
  ruleTimeoutMs?: number;
423
614
  hostLookup?: HostLookup;
615
+ maxEvaluationGapMs?: number;
616
+ holdStartedAt?: string;
424
617
  },
425
618
  ): Promise<{
426
619
  evaluated: number;
@@ -432,7 +625,10 @@ export async function evaluateAllRules(
432
625
  const tracer = options?.tracer;
433
626
  const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
434
627
  const db = sqlDbFor(env);
435
- const store = new AlertsStore(db, { allowPrivateWebhookTargets });
628
+ const store = new AlertsStore(db, {
629
+ allowPrivateWebhookTargets,
630
+ emailDeliveryAvailable: Boolean(env.ALERT_EMAIL),
631
+ });
436
632
  const rules = await store.listEnabledRules();
437
633
  return evaluateRuleBatch(store, rules, {
438
634
  logger,
@@ -441,6 +637,9 @@ export async function evaluateAllRules(
441
637
  ruleTimeoutMs: options?.ruleTimeoutMs,
442
638
  allowPrivateWebhookTargets,
443
639
  hostLookup: options?.hostLookup,
640
+ email: env.ALERT_EMAIL,
641
+ maxEvaluationGapMs: options?.maxEvaluationGapMs,
642
+ holdStartedAt: options?.holdStartedAt,
444
643
  onAlertFiring: async (rule) => {
445
644
  // Target the rule's service when it names one (environment taken from
446
645
  // that service's latest spans); otherwise fall back to the rule itself.
@@ -6,8 +6,12 @@ import { alertEvidenceReferences } from "../lib/evidence-references";
6
6
  import { sqlDbFor } from "../lib/sql-db";
7
7
  import { getProjectId } from "./_context";
8
8
 
9
- const storeOptions = (env: { ALERT_WEBHOOK_ALLOW_PRIVATE?: string }) => ({
9
+ const storeOptions = (env: {
10
+ ALERT_WEBHOOK_ALLOW_PRIVATE?: string;
11
+ ALERT_EMAIL?: unknown;
12
+ }) => ({
10
13
  allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
14
+ emailDeliveryAvailable: Boolean(env.ALERT_EMAIL),
11
15
  });
12
16
 
13
17
  export const alertsRoutesPlugin: CollectorPlugin = {
@@ -90,9 +94,20 @@ export const alertsRoutesPlugin: CollectorPlugin = {
90
94
  const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
91
95
  const rule = await store.getRule(id, projectId);
92
96
  if (!rule) return c.json({ error: "Rule not found" }, 404);
93
- const value = await store.evaluateRule(rule);
97
+ let value: number;
98
+ let noData: boolean;
99
+ try {
100
+ ({ value, noData } = await store.evaluateRuleWithStatus(rule));
101
+ } catch (err) {
102
+ // e.g. a metrics query over its series or point cap.
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return c.json({ error: message }, 422);
105
+ }
94
106
  const response: AlertTestResponse = {
95
107
  value,
108
+ noData,
109
+ // The bare comparison: a rule with `pendingSecs` additionally
110
+ // needs the condition to hold that long before it fires.
96
111
  wouldFire: compareValue(value, rule.threshold, rule.comparison),
97
112
  comparison: rule.comparison,
98
113
  threshold: rule.threshold,