@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
@@ -28,6 +28,12 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
28
28
  import Query from "../Types/Database/Query";
29
29
  import Includes from "../../Types/BaseDatabase/Includes";
30
30
  import logger from "../Utils/Logger";
31
+ import AIAgentTaskPullRequest from "../../Models/DatabaseModels/AIAgentTaskPullRequest";
32
+ import AIAgentTaskPullRequestService from "./AIAgentTaskPullRequestService";
33
+ import PullRequestState from "../../Types/CodeRepository/PullRequestState";
34
+ import { normalizeExceptionText } from "../Utils/Telemetry/ExceptionSanitizer";
35
+ import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
36
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
31
37
 
32
38
  /*
33
39
  * Hard cap on the fingerprint NOT IN list handed to the ClickHouse count
@@ -221,6 +227,11 @@ export class Service extends DatabaseService<Model> {
221
227
  select: {
222
228
  _id: true,
223
229
  projectId: true,
230
+ message: true,
231
+ exceptionType: true,
232
+ isResolved: true,
233
+ isArchived: true,
234
+ aiFixDeclinedAt: true,
224
235
  },
225
236
  props,
226
237
  });
@@ -229,6 +240,45 @@ export class Service extends DatabaseService<Model> {
229
240
  throw new BadDataException("Telemetry Exception not found");
230
241
  }
231
242
 
243
+ /*
244
+ * Server-side lifecycle gate (the dashboard hides the button for
245
+ * resolved exceptions, but the API must enforce it — the automatic
246
+ * insight lane and any direct API caller land here too).
247
+ */
248
+ if (telemetryException.isResolved) {
249
+ throw new BadDataException(
250
+ "This exception is marked as resolved. Unresolve it before starting an AI agent task for it.",
251
+ );
252
+ }
253
+
254
+ if (telemetryException.isArchived) {
255
+ throw new BadDataException(
256
+ "This exception is archived. Unarchive it before starting an AI agent task for it.",
257
+ );
258
+ }
259
+
260
+ /*
261
+ * Human "closed without merging" feedback: when an AI FIX PR for this
262
+ * exception was declined, the automatic lane must not keep re-opening
263
+ * fix PRs for it. Scoped to the FixException recipe — the stamp is only
264
+ * ever SET for closed fix PRs (SyncPullRequestStates), so a regression
265
+ * test or error-handling task is neither blocked by it nor allowed to
266
+ * clear it. A HUMAN clicking "Fix with AI" is the explicit override;
267
+ * the stamp is cleared only AFTER the retry run is actually created
268
+ * (see below), so a retry that fails a later gate leaves the human's
269
+ * decline in force.
270
+ */
271
+ const isDeclinedFixOverride: boolean = Boolean(
272
+ telemetryException.aiFixDeclinedAt &&
273
+ taskType === CodeFixTaskType.FixException,
274
+ );
275
+
276
+ if (isDeclinedFixOverride && !props.userId) {
277
+ throw new BadDataException(
278
+ "A previous AI fix pull request for this exception was closed without merging, so automatic fix attempts are paused for it. A user can retry from the exception page.",
279
+ );
280
+ }
281
+
232
282
  /*
233
283
  * G11 guardrail: the per-project daily fix-run budget. Checked before
234
284
  * the (more expensive) readiness probes — an over-budget project gets
@@ -261,38 +311,107 @@ export class Service extends DatabaseService<Model> {
261
311
  }
262
312
 
263
313
  /*
264
- * Duplicate guard is per (exception, taskType): an active FixException
265
- * run must not block queuing a WriteRegressionTest run and vice versa.
314
+ * The duplicate guards below are check-then-create: without
315
+ * serialization, two triage completions for splintered variants of the
316
+ * same root cause can both pass the guards and both create runs. A
317
+ * per-project Redis mutex closes the window; acquisition failure
318
+ * (Redis down) degrades to the unserialized behavior rather than
319
+ * blocking fix creation — the guards still run, they just race.
266
320
  */
267
- await this.validateNoActiveCodeFixRunExists(telemetryExceptionId, taskType);
268
-
269
- const run: AIRun = new AIRun();
270
- run.projectId = telemetryException.projectId;
271
- run.runType = AIRunType.CodeFix;
272
- run.codeFixTaskType = taskType;
273
- run.status = AIRunStatus.Queued;
274
- run.triggeredByTelemetryExceptionId = telemetryExceptionId;
275
-
276
- // Attribution: the user who clicked "Fix with AI Agent".
277
- if (props.userId) {
278
- run.userId = props.userId;
321
+ let mutex: SemaphoreMutex | null = null;
322
+
323
+ try {
324
+ mutex = await Semaphore.lock({
325
+ key: telemetryException.projectId.toString(),
326
+ namespace: "TelemetryExceptionService.createCodeFixRunForException",
327
+ lockTimeout: 15000,
328
+ });
329
+ } catch (err) {
330
+ logger.debug(
331
+ `Could not acquire the code-fix creation mutex for project ${telemetryException.projectId.toString()} — proceeding without serialization: ${err}`,
332
+ );
279
333
  }
280
334
 
281
- /*
282
- * Created as root: AIRun rows are server-written only (empty create
283
- * ACL); the user's access was already checked by the exception read.
284
- */
285
- const createdRun: AIRun = await AIRunService.create({
286
- data: run,
287
- props: {
288
- isRoot: true,
289
- },
290
- });
335
+ let createdRun: AIRun;
336
+
337
+ try {
338
+ /*
339
+ * Duplicate guard is per (exception, taskType): an active
340
+ * FixException run must not block queuing a WriteRegressionTest run
341
+ * and vice versa.
342
+ */
343
+ await this.validateNoActiveCodeFixRunExists(
344
+ telemetryExceptionId,
345
+ taskType,
346
+ );
347
+
348
+ /*
349
+ * Cross-group duplicate guard: interpolated dynamic values (garbage
350
+ * UUIDs, file paths, request payloads) splinter one root cause into
351
+ * many fingerprints, and the per-exception guard above cannot see
352
+ * that. Compare NORMALIZED messages against exceptions that already
353
+ * have an active run or an open AI pull request, so one root cause
354
+ * yields one PR — not one per interpolated variant.
355
+ */
356
+ await this.validateNoOpenFixForSimilarException({
357
+ projectId: telemetryException.projectId,
358
+ telemetryExceptionId: telemetryExceptionId,
359
+ taskType: taskType,
360
+ message: telemetryException.message || "",
361
+ exceptionType: telemetryException.exceptionType || "",
362
+ });
363
+
364
+ const run: AIRun = new AIRun();
365
+ run.projectId = telemetryException.projectId;
366
+ run.runType = AIRunType.CodeFix;
367
+ run.codeFixTaskType = taskType;
368
+ run.status = AIRunStatus.Queued;
369
+ run.triggeredByTelemetryExceptionId = telemetryExceptionId;
370
+
371
+ // Attribution: the user who clicked "Fix with AI Agent".
372
+ if (props.userId) {
373
+ run.userId = props.userId;
374
+ }
375
+
376
+ /*
377
+ * Created as root: AIRun rows are server-written only (empty create
378
+ * ACL); the user's access was already checked by the exception read.
379
+ */
380
+ createdRun = await AIRunService.create({
381
+ data: run,
382
+ props: {
383
+ isRoot: true,
384
+ },
385
+ });
386
+ } finally {
387
+ if (mutex) {
388
+ try {
389
+ await Semaphore.release(mutex);
390
+ } catch (err) {
391
+ logger.debug(`Failed to release the code-fix creation mutex: ${err}`);
392
+ }
393
+ }
394
+ }
291
395
 
292
396
  if (!createdRun.id) {
293
397
  throw new BadDataException("Failed to create the AI fix run");
294
398
  }
295
399
 
400
+ /*
401
+ * The human's retry actually went through — only now consume the
402
+ * decline override. A retry that failed any gate above leaves the
403
+ * suppression in force.
404
+ */
405
+ if (isDeclinedFixOverride) {
406
+ await this.updateOneById({
407
+ id: telemetryExceptionId,
408
+ data: {
409
+ aiFixDeclinedAt: null,
410
+ },
411
+ props: { isRoot: true },
412
+ });
413
+ }
414
+
296
415
  return createdRun;
297
416
  }
298
417
 
@@ -338,6 +457,183 @@ export class Service extends DatabaseService<Model> {
338
457
  }
339
458
  }
340
459
 
460
+ /*
461
+ * Cross-group duplicate guard. Two exception groups are "the same work"
462
+ * for the fix lane when their exceptionType and NORMALIZED message match
463
+ * (normalizeExceptionText replaces UUIDs, IDs, emails, paths' dynamic
464
+ * segments, timestamps...). Candidates are the exceptions behind
465
+ * (a) non-terminal CodeFix runs of the same recipe, and (b) OPEN
466
+ * AI-authored pull requests — a completed run whose PR is still open
467
+ * must keep blocking, or every interpolated variant re-fixes the same
468
+ * root cause (observed: 15 near-identical PRs for one invalid-uuid
469
+ * throw site).
470
+ */
471
+ @CaptureSpan()
472
+ private async validateNoOpenFixForSimilarException(data: {
473
+ projectId: ObjectID;
474
+ telemetryExceptionId: ObjectID;
475
+ taskType: CodeFixTaskType;
476
+ message: string;
477
+ exceptionType: string;
478
+ }): Promise<void> {
479
+ // (a) Exceptions with a non-terminal run of the same recipe.
480
+ const activeRuns: Array<AIRun> = await AIRunService.findBy({
481
+ query: {
482
+ projectId: data.projectId,
483
+ runType: AIRunType.CodeFix,
484
+ /*
485
+ * A null codeFixTaskType means FixException (rows created before
486
+ * task recipes existed) — same legacy-null matching as
487
+ * validateNoActiveCodeFixRunExists.
488
+ */
489
+ codeFixTaskType:
490
+ data.taskType === CodeFixTaskType.FixException
491
+ ? QueryHelper.equalToOrNull(CodeFixTaskType.FixException)
492
+ : data.taskType,
493
+ status: QueryHelper.notIn([
494
+ AIRunStatus.Completed,
495
+ AIRunStatus.NoFixFound,
496
+ AIRunStatus.Error,
497
+ AIRunStatus.Cancelled,
498
+ AIRunStatus.Stale,
499
+ ]),
500
+ },
501
+ select: {
502
+ triggeredByTelemetryExceptionId: true,
503
+ },
504
+ limit: LIMIT_MAX,
505
+ skip: 0,
506
+ props: { isRoot: true },
507
+ });
508
+
509
+ // (b) Exceptions behind still-open AI pull requests.
510
+ const openPullRequests: Array<AIAgentTaskPullRequest> =
511
+ await AIAgentTaskPullRequestService.findBy({
512
+ query: {
513
+ projectId: data.projectId,
514
+ pullRequestState: PullRequestState.Open,
515
+ },
516
+ select: {
517
+ aiRunId: true,
518
+ },
519
+ limit: LIMIT_MAX,
520
+ skip: 0,
521
+ props: { isRoot: true },
522
+ });
523
+
524
+ /*
525
+ * A guard that silently stops guarding is worse than none — surface
526
+ * candidate-scan truncation instead of quietly missing duplicates.
527
+ */
528
+ if (
529
+ activeRuns.length >= LIMIT_MAX ||
530
+ openPullRequests.length >= LIMIT_MAX
531
+ ) {
532
+ logger.warn(
533
+ `Cross-group AI fix dedupe guard: candidate scan truncated at ${LIMIT_MAX} rows for project ${data.projectId.toString()} — the duplicate-PR guard may miss matches.`,
534
+ );
535
+ }
536
+
537
+ const openPrRunIds: Array<ObjectID> = openPullRequests
538
+ .map((pullRequest: AIAgentTaskPullRequest) => {
539
+ return pullRequest.aiRunId;
540
+ })
541
+ .filter((runId: ObjectID | undefined): runId is ObjectID => {
542
+ return Boolean(runId);
543
+ });
544
+
545
+ const openPrRuns: Array<AIRun> =
546
+ openPrRunIds.length > 0
547
+ ? await AIRunService.findBy({
548
+ query: {
549
+ _id: new Includes(openPrRunIds),
550
+ projectId: data.projectId,
551
+ runType: AIRunType.CodeFix,
552
+ codeFixTaskType:
553
+ data.taskType === CodeFixTaskType.FixException
554
+ ? QueryHelper.equalToOrNull(CodeFixTaskType.FixException)
555
+ : data.taskType,
556
+ },
557
+ select: {
558
+ triggeredByTelemetryExceptionId: true,
559
+ },
560
+ limit: LIMIT_MAX,
561
+ skip: 0,
562
+ props: { isRoot: true },
563
+ })
564
+ : [];
565
+
566
+ /*
567
+ * The target exception is deliberately NOT excluded: its own COMPLETED
568
+ * run with a still-open PR must keep blocking a re-fix (the
569
+ * non-terminal guard above cannot see completed runs). Its own ACTIVE
570
+ * runs were already rejected by validateNoActiveCodeFixRunExists.
571
+ */
572
+ const candidateExceptionIds: Array<ObjectID> = [];
573
+ const seenIds: Set<string> = new Set<string>();
574
+
575
+ for (const run of [...activeRuns, ...openPrRuns]) {
576
+ const exceptionId: ObjectID | undefined =
577
+ run.triggeredByTelemetryExceptionId;
578
+
579
+ if (!exceptionId) {
580
+ continue;
581
+ }
582
+
583
+ const idString: string = exceptionId.toString();
584
+
585
+ if (seenIds.has(idString)) {
586
+ continue;
587
+ }
588
+
589
+ seenIds.add(idString);
590
+ candidateExceptionIds.push(exceptionId);
591
+ }
592
+
593
+ if (candidateExceptionIds.length === 0) {
594
+ return;
595
+ }
596
+
597
+ const targetSignature: string = `${data.exceptionType}|${normalizeExceptionText(
598
+ data.message,
599
+ )}`;
600
+
601
+ const candidates: Array<Model> = await this.findBy({
602
+ query: {
603
+ projectId: data.projectId,
604
+ _id: new Includes(candidateExceptionIds),
605
+ },
606
+ select: {
607
+ _id: true,
608
+ message: true,
609
+ exceptionType: true,
610
+ },
611
+ limit: candidateExceptionIds.length,
612
+ skip: 0,
613
+ props: { isRoot: true },
614
+ });
615
+
616
+ for (const candidate of candidates) {
617
+ const candidateSignature: string = `${candidate.exceptionType || ""}|${normalizeExceptionText(
618
+ candidate.message || "",
619
+ )}`;
620
+
621
+ if (candidateSignature !== targetSignature) {
622
+ continue;
623
+ }
624
+
625
+ if (candidate._id?.toString() === data.telemetryExceptionId.toString()) {
626
+ throw new BadDataException(
627
+ "This exception already has an open AI pull request for this task type. Review that pull request instead of opening another one.",
628
+ );
629
+ }
630
+
631
+ throw new BadDataException(
632
+ `A similar exception (same type and normalized message, exception ${candidate._id?.toString()}) already has an AI agent task in progress or an open AI pull request. Review that pull request instead of opening another one for the same root cause.`,
633
+ );
634
+ }
635
+ }
636
+
341
637
  @CaptureSpan()
342
638
  public async getDashboardSummary(
343
639
  props: DatabaseCommonInteractionProps,
@@ -13,6 +13,7 @@ import TelemetryExceptionService from "../../../../../Services/TelemetryExceptio
13
13
  import ExceptionInstanceService from "../../../../../Services/ExceptionInstanceService";
14
14
  import ServiceService from "../../../../../Services/ServiceService";
15
15
  import QueryHelper from "../../../../../Types/Database/QueryHelper";
16
+ import { sanitizeExceptionMessage } from "../../../../Telemetry/ExceptionSanitizer";
16
17
  import {
17
18
  InsightCandidate,
18
19
  InsightDetector,
@@ -239,9 +240,19 @@ export default class ExceptionSpikeDetector implements InsightDetector {
239
240
  exception.primaryEntityId,
240
241
  );
241
242
 
243
+ /*
244
+ * The message flows into the insight title/detail/evidence, and from
245
+ * there into the triage LLM prompt — sanitize it once here so no
246
+ * interpolated user data (IDs, emails, domains) rides along. The raw
247
+ * message stays on the exception row for the dashboard.
248
+ */
249
+ const safeMessage: string = sanitizeExceptionMessage(
250
+ exception.message || "",
251
+ );
252
+
242
253
  const label: string = ExceptionSpikeDetector.buildExceptionLabel(
243
254
  exception.exceptionType,
244
- exception.message,
255
+ safeMessage,
245
256
  );
246
257
 
247
258
  const firstSeenAtIso: string | undefined = exception.firstSeenAt
@@ -255,8 +266,8 @@ export default class ExceptionSpikeDetector implements InsightDetector {
255
266
  "",
256
267
  `- Exception: \`${label}\``,
257
268
  ];
258
- if (exception.message) {
259
- detailLines.push(`- Message: ${exception.message}`);
269
+ if (safeMessage) {
270
+ detailLines.push(`- Message: ${safeMessage}`);
260
271
  }
261
272
  if (serviceName) {
262
273
  detailLines.push(`- Service: ${serviceName}`);
@@ -281,7 +292,7 @@ export default class ExceptionSpikeDetector implements InsightDetector {
281
292
  telemetryExceptionId: exception.id,
282
293
  evidence: {
283
294
  exception: {
284
- exceptionMessage: exception.message,
295
+ exceptionMessage: safeMessage,
285
296
  exceptionType: exception.exceptionType,
286
297
  recentOccurrenceCount: recentCount.toNumber(),
287
298
  baselineHourlyAverage: decision.baselineHourlyAverage,
@@ -9,6 +9,7 @@ import Service from "../../../../../../Models/DatabaseModels/Service";
9
9
  import TelemetryExceptionService from "../../../../../Services/TelemetryExceptionService";
10
10
  import ServiceService from "../../../../../Services/ServiceService";
11
11
  import QueryHelper from "../../../../../Types/Database/QueryHelper";
12
+ import { sanitizeExceptionMessage } from "../../../../Telemetry/ExceptionSanitizer";
12
13
  import {
13
14
  InsightCandidate,
14
15
  InsightDetector,
@@ -166,9 +167,19 @@ export default class NewExceptionDetector implements InsightDetector {
166
167
  exception.primaryEntityId,
167
168
  );
168
169
 
170
+ /*
171
+ * The message flows into the insight title/detail/evidence, and from
172
+ * there into the triage LLM prompt — sanitize it once here so no
173
+ * interpolated user data (IDs, emails, domains) rides along. The raw
174
+ * message stays on the exception row for the dashboard.
175
+ */
176
+ const safeMessage: string = sanitizeExceptionMessage(
177
+ exception.message || "",
178
+ );
179
+
169
180
  const label: string = NewExceptionDetector.buildExceptionLabel(
170
181
  exception.exceptionType,
171
- exception.message,
182
+ safeMessage,
172
183
  );
173
184
 
174
185
  const firstSeenAtIso: string | undefined = exception.firstSeenAt
@@ -180,8 +191,8 @@ export default class NewExceptionDetector implements InsightDetector {
180
191
  "",
181
192
  `- Exception: \`${label}\``,
182
193
  ];
183
- if (exception.message) {
184
- detailLines.push(`- Message: ${exception.message}`);
194
+ if (safeMessage) {
195
+ detailLines.push(`- Message: ${safeMessage}`);
185
196
  }
186
197
  if (serviceName) {
187
198
  detailLines.push(`- Service: ${serviceName}`);
@@ -204,7 +215,7 @@ export default class NewExceptionDetector implements InsightDetector {
204
215
  telemetryExceptionId: exception.id,
205
216
  evidence: {
206
217
  exception: {
207
- exceptionMessage: exception.message,
218
+ exceptionMessage: safeMessage,
208
219
  exceptionType: exception.exceptionType,
209
220
  totalOccurrenceCount: occurrenceCount,
210
221
  firstSeenAt: firstSeenAtIso,