@agent-native/core 0.92.1 → 0.92.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 (181) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +32 -0
  3. package/corpus/core/docs/content/locales/ar-SA/server.mdx +35 -0
  4. package/corpus/core/docs/content/locales/de-DE/server.mdx +35 -0
  5. package/corpus/core/docs/content/locales/es-ES/server.mdx +35 -0
  6. package/corpus/core/docs/content/locales/fr-FR/server.mdx +35 -0
  7. package/corpus/core/docs/content/locales/hi-IN/server.mdx +35 -0
  8. package/corpus/core/docs/content/locales/ja-JP/server.mdx +35 -0
  9. package/corpus/core/docs/content/locales/ko-KR/server.mdx +35 -0
  10. package/corpus/core/docs/content/locales/pt-BR/server.mdx +35 -0
  11. package/corpus/core/docs/content/locales/zh-CN/server.mdx +35 -0
  12. package/corpus/core/docs/content/locales/zh-TW/server.mdx +35 -0
  13. package/corpus/core/docs/content/server.mdx +35 -0
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +14 -4
  16. package/corpus/core/src/agent/engine/anthropic-engine.ts +15 -3
  17. package/corpus/core/src/agent/engine/builder-engine.ts +30 -4
  18. package/corpus/core/src/agent/engine/registry.ts +63 -2
  19. package/corpus/core/src/agent/model-config.ts +0 -2
  20. package/corpus/core/src/agent/production-agent.ts +12 -1
  21. package/corpus/core/src/agent/run-manager.ts +57 -36
  22. package/corpus/core/src/agent/run-store.ts +81 -2
  23. package/corpus/core/src/agent/thread-data-builder.ts +33 -2
  24. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +29 -1
  25. package/corpus/core/src/client/AssistantChat.tsx +314 -96
  26. package/corpus/core/src/client/agent-chat-adapter.ts +17 -1
  27. package/corpus/core/src/client/chat/repo-helpers.ts +45 -0
  28. package/corpus/core/src/client/chat/tool-call-display.tsx +36 -27
  29. package/corpus/core/src/client/chat-model-groups.ts +1 -5
  30. package/corpus/core/src/client/composer/TiptapComposer.tsx +0 -2
  31. package/corpus/core/src/client/embed-auth.ts +20 -0
  32. package/corpus/core/src/client/settings/SettingsSection.tsx +66 -5
  33. package/corpus/core/src/client/sse-event-processor.ts +49 -17
  34. package/corpus/core/src/client/use-db-sync.ts +31 -9
  35. package/corpus/core/src/collab/agent-presence.ts +6 -1
  36. package/corpus/core/src/collab/awareness-store.ts +185 -0
  37. package/corpus/core/src/collab/awareness.ts +58 -2
  38. package/corpus/core/src/notifications/channels.ts +1 -22
  39. package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +7 -8
  40. package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +7 -18
  41. package/corpus/core/src/server/core-routes-plugin.ts +21 -2
  42. package/corpus/core/src/server/embed-session.ts +20 -4
  43. package/corpus/core/src/styles/agent-native.css +51 -0
  44. package/corpus/core/src/tracking/providers.ts +33 -2
  45. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +8 -1
  46. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +9 -1
  47. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +41 -11
  48. package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +44 -0
  49. package/corpus/templates/analytics/changelog/2026-07-08-alert-emails-now-show-the-message-without-raw-metadata-json.md +6 -0
  50. package/corpus/templates/analytics/changelog/2026-07-08-uptime-checks-no-longer-count-ssrf-setup-time-against-the-re.md +6 -0
  51. package/corpus/templates/analytics/changelog/2026-07-08-uptime-checks-reuse-one-keep-alive-connection-so-recorded-la.md +6 -0
  52. package/corpus/templates/analytics/changelog/2026-07-08-uptime-monitors-now-confirm-transient-timeout-failures-befor.md +6 -0
  53. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-action-menus-now-close-cleanly-before-opening-hist.md +6 -0
  54. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-email-reports-still-send-when-the-screenshot-captu.md +6 -0
  55. package/corpus/templates/analytics/changelog/2026-07-09-uptime-alerts-now-suppress-recovery-noise-during-flapping-an.md +6 -0
  56. package/corpus/templates/analytics/changelog/2026-07-09-uptime-check-history-now-shows-timing-diagnostics-so-slow-or.md +6 -0
  57. package/corpus/templates/analytics/changelog/2026-07-09-uptime-recovery-alerts-now-send-when-the-original-outage-not.md +6 -0
  58. package/corpus/templates/analytics/docs/uptime-monitoring.md +47 -22
  59. package/corpus/templates/analytics/netlify.toml +3 -0
  60. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
  61. package/corpus/templates/analytics/server/db/schema-monitoring.ts +5 -0
  62. package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +5 -1
  63. package/corpus/templates/analytics/server/lib/uptime-monitors.ts +538 -54
  64. package/corpus/templates/analytics/server/plugins/db.ts +15 -0
  65. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +1 -1
  66. package/corpus/templates/analytics/server/routes/api/uptime-monitors/run.post.ts +57 -0
  67. package/corpus/templates/clips/actions/list-meetings.ts +12 -0
  68. package/corpus/templates/clips/changelog/2026-07-09-meeting-reminders-now-ignore-obvious-solo-personal-calendar-.md +6 -0
  69. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +1 -0
  70. package/corpus/templates/clips/server/lib/calendar-event-classification.ts +79 -0
  71. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  72. package/dist/agent/engine/ai-sdk-engine.js +13 -4
  73. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  74. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  75. package/dist/agent/engine/anthropic-engine.js +14 -3
  76. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  77. package/dist/agent/engine/builder-engine.d.ts +1 -1
  78. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  79. package/dist/agent/engine/builder-engine.js +26 -4
  80. package/dist/agent/engine/builder-engine.js.map +1 -1
  81. package/dist/agent/engine/registry.d.ts.map +1 -1
  82. package/dist/agent/engine/registry.js +47 -2
  83. package/dist/agent/engine/registry.js.map +1 -1
  84. package/dist/agent/model-config.d.ts +2 -2
  85. package/dist/agent/model-config.d.ts.map +1 -1
  86. package/dist/agent/model-config.js +0 -2
  87. package/dist/agent/model-config.js.map +1 -1
  88. package/dist/agent/production-agent.d.ts.map +1 -1
  89. package/dist/agent/production-agent.js +12 -1
  90. package/dist/agent/production-agent.js.map +1 -1
  91. package/dist/agent/run-manager.d.ts.map +1 -1
  92. package/dist/agent/run-manager.js +52 -33
  93. package/dist/agent/run-manager.js.map +1 -1
  94. package/dist/agent/run-store.d.ts +24 -0
  95. package/dist/agent/run-store.d.ts.map +1 -1
  96. package/dist/agent/run-store.js +64 -2
  97. package/dist/agent/run-store.js.map +1 -1
  98. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  99. package/dist/agent/thread-data-builder.js +28 -2
  100. package/dist/agent/thread-data-builder.js.map +1 -1
  101. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
  102. package/dist/cli/sync-builder-starter-manifest.js +24 -1
  103. package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
  104. package/dist/client/AssistantChat.d.ts +6 -0
  105. package/dist/client/AssistantChat.d.ts.map +1 -1
  106. package/dist/client/AssistantChat.js +243 -36
  107. package/dist/client/AssistantChat.js.map +1 -1
  108. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  109. package/dist/client/agent-chat-adapter.js +15 -1
  110. package/dist/client/agent-chat-adapter.js.map +1 -1
  111. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  112. package/dist/client/chat/repo-helpers.js +43 -0
  113. package/dist/client/chat/repo-helpers.js.map +1 -1
  114. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  115. package/dist/client/chat/tool-call-display.js +4 -4
  116. package/dist/client/chat/tool-call-display.js.map +1 -1
  117. package/dist/client/chat-model-groups.d.ts.map +1 -1
  118. package/dist/client/chat-model-groups.js +1 -3
  119. package/dist/client/chat-model-groups.js.map +1 -1
  120. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  121. package/dist/client/composer/TiptapComposer.js +0 -2
  122. package/dist/client/composer/TiptapComposer.js.map +1 -1
  123. package/dist/client/embed-auth.d.ts.map +1 -1
  124. package/dist/client/embed-auth.js +14 -0
  125. package/dist/client/embed-auth.js.map +1 -1
  126. package/dist/client/settings/SettingsSection.d.ts.map +1 -1
  127. package/dist/client/settings/SettingsSection.js +32 -3
  128. package/dist/client/settings/SettingsSection.js.map +1 -1
  129. package/dist/client/sse-event-processor.d.ts.map +1 -1
  130. package/dist/client/sse-event-processor.js +44 -17
  131. package/dist/client/sse-event-processor.js.map +1 -1
  132. package/dist/client/use-db-sync.d.ts.map +1 -1
  133. package/dist/client/use-db-sync.js +29 -9
  134. package/dist/client/use-db-sync.js.map +1 -1
  135. package/dist/collab/agent-presence.d.ts.map +1 -1
  136. package/dist/collab/agent-presence.js +5 -2
  137. package/dist/collab/agent-presence.js.map +1 -1
  138. package/dist/collab/awareness-store.d.ts +1 -1
  139. package/dist/collab/awareness-store.d.ts.map +1 -1
  140. package/dist/collab/awareness-store.js +0 -0
  141. package/dist/collab/awareness-store.js.map +1 -1
  142. package/dist/collab/awareness.d.ts +2 -0
  143. package/dist/collab/awareness.d.ts.map +1 -1
  144. package/dist/collab/awareness.js +46 -2
  145. package/dist/collab/awareness.js.map +1 -1
  146. package/dist/collab/routes.d.ts +1 -1
  147. package/dist/notifications/channels.js +1 -17
  148. package/dist/notifications/channels.js.map +1 -1
  149. package/dist/notifications/routes.d.ts +1 -1
  150. package/dist/observability/routes.d.ts +5 -5
  151. package/dist/progress/routes.d.ts +1 -1
  152. package/dist/resources/handlers.d.ts +1 -1
  153. package/dist/scripts/agent-engines/manage-agent-engine.d.ts.map +1 -1
  154. package/dist/scripts/agent-engines/manage-agent-engine.js +6 -6
  155. package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
  156. package/dist/scripts/agent-engines/set-agent-engine.d.ts.map +1 -1
  157. package/dist/scripts/agent-engines/set-agent-engine.js +5 -13
  158. package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
  159. package/dist/server/core-routes-plugin.d.ts +5 -0
  160. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  161. package/dist/server/core-routes-plugin.js +10 -3
  162. package/dist/server/core-routes-plugin.js.map +1 -1
  163. package/dist/server/embed-session.d.ts.map +1 -1
  164. package/dist/server/embed-session.js +17 -4
  165. package/dist/server/embed-session.js.map +1 -1
  166. package/dist/styles/agent-native.css +51 -0
  167. package/dist/tracking/providers.d.ts.map +1 -1
  168. package/dist/tracking/providers.js +23 -5
  169. package/dist/tracking/providers.js.map +1 -1
  170. package/docs/content/locales/ar-SA/server.mdx +35 -0
  171. package/docs/content/locales/de-DE/server.mdx +35 -0
  172. package/docs/content/locales/es-ES/server.mdx +35 -0
  173. package/docs/content/locales/fr-FR/server.mdx +35 -0
  174. package/docs/content/locales/hi-IN/server.mdx +35 -0
  175. package/docs/content/locales/ja-JP/server.mdx +35 -0
  176. package/docs/content/locales/ko-KR/server.mdx +35 -0
  177. package/docs/content/locales/pt-BR/server.mdx +35 -0
  178. package/docs/content/locales/zh-CN/server.mdx +35 -0
  179. package/docs/content/locales/zh-TW/server.mdx +35 -0
  180. package/docs/content/server.mdx +35 -0
  181. package/package.json +1 -1
@@ -34,6 +34,7 @@ import {
34
34
  count,
35
35
  desc,
36
36
  eq,
37
+ gt,
37
38
  gte,
38
39
  isNull,
39
40
  lte,
@@ -43,6 +44,10 @@ import {
43
44
 
44
45
  import { getDb, schema } from "../db/index.js";
45
46
 
47
+ declare global {
48
+ var __AGENT_NATIVE_UPTIME_MONITOR_SCHEDULED_RUNTIME__: boolean | undefined;
49
+ }
50
+
46
51
  // ---------------------------------------------------------------------------
47
52
  // Types
48
53
  // ---------------------------------------------------------------------------
@@ -157,6 +162,52 @@ export interface MonitorCheckResult {
157
162
  latencyMs: number | null;
158
163
  error: string | null;
159
164
  failedAssertions: string[];
165
+ diagnostics: MonitorCheckDiagnostics;
166
+ }
167
+
168
+ export type MonitorCheckSource =
169
+ | "netlify-scheduled"
170
+ | "netlify-runtime"
171
+ | "in-process"
172
+ | "manual"
173
+ | "unknown";
174
+
175
+ export interface MonitorCheckDiagnostics {
176
+ source: MonitorCheckSource;
177
+ runtime: {
178
+ nodeEnv?: string;
179
+ netlify?: boolean;
180
+ deployId?: string;
181
+ deployContext?: string;
182
+ commitRef?: string;
183
+ functionName?: string;
184
+ region?: string;
185
+ };
186
+ request: {
187
+ method: MonitorMethod;
188
+ timeoutMs: number;
189
+ followRedirects: boolean;
190
+ assertionTypes: AssertionType[];
191
+ bodyReadRequired: boolean;
192
+ allowPrivateHosts: boolean;
193
+ };
194
+ timings: {
195
+ totalMs?: number;
196
+ ssrfSetupMs?: number;
197
+ requestMs?: number;
198
+ bodyReadMs?: number;
199
+ };
200
+ response?: {
201
+ finalUrl?: string;
202
+ finalHost?: string;
203
+ statusCode?: number;
204
+ headers?: Record<string, string>;
205
+ };
206
+ error?: {
207
+ kind: "config" | "timeout" | "network" | "body-timeout";
208
+ name?: string;
209
+ message: string;
210
+ };
160
211
  }
161
212
 
162
213
  export interface MonitorIncident {
@@ -169,6 +220,7 @@ export interface MonitorIncident {
169
220
  cause: string;
170
221
  lastError: string | null;
171
222
  notificationId: string | null;
223
+ notificationDelivered: boolean;
172
224
  checksFailed: number;
173
225
  createdAt: string;
174
226
  }
@@ -182,6 +234,7 @@ export interface CheckOutcome {
182
234
  latencyMs: number | null;
183
235
  error: string | null;
184
236
  failedAssertions: string[];
237
+ diagnostics: MonitorCheckDiagnostics;
185
238
  }
186
239
 
187
240
  export interface AssertionFailure {
@@ -211,11 +264,13 @@ const MAX_REQUEST_BODY_BYTES = 16 * 1024;
211
264
  const MAX_ASSERTIONS_PER_MONITOR = 20;
212
265
  const MAX_ASSERTION_VALUE_BYTES = 2048;
213
266
  const MAX_ASSERTION_HEADER_LENGTH = 128;
267
+ const MAX_MONITOR_DIAGNOSTICS_BYTES = 4096;
214
268
 
215
269
  const MIN_INTERVAL_SECONDS = 30;
216
270
  const MAX_INTERVAL_SECONDS = 24 * 60 * 60;
217
271
  const MIN_TIMEOUT_MS = 1000;
218
272
  const MAX_TIMEOUT_MS = 120_000;
273
+ const DEFAULT_TRANSIENT_FAILURE_CONFIRMATION_CHECKS = 2;
219
274
 
220
275
  const ASSERTION_TYPES: AssertionType[] = [
221
276
  "body_contains",
@@ -233,6 +288,152 @@ function nowIso(): string {
233
288
  return new Date().toISOString();
234
289
  }
235
290
 
291
+ function detectMonitorCheckSource(): MonitorCheckSource {
292
+ if (globalThis.__AGENT_NATIVE_UPTIME_MONITOR_SCHEDULED_RUNTIME__ === true) {
293
+ return "netlify-scheduled";
294
+ }
295
+ if (process.env.NETLIFY === "true") return "netlify-runtime";
296
+ if (process.env.NODE_ENV === "production") return "in-process";
297
+ return "unknown";
298
+ }
299
+
300
+ function monitorCheckRuntimeDiagnostics(): MonitorCheckDiagnostics["runtime"] {
301
+ return {
302
+ nodeEnv: process.env.NODE_ENV,
303
+ netlify: process.env.NETLIFY === "true" || undefined,
304
+ deployId: process.env.DEPLOY_ID,
305
+ deployContext: process.env.CONTEXT,
306
+ commitRef: process.env.COMMIT_REF,
307
+ functionName:
308
+ process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY_FUNCTION_NAME,
309
+ region: process.env.AWS_REGION,
310
+ };
311
+ }
312
+
313
+ function safeResponseHeaders(
314
+ headers: Record<string, string>,
315
+ ): Record<string, string> | undefined {
316
+ const picked: Record<string, string> = {};
317
+ for (const key of [
318
+ "server",
319
+ "x-nf-request-id",
320
+ "cache-status",
321
+ "cdn-cache-control",
322
+ "content-type",
323
+ ]) {
324
+ const value = headers[key];
325
+ if (value) picked[key] = value.slice(0, 300);
326
+ }
327
+ return Object.keys(picked).length ? picked : undefined;
328
+ }
329
+
330
+ function finalUrlDiagnostics(url: string | undefined): {
331
+ finalUrl?: string;
332
+ finalHost?: string;
333
+ } {
334
+ if (!url) return {};
335
+ try {
336
+ const parsed = new URL(url);
337
+ return {
338
+ finalHost: parsed.host,
339
+ };
340
+ } catch {
341
+ return {};
342
+ }
343
+ }
344
+
345
+ function defaultMonitorCheckDiagnostics(): MonitorCheckDiagnostics {
346
+ return {
347
+ source: "unknown",
348
+ runtime: {},
349
+ request: {
350
+ method: "GET",
351
+ timeoutMs: DEFAULT_MONITOR_TIMEOUT_MS,
352
+ followRedirects: true,
353
+ assertionTypes: [],
354
+ bodyReadRequired: false,
355
+ allowPrivateHosts: false,
356
+ },
357
+ timings: {},
358
+ };
359
+ }
360
+
361
+ function normalizeMonitorCheckDiagnostics(
362
+ raw: unknown,
363
+ ): MonitorCheckDiagnostics {
364
+ const fallback = defaultMonitorCheckDiagnostics();
365
+ if (!raw || typeof raw !== "object") return fallback;
366
+ const parsed = raw as Partial<MonitorCheckDiagnostics>;
367
+ return {
368
+ ...fallback,
369
+ ...parsed,
370
+ runtime:
371
+ parsed.runtime && typeof parsed.runtime === "object"
372
+ ? parsed.runtime
373
+ : fallback.runtime,
374
+ request:
375
+ parsed.request && typeof parsed.request === "object"
376
+ ? { ...fallback.request, ...parsed.request }
377
+ : fallback.request,
378
+ timings:
379
+ parsed.timings && typeof parsed.timings === "object"
380
+ ? parsed.timings
381
+ : fallback.timings,
382
+ response:
383
+ parsed.response && typeof parsed.response === "object"
384
+ ? {
385
+ finalHost: parsed.response.finalHost,
386
+ statusCode: parsed.response.statusCode,
387
+ headers: parsed.response.headers,
388
+ }
389
+ : undefined,
390
+ error:
391
+ parsed.error && typeof parsed.error === "object"
392
+ ? parsed.error
393
+ : undefined,
394
+ };
395
+ }
396
+
397
+ function compactDiagnostics(
398
+ diagnostics: MonitorCheckDiagnostics,
399
+ ): MonitorCheckDiagnostics {
400
+ return {
401
+ ...diagnostics,
402
+ runtime: Object.fromEntries(
403
+ Object.entries(diagnostics.runtime).filter(([, value]) => value != null),
404
+ ) as MonitorCheckDiagnostics["runtime"],
405
+ response: diagnostics.response
406
+ ? {
407
+ ...diagnostics.response,
408
+ headers: diagnostics.response.headers,
409
+ }
410
+ : undefined,
411
+ error: diagnostics.error
412
+ ? {
413
+ ...diagnostics.error,
414
+ message: diagnostics.error.message.slice(0, 500),
415
+ }
416
+ : undefined,
417
+ };
418
+ }
419
+
420
+ function serializeMonitorDiagnostics(
421
+ diagnostics: MonitorCheckDiagnostics,
422
+ ): string {
423
+ const compact = compactDiagnostics(diagnostics);
424
+ const serialized = JSON.stringify(compact);
425
+ if (byteLength(serialized) <= MAX_MONITOR_DIAGNOSTICS_BYTES) {
426
+ return serialized;
427
+ }
428
+ return JSON.stringify({
429
+ ...compact,
430
+ response: compact.response
431
+ ? { ...compact.response, headers: undefined }
432
+ : undefined,
433
+ truncated: true,
434
+ });
435
+ }
436
+
236
437
  function safeJsonParse<T>(raw: unknown, fallback: T): T {
237
438
  if (typeof raw !== "string" || !raw.trim()) return fallback;
238
439
  try {
@@ -281,6 +482,14 @@ function monitorLimitPerOwner(): number {
281
482
  : DEFAULT_MONITOR_LIMIT_PER_OWNER;
282
483
  }
283
484
 
485
+ function transientFailureConfirmationChecks(): number {
486
+ const raw = process.env.UPTIME_MONITOR_TRANSIENT_FAILURE_CONFIRMATION_CHECKS;
487
+ const parsed = Number.parseInt(raw ?? "", 10);
488
+ return Number.isFinite(parsed) && parsed > 0
489
+ ? Math.min(parsed, 10)
490
+ : DEFAULT_TRANSIENT_FAILURE_CONFIRMATION_CHECKS;
491
+ }
492
+
284
493
  export function hostFromUrl(url: string): string {
285
494
  try {
286
495
  return new URL(url).host;
@@ -675,6 +884,40 @@ export function evaluateCheck(params: EvaluateCheckParams): {
675
884
  // SSRF-safe fetch + probe
676
885
  // ---------------------------------------------------------------------------
677
886
 
887
+ // A single SSRF-safe dispatcher (undici Agent) is reused across every probe.
888
+ // Building a fresh Agent per check disabled HTTP keep-alive — each check paid a
889
+ // cold DNS + TCP + TLS handshake, inflating the recorded latency well above the
890
+ // site's real response time (and occasionally spiking near the timeout) — and
891
+ // leaked Agents, which are never closed. The connect-time private-IP guard runs
892
+ // on every new socket, so reuse keeps the exact same SSRF protection.
893
+ // `undefined` = not built yet; the resolved value may be `null` on runtimes
894
+ // without undici / node:dns, in which case callers fall back to plain `fetch`.
895
+ let sharedSsrfDispatcherPromise: Promise<unknown | null> | undefined;
896
+
897
+ function getSharedSsrfDispatcher(): Promise<unknown | null> {
898
+ if (!sharedSsrfDispatcherPromise) {
899
+ sharedSsrfDispatcherPromise = createSsrfSafeDispatcher().catch(() => null);
900
+ }
901
+ return sharedSsrfDispatcherPromise;
902
+ }
903
+
904
+ async function prepareMonitorFetch(
905
+ url: string,
906
+ opts: { allowPrivateHosts: boolean },
907
+ ): Promise<{ dispatcher: unknown | undefined }> {
908
+ const dispatcher = opts.allowPrivateHosts
909
+ ? undefined
910
+ : ((await getSharedSsrfDispatcher()) ?? undefined);
911
+
912
+ if (!opts.allowPrivateHosts && (await isBlockedExtensionUrlWithDns(url))) {
913
+ throw new Error(
914
+ `SSRF blocked: refusing to fetch private/internal address (${url})`,
915
+ );
916
+ }
917
+
918
+ return { dispatcher };
919
+ }
920
+
678
921
  async function safeMonitorFetch(
679
922
  url: string,
680
923
  init: RequestInit,
@@ -682,17 +925,26 @@ async function safeMonitorFetch(
682
925
  followRedirects: boolean;
683
926
  maxRedirects: number;
684
927
  allowPrivateHosts: boolean;
928
+ /** Prebuilt outside the abort window so SSRF setup is not billed as site latency. */
929
+ dispatcher?: unknown;
930
+ /** When true, the caller already DNS-checked `url` before starting the timer. */
931
+ initialDnsChecked?: boolean;
685
932
  },
686
933
  ): Promise<Response> {
687
- const dispatcher = opts.allowPrivateHosts
688
- ? undefined
689
- : ((await createSsrfSafeDispatcher()) ?? undefined);
934
+ const dispatcher =
935
+ opts.dispatcher !== undefined
936
+ ? opts.dispatcher
937
+ : opts.allowPrivateHosts
938
+ ? undefined
939
+ : ((await getSharedSsrfDispatcher()) ?? undefined);
690
940
 
691
941
  let currentUrl = url;
692
942
  const maxHops = opts.followRedirects ? opts.maxRedirects : 0;
693
943
 
694
944
  for (let hop = 0; hop <= maxHops; hop++) {
945
+ const skipDns = hop === 0 && opts.initialDnsChecked;
695
946
  if (
947
+ !skipDns &&
696
948
  !opts.allowPrivateHosts &&
697
949
  (await isBlockedExtensionUrlWithDns(currentUrl))
698
950
  ) {
@@ -711,6 +963,7 @@ async function safeMonitorFetch(
711
963
  if (opts.followRedirects && isRedirect) {
712
964
  const location = response.headers.get("location");
713
965
  if (!location) return response;
966
+ await cancelResponseBody(response);
714
967
  currentUrl = new URL(location, currentUrl).href;
715
968
  continue;
716
969
  }
@@ -721,17 +974,50 @@ async function safeMonitorFetch(
721
974
  );
722
975
  }
723
976
 
724
- async function readCappedText(res: Response, cap: number): Promise<string> {
977
+ async function cancelResponseBody(res: Response): Promise<void> {
978
+ try {
979
+ await res.body?.cancel();
980
+ } catch {
981
+ // Best-effort cleanup only.
982
+ }
983
+ }
984
+
985
+ async function readCappedText(
986
+ res: Response,
987
+ cap: number,
988
+ timeoutMs?: number,
989
+ ): Promise<{ text: string; timedOut: boolean }> {
990
+ let timedOut = false;
991
+ let timer: ReturnType<typeof setTimeout> | null = null;
725
992
  const body = res.body;
726
993
  if (!body) {
727
994
  try {
728
- const text = await res.text();
729
- return text.length > cap ? text.slice(0, cap) : text;
995
+ const text =
996
+ timeoutMs && timeoutMs > 0
997
+ ? await Promise.race([
998
+ res.text(),
999
+ new Promise<string>((resolve) => {
1000
+ timer = setTimeout(() => {
1001
+ timedOut = true;
1002
+ resolve("");
1003
+ }, timeoutMs);
1004
+ }),
1005
+ ])
1006
+ : await res.text();
1007
+ return { text: text.length > cap ? text.slice(0, cap) : text, timedOut };
730
1008
  } catch {
731
- return "";
1009
+ return { text: "", timedOut };
1010
+ } finally {
1011
+ if (timer) clearTimeout(timer);
732
1012
  }
733
1013
  }
734
1014
  const reader = body.getReader();
1015
+ if (timeoutMs && timeoutMs > 0) {
1016
+ timer = setTimeout(() => {
1017
+ timedOut = true;
1018
+ void reader.cancel().catch(() => {});
1019
+ }, timeoutMs);
1020
+ }
735
1021
  const chunks: Uint8Array[] = [];
736
1022
  let total = 0;
737
1023
  try {
@@ -746,6 +1032,7 @@ async function readCappedText(res: Response, cap: number): Promise<string> {
746
1032
  } catch {
747
1033
  // Partial body is fine for text assertions.
748
1034
  } finally {
1035
+ if (timer) clearTimeout(timer);
749
1036
  try {
750
1037
  await reader.cancel();
751
1038
  } catch {
@@ -762,7 +1049,10 @@ async function readCappedText(res: Response, cap: number): Promise<string> {
762
1049
  merged.set(slice, offset);
763
1050
  offset += slice.length;
764
1051
  }
765
- return new TextDecoder("utf-8", { fatal: false }).decode(merged);
1052
+ return {
1053
+ text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
1054
+ timedOut,
1055
+ };
766
1056
  }
767
1057
 
768
1058
  function headersToObject(headers: Headers): Record<string, string> {
@@ -773,10 +1063,22 @@ function headersToObject(headers: Headers): Record<string, string> {
773
1063
  return out;
774
1064
  }
775
1065
 
1066
+ function needsResponseBody(assertions: Assertion[]): boolean {
1067
+ return assertions.some(
1068
+ (assertion) =>
1069
+ assertion.type === "body_contains" || assertion.type === "body_absent",
1070
+ );
1071
+ }
1072
+
776
1073
  /**
777
1074
  * Execute one probe for `monitor`. Performs an SSRF-safe fetch with an
778
1075
  * AbortController timeout, measures latency, caps the response body, and
779
1076
  * classifies the result. Never throws — failures are captured in the outcome.
1077
+ *
1078
+ * The abort budget covers only the HTTP fetch (headers / redirect chain), not
1079
+ * SSRF dispatcher/DNS setup or optional body reads. Billing setup time against
1080
+ * `timeoutMs` produced false "Timed out after Nms" alerts when the site itself
1081
+ * was fine.
780
1082
  */
781
1083
  export async function runMonitorCheck(
782
1084
  monitor: Pick<
@@ -790,17 +1092,48 @@ export async function runMonitorCheck(
790
1092
  | "assertions"
791
1093
  | "followRedirects"
792
1094
  >,
793
- opts: { allowPrivateHosts?: boolean } = {},
1095
+ opts: { allowPrivateHosts?: boolean; source?: MonitorCheckSource } = {},
794
1096
  ): Promise<CheckOutcome> {
795
1097
  const checkedAt = nowIso();
796
1098
  const matcher = monitor.expectedStatus;
797
1099
  const assertions = monitor.assertions;
798
1100
  const allowPrivateHosts =
799
1101
  opts.allowPrivateHosts ?? monitorAllowPrivateHosts();
1102
+ const timeoutMs = clampInt(
1103
+ monitor.timeoutMs,
1104
+ MIN_TIMEOUT_MS,
1105
+ MAX_TIMEOUT_MS,
1106
+ DEFAULT_MONITOR_TIMEOUT_MS,
1107
+ );
1108
+ const method = normalizeMethod(monitor.method);
1109
+ const diagnostics: MonitorCheckDiagnostics = {
1110
+ source: opts.source ?? detectMonitorCheckSource(),
1111
+ runtime: monitorCheckRuntimeDiagnostics(),
1112
+ request: {
1113
+ method,
1114
+ timeoutMs,
1115
+ followRedirects: monitor.followRedirects,
1116
+ assertionTypes: assertions.map((assertion) => assertion.type),
1117
+ bodyReadRequired: method !== "HEAD" && needsResponseBody(assertions),
1118
+ allowPrivateHosts,
1119
+ },
1120
+ timings: {},
1121
+ };
1122
+ const totalStart = Date.now();
1123
+ const finish = <T extends Omit<CheckOutcome, "diagnostics">>(
1124
+ outcome: T,
1125
+ ): T & { diagnostics: MonitorCheckDiagnostics } => {
1126
+ diagnostics.timings.totalMs = Date.now() - totalStart;
1127
+ return { ...outcome, diagnostics };
1128
+ };
800
1129
 
801
1130
  // Fast, deterministic pre-flight guard (scheme + literal private hosts).
802
1131
  if (!allowPrivateHosts && isBlockedExtensionUrl(monitor.url)) {
803
- return {
1132
+ diagnostics.error = {
1133
+ kind: "config",
1134
+ message: "SSRF blocked: private/internal or non-http(s) address",
1135
+ };
1136
+ return finish({
804
1137
  checkedAt,
805
1138
  statusCode: null,
806
1139
  latencyMs: null,
@@ -815,21 +1148,57 @@ export async function runMonitorCheck(
815
1148
  errorKind: "config",
816
1149
  }),
817
1150
  error: "SSRF blocked: private/internal or non-http(s) address",
1151
+ });
1152
+ }
1153
+
1154
+ let dispatcher: unknown | undefined;
1155
+ try {
1156
+ const ssrfStart = Date.now();
1157
+ ({ dispatcher } = await prepareMonitorFetch(monitor.url, {
1158
+ allowPrivateHosts,
1159
+ }));
1160
+ diagnostics.timings.ssrfSetupMs = Date.now() - ssrfStart;
1161
+ } catch (err) {
1162
+ const message =
1163
+ err instanceof Error ? err.message : String(err ?? "check failed");
1164
+ const isConfig = message.startsWith("SSRF blocked");
1165
+ const errorText = message.slice(0, 500);
1166
+ diagnostics.timings.ssrfSetupMs = Date.now() - totalStart;
1167
+ diagnostics.error = {
1168
+ kind: isConfig ? "config" : "network",
1169
+ name: err instanceof Error ? err.name : undefined,
1170
+ message: errorText,
818
1171
  };
1172
+ const outcome = evaluateCheck({
1173
+ statusCode: null,
1174
+ latencyMs: null,
1175
+ bodyText: "",
1176
+ headers: {},
1177
+ matcher,
1178
+ assertions,
1179
+ fetchError: errorText,
1180
+ errorKind: isConfig ? "config" : "network",
1181
+ });
1182
+ return finish({
1183
+ checkedAt,
1184
+ statusCode: null,
1185
+ latencyMs: null,
1186
+ status: outcome.status,
1187
+ ok: outcome.ok,
1188
+ failedAssertions: outcome.failedAssertions,
1189
+ error: errorText,
1190
+ });
819
1191
  }
820
1192
 
821
1193
  const controller = new AbortController();
822
- const timeoutMs = clampInt(
823
- monitor.timeoutMs,
824
- MIN_TIMEOUT_MS,
825
- MAX_TIMEOUT_MS,
826
- DEFAULT_MONITOR_TIMEOUT_MS,
827
- );
828
- const timer = setTimeout(() => controller.abort(), timeoutMs);
829
- const start = Date.now();
1194
+ let timedOut = false;
1195
+ const timer = setTimeout(() => {
1196
+ timedOut = true;
1197
+ controller.abort();
1198
+ }, timeoutMs);
1199
+ const requestStart = Date.now();
830
1200
 
831
1201
  try {
832
- const method = normalizeMethod(monitor.method);
833
1202
  const hasBody =
834
1203
  method !== "GET" &&
835
1204
  method !== "HEAD" &&
@@ -846,15 +1215,44 @@ export async function runMonitorCheck(
846
1215
  followRedirects: monitor.followRedirects,
847
1216
  maxRedirects: MAX_REDIRECT_HOPS,
848
1217
  allowPrivateHosts,
1218
+ dispatcher,
1219
+ initialDnsChecked: Boolean(dispatcher),
849
1220
  });
1221
+ diagnostics.timings.requestMs = Date.now() - requestStart;
1222
+
1223
+ // Stop the abort timer as soon as headers arrive so a slow/optional body
1224
+ // read cannot be mislabeled as a request timeout with a null status code.
1225
+ clearTimeout(timer);
850
1226
 
851
- const bodyText =
852
- method === "HEAD"
853
- ? ""
854
- : await readCappedText(response, MAX_RESPONSE_BODY_BYTES);
855
- const latencyMs = Date.now() - start;
1227
+ const latencyMs = diagnostics.timings.requestMs;
856
1228
  const statusCode = response.status;
857
1229
  const headers = headersToObject(response.headers);
1230
+ diagnostics.response = {
1231
+ ...finalUrlDiagnostics(response.url),
1232
+ statusCode,
1233
+ headers: safeResponseHeaders(headers),
1234
+ };
1235
+ let bodyText = "";
1236
+ let bodyReadError: string | null = null;
1237
+ if (method !== "HEAD" && needsResponseBody(assertions)) {
1238
+ const bodyReadStart = Date.now();
1239
+ const body = await readCappedText(
1240
+ response,
1241
+ MAX_RESPONSE_BODY_BYTES,
1242
+ timeoutMs,
1243
+ );
1244
+ diagnostics.timings.bodyReadMs = Date.now() - bodyReadStart;
1245
+ bodyText = body.text;
1246
+ if (body.timedOut) {
1247
+ bodyReadError = `Response body read timed out after ${timeoutMs}ms`;
1248
+ diagnostics.error = {
1249
+ kind: "body-timeout",
1250
+ message: bodyReadError,
1251
+ };
1252
+ }
1253
+ } else {
1254
+ await cancelResponseBody(response);
1255
+ }
858
1256
 
859
1257
  const outcome = evaluateCheck({
860
1258
  statusCode,
@@ -864,31 +1262,39 @@ export async function runMonitorCheck(
864
1262
  matcher,
865
1263
  assertions,
866
1264
  });
1265
+ const failedAssertions = bodyReadError
1266
+ ? [...outcome.failedAssertions, bodyReadError]
1267
+ : outcome.failedAssertions;
1268
+ const status = bodyReadError ? "down" : outcome.status;
867
1269
 
868
- return {
1270
+ return finish({
869
1271
  checkedAt,
870
1272
  statusCode,
871
1273
  latencyMs,
872
- status: outcome.status,
873
- ok: outcome.ok,
874
- failedAssertions: outcome.failedAssertions,
875
- error: outcome.failedAssertions.length
876
- ? outcome.failedAssertions.join("; ")
877
- : null,
878
- };
1274
+ status,
1275
+ ok: status === "up" && outcome.ok,
1276
+ failedAssertions,
1277
+ error: failedAssertions.length ? failedAssertions.join("; ") : null,
1278
+ });
879
1279
  } catch (err) {
880
- const isTimeout =
881
- (err as { name?: string })?.name === "AbortError" ||
882
- controller.signal.aborted;
883
1280
  const message =
884
1281
  err instanceof Error ? err.message : String(err ?? "check failed");
885
1282
  const isConfig = message.startsWith("SSRF blocked");
1283
+ // Only our timer sets `timedOut`. Prefer the real SSRF/config message when
1284
+ // both happened (e.g. abort fired while a redirect DNS check was in flight).
1285
+ const isTimeout = timedOut && !isConfig;
886
1286
  const errorText = isTimeout
887
1287
  ? `Timed out after ${timeoutMs}ms`
888
1288
  : message.slice(0, 500);
1289
+ diagnostics.timings.requestMs = Date.now() - requestStart;
1290
+ diagnostics.error = {
1291
+ kind: isConfig ? "config" : isTimeout ? "timeout" : "network",
1292
+ name: err instanceof Error ? err.name : undefined,
1293
+ message: errorText,
1294
+ };
889
1295
  const outcome = evaluateCheck({
890
1296
  statusCode: null,
891
- latencyMs: null,
1297
+ latencyMs: isTimeout ? timeoutMs : null,
892
1298
  bodyText: "",
893
1299
  headers: {},
894
1300
  matcher,
@@ -896,15 +1302,15 @@ export async function runMonitorCheck(
896
1302
  fetchError: errorText,
897
1303
  errorKind: isConfig ? "config" : "network",
898
1304
  });
899
- return {
1305
+ return finish({
900
1306
  checkedAt,
901
1307
  statusCode: null,
902
- latencyMs: null,
1308
+ latencyMs: isTimeout ? timeoutMs : null,
903
1309
  status: outcome.status,
904
1310
  ok: outcome.ok,
905
1311
  failedAssertions: outcome.failedAssertions,
906
1312
  error: errorText,
907
- };
1313
+ });
908
1314
  } finally {
909
1315
  clearTimeout(timer);
910
1316
  }
@@ -964,6 +1370,9 @@ function rowToResult(row: any): MonitorCheckResult {
964
1370
  latencyMs: row.latencyMs ?? null,
965
1371
  error: row.error ?? null,
966
1372
  failedAssertions: safeJsonParse<string[]>(row.failedAssertions, []),
1373
+ diagnostics: normalizeMonitorCheckDiagnostics(
1374
+ safeJsonParse<unknown>(row.diagnostics, {}),
1375
+ ),
967
1376
  };
968
1377
  }
969
1378
 
@@ -978,6 +1387,10 @@ function rowToIncident(row: any): MonitorIncident {
978
1387
  cause: row.cause ?? "",
979
1388
  lastError: row.lastError ?? null,
980
1389
  notificationId: row.notificationId ?? null,
1390
+ notificationDelivered:
1391
+ row.notificationDelivered === true ||
1392
+ row.notificationDelivered === 1 ||
1393
+ Boolean(row.notificationId),
981
1394
  checksFailed: Number(row.checksFailed ?? 1),
982
1395
  createdAt: row.createdAt,
983
1396
  };
@@ -1390,6 +1803,7 @@ export async function recordMonitorResult(
1390
1803
  latencyMs: outcome.latencyMs,
1391
1804
  error: outcome.error,
1392
1805
  failedAssertions: JSON.stringify(outcome.failedAssertions),
1806
+ diagnostics: serializeMonitorDiagnostics(outcome.diagnostics),
1393
1807
  createdAt: outcome.checkedAt,
1394
1808
  ownerEmail: monitor.ownerEmail,
1395
1809
  orgId: monitor.orgId,
@@ -1434,6 +1848,37 @@ function describeCause(outcome: CheckOutcome): string {
1434
1848
  return "Check failed";
1435
1849
  }
1436
1850
 
1851
+ function isTransientNoResponseFailure(outcome: CheckOutcome): boolean {
1852
+ return (
1853
+ outcome.status === "down" &&
1854
+ outcome.statusCode == null &&
1855
+ Boolean(outcome.error)
1856
+ );
1857
+ }
1858
+
1859
+ function isTransientDegradedFailure(outcome: CheckOutcome): boolean {
1860
+ return (
1861
+ outcome.status === "degraded" &&
1862
+ outcome.failedAssertions.some((assertion) =>
1863
+ assertion.startsWith("Response took "),
1864
+ )
1865
+ );
1866
+ }
1867
+
1868
+ export function shouldOpenMonitorIncident(
1869
+ outcome: CheckOutcome,
1870
+ priorConsecutiveFailures: number,
1871
+ confirmationChecks = transientFailureConfirmationChecks(),
1872
+ ): boolean {
1873
+ const needsConfirmation =
1874
+ isTransientNoResponseFailure(outcome) ||
1875
+ isTransientDegradedFailure(outcome);
1876
+ const needed = needsConfirmation
1877
+ ? Math.max(1, Math.min(10, Math.floor(confirmationChecks)))
1878
+ : 1;
1879
+ return Math.max(0, Math.floor(priorConsecutiveFailures)) + 1 >= needed;
1880
+ }
1881
+
1437
1882
  async function getOpenIncident(
1438
1883
  monitorId: string,
1439
1884
  ctx: AccessCtx,
@@ -1478,6 +1923,29 @@ async function recentlyResolvedWithinCooldown(
1478
1923
  return now.getTime() - resolved < monitor.cooldownMinutes * 60 * 1000;
1479
1924
  }
1480
1925
 
1926
+ async function getFailureStreakStartedAt(
1927
+ monitor: Monitor,
1928
+ ctx: AccessCtx,
1929
+ fallback: string,
1930
+ ): Promise<string> {
1931
+ const db = getDb() as any;
1932
+ const table = schema.monitorCheckResults;
1933
+ const clauses: any[] = [
1934
+ resultsOwnerWhere(ctx),
1935
+ eq(table.monitorId, monitor.id),
1936
+ eq(table.ok, false),
1937
+ ];
1938
+ if (monitor.lastSuccessAt)
1939
+ clauses.push(gt(table.checkedAt, monitor.lastSuccessAt));
1940
+ const [row] = await db
1941
+ .select({ checkedAt: table.checkedAt })
1942
+ .from(table)
1943
+ .where(and(...clauses))
1944
+ .orderBy(asc(table.checkedAt))
1945
+ .limit(1);
1946
+ return row?.checkedAt ?? fallback;
1947
+ }
1948
+
1481
1949
  async function notifyMonitorDown(monitor: Monitor, outcome: CheckOutcome) {
1482
1950
  const host = hostFromUrl(monitor.url);
1483
1951
  const label = outcome.status === "degraded" ? "degraded" : "down";
@@ -1561,8 +2029,8 @@ export interface EvaluateMonitorResult {
1561
2029
 
1562
2030
  /**
1563
2031
  * Open / update / resolve incidents based on the latest outcome and send
1564
- * notifications. On a transition into failure it opens an incident and
1565
- * notifies (respecting an anti-flap cooldown); on recovery it resolves the
2032
+ * notifications. On a transition into confirmed failure it opens an incident
2033
+ * and notifies (respecting an anti-flap cooldown); on recovery it resolves the
1566
2034
  * open incident and sends a recovery notice.
1567
2035
  */
1568
2036
  export async function evaluateAndNotifyMonitor(
@@ -1576,6 +2044,8 @@ export async function evaluateAndNotifyMonitor(
1576
2044
 
1577
2045
  if (!outcome.ok) {
1578
2046
  const cause = describeCause(outcome);
2047
+ const priorConsecutiveFailures = monitor.consecutiveFailures ?? 0;
2048
+ const nextChecksFailed = priorConsecutiveFailures + 1;
1579
2049
  if (open) {
1580
2050
  const nextStatus =
1581
2051
  open.status === "down" ? "down" : (outcome.status as MonitorStatus);
@@ -1591,12 +2061,19 @@ export async function evaluateAndNotifyMonitor(
1591
2061
  return { status: outcome.status, incidentId: open.id, notified: false };
1592
2062
  }
1593
2063
 
2064
+ if (!shouldOpenMonitorIncident(outcome, priorConsecutiveFailures)) {
2065
+ return { status: outcome.status, notified: false };
2066
+ }
2067
+
1594
2068
  const suppressed = await recentlyResolvedWithinCooldown(monitor, ctx, now);
1595
2069
  let notificationId: string | undefined;
2070
+ let notificationDelivered = false;
1596
2071
  if (!suppressed) {
1597
2072
  try {
1598
2073
  const delivery = await notifyMonitorDown(monitor, outcome);
1599
2074
  notificationId = delivery.notification?.id;
2075
+ notificationDelivered =
2076
+ Boolean(notificationId) || delivery.deliveredChannels.length > 0;
1600
2077
  } catch (err) {
1601
2078
  console.error(
1602
2079
  `[uptime-monitors] notify failed for ${monitor.id}:`,
@@ -1605,17 +2082,22 @@ export async function evaluateAndNotifyMonitor(
1605
2082
  }
1606
2083
  }
1607
2084
  const incidentId = randomUUID();
2085
+ const startedAt =
2086
+ nextChecksFailed > 1
2087
+ ? await getFailureStreakStartedAt(monitor, ctx, outcome.checkedAt)
2088
+ : outcome.checkedAt;
1608
2089
  await db.insert(schema.monitorIncidents).values({
1609
2090
  id: incidentId,
1610
2091
  monitorId: monitor.id,
1611
- startedAt: outcome.checkedAt,
2092
+ startedAt,
1612
2093
  resolvedAt: null,
1613
2094
  status: outcome.status === "degraded" ? "degraded" : "down",
1614
2095
  severity: monitor.severity,
1615
2096
  cause,
1616
2097
  lastError: outcome.error,
1617
2098
  notificationId: notificationId ?? null,
1618
- checksFailed: 1,
2099
+ notificationDelivered,
2100
+ checksFailed: nextChecksFailed,
1619
2101
  createdAt: outcome.checkedAt,
1620
2102
  ownerEmail: monitor.ownerEmail,
1621
2103
  orgId: monitor.orgId,
@@ -1623,7 +2105,7 @@ export async function evaluateAndNotifyMonitor(
1623
2105
  return {
1624
2106
  status: outcome.status,
1625
2107
  incidentId,
1626
- notified: Boolean(notificationId),
2108
+ notified: notificationDelivered,
1627
2109
  };
1628
2110
  }
1629
2111
 
@@ -1634,14 +2116,16 @@ export async function evaluateAndNotifyMonitor(
1634
2116
  .set({ resolvedAt: outcome.checkedAt })
1635
2117
  .where(eq(schema.monitorIncidents.id, open.id));
1636
2118
  let notified = false;
1637
- try {
1638
- await notifyMonitorRecovered(monitor, outcome, open);
1639
- notified = true;
1640
- } catch (err) {
1641
- console.error(
1642
- `[uptime-monitors] recovery notify failed for ${monitor.id}:`,
1643
- err,
1644
- );
2119
+ if (open.notificationDelivered) {
2120
+ try {
2121
+ await notifyMonitorRecovered(monitor, outcome, open);
2122
+ notified = true;
2123
+ } catch (err) {
2124
+ console.error(
2125
+ `[uptime-monitors] recovery notify failed for ${monitor.id}:`,
2126
+ err,
2127
+ );
2128
+ }
1645
2129
  }
1646
2130
  return { status: "up", incidentId: open.id, notified, recovered: true };
1647
2131
  }
@@ -1656,7 +2140,7 @@ export async function evaluateAndNotifyMonitor(
1656
2140
  export async function runAndProcessMonitor(
1657
2141
  monitor: Monitor,
1658
2142
  ctx: AccessCtx,
1659
- opts: { allowPrivateHosts?: boolean } = {},
2143
+ opts: { allowPrivateHosts?: boolean; source?: MonitorCheckSource } = {},
1660
2144
  ): Promise<CheckOutcome> {
1661
2145
  const outcome = await runMonitorCheck(monitor, opts);
1662
2146
  // recordMonitorResult() emits the "monitors" change for the UI.
@@ -1687,7 +2171,7 @@ export async function runMonitorNow(
1687
2171
  statusCode: 409,
1688
2172
  });
1689
2173
  }
1690
- return runAndProcessMonitor(monitor, ctx);
2174
+ return runAndProcessMonitor(monitor, ctx, { source: "manual" });
1691
2175
  }
1692
2176
 
1693
2177
  // ---------------------------------------------------------------------------