@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.
- package/Models/AnalyticsModels/NetworkFlow.ts +12 -1
- package/Models/DatabaseModels/AIInsight.ts +30 -0
- package/Models/DatabaseModels/Project.ts +30 -0
- package/Models/DatabaseModels/TelemetryException.ts +128 -0
- package/Server/API/AIAgentDataAPI.ts +129 -5
- package/Server/API/AIInvestigationAPI.ts +87 -0
- package/Server/API/StatusPageAPI.ts +14 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
- package/Server/Services/AIRunService.ts +8 -0
- package/Server/Services/IncidentService.ts +62 -67
- package/Server/Services/MonitorGroupService.ts +15 -2
- package/Server/Services/MonitorService.ts +91 -3
- package/Server/Services/MonitorStatusTimelineService.ts +285 -100
- package/Server/Services/StatusPageService.ts +59 -34
- package/Server/Services/TelemetryExceptionService.ts +320 -24
- package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
- package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
- package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
- package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
- package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
- package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
- package/Server/Utils/Monitor/MonitorResource.ts +43 -14
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
- package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
- package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
- package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
- package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
- package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
- package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
- package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
- package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
- package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
- package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
- package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
- package/Tests/Types/Database/GreaterThan.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
- package/Tests/Types/Database/LessThan.test.ts +5 -1
- package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
- package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
- package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
- package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
- package/Types/AI/CodeFixTaskContext.ts +6 -0
- package/Types/AI/CodeFixTaskType.ts +38 -1
- package/Types/AI/ExceptionAIClassification.ts +27 -0
- package/Types/BaseDatabase/GreaterThan.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
- package/Types/BaseDatabase/LessThan.ts +8 -1
- package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
- package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
- package/Types/Log/LogScrubPatternType.ts +7 -0
- package/Types/Trace/TraceScrubPatternType.ts +7 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
- package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
- package/Utils/StatusPage/ResourceUptime.ts +11 -1
- package/Utils/Uptime/UptimeUtil.ts +142 -36
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
- package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +31 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
- package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
- package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +12 -2
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AIRunService.js +9 -2
- package/build/dist/Server/Services/AIRunService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +47 -56
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +15 -2
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +66 -3
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
- package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +57 -33
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
- package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
- package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
- package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
- package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
- package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
- package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
- package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
- package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
- package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
- package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
- package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
- package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
- package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
- package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
- package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
- package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
- package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
- package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
- package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
- package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +0 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import ToolResultSerializer from "../AI/Toolbox/Serializer";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Shared exception-text sanitization.
|
|
5
|
+
*
|
|
6
|
+
* normalizeExceptionText is the fingerprint normalizer that used to live
|
|
7
|
+
* in App/FeatureSet/Telemetry/Utils/Exception.ts — it was moved here (the
|
|
8
|
+
* App util now delegates) so server-side consumers in Common (the AI
|
|
9
|
+
* agent data API) can sanitize exception messages before they reach LLM
|
|
10
|
+
* prompts, pull-request text, and commit messages. The replacement
|
|
11
|
+
* behavior MUST stay byte-for-byte stable: fingerprints are computed from
|
|
12
|
+
* its output, and changing it regroups every existing exception.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes a string by replacing dynamic values with placeholders.
|
|
17
|
+
* This ensures that exceptions with the same root cause but different
|
|
18
|
+
* dynamic values (like IDs, timestamps, etc.) get the same fingerprint.
|
|
19
|
+
*
|
|
20
|
+
* @param text - The text to normalize (message or stack trace)
|
|
21
|
+
* @returns The normalized text with dynamic values replaced
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeExceptionText(text: string): string {
|
|
24
|
+
if (!text) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let normalized: string = text;
|
|
29
|
+
|
|
30
|
+
// Order matters! More specific patterns should come before generic ones.
|
|
31
|
+
|
|
32
|
+
// 1. UUIDs (e.g., 550e8400-e29b-41d4-a716-446655440000)
|
|
33
|
+
normalized = normalized.replace(
|
|
34
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
|
35
|
+
"<UUID>",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// 2. MongoDB ObjectIDs (24 hex characters)
|
|
39
|
+
normalized = normalized.replace(/\b[0-9a-f]{24}\b/gi, "<OBJECT_ID>");
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* 3. Stripe-style IDs (e.g., sub_xxx, cus_xxx, pi_xxx, ch_xxx, etc.)
|
|
43
|
+
* These have a prefix followed by underscore and alphanumeric characters
|
|
44
|
+
*/
|
|
45
|
+
normalized = normalized.replace(
|
|
46
|
+
/\b(sub|cus|pi|ch|pm|card|price|prod|inv|txn|evt|req|acct|payout|ba|btok|src|tok|seti|si|cs|link|file|dp|icr|ii|il|is|isci|mbur|or|po|qt|rcpt|re|refund|sku|tax|txi|tr|us|wh)_[A-Za-z0-9]{10,32}\b/g,
|
|
47
|
+
"<STRIPE_ID>",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* 4. Generic API/Service IDs - alphanumeric strings that look like IDs
|
|
52
|
+
* Matches patterns like: prefix_alphanumeric or just long alphanumeric strings
|
|
53
|
+
* Common in many services (AWS, GCP, etc.)
|
|
54
|
+
*/
|
|
55
|
+
normalized = normalized.replace(
|
|
56
|
+
/\b[a-z]{2,10}_[A-Za-z0-9]{8,}\b/g,
|
|
57
|
+
"<SERVICE_ID>",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// 5. JWT tokens (three base64 segments separated by dots)
|
|
61
|
+
normalized = normalized.replace(
|
|
62
|
+
/\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\b/g,
|
|
63
|
+
"<JWT>",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 6. Base64 encoded strings (long sequences, likely tokens or encoded data)
|
|
67
|
+
normalized = normalized.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "<BASE64>");
|
|
68
|
+
|
|
69
|
+
// 7. IP addresses (IPv4)
|
|
70
|
+
normalized = normalized.replace(
|
|
71
|
+
/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
|
|
72
|
+
"<IP>",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// 8. IP addresses (IPv6) - simplified pattern
|
|
76
|
+
normalized = normalized.replace(
|
|
77
|
+
/\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
|
|
78
|
+
"<IPV6>",
|
|
79
|
+
);
|
|
80
|
+
normalized = normalized.replace(/\b::1\b/g, "<IPV6>"); // localhost IPv6
|
|
81
|
+
|
|
82
|
+
// 9. Email addresses
|
|
83
|
+
normalized = normalized.replace(
|
|
84
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
|
85
|
+
"<EMAIL>",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* 10. URLs with dynamic paths/query params (normalize the dynamic parts)
|
|
90
|
+
* Keep the domain but normalize path segments that look like IDs
|
|
91
|
+
*/
|
|
92
|
+
normalized = normalized.replace(
|
|
93
|
+
/\/[0-9a-f]{8,}(?=\/|$|\?|#|\s|'|")/gi,
|
|
94
|
+
"/<ID>",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* 11. Timestamps in various formats
|
|
99
|
+
* ISO 8601 timestamps
|
|
100
|
+
*/
|
|
101
|
+
normalized = normalized.replace(
|
|
102
|
+
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g,
|
|
103
|
+
"<TIMESTAMP>",
|
|
104
|
+
);
|
|
105
|
+
// Unix timestamps (10 or 13 digits)
|
|
106
|
+
normalized = normalized.replace(/\b1[0-9]{9,12}\b/g, "<TIMESTAMP>");
|
|
107
|
+
|
|
108
|
+
// 12. Date formats (YYYY-MM-DD, MM/DD/YYYY, etc.)
|
|
109
|
+
normalized = normalized.replace(/\b\d{4}[-/]\d{2}[-/]\d{2}\b/g, "<DATE>");
|
|
110
|
+
normalized = normalized.replace(/\b\d{2}[-/]\d{2}[-/]\d{4}\b/g, "<DATE>");
|
|
111
|
+
|
|
112
|
+
// 13. Time formats (HH:MM:SS, HH:MM)
|
|
113
|
+
normalized = normalized.replace(/\b\d{2}:\d{2}(?::\d{2})?\b/g, "<TIME>");
|
|
114
|
+
|
|
115
|
+
// 14. Memory addresses (0x followed by hex)
|
|
116
|
+
normalized = normalized.replace(/\b0x[0-9a-fA-F]+\b/g, "<MEMORY_ADDR>");
|
|
117
|
+
|
|
118
|
+
// 15. Session IDs (common patterns) - MUST come before hex ID pattern
|
|
119
|
+
normalized = normalized.replace(
|
|
120
|
+
/\bsession[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
|
|
121
|
+
"session_id=<SESSION>",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// 16. Request IDs (common patterns) - MUST come before hex ID pattern
|
|
125
|
+
normalized = normalized.replace(
|
|
126
|
+
/\brequest[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
|
|
127
|
+
"request_id=<REQUEST>",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// 17. Correlation IDs - MUST come before hex ID pattern
|
|
131
|
+
normalized = normalized.replace(
|
|
132
|
+
/\bcorrelation[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
|
|
133
|
+
"correlation_id=<CORRELATION>",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// 18. Transaction IDs - MUST come before hex ID pattern
|
|
137
|
+
normalized = normalized.replace(
|
|
138
|
+
/\btransaction[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
|
|
139
|
+
"transaction_id=<TRANSACTION>",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// 19. Hex strings that are likely IDs (8+ chars)
|
|
143
|
+
normalized = normalized.replace(/\b[0-9a-f]{8,}\b/gi, "<HEX_ID>");
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* 20. Quoted strings containing IDs or dynamic values
|
|
147
|
+
* Match strings in single or double quotes that look like IDs
|
|
148
|
+
*/
|
|
149
|
+
normalized = normalized.replace(/'[A-Za-z0-9_-]{16,}'/g, "'<ID>'");
|
|
150
|
+
normalized = normalized.replace(/"[A-Za-z0-9_-]{16,}"/g, '"<ID>"');
|
|
151
|
+
|
|
152
|
+
// 21. Port numbers in URLs or connection strings
|
|
153
|
+
normalized = normalized.replace(/:(\d{4,5})(?=\/|$|\s)/g, ":<PORT>");
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* 22. Line numbers in stack traces (keep for context, but normalize large numbers)
|
|
157
|
+
* This normalizes specific line/column references that might vary
|
|
158
|
+
*/
|
|
159
|
+
normalized = normalized.replace(/:\d+:\d+\)?$/gm, ":<LINE>:<COL>)");
|
|
160
|
+
|
|
161
|
+
// 23. Process/Thread IDs
|
|
162
|
+
normalized = normalized.replace(/\bPID[:\s]*\d+\b/gi, "PID:<PID>");
|
|
163
|
+
normalized = normalized.replace(/\bTID[:\s]*\d+\b/gi, "TID:<TID>");
|
|
164
|
+
|
|
165
|
+
// 24. Numeric IDs in common patterns (id=123, id: 123, etc.)
|
|
166
|
+
normalized = normalized.replace(/\bid[=:\s]*['"]?\d+['"]?/gi, "id=<ID>");
|
|
167
|
+
|
|
168
|
+
// 25. Large numbers that are likely IDs (more than 6 digits)
|
|
169
|
+
normalized = normalized.replace(/\b\d{7,}\b/g, "<NUMBER>");
|
|
170
|
+
|
|
171
|
+
return normalized;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sanitize an exception message for surfaces that leave the platform:
|
|
176
|
+
* LLM prompts, pull-request titles/bodies, and commit messages.
|
|
177
|
+
*
|
|
178
|
+
* Normalization replaces the dynamic tokens (UUIDs, emails, IPs, IDs,
|
|
179
|
+
* timestamps...) that carry user data, then the secret redactor sweeps
|
|
180
|
+
* whatever is left (bearer tokens, API keys, cards...). The message
|
|
181
|
+
* keeps its structure — "invalid input syntax for type uuid: <HEX_ID>"
|
|
182
|
+
* is still perfectly actionable for a fix.
|
|
183
|
+
*/
|
|
184
|
+
export function sanitizeExceptionMessage(message: string): string {
|
|
185
|
+
if (!message) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
return ToolResultSerializer.redact(normalizeExceptionText(message)).text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sanitize a stack trace for the same surfaces.
|
|
193
|
+
*
|
|
194
|
+
* Runtimes prefix the native stack string with the exception MESSAGE
|
|
195
|
+
* ("Error: <message>" in Node, "ValueError: <message>" in Python
|
|
196
|
+
* headers, "Caused by: ..." in Java) — so a redact-only pass would leak
|
|
197
|
+
* the very identifiers sanitizeExceptionMessage strips. Frame lines are
|
|
198
|
+
* indented (" at fn (file:42:7)", ' File "x.py", line 3', "\tat ...")
|
|
199
|
+
* and must keep exact file:line references for the code agent, so the
|
|
200
|
+
* split is: unindented header/message lines get full normalization,
|
|
201
|
+
* indented frame lines are left intact, and the secret redactor sweeps
|
|
202
|
+
* everything at the end.
|
|
203
|
+
*/
|
|
204
|
+
// Frame lines are indented; header/message lines are not.
|
|
205
|
+
const INDENTED_FRAME_LINE_REGEX: RegExp = /^\s/;
|
|
206
|
+
|
|
207
|
+
export function sanitizeStackTrace(stackTrace: string): string {
|
|
208
|
+
if (!stackTrace) {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const normalized: string = stackTrace
|
|
213
|
+
.split("\n")
|
|
214
|
+
.map((line: string): string => {
|
|
215
|
+
return INDENTED_FRAME_LINE_REGEX.test(line)
|
|
216
|
+
? line
|
|
217
|
+
: normalizeExceptionText(line);
|
|
218
|
+
})
|
|
219
|
+
.join("\n");
|
|
220
|
+
|
|
221
|
+
return ToolResultSerializer.redact(normalized).text;
|
|
222
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* These tests pin the ownership of the per-monitor mutex in
|
|
5
|
+
* MonitorStatusTimelineService.create() - the prevention layer that stops new
|
|
6
|
+
* orphaned (endsAt = NULL) timeline rows at the source:
|
|
7
|
+
*
|
|
8
|
+
* - fail-closed: a lock that cannot be acquired refuses the write with the
|
|
9
|
+
* exact MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE callers match on,
|
|
10
|
+
* instead of falling through and inserting unlocked (the original bug),
|
|
11
|
+
* - release-on-every-path: the mutex is released whether the create
|
|
12
|
+
* succeeds or throws (a leaked redis-semaphore mutex refreshes its own
|
|
13
|
+
* key for the life of the process, blocking every later create for the
|
|
14
|
+
* monitor until acquireTimeout),
|
|
15
|
+
* - the feed/workspace-notification side effects run only AFTER release,
|
|
16
|
+
* so third-party HTTP latency (Slack/Teams) can never extend the
|
|
17
|
+
* critical section,
|
|
18
|
+
* - the ignoreHooks and missing-monitorId paths skip locking entirely.
|
|
19
|
+
*
|
|
20
|
+
* Semaphore is mocked at the module boundary; DatabaseService.prototype.create
|
|
21
|
+
* is spied so super.create() is observable without a database.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const lockMock: jest.Mock = jest.fn();
|
|
25
|
+
const releaseMock: jest.Mock = jest.fn();
|
|
26
|
+
|
|
27
|
+
jest.mock("../../../Server/Infrastructure/Semaphore", () => {
|
|
28
|
+
return {
|
|
29
|
+
__esModule: true,
|
|
30
|
+
default: {
|
|
31
|
+
lock: (...args: Array<unknown>) => {
|
|
32
|
+
return lockMock(...args);
|
|
33
|
+
},
|
|
34
|
+
release: (...args: Array<unknown>) => {
|
|
35
|
+
return releaseMock(...args);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import MonitorStatusTimelineService, {
|
|
42
|
+
MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE,
|
|
43
|
+
} from "../../../Server/Services/MonitorStatusTimelineService";
|
|
44
|
+
import DatabaseService from "../../../Server/Services/DatabaseService";
|
|
45
|
+
import MonitorStatusTimeline from "../../../Models/DatabaseModels/MonitorStatusTimeline";
|
|
46
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
47
|
+
import ServerException from "../../../Types/Exception/ServerException";
|
|
48
|
+
|
|
49
|
+
const MONITOR_ID: ObjectID = new ObjectID(
|
|
50
|
+
"11111111-1111-4111-8111-111111111111",
|
|
51
|
+
);
|
|
52
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
53
|
+
"22222222-2222-4222-8222-222222222222",
|
|
54
|
+
);
|
|
55
|
+
const STATUS_ID: ObjectID = new ObjectID(
|
|
56
|
+
"33333333-3333-4333-8333-333333333333",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
type MakeCreateByFunction = (data?: {
|
|
60
|
+
ignoreHooks?: boolean;
|
|
61
|
+
omitMonitorId?: boolean;
|
|
62
|
+
}) => CreateBy<MonitorStatusTimeline>;
|
|
63
|
+
|
|
64
|
+
const makeCreateBy: MakeCreateByFunction = (data?: {
|
|
65
|
+
ignoreHooks?: boolean;
|
|
66
|
+
omitMonitorId?: boolean;
|
|
67
|
+
}): CreateBy<MonitorStatusTimeline> => {
|
|
68
|
+
const timeline: MonitorStatusTimeline = new MonitorStatusTimeline();
|
|
69
|
+
|
|
70
|
+
if (!data?.omitMonitorId) {
|
|
71
|
+
timeline.monitorId = MONITOR_ID;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
timeline.projectId = PROJECT_ID;
|
|
75
|
+
timeline.monitorStatusId = STATUS_ID;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
data: timeline,
|
|
79
|
+
props: {
|
|
80
|
+
isRoot: true,
|
|
81
|
+
ignoreHooks: data?.ignoreHooks || false,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe("MonitorStatusTimelineService.create mutex ownership", () => {
|
|
87
|
+
let superCreateSpy: jest.SpyInstance;
|
|
88
|
+
let feedItemSpy: jest.SpyInstance;
|
|
89
|
+
let createdItem: MonitorStatusTimeline;
|
|
90
|
+
|
|
91
|
+
// a unique object standing in for the redis-semaphore mutex.
|
|
92
|
+
const fakeMutex: { id: string } = { id: "fake-mutex" };
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
lockMock.mockReset();
|
|
96
|
+
releaseMock.mockReset();
|
|
97
|
+
|
|
98
|
+
lockMock.mockResolvedValue(fakeMutex);
|
|
99
|
+
releaseMock.mockResolvedValue(undefined);
|
|
100
|
+
|
|
101
|
+
createdItem = new MonitorStatusTimeline();
|
|
102
|
+
createdItem.monitorId = MONITOR_ID;
|
|
103
|
+
createdItem.projectId = PROJECT_ID;
|
|
104
|
+
createdItem.monitorStatusId = STATUS_ID;
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* super.create - everything between lock and release (hooks, validation,
|
|
108
|
+
* the INSERT) is DatabaseService.create's responsibility and is not under
|
|
109
|
+
* test here.
|
|
110
|
+
*/
|
|
111
|
+
superCreateSpy = jest
|
|
112
|
+
.spyOn(DatabaseService.prototype, "create")
|
|
113
|
+
.mockResolvedValue(createdItem);
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* The feed side effect does DB lookups and third-party HTTP; stub it and
|
|
117
|
+
* assert WHEN it runs relative to the release.
|
|
118
|
+
*/
|
|
119
|
+
feedItemSpy = jest
|
|
120
|
+
.spyOn(
|
|
121
|
+
MonitorStatusTimelineService as unknown as {
|
|
122
|
+
createStatusChangeFeedItem: () => Promise<void>;
|
|
123
|
+
},
|
|
124
|
+
"createStatusChangeFeedItem",
|
|
125
|
+
)
|
|
126
|
+
.mockResolvedValue(undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
superCreateSpy.mockRestore();
|
|
131
|
+
feedItemSpy.mockRestore();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("acquires the per-monitor lock, creates, then releases", async () => {
|
|
135
|
+
const result: MonitorStatusTimeline =
|
|
136
|
+
await MonitorStatusTimelineService.create(makeCreateBy());
|
|
137
|
+
|
|
138
|
+
expect(result).toBe(createdItem);
|
|
139
|
+
|
|
140
|
+
expect(lockMock).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(lockMock).toHaveBeenCalledWith({
|
|
142
|
+
key: MONITOR_ID.toString(),
|
|
143
|
+
namespace: "MonitorStatusTimeline.create",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(superCreateSpy).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(releaseMock).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(releaseMock).toHaveBeenCalledWith(fakeMutex);
|
|
149
|
+
|
|
150
|
+
// lock -> create -> release, in that order.
|
|
151
|
+
expect(lockMock.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
152
|
+
superCreateSpy.mock.invocationCallOrder[0]!,
|
|
153
|
+
);
|
|
154
|
+
expect(superCreateSpy.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
155
|
+
releaseMock.mock.invocationCallOrder[0]!,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("runs the feed side effect only after the mutex is released", async () => {
|
|
160
|
+
await MonitorStatusTimelineService.create(makeCreateBy());
|
|
161
|
+
|
|
162
|
+
expect(feedItemSpy).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(releaseMock.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
164
|
+
feedItemSpy.mock.invocationCallOrder[0]!,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("fails closed with the exact lock error message and never inserts unlocked", async () => {
|
|
169
|
+
lockMock.mockRejectedValue(new Error("redis unavailable"));
|
|
170
|
+
|
|
171
|
+
await expect(
|
|
172
|
+
MonitorStatusTimelineService.create(makeCreateBy()),
|
|
173
|
+
).rejects.toThrow(MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE);
|
|
174
|
+
|
|
175
|
+
await expect(
|
|
176
|
+
MonitorStatusTimelineService.create(makeCreateBy()),
|
|
177
|
+
).rejects.toBeInstanceOf(ServerException);
|
|
178
|
+
|
|
179
|
+
/*
|
|
180
|
+
* The whole point of fail-closed: the write must NOT happen without the
|
|
181
|
+
* lock. The pre-fix code logged and fell through unlocked, which is what
|
|
182
|
+
* produced permanently orphaned endsAt = NULL rows.
|
|
183
|
+
*/
|
|
184
|
+
expect(superCreateSpy).not.toHaveBeenCalled();
|
|
185
|
+
expect(releaseMock).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("releases the mutex when the create itself throws", async () => {
|
|
189
|
+
superCreateSpy.mockRejectedValue(new Error("insert failed"));
|
|
190
|
+
|
|
191
|
+
await expect(
|
|
192
|
+
MonitorStatusTimelineService.create(makeCreateBy()),
|
|
193
|
+
).rejects.toThrow("insert failed");
|
|
194
|
+
|
|
195
|
+
// release must run on the throw path - a leaked mutex never expires.
|
|
196
|
+
expect(releaseMock).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(releaseMock).toHaveBeenCalledWith(fakeMutex);
|
|
198
|
+
|
|
199
|
+
// and the feed side effect must not run for a failed create.
|
|
200
|
+
expect(feedItemSpy).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("does not fail a successful create when the release itself fails", async () => {
|
|
204
|
+
releaseMock.mockRejectedValue(new Error("redis blip on release"));
|
|
205
|
+
|
|
206
|
+
const result: MonitorStatusTimeline =
|
|
207
|
+
await MonitorStatusTimelineService.create(makeCreateBy());
|
|
208
|
+
|
|
209
|
+
expect(result).toBe(createdItem);
|
|
210
|
+
expect(superCreateSpy).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("skips locking entirely when ignoreHooks is set", async () => {
|
|
214
|
+
const result: MonitorStatusTimeline =
|
|
215
|
+
await MonitorStatusTimelineService.create(
|
|
216
|
+
makeCreateBy({ ignoreHooks: true }),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result).toBe(createdItem);
|
|
220
|
+
expect(lockMock).not.toHaveBeenCalled();
|
|
221
|
+
expect(releaseMock).not.toHaveBeenCalled();
|
|
222
|
+
// no hooks -> no predecessor bookkeeping -> no feed side effect either.
|
|
223
|
+
expect(feedItemSpy).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("skips locking when monitorId is missing and lets onBeforeCreate reject it", async () => {
|
|
227
|
+
await MonitorStatusTimelineService.create(
|
|
228
|
+
makeCreateBy({ omitMonitorId: true }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// super.create's onBeforeCreate throws BadDataException for this case.
|
|
232
|
+
expect(lockMock).not.toHaveBeenCalled();
|
|
233
|
+
expect(superCreateSpy).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -6,6 +6,7 @@ import ServiceService from "../../../Server/Services/ServiceService";
|
|
|
6
6
|
import CodeRepositoryService from "../../../Server/Services/CodeRepositoryService";
|
|
7
7
|
import AIAgentService from "../../../Server/Services/AIAgentService";
|
|
8
8
|
import AIRunService from "../../../Server/Services/AIRunService";
|
|
9
|
+
import AIAgentTaskPullRequestService from "../../../Server/Services/AIAgentTaskPullRequestService";
|
|
9
10
|
import { RepoResolution } from "../../../Server/Utils/CodeRepository/StackTraceRepoResolver";
|
|
10
11
|
import FindOneBy from "../../../Server/Types/Database/FindOneBy";
|
|
11
12
|
import TelemetryException from "../../../Models/DatabaseModels/TelemetryException";
|
|
@@ -37,12 +38,19 @@ const projectId: ObjectID = ObjectID.generate();
|
|
|
37
38
|
const exceptionId: ObjectID = ObjectID.generate();
|
|
38
39
|
const serviceId: ObjectID = ObjectID.generate();
|
|
39
40
|
|
|
40
|
-
function fakeException(
|
|
41
|
+
function fakeException(
|
|
42
|
+
overrides: Partial<TelemetryException> = {},
|
|
43
|
+
): TelemetryException {
|
|
41
44
|
return {
|
|
42
45
|
id: exceptionId,
|
|
43
46
|
projectId: projectId,
|
|
44
47
|
primaryEntityId: serviceId,
|
|
48
|
+
message: "Failed to charge card for user 123",
|
|
49
|
+
exceptionType: "PaymentError",
|
|
45
50
|
stackTrace: "at charge (/app/src/billing/charge.ts:12:5)",
|
|
51
|
+
isResolved: false,
|
|
52
|
+
isArchived: false,
|
|
53
|
+
...overrides,
|
|
46
54
|
} as unknown as TelemetryException;
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -91,6 +99,14 @@ function mockReadinessOk(): void {
|
|
|
91
99
|
limitInTokens: null,
|
|
92
100
|
usedTokensToday: 0,
|
|
93
101
|
});
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* Cross-group duplicate guard collaborators: no non-terminal runs and no
|
|
105
|
+
* open AI pull requests unless a test overrides these. findBy on the
|
|
106
|
+
* exception service itself is only reached when candidates exist.
|
|
107
|
+
*/
|
|
108
|
+
jest.spyOn(AIRunService, "findBy").mockResolvedValue([]);
|
|
109
|
+
jest.spyOn(AIAgentTaskPullRequestService, "findBy").mockResolvedValue([]);
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
describe("TelemetryExceptionService.createCodeFixRunForException", () => {
|
|
@@ -283,6 +299,194 @@ describe("TelemetryExceptionService.createCodeFixRunForException", () => {
|
|
|
283
299
|
}),
|
|
284
300
|
);
|
|
285
301
|
});
|
|
302
|
+
|
|
303
|
+
test("rejects resolved and archived exceptions server-side", async () => {
|
|
304
|
+
mockReadinessOk();
|
|
305
|
+
const create: jest.SpyInstance = jest.spyOn(AIRunService, "create");
|
|
306
|
+
|
|
307
|
+
jest
|
|
308
|
+
.spyOn(TelemetryExceptionService, "findOneById")
|
|
309
|
+
.mockResolvedValue(fakeException({ isResolved: true }));
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
313
|
+
telemetryExceptionId: exceptionId,
|
|
314
|
+
props: { isRoot: true },
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(/marked as resolved/);
|
|
317
|
+
|
|
318
|
+
jest
|
|
319
|
+
.spyOn(TelemetryExceptionService, "findOneById")
|
|
320
|
+
.mockResolvedValue(fakeException({ isArchived: true }));
|
|
321
|
+
|
|
322
|
+
await expect(
|
|
323
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
324
|
+
telemetryExceptionId: exceptionId,
|
|
325
|
+
props: { isRoot: true },
|
|
326
|
+
}),
|
|
327
|
+
).rejects.toThrow(/archived/);
|
|
328
|
+
|
|
329
|
+
expect(create).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a declined AI fix (aiFixDeclinedAt) blocks the automatic lane but a user click clears the stamp and proceeds", async () => {
|
|
333
|
+
mockReadinessOk();
|
|
334
|
+
|
|
335
|
+
jest
|
|
336
|
+
.spyOn(TelemetryExceptionService, "findOneById")
|
|
337
|
+
.mockResolvedValue(fakeException({ aiFixDeclinedAt: new Date() }));
|
|
338
|
+
jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
|
|
339
|
+
const create: jest.SpyInstance = jest
|
|
340
|
+
.spyOn(AIRunService, "create")
|
|
341
|
+
.mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
|
|
342
|
+
const clearStamp: jest.SpyInstance = jest
|
|
343
|
+
.spyOn(TelemetryExceptionService, "updateOneById")
|
|
344
|
+
.mockResolvedValue(undefined as never);
|
|
345
|
+
|
|
346
|
+
// System-triggered (no userId): blocked.
|
|
347
|
+
await expect(
|
|
348
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
349
|
+
telemetryExceptionId: exceptionId,
|
|
350
|
+
props: { isRoot: true },
|
|
351
|
+
}),
|
|
352
|
+
).rejects.toThrow(/closed without merging/);
|
|
353
|
+
expect(create).not.toHaveBeenCalled();
|
|
354
|
+
|
|
355
|
+
// A human click: clears the stamp and creates the run.
|
|
356
|
+
await expect(
|
|
357
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
358
|
+
telemetryExceptionId: exceptionId,
|
|
359
|
+
props: { isRoot: true, userId: ObjectID.generate() },
|
|
360
|
+
}),
|
|
361
|
+
).resolves.toBeDefined();
|
|
362
|
+
|
|
363
|
+
expect(clearStamp).toHaveBeenCalledWith(
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
id: exceptionId,
|
|
366
|
+
data: expect.objectContaining({ aiFixDeclinedAt: null }),
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
expect(create).toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("the decline stamp is scoped to FixException: other recipes are neither blocked by it nor clear it", async () => {
|
|
373
|
+
mockReadinessOk();
|
|
374
|
+
|
|
375
|
+
jest
|
|
376
|
+
.spyOn(TelemetryExceptionService, "findOneById")
|
|
377
|
+
.mockResolvedValue(fakeException({ aiFixDeclinedAt: new Date() }));
|
|
378
|
+
jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
|
|
379
|
+
const create: jest.SpyInstance = jest
|
|
380
|
+
.spyOn(AIRunService, "create")
|
|
381
|
+
.mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
|
|
382
|
+
const exceptionUpdates: jest.SpyInstance = jest
|
|
383
|
+
.spyOn(TelemetryExceptionService, "updateOneById")
|
|
384
|
+
.mockResolvedValue(undefined as never);
|
|
385
|
+
|
|
386
|
+
/*
|
|
387
|
+
* A user asking for a regression TEST on a group whose FIX a human
|
|
388
|
+
* declined: the test run proceeds, and — critically — the fix-decline
|
|
389
|
+
* suppression stays in force (the user never overrode the fix).
|
|
390
|
+
*/
|
|
391
|
+
await expect(
|
|
392
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
393
|
+
telemetryExceptionId: exceptionId,
|
|
394
|
+
props: { isRoot: true, userId: ObjectID.generate() },
|
|
395
|
+
taskType: CodeFixTaskType.WriteRegressionTest,
|
|
396
|
+
}),
|
|
397
|
+
).resolves.toBeDefined();
|
|
398
|
+
|
|
399
|
+
expect(create).toHaveBeenCalled();
|
|
400
|
+
expect(exceptionUpdates).not.toHaveBeenCalledWith(
|
|
401
|
+
expect.objectContaining({
|
|
402
|
+
data: expect.objectContaining({ aiFixDeclinedAt: null }),
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("cross-group duplicate guard: a similar exception (same type + normalized message) with an active run blocks creation", async () => {
|
|
408
|
+
mockReadinessOk();
|
|
409
|
+
|
|
410
|
+
/*
|
|
411
|
+
* The observed flood shape: one throw site interpolating a different
|
|
412
|
+
* UUID per request splinters into one fingerprint (and one exception
|
|
413
|
+
* group) per value. Normalization collapses both messages to
|
|
414
|
+
* `...uuid: "<UUID>"`, which is what the guard compares.
|
|
415
|
+
*/
|
|
416
|
+
jest.spyOn(TelemetryExceptionService, "findOneById").mockResolvedValue(
|
|
417
|
+
fakeException({
|
|
418
|
+
message:
|
|
419
|
+
'invalid input syntax for type uuid: "550e8400-e29b-41d4-a716-446655440000"',
|
|
420
|
+
exceptionType: "QueryFailedError",
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Per-exception guard finds nothing for THIS exception...
|
|
425
|
+
jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
|
|
426
|
+
|
|
427
|
+
/*
|
|
428
|
+
* ...but another exception group — same root cause, different
|
|
429
|
+
* interpolated value — has a non-terminal FixException run.
|
|
430
|
+
*/
|
|
431
|
+
const similarExceptionId: ObjectID = ObjectID.generate();
|
|
432
|
+
jest.spyOn(AIRunService, "findBy").mockResolvedValue([
|
|
433
|
+
{
|
|
434
|
+
triggeredByTelemetryExceptionId: similarExceptionId,
|
|
435
|
+
} as unknown as AIRun,
|
|
436
|
+
]);
|
|
437
|
+
jest.spyOn(TelemetryExceptionService, "findBy").mockResolvedValue([
|
|
438
|
+
{
|
|
439
|
+
_id: similarExceptionId.toString(),
|
|
440
|
+
message:
|
|
441
|
+
'invalid input syntax for type uuid: "123e4567-e89b-12d3-a456-426614174000"',
|
|
442
|
+
exceptionType: "QueryFailedError",
|
|
443
|
+
} as unknown as TelemetryException,
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const create: jest.SpyInstance = jest.spyOn(AIRunService, "create");
|
|
447
|
+
|
|
448
|
+
await expect(
|
|
449
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
450
|
+
telemetryExceptionId: exceptionId,
|
|
451
|
+
props: { isRoot: true },
|
|
452
|
+
}),
|
|
453
|
+
).rejects.toThrow(/similar exception/);
|
|
454
|
+
|
|
455
|
+
expect(create).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("cross-group duplicate guard: a DIFFERENT normalized message does not block", async () => {
|
|
459
|
+
mockReadinessOk();
|
|
460
|
+
|
|
461
|
+
jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
|
|
462
|
+
|
|
463
|
+
const otherExceptionId: ObjectID = ObjectID.generate();
|
|
464
|
+
jest.spyOn(AIRunService, "findBy").mockResolvedValue([
|
|
465
|
+
{
|
|
466
|
+
triggeredByTelemetryExceptionId: otherExceptionId,
|
|
467
|
+
} as unknown as AIRun,
|
|
468
|
+
]);
|
|
469
|
+
jest.spyOn(TelemetryExceptionService, "findBy").mockResolvedValue([
|
|
470
|
+
{
|
|
471
|
+
_id: otherExceptionId.toString(),
|
|
472
|
+
message: "Connection refused to redis",
|
|
473
|
+
exceptionType: "ConnectionError",
|
|
474
|
+
} as unknown as TelemetryException,
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
const create: jest.SpyInstance = jest
|
|
478
|
+
.spyOn(AIRunService, "create")
|
|
479
|
+
.mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
|
|
480
|
+
|
|
481
|
+
await expect(
|
|
482
|
+
TelemetryExceptionService.createCodeFixRunForException({
|
|
483
|
+
telemetryExceptionId: exceptionId,
|
|
484
|
+
props: { isRoot: true },
|
|
485
|
+
}),
|
|
486
|
+
).resolves.toBeDefined();
|
|
487
|
+
|
|
488
|
+
expect(create).toHaveBeenCalled();
|
|
489
|
+
});
|
|
286
490
|
});
|
|
287
491
|
|
|
288
492
|
/*
|