@hasna/uptime 0.1.0 → 0.1.1

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/dist/mcp/index.js CHANGED
@@ -14362,6 +14362,9 @@ async function runTcpCheck(monitor) {
14362
14362
  });
14363
14363
  }
14364
14364
 
14365
+ // src/service.ts
14366
+ import { randomUUID as randomUUID2 } from "crypto";
14367
+
14365
14368
  // src/store.ts
14366
14369
  import { mkdirSync } from "fs";
14367
14370
  import { dirname } from "path";
@@ -14388,6 +14391,13 @@ var MAX_RETRY_COUNT = 10;
14388
14391
  var MAX_RESULT_LIMIT = 1000;
14389
14392
 
14390
14393
  // src/store.ts
14394
+ class StaleCheckResultError extends Error {
14395
+ constructor(message) {
14396
+ super(message);
14397
+ this.name = "StaleCheckResultError";
14398
+ }
14399
+ }
14400
+
14391
14401
  class UptimeStore {
14392
14402
  dbPath;
14393
14403
  db;
@@ -14421,10 +14431,12 @@ class UptimeStore {
14421
14431
  enabled INTEGER NOT NULL DEFAULT 1,
14422
14432
  status TEXT NOT NULL DEFAULT 'unknown',
14423
14433
  last_checked_at TEXT,
14434
+ revision INTEGER NOT NULL DEFAULT 1,
14424
14435
  created_at TEXT NOT NULL,
14425
14436
  updated_at TEXT NOT NULL
14426
14437
  )
14427
14438
  `);
14439
+ this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
14428
14440
  this.db.run(`
14429
14441
  CREATE TABLE IF NOT EXISTS check_results (
14430
14442
  id TEXT PRIMARY KEY,
@@ -14450,8 +14462,17 @@ class UptimeStore {
14450
14462
  reason TEXT
14451
14463
  )
14452
14464
  `);
14465
+ this.db.run(`
14466
+ CREATE TABLE IF NOT EXISTS check_leases (
14467
+ monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
14468
+ owner TEXT NOT NULL,
14469
+ leased_until TEXT NOT NULL,
14470
+ acquired_at TEXT NOT NULL
14471
+ )
14472
+ `);
14453
14473
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
14454
14474
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
14475
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
14455
14476
  }
14456
14477
  createMonitor(input) {
14457
14478
  const normalized = normalizeCreateMonitor(input);
@@ -14471,14 +14492,15 @@ class UptimeStore {
14471
14492
  enabled: normalized.enabled ?? true,
14472
14493
  status: normalized.enabled === false ? "paused" : "unknown",
14473
14494
  lastCheckedAt: null,
14495
+ revision: 1,
14474
14496
  createdAt: now,
14475
14497
  updatedAt: now
14476
14498
  };
14477
14499
  this.db.query(`INSERT INTO monitors (
14478
14500
  id, name, kind, url, host, port, method, expected_status,
14479
14501
  interval_seconds, timeout_ms, retry_count, enabled, status,
14480
- last_checked_at, created_at, updated_at
14481
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.createdAt, monitor.updatedAt);
14502
+ last_checked_at, revision, created_at, updated_at
14503
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.revision, monitor.createdAt, monitor.updatedAt);
14482
14504
  return monitor;
14483
14505
  }
14484
14506
  listMonitors(options = {}) {
@@ -14498,8 +14520,12 @@ class UptimeStore {
14498
14520
  this.db.query(`UPDATE monitors SET
14499
14521
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
14500
14522
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
14501
- retry_count = ?, enabled = ?, status = ?, last_checked_at = ?, updated_at = ?
14523
+ retry_count = ?, enabled = ?, status = ?, last_checked_at = ?,
14524
+ revision = revision + 1, updated_at = ?
14502
14525
  WHERE id = ?`).run(next.name, next.kind, next.url, next.host, next.port, next.method, next.expectedStatus, next.intervalSeconds, next.timeoutMs, next.retryCount, next.enabled ? 1 : 0, next.status, next.lastCheckedAt, updatedAt, current.id);
14526
+ if (definitionChanged(current, next)) {
14527
+ this.closeOpenIncident(current.id, updatedAt);
14528
+ }
14503
14529
  return this.getMonitor(current.id);
14504
14530
  }
14505
14531
  deleteMonitor(idOrName) {
@@ -14509,10 +14535,31 @@ class UptimeStore {
14509
14535
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
14510
14536
  return true;
14511
14537
  }
14538
+ acquireCheckLease(monitorId, owner, ttlMs) {
14539
+ const now = new Date;
14540
+ const nowIso = now.toISOString();
14541
+ const leasedUntil = new Date(now.getTime() + Math.max(1000, ttlMs)).toISOString();
14542
+ const tx = this.db.transaction(() => {
14543
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND leased_until <= ?").run(monitorId, nowIso);
14544
+ this.db.query("INSERT OR IGNORE INTO check_leases (monitor_id, owner, leased_until, acquired_at) VALUES (?, ?, ?, ?)").run(monitorId, owner, leasedUntil, nowIso);
14545
+ const row = this.db.query("SELECT * FROM check_leases WHERE monitor_id = ?").get(monitorId);
14546
+ return row?.owner === owner;
14547
+ });
14548
+ return tx();
14549
+ }
14550
+ releaseCheckLease(monitorId, owner) {
14551
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND owner = ?").run(monitorId, owner);
14552
+ }
14512
14553
  recordCheckResult(input) {
14513
14554
  const monitor = this.getMonitor(input.monitorId);
14514
14555
  if (!monitor)
14515
14556
  throw new Error(`Monitor not found: ${input.monitorId}`);
14557
+ if (input.expectedMonitorRevision !== undefined && monitor.revision !== input.expectedMonitorRevision) {
14558
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${monitor.name}`);
14559
+ }
14560
+ if (!monitor.enabled) {
14561
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${monitor.name}`);
14562
+ }
14516
14563
  const checkedAt = input.checkedAt ?? new Date().toISOString();
14517
14564
  const result = {
14518
14565
  id: newId("chk"),
@@ -14525,6 +14572,15 @@ class UptimeStore {
14525
14572
  attemptCount: Math.max(1, input.attemptCount)
14526
14573
  };
14527
14574
  const tx = this.db.transaction(() => {
14575
+ const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
14576
+ if (!current)
14577
+ throw new Error(`Monitor not found: ${result.monitorId}`);
14578
+ if (input.expectedMonitorRevision !== undefined && current.revision !== input.expectedMonitorRevision) {
14579
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${current.name}`);
14580
+ }
14581
+ if (!current.enabled) {
14582
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
14583
+ }
14528
14584
  this.db.query(`INSERT INTO check_results (
14529
14585
  id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
14530
14586
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
@@ -14572,10 +14628,14 @@ class UptimeStore {
14572
14628
  down: monitors.filter((m) => m.status === "down").length,
14573
14629
  paused: monitors.filter((m) => !m.enabled || m.status === "paused").length,
14574
14630
  unknown: monitors.filter((m) => m.status === "unknown").length,
14575
- openIncidents: this.listIncidents({ status: "open", limit: 1000 }).length
14631
+ openIncidents: this.countOpenIncidents()
14576
14632
  }
14577
14633
  };
14578
14634
  }
14635
+ countOpenIncidents() {
14636
+ const row = this.db.query("SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'").get();
14637
+ return Number(row?.count ?? 0);
14638
+ }
14579
14639
  monitorSummary(monitor) {
14580
14640
  const row = this.db.query(`SELECT
14581
14641
  COUNT(*) as total,
@@ -14613,13 +14673,24 @@ class UptimeStore {
14613
14673
  this.db.query("UPDATE incidents SET status = 'closed', closed_at = ?, recovery_check_id = ? WHERE id = ?").run(result.checkedAt, result.id, open.id);
14614
14674
  }
14615
14675
  }
14676
+ closeOpenIncident(monitorId, closedAt) {
14677
+ this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
14678
+ }
14679
+ ensureColumn(table, name, definition) {
14680
+ const columns = this.db.query(`PRAGMA table_info(${table})`).all();
14681
+ if (!columns.some((column) => column.name === name)) {
14682
+ this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
14683
+ }
14684
+ }
14616
14685
  }
14617
14686
  function normalizeCreateMonitor(input) {
14618
14687
  const name = input.name?.trim();
14619
14688
  if (!name)
14620
14689
  throw new Error("Monitor name is required");
14690
+ rejectControlCharacters(name, "Monitor name");
14621
14691
  const method = normalizeMethod(input.method ?? "GET");
14622
14692
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
14693
+ const enabled = normalizeEnabled(input.enabled);
14623
14694
  if (input.kind === "http") {
14624
14695
  const url2 = normalizeHttpUrl(input.url);
14625
14696
  return {
@@ -14631,12 +14702,13 @@ function normalizeCreateMonitor(input) {
14631
14702
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
14632
14703
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
14633
14704
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
14634
- enabled: input.enabled ?? true
14705
+ enabled
14635
14706
  };
14636
14707
  } else if (input.kind === "tcp") {
14637
14708
  const host = input.host?.trim();
14638
14709
  if (!host)
14639
14710
  throw new Error("TCP monitors require host");
14711
+ rejectControlCharacters(host, "TCP host");
14640
14712
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
14641
14713
  throw new Error("TCP monitors require a port from 1 to 65535");
14642
14714
  }
@@ -14650,12 +14722,15 @@ function normalizeCreateMonitor(input) {
14650
14722
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
14651
14723
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
14652
14724
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
14653
- enabled: input.enabled ?? true
14725
+ enabled
14654
14726
  };
14655
14727
  } else {
14656
14728
  throw new Error("Monitor kind must be http or tcp");
14657
14729
  }
14658
14730
  }
14731
+ function definitionChanged(current, next) {
14732
+ return next.kind !== current.kind || next.url !== current.url || next.host !== current.host || next.port !== current.port || next.method !== current.method || next.expectedStatus !== current.expectedStatus;
14733
+ }
14659
14734
  function normalizeUpdateMonitor(current, input, updatedAt) {
14660
14735
  const merged = {
14661
14736
  ...current,
@@ -14720,6 +14795,18 @@ function normalizeExpectedStatus(value) {
14720
14795
  }
14721
14796
  return value;
14722
14797
  }
14798
+ function normalizeEnabled(value) {
14799
+ if (value === undefined)
14800
+ return true;
14801
+ if (typeof value !== "boolean")
14802
+ throw new Error("enabled must be a boolean");
14803
+ return value;
14804
+ }
14805
+ function rejectControlCharacters(value, label) {
14806
+ if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
14807
+ throw new Error(`${label} must not contain control characters`);
14808
+ }
14809
+ }
14723
14810
  function monitorFromRow(row) {
14724
14811
  return {
14725
14812
  id: row.id,
@@ -14736,6 +14823,7 @@ function monitorFromRow(row) {
14736
14823
  enabled: Boolean(row.enabled),
14737
14824
  status: row.status,
14738
14825
  lastCheckedAt: row.last_checked_at,
14826
+ revision: row.revision ?? 1,
14739
14827
  createdAt: row.created_at,
14740
14828
  updatedAt: row.updated_at
14741
14829
  };
@@ -14784,10 +14872,281 @@ function round(value, places) {
14784
14872
  return Math.round(value * factor) / factor;
14785
14873
  }
14786
14874
 
14875
+ // src/report.ts
14876
+ var DEFAULT_MAILERY_API_URL = "http://localhost:3900";
14877
+ var DEFAULT_TELEPHONY_API_URL = "http://localhost:19451";
14878
+ var DEFAULT_LOGS_API_URL = "http://localhost:3460";
14879
+ var DEFAULT_TIMEOUT_MS = 15000;
14880
+ function buildUptimeReport(summary, options = {}) {
14881
+ const subject = options.subject ?? defaultSubject(summary);
14882
+ const lines = [
14883
+ subject,
14884
+ `Generated: ${summary.generatedAt}`,
14885
+ `Monitors: ${summary.totals.monitors} total, ${summary.totals.enabled} enabled, ${summary.totals.up} up, ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`,
14886
+ "",
14887
+ ...summary.monitors.map(renderMonitorLine)
14888
+ ];
14889
+ const text = lines.join(`
14890
+ `).trimEnd();
14891
+ const json2 = {
14892
+ kind: "open-uptime.report",
14893
+ generated_at: summary.generatedAt,
14894
+ subject,
14895
+ totals: summary.totals,
14896
+ monitors: summary.monitors
14897
+ };
14898
+ return {
14899
+ subject,
14900
+ generatedAt: summary.generatedAt,
14901
+ summary,
14902
+ text,
14903
+ html: `<pre>${escapeHtml(text)}</pre>`,
14904
+ json: json2
14905
+ };
14906
+ }
14907
+ async function sendUptimeReport(summary, options = {}) {
14908
+ const report = buildUptimeReport(summary, options);
14909
+ const fetchImpl = options.fetchImpl ?? fetch;
14910
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
14911
+ const deliveries = [];
14912
+ if (options.email) {
14913
+ deliveries.push(await sendEmailReport(report, resolveEmailTarget(options.email), fetchImpl, timeoutMs));
14914
+ }
14915
+ if (options.sms) {
14916
+ const smsTarget = resolveSmsTarget(options.sms);
14917
+ const recipients = splitTargets(smsTarget.to);
14918
+ if (recipients.length === 0) {
14919
+ deliveries.push(await sendSmsReport(report, smsTarget, fetchImpl, timeoutMs));
14920
+ } else {
14921
+ for (const target of recipients) {
14922
+ deliveries.push(await sendSmsReport(report, { ...smsTarget, to: target }, fetchImpl, timeoutMs));
14923
+ }
14924
+ }
14925
+ }
14926
+ if (options.logs) {
14927
+ deliveries.push(await sendLogsReport(report, resolveLogsTarget(options.logs), fetchImpl, timeoutMs));
14928
+ }
14929
+ return deliveries;
14930
+ }
14931
+ function defaultSubject(summary) {
14932
+ if (summary.totals.openIncidents > 0 || summary.totals.down > 0) {
14933
+ return `Open Uptime alert: ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`;
14934
+ }
14935
+ return `Open Uptime report: ${summary.totals.up}/${summary.totals.enabled} enabled monitors up`;
14936
+ }
14937
+ function renderMonitorLine(item) {
14938
+ const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
14939
+ const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
14940
+ const incident = item.openIncident ? ` open incident: ${item.openIncident.reason ?? "down"}` : "";
14941
+ return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
14942
+ }
14943
+ function targetLabel(item) {
14944
+ return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
14945
+ }
14946
+ function resolveEmailTarget(value) {
14947
+ const target = typeof value === "boolean" ? {} : value;
14948
+ return {
14949
+ apiUrl: target.apiUrl ?? env("HASNA_MAILERY_API_URL", "MAILERY_API_URL") ?? DEFAULT_MAILERY_API_URL,
14950
+ sendKey: target.sendKey ?? env("HASNA_MAILERY_SEND_KEY", "MAILERY_SEND_KEY", "ESK"),
14951
+ from: target.from ?? env("HASNA_UPTIME_REPORT_EMAIL_FROM", "UPTIME_REPORT_EMAIL_FROM"),
14952
+ to: target.to ?? env("HASNA_UPTIME_REPORT_EMAIL_TO", "UPTIME_REPORT_EMAIL_TO"),
14953
+ subject: target.subject,
14954
+ providerId: target.providerId ?? env("HASNA_MAILERY_PROVIDER_ID", "MAILERY_PROVIDER_ID")
14955
+ };
14956
+ }
14957
+ function resolveSmsTarget(value) {
14958
+ const target = typeof value === "boolean" ? {} : value;
14959
+ return {
14960
+ apiUrl: target.apiUrl ?? env("HASNA_TELEPHONY_API_URL", "TELEPHONY_API_URL") ?? DEFAULT_TELEPHONY_API_URL,
14961
+ from: target.from ?? env("HASNA_UPTIME_REPORT_SMS_FROM", "UPTIME_REPORT_SMS_FROM"),
14962
+ to: target.to ?? env("HASNA_UPTIME_REPORT_PHONE_TO", "UPTIME_REPORT_PHONE_TO")
14963
+ };
14964
+ }
14965
+ function resolveLogsTarget(value) {
14966
+ const target = typeof value === "boolean" ? {} : value;
14967
+ return {
14968
+ apiUrl: target.apiUrl ?? env("HASNA_LOGS_URL", "LOGS_URL") ?? DEFAULT_LOGS_API_URL,
14969
+ apiKey: target.apiKey ?? env("HASNA_LOGS_API_TOKEN", "LOGS_API_TOKEN", "HASNA_LOGS_API_KEY", "LOGS_API_KEY"),
14970
+ projectId: target.projectId ?? env("HASNA_LOGS_PROJECT_ID", "LOGS_PROJECT_ID") ?? "open-uptime",
14971
+ environment: target.environment ?? env("HASNA_ENV", "NODE_ENV"),
14972
+ service: target.service ?? "open-uptime"
14973
+ };
14974
+ }
14975
+ async function sendEmailReport(report, target, fetchImpl, timeoutMs) {
14976
+ if (!target.sendKey)
14977
+ return { channel: "email", ok: false, error: "Mailery send key is required" };
14978
+ if (!target.from)
14979
+ return { channel: "email", ok: false, error: "Email from address is required" };
14980
+ if (!hasTargets(target.to))
14981
+ return { channel: "email", ok: false, error: "Email recipient is required" };
14982
+ const body = {
14983
+ from: target.from,
14984
+ to: splitTargets(target.to),
14985
+ subject: target.subject ?? report.subject,
14986
+ text: report.text,
14987
+ html: report.html,
14988
+ provider_id: target.providerId
14989
+ };
14990
+ return requestJson("email", `${normalizeUrl(target.apiUrl ?? DEFAULT_MAILERY_API_URL)}/api/v1/send`, {
14991
+ method: "POST",
14992
+ headers: { authorization: `Bearer ${target.sendKey}` },
14993
+ body
14994
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
14995
+ }
14996
+ async function sendSmsReport(report, target, fetchImpl, timeoutMs) {
14997
+ if (!hasTargets(target.to))
14998
+ return { channel: "sms", ok: false, error: "SMS recipient phone number is required" };
14999
+ return requestJson("sms", `${normalizeUrl(target.apiUrl ?? DEFAULT_TELEPHONY_API_URL)}/api/sms/send`, {
15000
+ method: "POST",
15001
+ body: {
15002
+ to: Array.isArray(target.to) ? target.to[0] : target.to,
15003
+ from: target.from,
15004
+ body: truncateSms(report.text)
15005
+ }
15006
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
15007
+ }
15008
+ async function sendLogsReport(report, target, fetchImpl, timeoutMs) {
15009
+ const params = new URLSearchParams({
15010
+ format: "json",
15011
+ source: "structured",
15012
+ service: target.service ?? "open-uptime",
15013
+ project_id: target.projectId ?? "open-uptime"
15014
+ });
15015
+ if (target.environment)
15016
+ params.set("environment", target.environment);
15017
+ return requestJson("logs", `${normalizeUrl(target.apiUrl ?? DEFAULT_LOGS_API_URL)}/api/logs/structured?${params}`, {
15018
+ method: "POST",
15019
+ headers: target.apiKey ? { authorization: `Bearer ${target.apiKey}` } : undefined,
15020
+ body: {
15021
+ timestamp: report.generatedAt,
15022
+ level: report.summary.totals.down > 0 || report.summary.totals.openIncidents > 0 ? "warn" : "info",
15023
+ message: report.subject,
15024
+ report: report.json
15025
+ }
15026
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
15027
+ }
15028
+ async function requestJson(channel, url2, options, fetchImpl, timeoutMs, secrets = []) {
15029
+ const controller = new AbortController;
15030
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
15031
+ try {
15032
+ const response = await fetchImpl(url2, {
15033
+ method: options.method,
15034
+ signal: controller.signal,
15035
+ headers: {
15036
+ "content-type": "application/json",
15037
+ accept: "application/json",
15038
+ ...options.headers
15039
+ },
15040
+ body: JSON.stringify(options.body)
15041
+ });
15042
+ const text = await response.text();
15043
+ const data = parseMaybeJson(text);
15044
+ if (!response.ok) {
15045
+ return { channel, ok: false, status: response.status, error: errorFromResponse(data, response.statusText, secrets) };
15046
+ }
15047
+ return { channel, ok: true, status: response.status, id: redactOptional(idFromResponse(data), secrets) };
15048
+ } catch (error51) {
15049
+ const message = error51 instanceof Error && error51.name === "AbortError" ? "request timed out" : error51 instanceof Error ? error51.message : String(error51);
15050
+ return { channel, ok: false, error: redactSecrets(message, secrets) };
15051
+ } finally {
15052
+ clearTimeout(timer);
15053
+ }
15054
+ }
15055
+ function hasTargets(value) {
15056
+ return splitTargets(value).length > 0;
15057
+ }
15058
+ function splitTargets(value) {
15059
+ if (!value)
15060
+ return [];
15061
+ const values = Array.isArray(value) ? value : value.split(",");
15062
+ return values.map((item) => item.trim()).filter(Boolean);
15063
+ }
15064
+ function normalizeUrl(value) {
15065
+ const parsed = new URL(value.trim());
15066
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15067
+ throw new Error("Integration API URL must use http or https");
15068
+ }
15069
+ return parsed.toString().replace(/\/$/, "");
15070
+ }
15071
+ function truncateSms(value) {
15072
+ return value.length > 1400 ? `${value.slice(0, 1397)}...` : value;
15073
+ }
15074
+ function parseMaybeJson(text) {
15075
+ if (!text.trim())
15076
+ return {};
15077
+ try {
15078
+ return JSON.parse(text);
15079
+ } catch {
15080
+ return { message: text };
15081
+ }
15082
+ }
15083
+ function idFromResponse(data) {
15084
+ if (!data || typeof data !== "object")
15085
+ return;
15086
+ const record2 = data;
15087
+ for (const key of ["id", "message_id", "event_id"]) {
15088
+ if (typeof record2[key] === "string")
15089
+ return record2[key];
15090
+ }
15091
+ return;
15092
+ }
15093
+ function errorFromResponse(data, fallback, secrets = []) {
15094
+ if (data && typeof data === "object") {
15095
+ const record2 = data;
15096
+ if (typeof record2.error === "string")
15097
+ return redactSecrets(record2.error, secrets);
15098
+ if (typeof record2.message === "string")
15099
+ return redactSecrets(record2.message, secrets);
15100
+ }
15101
+ return redactSecrets(fallback, secrets);
15102
+ }
15103
+ function env(...keys) {
15104
+ for (const key of keys) {
15105
+ const value = process.env[key]?.trim();
15106
+ if (value)
15107
+ return value;
15108
+ }
15109
+ return;
15110
+ }
15111
+ function escapeHtml(value) {
15112
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
15113
+ }
15114
+ function secretsForTarget(target) {
15115
+ const values = new Set;
15116
+ for (const key of ["sendKey", "apiKey"]) {
15117
+ const value = target[key];
15118
+ if (typeof value === "string" && value.trim())
15119
+ values.add(value.trim());
15120
+ }
15121
+ const apiUrl = target.apiUrl;
15122
+ if (apiUrl) {
15123
+ try {
15124
+ const parsed = new URL(apiUrl);
15125
+ if (parsed.username)
15126
+ values.add(decodeURIComponent(parsed.username));
15127
+ if (parsed.password)
15128
+ values.add(decodeURIComponent(parsed.password));
15129
+ } catch {}
15130
+ }
15131
+ return [...values];
15132
+ }
15133
+ function redactSecrets(value, secrets = []) {
15134
+ let redacted = value;
15135
+ for (const secret of secrets) {
15136
+ if (secret.length >= 3)
15137
+ redacted = redacted.split(secret).join("[REDACTED]");
15138
+ }
15139
+ return redacted.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/\besk_[A-Za-z0-9._~+/=-]+/g, "esk_[REDACTED]");
15140
+ }
15141
+ function redactOptional(value, secrets) {
15142
+ return value === undefined ? undefined : redactSecrets(value, secrets);
15143
+ }
15144
+
14787
15145
  // src/service.ts
14788
15146
  class UptimeService {
14789
15147
  store;
14790
15148
  checkRunner;
15149
+ leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
14791
15150
  inFlightChecks = new Set;
14792
15151
  constructor(options = {}) {
14793
15152
  this.store = options.store ?? new UptimeStore(options);
@@ -14820,6 +15179,12 @@ class UptimeService {
14820
15179
  summary() {
14821
15180
  return this.store.summary();
14822
15181
  }
15182
+ buildReport(options = {}) {
15183
+ return buildUptimeReport(this.summary(), options);
15184
+ }
15185
+ async sendReport(options = {}) {
15186
+ return sendUptimeReport(this.summary(), options);
15187
+ }
14823
15188
  async checkMonitor(idOrName) {
14824
15189
  const monitor = this.store.getMonitor(idOrName);
14825
15190
  if (!monitor)
@@ -14828,6 +15193,10 @@ class UptimeService {
14828
15193
  throw new Error(`Monitor is disabled: ${monitor.name}`);
14829
15194
  if (this.inFlightChecks.has(monitor.id))
14830
15195
  throw new Error(`Monitor check already in progress: ${monitor.name}`);
15196
+ const leaseTtlMs = Math.max(60000, (monitor.retryCount + 1) * monitor.timeoutMs + 1e4);
15197
+ if (!this.store.acquireCheckLease(monitor.id, this.leaseOwner, leaseTtlMs)) {
15198
+ throw new MonitorCheckBusyError(`Monitor check already in progress: ${monitor.name}`);
15199
+ }
14831
15200
  this.inFlightChecks.add(monitor.id);
14832
15201
  try {
14833
15202
  let attemptCount = 0;
@@ -14845,10 +15214,12 @@ class UptimeService {
14845
15214
  latencyMs: last.latencyMs,
14846
15215
  statusCode: last.statusCode ?? null,
14847
15216
  error: last.error ?? null,
14848
- attemptCount
15217
+ attemptCount,
15218
+ expectedMonitorRevision: monitor.revision
14849
15219
  });
14850
15220
  } finally {
14851
15221
  this.inFlightChecks.delete(monitor.id);
15222
+ this.store.releaseCheckLease(monitor.id, this.leaseOwner);
14852
15223
  }
14853
15224
  }
14854
15225
  async checkAll() {
@@ -14877,7 +15248,13 @@ class UptimeService {
14877
15248
  const current = this.store.getMonitor(monitor.id);
14878
15249
  if (!current || !this.isDue(current, now))
14879
15250
  continue;
14880
- results.push(await this.checkMonitor(current.id));
15251
+ try {
15252
+ results.push(await this.checkMonitor(current.id));
15253
+ } catch (error51) {
15254
+ if (error51 instanceof MonitorCheckBusyError || error51 instanceof StaleCheckResultError)
15255
+ continue;
15256
+ throw error51;
15257
+ }
14881
15258
  }
14882
15259
  return results;
14883
15260
  }
@@ -14892,6 +15269,12 @@ class UptimeService {
14892
15269
  return now.getTime() - last >= monitor.intervalSeconds * 1000;
14893
15270
  }
14894
15271
  }
15272
+ class MonitorCheckBusyError extends Error {
15273
+ constructor(message) {
15274
+ super(message);
15275
+ this.name = "MonitorCheckBusyError";
15276
+ }
15277
+ }
14895
15278
 
14896
15279
  // src/version.ts
14897
15280
  import { readFileSync } from "fs";
@@ -15003,6 +15386,39 @@ function createMcpServer(options = {}) {
15003
15386
  description: "Summarize monitor status, uptime percentages, latency, and open incidents.",
15004
15387
  inputSchema: {}
15005
15388
  }, async () => jsonResult(service.summary()));
15389
+ server.registerTool("uptime_send_report", {
15390
+ title: "Send an uptime report",
15391
+ description: "Build an uptime report and send it through Mailery email, Telephony SMS, and/or Open Logs structured logs.",
15392
+ inputSchema: {
15393
+ subject: exports_external.string().optional(),
15394
+ email: exports_external.object({
15395
+ apiUrl: exports_external.string().url().optional(),
15396
+ sendKey: exports_external.string().optional(),
15397
+ from: exports_external.string().optional(),
15398
+ to: exports_external.union([exports_external.string(), exports_external.array(exports_external.string())]).optional(),
15399
+ providerId: exports_external.string().optional()
15400
+ }).optional(),
15401
+ sms: exports_external.object({
15402
+ apiUrl: exports_external.string().url().optional(),
15403
+ from: exports_external.string().optional(),
15404
+ to: exports_external.union([exports_external.string(), exports_external.array(exports_external.string())]).optional()
15405
+ }).optional(),
15406
+ logs: exports_external.object({
15407
+ apiUrl: exports_external.string().url().optional(),
15408
+ apiKey: exports_external.string().optional(),
15409
+ projectId: exports_external.string().optional(),
15410
+ environment: exports_external.string().optional(),
15411
+ service: exports_external.string().optional()
15412
+ }).optional(),
15413
+ timeoutMs: exports_external.number().int().min(1000).max(60000).optional()
15414
+ }
15415
+ }, async (args) => jsonResult(await service.sendReport({
15416
+ subject: args.subject,
15417
+ email: args.email,
15418
+ sms: args.sms,
15419
+ logs: args.logs,
15420
+ timeoutMs: args.timeoutMs
15421
+ })));
15006
15422
  server.registerTool("uptime_results", {
15007
15423
  title: "List uptime check results",
15008
15424
  description: "List recent check results.",
@@ -0,0 +1,49 @@
1
+ import type { UptimeSummary } from "./types.js";
2
+ export interface BuildUptimeReportOptions {
3
+ subject?: string;
4
+ }
5
+ export interface UptimeReport {
6
+ subject: string;
7
+ generatedAt: string;
8
+ summary: UptimeSummary;
9
+ text: string;
10
+ html: string;
11
+ json: Record<string, unknown>;
12
+ }
13
+ export interface SendUptimeReportOptions extends BuildUptimeReportOptions {
14
+ email?: boolean | UptimeEmailReportTarget;
15
+ sms?: boolean | UptimeSmsReportTarget;
16
+ logs?: boolean | UptimeLogsReportTarget;
17
+ fetchImpl?: typeof fetch;
18
+ timeoutMs?: number;
19
+ }
20
+ export interface UptimeEmailReportTarget {
21
+ apiUrl?: string;
22
+ sendKey?: string;
23
+ from?: string;
24
+ to?: string | string[];
25
+ subject?: string;
26
+ providerId?: string;
27
+ }
28
+ export interface UptimeSmsReportTarget {
29
+ apiUrl?: string;
30
+ from?: string;
31
+ to?: string | string[];
32
+ }
33
+ export interface UptimeLogsReportTarget {
34
+ apiUrl?: string;
35
+ apiKey?: string;
36
+ projectId?: string;
37
+ environment?: string;
38
+ service?: string;
39
+ }
40
+ export interface UptimeReportDelivery {
41
+ channel: "email" | "sms" | "logs";
42
+ ok: boolean;
43
+ status?: number;
44
+ id?: string;
45
+ error?: string;
46
+ }
47
+ export declare function buildUptimeReport(summary: UptimeSummary, options?: BuildUptimeReportOptions): UptimeReport;
48
+ export declare function sendUptimeReport(summary: UptimeSummary, options?: SendUptimeReportOptions): Promise<UptimeReportDelivery[]>;
49
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,aAAa,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,uBAAwB,SAAQ,wBAAwB;IACvE,KAAK,CAAC,EAAE,OAAO,GAAG,uBAAuB,CAAC;IAC1C,GAAG,CAAC,EAAE,OAAO,GAAG,qBAAqB,CAAC;IACtC,IAAI,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IACxC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,GAAE,wBAA6B,GAAG,YAAY,CAyB9G;AAED,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,GAAE,uBAA4B,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAyBrI"}