@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +32 -0
- package/corpus/core/docs/content/locales/ar-SA/server.mdx +35 -0
- package/corpus/core/docs/content/locales/de-DE/server.mdx +35 -0
- package/corpus/core/docs/content/locales/es-ES/server.mdx +35 -0
- package/corpus/core/docs/content/locales/fr-FR/server.mdx +35 -0
- package/corpus/core/docs/content/locales/hi-IN/server.mdx +35 -0
- package/corpus/core/docs/content/locales/ja-JP/server.mdx +35 -0
- package/corpus/core/docs/content/locales/ko-KR/server.mdx +35 -0
- package/corpus/core/docs/content/locales/pt-BR/server.mdx +35 -0
- package/corpus/core/docs/content/locales/zh-CN/server.mdx +35 -0
- package/corpus/core/docs/content/locales/zh-TW/server.mdx +35 -0
- package/corpus/core/docs/content/server.mdx +35 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +14 -4
- package/corpus/core/src/agent/engine/anthropic-engine.ts +15 -3
- package/corpus/core/src/agent/engine/builder-engine.ts +30 -4
- package/corpus/core/src/agent/engine/registry.ts +63 -2
- package/corpus/core/src/agent/model-config.ts +0 -2
- package/corpus/core/src/agent/production-agent.ts +12 -1
- package/corpus/core/src/agent/run-manager.ts +57 -36
- package/corpus/core/src/agent/run-store.ts +81 -2
- package/corpus/core/src/agent/thread-data-builder.ts +33 -2
- package/corpus/core/src/cli/sync-builder-starter-manifest.ts +29 -1
- package/corpus/core/src/client/AssistantChat.tsx +314 -96
- package/corpus/core/src/client/agent-chat-adapter.ts +17 -1
- package/corpus/core/src/client/chat/repo-helpers.ts +45 -0
- package/corpus/core/src/client/chat/tool-call-display.tsx +36 -27
- package/corpus/core/src/client/chat-model-groups.ts +1 -5
- package/corpus/core/src/client/composer/TiptapComposer.tsx +0 -2
- package/corpus/core/src/client/embed-auth.ts +20 -0
- package/corpus/core/src/client/settings/SettingsSection.tsx +66 -5
- package/corpus/core/src/client/sse-event-processor.ts +49 -17
- package/corpus/core/src/client/use-db-sync.ts +31 -9
- package/corpus/core/src/collab/agent-presence.ts +6 -1
- package/corpus/core/src/collab/awareness-store.ts +185 -0
- package/corpus/core/src/collab/awareness.ts +58 -2
- package/corpus/core/src/notifications/channels.ts +1 -22
- package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +7 -8
- package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +7 -18
- package/corpus/core/src/server/core-routes-plugin.ts +21 -2
- package/corpus/core/src/server/embed-session.ts +20 -4
- package/corpus/core/src/styles/agent-native.css +51 -0
- package/corpus/core/src/tracking/providers.ts +33 -2
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +8 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +9 -1
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +41 -11
- package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +44 -0
- package/corpus/templates/analytics/changelog/2026-07-08-alert-emails-now-show-the-message-without-raw-metadata-json.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-uptime-checks-no-longer-count-ssrf-setup-time-against-the-re.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-uptime-checks-reuse-one-keep-alive-connection-so-recorded-la.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-uptime-monitors-now-confirm-transient-timeout-failures-befor.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-dashboard-action-menus-now-close-cleanly-before-opening-hist.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-dashboard-email-reports-still-send-when-the-screenshot-captu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-uptime-alerts-now-suppress-recovery-noise-during-flapping-an.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-uptime-check-history-now-shows-timing-diagnostics-so-slow-or.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-uptime-recovery-alerts-now-send-when-the-original-outage-not.md +6 -0
- package/corpus/templates/analytics/docs/uptime-monitoring.md +47 -22
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
- package/corpus/templates/analytics/server/db/schema-monitoring.ts +5 -0
- package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +5 -1
- package/corpus/templates/analytics/server/lib/uptime-monitors.ts +538 -54
- package/corpus/templates/analytics/server/plugins/db.ts +15 -0
- package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +1 -1
- package/corpus/templates/analytics/server/routes/api/uptime-monitors/run.post.ts +57 -0
- package/corpus/templates/clips/actions/list-meetings.ts +12 -0
- package/corpus/templates/clips/changelog/2026-07-09-meeting-reminders-now-ignore-obvious-solo-personal-calendar-.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +1 -0
- package/corpus/templates/clips/server/lib/calendar-event-classification.ts +79 -0
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +13 -4
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +14 -3
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +26 -4
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/agent/engine/registry.d.ts.map +1 -1
- package/dist/agent/engine/registry.js +47 -2
- package/dist/agent/engine/registry.js.map +1 -1
- package/dist/agent/model-config.d.ts +2 -2
- package/dist/agent/model-config.d.ts.map +1 -1
- package/dist/agent/model-config.js +0 -2
- package/dist/agent/model-config.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +12 -1
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +52 -33
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +24 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +64 -2
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/thread-data-builder.d.ts.map +1 -1
- package/dist/agent/thread-data-builder.js +28 -2
- package/dist/agent/thread-data-builder.js.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.js +24 -1
- package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +6 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +243 -36
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +15 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/chat/repo-helpers.d.ts.map +1 -1
- package/dist/client/chat/repo-helpers.js +43 -0
- package/dist/client/chat/repo-helpers.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +4 -4
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/chat-model-groups.d.ts.map +1 -1
- package/dist/client/chat-model-groups.js +1 -3
- package/dist/client/chat-model-groups.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +0 -2
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/embed-auth.d.ts.map +1 -1
- package/dist/client/embed-auth.js +14 -0
- package/dist/client/embed-auth.js.map +1 -1
- package/dist/client/settings/SettingsSection.d.ts.map +1 -1
- package/dist/client/settings/SettingsSection.js +32 -3
- package/dist/client/settings/SettingsSection.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +44 -17
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +29 -9
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/agent-presence.d.ts.map +1 -1
- package/dist/collab/agent-presence.js +5 -2
- package/dist/collab/agent-presence.js.map +1 -1
- package/dist/collab/awareness-store.d.ts +1 -1
- package/dist/collab/awareness-store.d.ts.map +1 -1
- package/dist/collab/awareness-store.js +0 -0
- package/dist/collab/awareness-store.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -0
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/awareness.js +46 -2
- package/dist/collab/awareness.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/notifications/channels.js +1 -17
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/agent-engines/manage-agent-engine.d.ts.map +1 -1
- package/dist/scripts/agent-engines/manage-agent-engine.js +6 -6
- package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
- package/dist/scripts/agent-engines/set-agent-engine.d.ts.map +1 -1
- package/dist/scripts/agent-engines/set-agent-engine.js +5 -13
- package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +5 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +10 -3
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/embed-session.d.ts.map +1 -1
- package/dist/server/embed-session.js +17 -4
- package/dist/server/embed-session.js.map +1 -1
- package/dist/styles/agent-native.css +51 -0
- package/dist/tracking/providers.d.ts.map +1 -1
- package/dist/tracking/providers.js +23 -5
- package/dist/tracking/providers.js.map +1 -1
- package/docs/content/locales/ar-SA/server.mdx +35 -0
- package/docs/content/locales/de-DE/server.mdx +35 -0
- package/docs/content/locales/es-ES/server.mdx +35 -0
- package/docs/content/locales/fr-FR/server.mdx +35 -0
- package/docs/content/locales/hi-IN/server.mdx +35 -0
- package/docs/content/locales/ja-JP/server.mdx +35 -0
- package/docs/content/locales/ko-KR/server.mdx +35 -0
- package/docs/content/locales/pt-BR/server.mdx +35 -0
- package/docs/content/locales/zh-CN/server.mdx +35 -0
- package/docs/content/locales/zh-TW/server.mdx +35 -0
- package/docs/content/server.mdx +35 -0
- 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 =
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
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 =
|
|
729
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
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
|
|
873
|
-
ok: outcome.ok,
|
|
874
|
-
failedAssertions
|
|
875
|
-
error:
|
|
876
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|