@oneuptime/common 11.5.11 → 11.5.13

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 (142) hide show
  1. package/Models/AnalyticsModels/NetworkFlow.ts +12 -1
  2. package/Models/DatabaseModels/AIInsight.ts +30 -0
  3. package/Models/DatabaseModels/Project.ts +30 -0
  4. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  5. package/Server/API/AIAgentDataAPI.ts +129 -5
  6. package/Server/API/AIInvestigationAPI.ts +87 -0
  7. package/Server/API/StatusPageAPI.ts +14 -1
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/AIRunService.ts +8 -0
  11. package/Server/Services/IncidentService.ts +62 -67
  12. package/Server/Services/MonitorGroupService.ts +15 -2
  13. package/Server/Services/MonitorService.ts +91 -3
  14. package/Server/Services/MonitorStatusTimelineService.ts +285 -100
  15. package/Server/Services/StatusPageService.ts +59 -34
  16. package/Server/Services/TelemetryExceptionService.ts +320 -24
  17. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  18. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  19. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  20. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  21. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  22. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  23. package/Server/Utils/Monitor/MonitorResource.ts +43 -14
  24. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
  25. package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
  26. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  27. package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
  28. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  29. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  30. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  31. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  32. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  33. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
  34. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  35. package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
  36. package/Tests/Types/Database/GreaterThan.test.ts +5 -1
  37. package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
  38. package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
  39. package/Tests/Types/Database/LessThan.test.ts +5 -1
  40. package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
  41. package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
  42. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  43. package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
  44. package/Types/AI/CodeFixTaskContext.ts +6 -0
  45. package/Types/AI/CodeFixTaskType.ts +38 -1
  46. package/Types/AI/ExceptionAIClassification.ts +27 -0
  47. package/Types/BaseDatabase/GreaterThan.ts +8 -1
  48. package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
  49. package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
  50. package/Types/BaseDatabase/LessThan.ts +8 -1
  51. package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
  52. package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
  53. package/Types/Log/LogScrubPatternType.ts +7 -0
  54. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  55. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  56. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  57. package/Utils/StatusPage/ResourceUptime.ts +11 -1
  58. package/Utils/Uptime/UptimeUtil.ts +142 -36
  59. package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
  60. package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
  61. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  62. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  63. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  64. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  65. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  66. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  67. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  68. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  69. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  70. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  71. package/build/dist/Server/API/StatusPageAPI.js +12 -2
  72. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  77. package/build/dist/Server/Services/AIRunService.js +9 -2
  78. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  79. package/build/dist/Server/Services/IncidentService.js +47 -56
  80. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  81. package/build/dist/Server/Services/MonitorGroupService.js +15 -2
  82. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  83. package/build/dist/Server/Services/MonitorService.js +66 -3
  84. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  85. package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
  86. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  87. package/build/dist/Server/Services/StatusPageService.js +57 -33
  88. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  89. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  90. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  91. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  92. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  93. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  94. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  95. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  96. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  97. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  98. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  99. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  100. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  101. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  102. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  103. package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
  104. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  105. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
  106. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  107. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
  108. package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
  109. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  110. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  111. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  112. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  113. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  114. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  115. package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
  116. package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
  117. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
  118. package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
  119. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
  120. package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
  121. package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
  122. package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
  123. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
  124. package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
  125. package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
  126. package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
  127. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  128. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  129. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  130. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  131. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  132. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  133. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  134. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  135. package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
  136. package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
  137. package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
  138. package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
  139. package/package.json +1 -1
  140. package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +0 -1
@@ -0,0 +1,114 @@
1
+ import ObjectID from "../../../../Types/ObjectID";
2
+ import CodeFixTaskType from "../../../../Types/AI/CodeFixTaskType";
3
+ import CodeFixTaskContext from "../../../../Types/AI/CodeFixTaskContext";
4
+ import BadDataException from "../../../../Types/Exception/BadDataException";
5
+ import AIRun from "../../../../Models/DatabaseModels/AIRun";
6
+ import Service from "../../../../Models/DatabaseModels/Service";
7
+ import ServiceService from "../../../Services/ServiceService";
8
+ import SubjectCodeFixRun from "./SubjectCodeFixRun";
9
+ import CaptureSpan from "../../Telemetry/CaptureSpan";
10
+
11
+ /*
12
+ * AI SRE — the service-scoped instrumentation-improvement triggers:
13
+ * ImproveLogging and ImproveTracing. One click on a service's Logs or
14
+ * Traces page opens a draft PR that improves that service's logging or
15
+ * tracing hygiene — parameterized log messages, correct severities, spans
16
+ * on uninstrumented paths, proper exception recording — WITHOUT changing
17
+ * behavior.
18
+ *
19
+ * Like FixFromIncident and FixPerformance these are human-triggered from a
20
+ * user-facing endpoint (POST /ai-investigation/
21
+ * create-telemetry-improvement-task), so there is no project opt-in flag —
22
+ * the human in the loop IS the gate — and every unmet gate FAILS EARLY
23
+ * with a clear message. The recipe's grounding is the SERVICE: repository
24
+ * resolution at task-details time uses the service name (name-match /
25
+ * only-repository), the same path the incident-subject recipes use.
26
+ */
27
+ export default class TelemetryImprovementTaskTrigger {
28
+ /*
29
+ * Gate and enqueue an ImproveLogging / ImproveTracing CodeFix run for a
30
+ * telemetry service. The caller must already have loaded the service
31
+ * under the USER's permissions (the access check) — this method reads
32
+ * and writes as root.
33
+ *
34
+ * Throws BadDataException naming the failed gate: unsupported recipe, no
35
+ * GitHub-App repository, a duplicate active run for the same service, or
36
+ * (via enqueueSubjectCodeFixRun) the daily fix-run budget.
37
+ */
38
+ @CaptureSpan()
39
+ public static async createTelemetryImprovementTask(data: {
40
+ projectId: ObjectID;
41
+ telemetryServiceId: ObjectID;
42
+ taskType: CodeFixTaskType;
43
+ userId: ObjectID;
44
+ }): Promise<AIRun> {
45
+ if (
46
+ data.taskType !== CodeFixTaskType.ImproveLogging &&
47
+ data.taskType !== CodeFixTaskType.ImproveTracing
48
+ ) {
49
+ throw new BadDataException(
50
+ `Task type "${data.taskType}" is not a telemetry-improvement recipe. Supported: ${CodeFixTaskType.ImproveLogging}, ${CodeFixTaskType.ImproveTracing}.`,
51
+ );
52
+ }
53
+
54
+ const service: Service | null = await ServiceService.findOneById({
55
+ id: data.telemetryServiceId,
56
+ select: {
57
+ _id: true,
58
+ name: true,
59
+ projectId: true,
60
+ },
61
+ props: { isRoot: true },
62
+ });
63
+
64
+ if (
65
+ !service ||
66
+ service.projectId?.toString() !== data.projectId.toString()
67
+ ) {
68
+ throw new BadDataException("Telemetry service not found.");
69
+ }
70
+
71
+ // Gate — a repository the agent can actually open a PR against.
72
+ const hasConnectedRepository: boolean =
73
+ await SubjectCodeFixRun.hasGitHubAppConnectedRepository(data.projectId);
74
+
75
+ if (!hasConnectedRepository) {
76
+ throw new BadDataException(
77
+ "No GitHub-App-connected repository exists for this project, so the agent has nowhere to open the pull request. Connect one under AI > Code Repositories.",
78
+ );
79
+ }
80
+
81
+ /*
82
+ * Gate — per-(service, recipe) dedupe: repeated clicks must not fan
83
+ * out into duplicate PRs.
84
+ */
85
+ const existingRun: AIRun | null =
86
+ await SubjectCodeFixRun.findNonTerminalRunForTelemetryService({
87
+ projectId: data.projectId,
88
+ taskType: data.taskType,
89
+ telemetryServiceId: data.telemetryServiceId.toString(),
90
+ });
91
+
92
+ if (existingRun) {
93
+ throw new BadDataException(
94
+ `An ${
95
+ data.taskType === CodeFixTaskType.ImproveLogging
96
+ ? "improve-logging"
97
+ : "improve-tracing"
98
+ } task is already queued or running for this service. Track its progress on the AI > Tasks page.`,
99
+ );
100
+ }
101
+
102
+ const taskContext: CodeFixTaskContext = {
103
+ telemetryServiceId: data.telemetryServiceId.toString(),
104
+ serviceName: service.name || undefined,
105
+ };
106
+
107
+ return SubjectCodeFixRun.enqueueSubjectCodeFixRun({
108
+ projectId: data.projectId,
109
+ taskType: data.taskType,
110
+ userId: data.userId,
111
+ taskContext,
112
+ });
113
+ }
114
+ }
@@ -1,6 +1,10 @@
1
1
  import MonitorProbeService from "../../Services/MonitorProbeService";
2
2
  import MonitorService from "../../Services/MonitorService";
3
- import MonitorStatusTimelineService from "../../Services/MonitorStatusTimelineService";
3
+ import MonitorStatusTimelineService, {
4
+ MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE,
5
+ MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE,
6
+ } from "../../Services/MonitorStatusTimelineService";
7
+ import ServerException from "../../../Types/Exception/ServerException";
4
8
  import logger from "../Logger";
5
9
  import MonitorCriteriaEvaluator from "./MonitorCriteriaEvaluator";
6
10
  import MonitorLogUtil from "./MonitorLogUtil";
@@ -1013,6 +1017,15 @@ export default class MonitorResourceUtil {
1013
1017
  monitorStatusTimeline.rootCause =
1014
1018
  "No monitoring criteria met. Change to default status. ";
1015
1019
 
1020
+ /*
1021
+ * Tracks whether the monitor really is at its default status after the
1022
+ * create call, so the summary event below records only what actually
1023
+ * happened. On the lock-skip path the write was REFUSED - recording a
1024
+ * "reverted" event there would put a false entry in the monitor log and
1025
+ * mask the very lock failure the error log surfaces.
1026
+ */
1027
+ let revertedToDefaultStatus: boolean = true;
1028
+
1016
1029
  try {
1017
1030
  await MonitorStatusTimelineService.create({
1018
1031
  data: monitorStatusTimeline,
@@ -1033,29 +1046,45 @@ export default class MonitorResourceUtil {
1033
1046
  */
1034
1047
  if (
1035
1048
  err instanceof BadDataException &&
1036
- err.message ===
1037
- "Monitor Status cannot be same as previous status."
1049
+ err.message === MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE
1038
1050
  ) {
1039
1051
  logger.debug(
1040
1052
  `${dataToProcess.monitorId.toString()} - Monitor status already at default; skipping duplicate status timeline (concurrent race).`,
1041
1053
  );
1054
+ } else if (
1055
+ err instanceof ServerException &&
1056
+ err.message === MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE
1057
+ ) {
1058
+ /*
1059
+ * The per-monitor timeline mutex could not be acquired (fail-closed
1060
+ * create, see MonitorStatusTimelineService). Skipping is recoverable:
1061
+ * the next probe result re-evaluates the same criteria and recreates
1062
+ * the revert-to-default, while failing here would abort the whole
1063
+ * ingest run and lose the monitor log for this probe result.
1064
+ */
1065
+ logger.error(
1066
+ `${dataToProcess.monitorId.toString()} - Could not acquire the monitor status timeline lock; skipping revert to default status. It will be retried on the next probe result.`,
1067
+ );
1068
+ revertedToDefaultStatus = false;
1042
1069
  } else {
1043
1070
  throw err;
1044
1071
  }
1045
1072
  }
1046
1073
 
1047
- const defaultStatusName: string | null = await getMonitorStatusName(
1048
- monitorSteps.data.defaultMonitorStatusId,
1049
- );
1074
+ if (revertedToDefaultStatus) {
1075
+ const defaultStatusName: string | null = await getMonitorStatusName(
1076
+ monitorSteps.data.defaultMonitorStatusId,
1077
+ );
1050
1078
 
1051
- evaluationSummary.events.push({
1052
- type: "monitor-status-changed",
1053
- title: "Monitor status reverted",
1054
- message: defaultStatusName
1055
- ? `Monitor status reverted to "${defaultStatusName}" because no monitoring criteria were met.`
1056
- : "Monitor status reverted to its default state because no monitoring criteria were met.",
1057
- at: OneUptimeDate.getCurrentDate(),
1058
- });
1079
+ evaluationSummary.events.push({
1080
+ type: "monitor-status-changed",
1081
+ title: "Monitor status reverted",
1082
+ message: defaultStatusName
1083
+ ? `Monitor status reverted to "${defaultStatusName}" because no monitoring criteria were met.`
1084
+ : "Monitor status reverted to its default state because no monitoring criteria were met.",
1085
+ at: OneUptimeDate.getCurrentDate(),
1086
+ });
1087
+ }
1059
1088
  }
1060
1089
  }
1061
1090
 
@@ -5,7 +5,11 @@ import BadDataException from "../../../Types/Exception/BadDataException";
5
5
  import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
6
6
  import ObjectID from "../../../Types/ObjectID";
7
7
  import { TelemetryQuery } from "../../../Types/Telemetry/TelemetryQuery";
8
- import MonitorStatusTimelineService from "../../Services/MonitorStatusTimelineService";
8
+ import MonitorStatusTimelineService, {
9
+ MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE,
10
+ MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE,
11
+ } from "../../Services/MonitorStatusTimelineService";
12
+ import ServerException from "../../../Types/Exception/ServerException";
9
13
  import logger, { LogAttributes } from "../Logger";
10
14
  import CaptureSpan from "../Telemetry/CaptureSpan";
11
15
  import DataToProcess from "./DataToProcess";
@@ -124,19 +128,19 @@ export default class MonitorStatusTimelineUtil {
124
128
  } catch (err) {
125
129
  /*
126
130
  * Concurrency race: two probe/ingest results for the same monitor can be
127
- * processed near-simultaneously (the per-monitor mutexes in
128
- * MonitorResourceUtil.monitorResource and MonitorStatusTimeline.create can
129
- * time out under load and fall through unlocked). Both see the same prior
130
- * status and both try to write the same new status row. The
131
+ * processed near-simultaneously and both see the same prior status, so both
132
+ * try to write the same new status row. The
131
133
  * MonitorStatusTimelineService.onBeforeCreate dedupe check then throws this
132
134
  * exact BadDataException for the loser of the race. This is an idempotent
133
135
  * no-op (the desired status is already the current status), so swallow it at
134
- * debug level instead of failing the job and logging a full ERROR stack.
136
+ * debug level instead of failing the job and logging a full ERROR stack. The
137
+ * race itself is now logged at warn by onBeforeCreate, which is the
138
+ * authoritative telemetry for it - this log only records that we skipped.
135
139
  * Match the exact message so unrelated BadDataExceptions still propagate.
136
140
  */
137
141
  if (
138
142
  err instanceof BadDataException &&
139
- err.message === "Monitor Status cannot be same as previous status."
143
+ err.message === MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE
140
144
  ) {
141
145
  logger.debug(
142
146
  `${input.monitor.id?.toString()} - Monitor status already equals desired status; skipping duplicate status timeline (concurrent race).`,
@@ -144,6 +148,27 @@ export default class MonitorStatusTimelineUtil {
144
148
  return null;
145
149
  }
146
150
 
151
+ /*
152
+ * The per-monitor mutex in MonitorStatusTimelineService.create() is
153
+ * fail-closed: if Redis is unavailable or the lock cannot be acquired within
154
+ * the acquire timeout, the create is refused rather than performed unlocked.
155
+ * Writing unlocked is what produced permanently orphaned (endsAt = NULL)
156
+ * timeline rows, which read back as unbounded downtime on status pages and
157
+ * uptime reports - far worse than a missed status transition. Skipping is
158
+ * recoverable: the next probe result for this monitor evaluates the same
159
+ * criteria and creates the same status change. So log and skip rather than
160
+ * failing the whole probe ingest run for this monitor.
161
+ */
162
+ if (
163
+ err instanceof ServerException &&
164
+ err.message === MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE
165
+ ) {
166
+ logger.error(
167
+ `${input.monitor.id?.toString()} - Could not acquire the monitor status timeline lock; skipping this status change. It will be retried on the next probe result.`,
168
+ );
169
+ return null;
170
+ }
171
+
147
172
  throw err;
148
173
  }
149
174
  }
@@ -0,0 +1,362 @@
1
+ import PostgresAppInstance, {
2
+ DatabaseSource,
3
+ } from "../../Infrastructure/PostgresDatabase";
4
+ import DatabaseNotConnectedException from "../../../Types/Exception/DatabaseNotConnectedException";
5
+ import ObjectID from "../../../Types/ObjectID";
6
+ import logger from "../Logger";
7
+ import CaptureSpan from "../Telemetry/CaptureSpan";
8
+
9
+ /*
10
+ * A "stale open row" is a MonitorStatusTimeline row with endsAt IS NULL for which a row
11
+ * with a strictly later startsAt exists on the same monitor.
12
+ *
13
+ * These are produced by a write-path race: two probe results for the same monitor can be
14
+ * processed near-simultaneously, both resolve the same predecessor row, and both INSERT a
15
+ * new status row milliseconds apart. Only the later one is ever closed, because every
16
+ * subsequent writer resolves its predecessor with ORDER BY startsAt DESC LIMIT 1 and so
17
+ * permanently shadows the earlier one. The earlier row keeps endsAt = NULL forever.
18
+ *
19
+ * An open row with no successor is the monitor's legitimate current-state row and MUST be
20
+ * left open. Only rows that have been superseded are repaired here.
21
+ *
22
+ * The repaired endsAt is the MIN startsAt among strictly later rows on the same monitor -
23
+ * i.e. the moment the row was actually superseded. It is deliberately NOT "now" and NOT the
24
+ * startsAt of the newest row: closing an orphan at the newest row's startsAt would convert a
25
+ * near-zero-duration orphan into a multi-month closed downtime row, which is unrecoverable
26
+ * and strictly worse than leaving it open.
27
+ */
28
+
29
+ export interface RepairStaleOpenRowsOptions {
30
+ // Maximum number of open rows examined per monitor per round trip.
31
+ batchSize?: number | undefined;
32
+ // When set, only this monitor is reconciled. When unset, every affected monitor is.
33
+ monitorId?: ObjectID | undefined;
34
+ }
35
+
36
+ export interface RepairStaleOpenRowsResult {
37
+ // Open rows examined across every batch of this run.
38
+ scanned: number;
39
+ // Open rows that were given an endsAt by this run.
40
+ repaired: number;
41
+ // Monitors that had at least one stale open row when the run started.
42
+ monitorsWithStaleRows: number;
43
+ // Monitors that had at least one row repaired. Callers may want to refresh their current status.
44
+ repairedMonitorIds: Array<ObjectID>;
45
+ // Monitors whose repair threw. They are skipped, not retried, so one failure cannot abort a run.
46
+ failedMonitorIds: Array<ObjectID>;
47
+ }
48
+
49
+ interface RepairBatchResult {
50
+ scanned: number;
51
+ repaired: number;
52
+ }
53
+
54
+ /*
55
+ * Bounded so a single run never loads a pathological monitor's whole backlog into memory.
56
+ * Production currently has ~67.5k stale rows, ~56k of them on ONE monitor.
57
+ */
58
+ export const DEFAULT_STALE_OPEN_ROW_BATCH_SIZE: number = 1000;
59
+
60
+ const MIN_BATCH_SIZE: number = 1;
61
+ const MAX_BATCH_SIZE: number = 10000;
62
+
63
+ /*
64
+ * Pure runaway guard. Every non-terminal round repairs exactly batchSize rows, so the loop
65
+ * is already guaranteed to terminate; this only bounds a run if that invariant is ever broken.
66
+ */
67
+ const MAX_ROUNDS: number = 100000;
68
+
69
+ export default class MonitorStatusTimelineReconciler {
70
+ /*
71
+ * Finds and repairs stale open rows. Idempotent: a second run over the same data repairs
72
+ * nothing, because a repaired row no longer matches endsAt IS NULL.
73
+ *
74
+ * Monitors are processed round-robin, one batch each per round, so a monitor with tens of
75
+ * thousands of stale rows cannot starve the rest of the fleet.
76
+ */
77
+ @CaptureSpan()
78
+ public static async repairStaleOpenRows(
79
+ options: RepairStaleOpenRowsOptions = {},
80
+ ): Promise<RepairStaleOpenRowsResult> {
81
+ const batchSize: number = this.getBatchSize(options.batchSize);
82
+
83
+ const monitorIds: Array<ObjectID> = options.monitorId
84
+ ? [options.monitorId]
85
+ : await this.getMonitorIdsWithStaleOpenRows();
86
+
87
+ const result: RepairStaleOpenRowsResult = {
88
+ scanned: 0,
89
+ repaired: 0,
90
+ monitorsWithStaleRows: monitorIds.length,
91
+ repairedMonitorIds: [],
92
+ failedMonitorIds: [],
93
+ };
94
+
95
+ if (monitorIds.length === 0) {
96
+ logger.debug(
97
+ "MonitorStatusTimelineReconciler: no monitors have stale open status timeline rows.",
98
+ );
99
+ return result;
100
+ }
101
+
102
+ logger.debug(
103
+ `MonitorStatusTimelineReconciler: ${monitorIds.length} monitor(s) have stale open status timeline rows. Batch size ${batchSize}.`,
104
+ );
105
+
106
+ const repairedMonitorIds: Set<string> = new Set<string>();
107
+ const failedMonitorIds: Set<string> = new Set<string>();
108
+
109
+ let pendingMonitorIds: Array<ObjectID> = monitorIds;
110
+ let round: number = 0;
111
+
112
+ while (pendingMonitorIds.length > 0) {
113
+ round++;
114
+
115
+ if (round > MAX_ROUNDS) {
116
+ logger.error(
117
+ `MonitorStatusTimelineReconciler: aborting after ${MAX_ROUNDS} rounds with ${pendingMonitorIds.length} monitor(s) still pending. This should be unreachable.`,
118
+ );
119
+ break;
120
+ }
121
+
122
+ const stillPendingMonitorIds: Array<ObjectID> = [];
123
+
124
+ for (const monitorId of pendingMonitorIds) {
125
+ /*
126
+ * Per-monitor try/catch, matching the sibling KeepCurrentStateConsistent jobs: one bad
127
+ * monitor is skipped for the remainder of the run instead of aborting it. It is not
128
+ * re-queued, so a monitor that fails deterministically cannot spin forever.
129
+ */
130
+ try {
131
+ const batch: RepairBatchResult = await this.repairStaleOpenRowsBatch({
132
+ monitorId: monitorId,
133
+ batchSize: batchSize,
134
+ });
135
+
136
+ result.scanned += batch.scanned;
137
+ result.repaired += batch.repaired;
138
+
139
+ if (batch.repaired > 0) {
140
+ repairedMonitorIds.add(monitorId.toString());
141
+ }
142
+
143
+ /*
144
+ * A full batch of repairs means there may be more stale rows on this monitor. Anything
145
+ * short of a full batch means every open row on this monitor has now been examined -
146
+ * whatever is still open has no strictly later row and is therefore legitimate.
147
+ */
148
+ if (batch.repaired >= batchSize) {
149
+ stillPendingMonitorIds.push(monitorId);
150
+ }
151
+ } catch (err) {
152
+ failedMonitorIds.add(monitorId.toString());
153
+ logger.error(
154
+ `MonitorStatusTimelineReconciler: failed to repair stale open rows for monitor ${monitorId.toString()}.`,
155
+ );
156
+ logger.error(err);
157
+ continue;
158
+ }
159
+ }
160
+
161
+ pendingMonitorIds = stillPendingMonitorIds;
162
+ }
163
+
164
+ result.repairedMonitorIds = Array.from(repairedMonitorIds).map(
165
+ (id: string) => {
166
+ return new ObjectID(id);
167
+ },
168
+ );
169
+
170
+ result.failedMonitorIds = Array.from(failedMonitorIds).map((id: string) => {
171
+ return new ObjectID(id);
172
+ });
173
+
174
+ return result;
175
+ }
176
+
177
+ /*
178
+ * Counts stale open rows without repairing anything. Useful for telemetry and for asserting
179
+ * that a repair run actually converged.
180
+ */
181
+ @CaptureSpan()
182
+ public static async countStaleOpenRows(
183
+ options: { monitorId?: ObjectID | undefined } = {},
184
+ ): Promise<number> {
185
+ const dataSource: DatabaseSource = this.getDataSource();
186
+
187
+ const parameters: Array<string> = [];
188
+ let monitorFilter: string = "";
189
+
190
+ if (options.monitorId) {
191
+ monitorFilter = `AND t."monitorId" = $1::uuid`;
192
+ parameters.push(options.monitorId.toString());
193
+ }
194
+
195
+ const sql: string = `
196
+ SELECT COUNT(*)::int AS "staleCount"
197
+ FROM "MonitorStatusTimeline" t
198
+ WHERE t."endsAt" IS NULL
199
+ AND t."deletedAt" IS NULL
200
+ AND t."startsAt" IS NOT NULL
201
+ ${monitorFilter}
202
+ AND EXISTS (
203
+ SELECT 1
204
+ FROM "MonitorStatusTimeline" n
205
+ WHERE n."monitorId" = t."monitorId"
206
+ AND n."deletedAt" IS NULL
207
+ AND n."startsAt" > t."startsAt"
208
+ )
209
+ `;
210
+
211
+ const rows: Array<{ staleCount: number }> = await dataSource.query(
212
+ sql,
213
+ parameters,
214
+ );
215
+
216
+ return rows[0]?.staleCount || 0;
217
+ }
218
+
219
+ /*
220
+ * The set of monitors that currently have at least one stale open row.
221
+ *
222
+ * Note this is deliberately NOT "monitors with more than one open row". A monitor can have
223
+ * exactly one open row that is still stale, if the newest row on the monitor happens to be
224
+ * closed. Encoding the definition directly keeps both cases covered.
225
+ */
226
+ private static async getMonitorIdsWithStaleOpenRows(): Promise<
227
+ Array<ObjectID>
228
+ > {
229
+ const dataSource: DatabaseSource = this.getDataSource();
230
+
231
+ const sql: string = `
232
+ SELECT DISTINCT t."monitorId" AS "monitorId"
233
+ FROM "MonitorStatusTimeline" t
234
+ WHERE t."endsAt" IS NULL
235
+ AND t."deletedAt" IS NULL
236
+ AND t."startsAt" IS NOT NULL
237
+ AND EXISTS (
238
+ SELECT 1
239
+ FROM "MonitorStatusTimeline" n
240
+ WHERE n."monitorId" = t."monitorId"
241
+ AND n."deletedAt" IS NULL
242
+ AND n."startsAt" > t."startsAt"
243
+ )
244
+ ORDER BY t."monitorId" ASC
245
+ `;
246
+
247
+ const rows: Array<{ monitorId: string }> = await dataSource.query(sql);
248
+
249
+ return rows.map((row: { monitorId: string }) => {
250
+ return new ObjectID(row.monitorId);
251
+ });
252
+ }
253
+
254
+ /*
255
+ * Repairs up to batchSize stale open rows on a single monitor, oldest first.
256
+ *
257
+ * Ordering is by startsAt, NEVER createdAt. createdAt is assigned DB-side by now() while
258
+ * startsAt is assigned by the worker pod's clock, so the two orderings genuinely disagree on
259
+ * real data. Two earlier migrations ordered by createdAt and produced wrong results.
260
+ */
261
+ private static async repairStaleOpenRowsBatch(data: {
262
+ monitorId: ObjectID;
263
+ batchSize: number;
264
+ }): Promise<RepairBatchResult> {
265
+ const dataSource: DatabaseSource = this.getDataSource();
266
+
267
+ /*
268
+ * candidates is materialised once (a data-modifying CTE forces materialisation), so the
269
+ * counts below and the UPDATE all see the same snapshot and one round trip yields both the
270
+ * scanned and the repaired count.
271
+ *
272
+ * nextStartsAt is NULL for an open row with no strictly later row. Those rows are the
273
+ * legitimate current-state row (or a startsAt tie at the head of the timeline) and are
274
+ * excluded from the UPDATE, so they stay open.
275
+ *
276
+ * The UPDATE re-checks m."endsAt" IS NULL even though candidates already filtered on it:
277
+ * the reconciler runs WITHOUT the per-monitor mutex, and under READ COMMITTED a live
278
+ * writer can close a candidate row between this statement's snapshot and the UPDATE
279
+ * taking the row lock. Postgres then re-evaluates only the UPDATE's own WHERE against
280
+ * the new row version (EvalPlanQual), so the openness check must live in the UPDATE
281
+ * itself - otherwise the reconciler would overwrite the endsAt the writer just committed
282
+ * with its stale snapshot-time value.
283
+ */
284
+ const sql: string = `
285
+ WITH candidates AS (
286
+ SELECT
287
+ t."_id" AS "_id",
288
+ (
289
+ SELECT MIN(n."startsAt")
290
+ FROM "MonitorStatusTimeline" n
291
+ WHERE n."monitorId" = t."monitorId"
292
+ AND n."deletedAt" IS NULL
293
+ AND n."startsAt" > t."startsAt"
294
+ ) AS "nextStartsAt"
295
+ FROM "MonitorStatusTimeline" t
296
+ WHERE t."monitorId" = $1::uuid
297
+ AND t."endsAt" IS NULL
298
+ AND t."deletedAt" IS NULL
299
+ AND t."startsAt" IS NOT NULL
300
+ ORDER BY t."startsAt" ASC
301
+ LIMIT $2
302
+ ),
303
+ repaired AS (
304
+ UPDATE "MonitorStatusTimeline" m
305
+ SET "endsAt" = c."nextStartsAt",
306
+ "updatedAt" = NOW(),
307
+ "version" = m."version" + 1
308
+ FROM candidates c
309
+ WHERE m."_id" = c."_id"
310
+ AND m."endsAt" IS NULL
311
+ AND c."nextStartsAt" IS NOT NULL
312
+ RETURNING m."_id"
313
+ )
314
+ SELECT
315
+ (SELECT COUNT(*) FROM candidates)::int AS "scanned",
316
+ (SELECT COUNT(*) FROM repaired)::int AS "repaired"
317
+ `;
318
+
319
+ const rows: Array<{ scanned: number; repaired: number }> =
320
+ await dataSource.query(sql, [
321
+ data.monitorId.toString(),
322
+ data.batchSize.toString(),
323
+ ]);
324
+
325
+ const scanned: number = rows[0]?.scanned || 0;
326
+ const repaired: number = rows[0]?.repaired || 0;
327
+
328
+ if (repaired > 0) {
329
+ logger.debug(
330
+ `MonitorStatusTimelineReconciler: repaired ${repaired} of ${scanned} open row(s) examined on monitor ${data.monitorId.toString()}.`,
331
+ );
332
+ }
333
+
334
+ return {
335
+ scanned: scanned,
336
+ repaired: repaired,
337
+ };
338
+ }
339
+
340
+ private static getBatchSize(batchSize?: number | undefined): number {
341
+ if (
342
+ !batchSize ||
343
+ !Number.isFinite(batchSize) ||
344
+ batchSize < MIN_BATCH_SIZE
345
+ ) {
346
+ return DEFAULT_STALE_OPEN_ROW_BATCH_SIZE;
347
+ }
348
+
349
+ return Math.min(Math.floor(batchSize), MAX_BATCH_SIZE);
350
+ }
351
+
352
+ private static getDataSource(): DatabaseSource {
353
+ const dataSource: DatabaseSource | null =
354
+ PostgresAppInstance.getDataSource();
355
+
356
+ if (!dataSource) {
357
+ throw new DatabaseNotConnectedException();
358
+ }
359
+
360
+ return dataSource;
361
+ }
362
+ }