querysub 0.378.0 → 0.379.0
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/.cursorrules +2 -0
- package/package.json +1 -1
- package/src/diagnostics/MachineThreadInfo.tsx +33 -10
- package/src/diagnostics/logs/IndexedLogs/LogViewer3.tsx +5 -1
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +430 -201
- package/src/diagnostics/logs/errorNotifications2/ErrorWarning.tsx +32 -0
- package/src/diagnostics/logs/errorNotifications2/errorNotifications.ts +414 -54
- package/src/diagnostics/logs/errorNotifications2/errorWatcher.ts +168 -0
- package/src/diagnostics/logs/errorNotifications2/openRouterHelper.ts +77 -0
- package/src/diagnostics/logs/lifeCycleAnalysis/lifeCycles.tsx +1 -30
- package/src/diagnostics/managementPages.tsx +6 -2
- package/src/user-implementation/SecurityPage.tsx +6 -0
- package/src/user-implementation/userData.ts +6 -1
- /package/src/library-components/{errorNotifications.tsx → uncaughtToast.tsx} +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { qreact } from "../../../4-dom/qreact";
|
|
2
|
+
import { t } from "../../../2-proxy/schema2";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { Querysub } from "../../../4-querysub/QuerysubController";
|
|
5
|
+
import { ATag } from "../../../library-components/ATag";
|
|
6
|
+
import { managementPageURL } from "../../managementPages";
|
|
7
|
+
import { LogDatumRenderer } from "./ErrorNotificationPage";
|
|
8
|
+
import { getErrorNotificationsManager } from "./errorWatcher";
|
|
9
|
+
|
|
10
|
+
export class ErrorWarning extends qreact.Component {
|
|
11
|
+
manager = getErrorNotificationsManager();
|
|
12
|
+
|
|
13
|
+
render() {
|
|
14
|
+
if (this.manager.state.isLoading) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (this.manager.state.unmatchedErrors.length === 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let firstError = this.manager.state.unmatchedErrors[0];
|
|
23
|
+
|
|
24
|
+
return <div className={css.hbox(4).alignItems("start").hsl(0, 0, 90).bord2(0, 0, 85).pad2(4, 2).hslcolor(0, 0, 0)}>
|
|
25
|
+
<ATag className={css.paddingTop(5).flexShrink0} values={[managementPageURL.getOverride("ErrorNotificationPage")]}>
|
|
26
|
+
Suppress
|
|
27
|
+
</ATag>
|
|
28
|
+
<div className={css.paddingTop(5)}>|</div>
|
|
29
|
+
<LogDatumRenderer datum={firstError} inlineMode />
|
|
30
|
+
</div>;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -8,29 +8,54 @@ import { MachineInfo } from "../../../deployManager/machineSchema";
|
|
|
8
8
|
import { createMatchesPattern } from "../IndexedLogs/bufferSearchFindMatcher";
|
|
9
9
|
import { LogDatum, getErrorLogs } from "../diskLogger";
|
|
10
10
|
import { watchAllValues } from "./logWatcher";
|
|
11
|
-
import { batchFunction, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
12
|
-
import { nextId, timeInMinute } from "socket-function/src/misc";
|
|
11
|
+
import { batchFunction, runInSerial, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
12
|
+
import { nextId, sort, throttleFunction, timeInMinute } from "socket-function/src/misc";
|
|
13
13
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
14
14
|
import { assertIsManagementUser } from "../../managementPages";
|
|
15
15
|
import { getSyncedController } from "../../../library-components/SyncedController";
|
|
16
16
|
import { getControllerNodeId } from "../../../-g-core-values/NodeCapabilities";
|
|
17
|
+
import { t } from "../../../2-proxy/schema2";
|
|
18
|
+
import { sendDiscordMessage } from "../../../email_ims_notifications/discord";
|
|
19
|
+
import { user_data } from "../../../user-implementation/userData";
|
|
20
|
+
import { callOpenRouterJSON } from "./openRouterHelper";
|
|
21
|
+
import { createLink } from "../../../library-components/ATag";
|
|
22
|
+
import { showingManagementURL, managementPageURL } from "../../managementPages";
|
|
23
|
+
import { atomic } from "../../../2-proxy/PathValueProxyWatcher";
|
|
24
|
+
import { magenta } from "socket-function/src/formatting/logColors";
|
|
17
25
|
|
|
18
26
|
|
|
19
27
|
type SuppressionEntry = {
|
|
20
28
|
id: string;
|
|
21
|
-
|
|
29
|
+
notes?: string;
|
|
22
30
|
pattern: string;
|
|
23
31
|
|
|
32
|
+
// After this time, this suppression won't suppress data anymore.
|
|
33
|
+
timeout: number;
|
|
34
|
+
|
|
24
35
|
createdTime: number;
|
|
25
36
|
lastUpdatedTime: number;
|
|
26
37
|
};
|
|
27
38
|
const suppression = archiveJSONT<SuppressionEntry>(() => nestArchives("logs/error-suppression/", getArchivesBackblaze(getDomain())));
|
|
28
39
|
let suppressionCache: SuppressionEntry[] = [];
|
|
40
|
+
|
|
41
|
+
// In-memory Discord notification throttling
|
|
42
|
+
const timeInHour = 60 * 60 * 1000;
|
|
43
|
+
const NEW_SUPPRESSION_THROTTLE = timeInHour * 6;
|
|
44
|
+
const EXISTING_SUPPRESSION_THROTTLE = timeInHour * 24;
|
|
45
|
+
|
|
46
|
+
let isThrottlingNewSuppressions = false;
|
|
47
|
+
let isThrottlingExistingSuppressions = false;
|
|
48
|
+
let pendingDiscordNotifications = new Map<string, {
|
|
49
|
+
errorCount: number;
|
|
50
|
+
isNew: boolean;
|
|
51
|
+
}>();
|
|
29
52
|
let ensureWatching = lazy(async () => {
|
|
30
53
|
await runInfinitePollCallAtStart(timeInMinute * 5, updateNow);
|
|
31
54
|
});
|
|
32
55
|
async function updateNow() {
|
|
33
56
|
suppressionCache = await suppression.values();
|
|
57
|
+
// Can't wait, because that will cause a cyclic loop
|
|
58
|
+
void reapplySuppressions();
|
|
34
59
|
}
|
|
35
60
|
async function getSuppressionEntries(): Promise<SuppressionEntry[]> {
|
|
36
61
|
await ensureWatching();
|
|
@@ -43,14 +68,16 @@ const MAX_UNMATCHED = 10000;
|
|
|
43
68
|
const MAX_EXAMPLES = 100;
|
|
44
69
|
let unmatchedIndex = 0;
|
|
45
70
|
let unmatchedErrors: LogDatum[] = [];
|
|
46
|
-
|
|
71
|
+
export type SuppressionMatch = {
|
|
72
|
+
id: string;
|
|
47
73
|
// getHistoryChunk =>
|
|
48
74
|
history: Map<number, {
|
|
49
75
|
count: number;
|
|
50
76
|
}>;
|
|
51
77
|
exampleIndex: number;
|
|
52
78
|
examples: LogDatum[];
|
|
53
|
-
}
|
|
79
|
+
};
|
|
80
|
+
let suppressionMatches = new Map<string, SuppressionMatch>();
|
|
54
81
|
const chunkUnit = timeInMinute * 5;
|
|
55
82
|
export function getHistoryChunk(time: number) {
|
|
56
83
|
return Math.floor(time / chunkUnit) * chunkUnit;
|
|
@@ -60,50 +87,265 @@ export function getChunkEndTime(chunk: number) {
|
|
|
60
87
|
return chunk + chunkUnit;
|
|
61
88
|
}
|
|
62
89
|
|
|
90
|
+
type SuppressionStatus = "suppressed" | "matched-timeout-passed" | "no-match";
|
|
91
|
+
|
|
92
|
+
function applySuppression(error: LogDatum, suppressionEntry: SuppressionEntry): SuppressionStatus {
|
|
93
|
+
let errorBuffer = Buffer.from(JSON.stringify(error));
|
|
94
|
+
let isMatch = getMatcher(suppressionEntry.pattern);
|
|
95
|
+
if (!isMatch(errorBuffer)) return "no-match";
|
|
96
|
+
|
|
97
|
+
let history = suppressionMatches.get(suppressionEntry.id);
|
|
98
|
+
if (!history) {
|
|
99
|
+
history = {
|
|
100
|
+
id: suppressionEntry.id,
|
|
101
|
+
history: new Map(),
|
|
102
|
+
exampleIndex: 0,
|
|
103
|
+
examples: [],
|
|
104
|
+
};
|
|
105
|
+
suppressionMatches.set(suppressionEntry.id, history);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (history.examples.length > MAX_EXAMPLES) {
|
|
109
|
+
history.examples[history.exampleIndex] = error;
|
|
110
|
+
history.exampleIndex = (history.exampleIndex + 1) % MAX_EXAMPLES;
|
|
111
|
+
} else {
|
|
112
|
+
history.examples.push(error);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let historyChunk = getHistoryChunk(error.time);
|
|
116
|
+
let historyEntry = history.history.get(historyChunk);
|
|
117
|
+
if (!historyEntry) {
|
|
118
|
+
historyEntry = { count: 0 };
|
|
119
|
+
history.history.set(historyChunk, historyEntry);
|
|
120
|
+
}
|
|
121
|
+
historyEntry.count++;
|
|
122
|
+
|
|
123
|
+
if (error.time > suppressionEntry.timeout) {
|
|
124
|
+
return "matched-timeout-passed";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return "suppressed";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function reapplySuppressions() {
|
|
131
|
+
let suppressionEntries = await getSuppressionEntries();
|
|
132
|
+
let newUnmatchedErrors: LogDatum[] = [];
|
|
133
|
+
let changedSuppressionIds = new Set<string>();
|
|
134
|
+
|
|
135
|
+
for (let error of unmatchedErrors) {
|
|
136
|
+
let suppressed = false;
|
|
137
|
+
for (let suppressionEntry of suppressionEntries) {
|
|
138
|
+
let status = applySuppression(error, suppressionEntry);
|
|
139
|
+
if (status === "suppressed" || status === "matched-timeout-passed") {
|
|
140
|
+
changedSuppressionIds.add(suppressionEntry.id);
|
|
141
|
+
}
|
|
142
|
+
if (status === "suppressed") {
|
|
143
|
+
suppressed = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!suppressed) {
|
|
147
|
+
newUnmatchedErrors.push(error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
unmatchedErrors = newUnmatchedErrors;
|
|
152
|
+
unmatchedIndex = unmatchedErrors.length % MAX_UNMATCHED;
|
|
153
|
+
|
|
154
|
+
for (let id of changedSuppressionIds) {
|
|
155
|
+
void ErrorNotificationService.triggerSuppressionUpdate(id);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function onSuppressionsChanged() {
|
|
160
|
+
// NOTE: Update Now also reapplies suppression internally. after it finishes the update.
|
|
161
|
+
void updateNow();
|
|
162
|
+
await reapplySuppressions();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type PatternResponse = {
|
|
166
|
+
pattern: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const PATTERN_GENERATION_LIMIT = 100;
|
|
170
|
+
const PATTERN_GENERATION_WINDOW = 15 * 60 * 1000;
|
|
171
|
+
let patternGenerationTimes: number[] = [];
|
|
172
|
+
|
|
173
|
+
async function generatePatternForError(error: LogDatum): Promise<string> {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const windowStart = now - PATTERN_GENERATION_WINDOW;
|
|
176
|
+
|
|
177
|
+
patternGenerationTimes = patternGenerationTimes.filter(time => time > windowStart);
|
|
178
|
+
|
|
179
|
+
if (patternGenerationTimes.length >= PATTERN_GENERATION_LIMIT) {
|
|
180
|
+
console.log(magenta(`Rate limit exceeded for pattern generation (${patternGenerationTimes.length}/${PATTERN_GENERATION_LIMIT} in last 15 minutes)`));
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
patternGenerationTimes.push(now);
|
|
185
|
+
const openRouterAPIKey = await Querysub.commitAsync(() => atomic(user_data().secure.openRouterAPIKey));
|
|
186
|
+
if (!openRouterAPIKey) {
|
|
187
|
+
throw new Error("OpenRouter API key not set");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(magenta(`Createing pattern for error: ${error.param0}`));
|
|
191
|
+
|
|
192
|
+
const param0 = error.param0 && String(error.param0) || "(no message)";
|
|
193
|
+
|
|
194
|
+
const prompt = `You are helping to categorize error messages. We categorize them by finding specific unique text inside of the error message which will be the same even if the variables and the instance changes.
|
|
195
|
+
|
|
196
|
+
Here is an error message:
|
|
197
|
+
${param0}
|
|
198
|
+
|
|
199
|
+
Please provide a SHORT search string that would match this type of error. The string should:
|
|
200
|
+
- Be specific enough to identify this error type
|
|
201
|
+
- Be generic enough to match similar errors (avoid dynamic values like timestamps, IDs, or specific numbers)
|
|
202
|
+
- Be a simple substring (no special operators)
|
|
203
|
+
|
|
204
|
+
Respond with a JSON object with a "pattern" field containing the search string.`;
|
|
205
|
+
|
|
206
|
+
const response = await callOpenRouterJSON<PatternResponse>(openRouterAPIKey, prompt);
|
|
207
|
+
|
|
208
|
+
const pattern = response.pattern && response.pattern.trim();
|
|
209
|
+
|
|
210
|
+
if (!pattern) {
|
|
211
|
+
throw new Error("AI returned an empty pattern");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return pattern;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function queueDiscordNotification(suppressionId: string, errorCount: number, isNew: boolean) {
|
|
218
|
+
const existing = pendingDiscordNotifications.get(suppressionId);
|
|
219
|
+
if (existing) {
|
|
220
|
+
existing.errorCount += errorCount;
|
|
221
|
+
} else {
|
|
222
|
+
pendingDiscordNotifications.set(suppressionId, { errorCount, isNew });
|
|
223
|
+
}
|
|
224
|
+
void trySendDiscordNotifications(isNew);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function trySendDiscordNotifications(isNew: boolean) {
|
|
228
|
+
if (isNew && isThrottlingNewSuppressions) return;
|
|
229
|
+
if (isThrottlingExistingSuppressions) return;
|
|
230
|
+
if (pendingDiscordNotifications.size === 0) return;
|
|
231
|
+
void trySendDiscordNotificationsBase();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const trySendDiscordNotificationsBase = batchFunction({ delay: 1000 * 3 }, async function trySendDiscordNotificationsBase(nothing: void[]) {
|
|
235
|
+
if (pendingDiscordNotifications.size === 0) return;
|
|
236
|
+
|
|
237
|
+
const webhookURL = await Querysub.commitAsync(() => atomic(user_data().secure.notifyDiscordWebhookURL));
|
|
238
|
+
if (!webhookURL) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
const newSuppressions: Array<{ id: string; count: number; pattern: string }> = [];
|
|
244
|
+
const existingSuppressions: Array<{ id: string; count: number; pattern: string }> = [];
|
|
245
|
+
|
|
246
|
+
for (const [suppressionId, data] of pendingDiscordNotifications.entries()) {
|
|
247
|
+
const suppressionEntry = suppressionCache.find(e => e.id === suppressionId);
|
|
248
|
+
if (!suppressionEntry) continue;
|
|
249
|
+
|
|
250
|
+
if (data.isNew) {
|
|
251
|
+
newSuppressions.push({ id: suppressionId, count: data.errorCount, pattern: suppressionEntry.pattern });
|
|
252
|
+
} else {
|
|
253
|
+
existingSuppressions.push({ id: suppressionId, count: data.errorCount, pattern: suppressionEntry.pattern });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let messageLines: string[] = [];
|
|
258
|
+
|
|
259
|
+
for (const sup of newSuppressions) {
|
|
260
|
+
const line = `(${sup.count}) "${sup.pattern.slice(0, 50)}" [NEW]`;
|
|
261
|
+
if (messageLines.join("\n").length + line.length < 400) {
|
|
262
|
+
messageLines.push(line);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const sup of existingSuppressions) {
|
|
267
|
+
const line = `(${sup.count}) "${sup.pattern.slice(0, 50)}" [EXISTING]`;
|
|
268
|
+
if (messageLines.join("\n").length + line.length < 400) {
|
|
269
|
+
messageLines.push(line);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const link = createLink([
|
|
274
|
+
{ param: showingManagementURL, value: true },
|
|
275
|
+
{ param: managementPageURL, value: "ErrorNotificationPage" },
|
|
276
|
+
]);
|
|
277
|
+
const messageText = messageLines.join("\n");
|
|
278
|
+
const message = `[${messageText}](${link})`;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await sendDiscordMessage({ webhookURL, message });
|
|
282
|
+
|
|
283
|
+
isThrottlingNewSuppressions = true;
|
|
284
|
+
isThrottlingExistingSuppressions = true;
|
|
285
|
+
setTimeout(() => {
|
|
286
|
+
isThrottlingNewSuppressions = false;
|
|
287
|
+
let anyNew = Array.from(pendingDiscordNotifications.values()).some(data => data.isNew);
|
|
288
|
+
void trySendDiscordNotifications(anyNew);
|
|
289
|
+
}, NEW_SUPPRESSION_THROTTLE);
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
isThrottlingExistingSuppressions = false;
|
|
292
|
+
void trySendDiscordNotifications(false);
|
|
293
|
+
}, EXISTING_SUPPRESSION_THROTTLE);
|
|
294
|
+
|
|
295
|
+
pendingDiscordNotifications.clear();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error("Failed to send Discord notification:", error);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
63
301
|
export async function exposeErrorWatchService() {
|
|
64
302
|
SocketFunction.expose(ErrorNotificationServiceBase);
|
|
65
303
|
|
|
66
304
|
let errorLogs = await getErrorLogs();
|
|
67
305
|
for await (let error of watchAllValues(errorLogs)) {
|
|
68
|
-
let errorBuffer = Buffer.from(JSON.stringify(error));
|
|
69
306
|
let suppressionEntries = await getSuppressionEntries();
|
|
70
|
-
let
|
|
71
|
-
|
|
72
|
-
let matcher = getMatcher(suppressionEntry.pattern);
|
|
73
|
-
if (!matcher(errorBuffer)) continue;
|
|
74
|
-
anyMatches = true;
|
|
75
|
-
let history = suppressionMatches.get(suppressionEntry.id);
|
|
76
|
-
if (!history) {
|
|
77
|
-
history = {
|
|
78
|
-
history: new Map(),
|
|
79
|
-
exampleIndex: 0,
|
|
80
|
-
examples: [],
|
|
81
|
-
};
|
|
82
|
-
suppressionMatches.set(suppressionEntry.id, history);
|
|
83
|
-
}
|
|
307
|
+
let suppressed = false;
|
|
308
|
+
let matchedSuppressionId: string | undefined = undefined;
|
|
84
309
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
history.examples.push(error);
|
|
310
|
+
for (let suppressionEntry of suppressionEntries) {
|
|
311
|
+
let status = applySuppression(error, suppressionEntry);
|
|
312
|
+
if (status === "suppressed" || status === "matched-timeout-passed") {
|
|
313
|
+
void ErrorNotificationService.triggerSuppressionUpdate(suppressionEntry.id);
|
|
90
314
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!historyEntry) {
|
|
95
|
-
historyEntry = { count: 0 };
|
|
96
|
-
history.history.set(historyChunk, historyEntry);
|
|
315
|
+
if (status === "suppressed") {
|
|
316
|
+
suppressed = true;
|
|
317
|
+
matchedSuppressionId = suppressionEntry.id;
|
|
97
318
|
}
|
|
98
|
-
historyEntry.count++;
|
|
99
319
|
}
|
|
100
|
-
|
|
320
|
+
|
|
321
|
+
if (suppressed && matchedSuppressionId) {
|
|
322
|
+
queueDiscordNotification(matchedSuppressionId, 1, false);
|
|
323
|
+
} else if (!suppressed) {
|
|
101
324
|
if (unmatchedErrors.length > unmatchedIndex) {
|
|
102
325
|
unmatchedErrors[unmatchedIndex] = error;
|
|
103
326
|
unmatchedIndex = (unmatchedIndex + 1) % MAX_UNMATCHED;
|
|
104
327
|
} else {
|
|
105
328
|
unmatchedErrors.push(error);
|
|
106
329
|
}
|
|
330
|
+
|
|
331
|
+
const pattern = await generatePatternForError(error);
|
|
332
|
+
if (pattern) {
|
|
333
|
+
const newEntry: SuppressionEntry = {
|
|
334
|
+
id: nextId(),
|
|
335
|
+
pattern,
|
|
336
|
+
timeout: 0,
|
|
337
|
+
createdTime: Date.now(),
|
|
338
|
+
lastUpdatedTime: Date.now(),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
suppressionCache.push(newEntry);
|
|
342
|
+
void suppression.set(newEntry.id, newEntry);
|
|
343
|
+
applySuppression(error, newEntry);
|
|
344
|
+
queueDiscordNotification(newEntry.id, 1, true);
|
|
345
|
+
|
|
346
|
+
void ErrorNotificationService.triggerSuppressionUpdate(newEntry.id);
|
|
347
|
+
}
|
|
348
|
+
|
|
107
349
|
void ErrorNotificationService.triggerUnmatchedError(error);
|
|
108
350
|
}
|
|
109
351
|
}
|
|
@@ -117,6 +359,14 @@ class ErrorNotificationService {
|
|
|
117
359
|
};
|
|
118
360
|
}
|
|
119
361
|
|
|
362
|
+
public async getUnmatchedErrorsLimited() {
|
|
363
|
+
// Oldest first, otherwise, while you're trying to deal with one match, it might keep changing, which is annoying
|
|
364
|
+
sort(unmatchedErrors, x => x.time);
|
|
365
|
+
// We have to sort from oldest to first, otherwise the way we write it will be weird, as we write it in the order from oldest to first already. However, we don't want to clobber the ones we're returning, so we start right after them. So we're going to keep the oldest ones for a little bit longer after we call this function.
|
|
366
|
+
unmatchedIndex = 5;
|
|
367
|
+
return unmatchedErrors.slice(0, 5);
|
|
368
|
+
}
|
|
369
|
+
|
|
120
370
|
private static watchersSERVICE = new Set<string>();
|
|
121
371
|
public async watchUnmatchedErrorsSERVICE(): Promise<void> {
|
|
122
372
|
let caller = SocketFunction.getCaller();
|
|
@@ -134,6 +384,67 @@ class ErrorNotificationService {
|
|
|
134
384
|
})();
|
|
135
385
|
}
|
|
136
386
|
});
|
|
387
|
+
|
|
388
|
+
public static triggerSuppressionUpdate = batchFunction({ delay: 100 }, (ids: string[]) => {
|
|
389
|
+
let uniqueIds = new Set(ids);
|
|
390
|
+
let matches: SuppressionMatch[] = [];
|
|
391
|
+
for (let id of uniqueIds) {
|
|
392
|
+
let match = suppressionMatches.get(id);
|
|
393
|
+
if (match) {
|
|
394
|
+
matches.push(match);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (let nodeId of ErrorNotificationService.watchersSERVICE) {
|
|
399
|
+
void (async () => {
|
|
400
|
+
try {
|
|
401
|
+
await ErrorNotificationDataBase.nodes[nodeId].receiveSuppressionHTTP(matches);
|
|
402
|
+
} catch {
|
|
403
|
+
ErrorNotificationService.watchersSERVICE.delete(nodeId);
|
|
404
|
+
}
|
|
405
|
+
})();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
public async getSuppressionEntries() {
|
|
410
|
+
return await getSuppressionEntries();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
public async setSuppressionEntry(entry: SuppressionEntry) {
|
|
414
|
+
let suppressionPromise = suppression.set(entry.id, entry);
|
|
415
|
+
let prevEntry = suppressionCache.findIndex(e => e.id === entry.id);
|
|
416
|
+
if (prevEntry !== -1) {
|
|
417
|
+
suppressionCache[prevEntry] = entry;
|
|
418
|
+
} else {
|
|
419
|
+
suppressionCache.push(entry);
|
|
420
|
+
}
|
|
421
|
+
await onSuppressionsChanged();
|
|
422
|
+
void suppressionPromise.finally(() => {
|
|
423
|
+
void onSuppressionsChanged();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
public async deleteSuppressionEntry(id: string) {
|
|
428
|
+
let suppressionPromise = suppression.delete(id);
|
|
429
|
+
let prevEntry = suppressionCache.findIndex(e => e.id === id);
|
|
430
|
+
if (prevEntry !== -1) {
|
|
431
|
+
suppressionCache.splice(prevEntry, 1);
|
|
432
|
+
}
|
|
433
|
+
await onSuppressionsChanged();
|
|
434
|
+
void suppressionPromise.finally(() => {
|
|
435
|
+
void onSuppressionsChanged();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
public async updateSuppressionNotes(id: string, notes: string | undefined) {
|
|
440
|
+
let entry = suppressionCache.find(e => e.id === id);
|
|
441
|
+
if (!entry) {
|
|
442
|
+
throw new Error(`Suppression entry ${id} not found`);
|
|
443
|
+
}
|
|
444
|
+
let updatedEntry = { ...entry, notes, lastUpdatedTime: Date.now() };
|
|
445
|
+
suppressionCache[suppressionCache.findIndex(e => e.id === id)] = updatedEntry;
|
|
446
|
+
void suppression.set(id, updatedEntry);
|
|
447
|
+
}
|
|
137
448
|
}
|
|
138
449
|
|
|
139
450
|
const ErrorNotificationServiceBase = SocketFunction.register(
|
|
@@ -141,7 +452,12 @@ const ErrorNotificationServiceBase = SocketFunction.register(
|
|
|
141
452
|
new ErrorNotificationService(),
|
|
142
453
|
() => ({
|
|
143
454
|
getData: {},
|
|
455
|
+
getUnmatchedErrorsLimited: {},
|
|
144
456
|
watchUnmatchedErrorsSERVICE: {},
|
|
457
|
+
getSuppressionEntries: {},
|
|
458
|
+
setSuppressionEntry: {},
|
|
459
|
+
deleteSuppressionEntry: {},
|
|
460
|
+
updateSuppressionNotes: {},
|
|
145
461
|
}),
|
|
146
462
|
() => ({
|
|
147
463
|
hooks: [assertIsManagementUser],
|
|
@@ -160,14 +476,26 @@ class ErrorNotificationData {
|
|
|
160
476
|
return await ErrorNotificationServiceBase.nodes[controllerNodeId].getData();
|
|
161
477
|
}
|
|
162
478
|
|
|
163
|
-
|
|
479
|
+
public async getUnmatchedErrorsLimited() {
|
|
480
|
+
let controllerNodeId = await getControllerNodeId(ErrorNotificationServiceBase);
|
|
481
|
+
if (!controllerNodeId) {
|
|
482
|
+
throw new Error(`Could not find node exposing controller ErrorNotificationServiceBase`);
|
|
483
|
+
}
|
|
484
|
+
return await ErrorNotificationServiceBase.nodes[controllerNodeId].getUnmatchedErrorsLimited();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
static browserErrorCallbacks = new Set<(datum: LogDatum[]) => void>();
|
|
488
|
+
static browserSuppressionCallbacks = new Set<(matches: SuppressionMatch[]) => void>();
|
|
164
489
|
public static async watchUnmatchedErrors(config: {
|
|
165
|
-
|
|
490
|
+
errorCallback: (datum: LogDatum[]) => void;
|
|
491
|
+
suppressionCallback: (matches: SuppressionMatch[]) => void;
|
|
166
492
|
}) {
|
|
167
|
-
ErrorNotificationData.
|
|
493
|
+
ErrorNotificationData.browserErrorCallbacks.add(config.errorCallback);
|
|
494
|
+
ErrorNotificationData.browserSuppressionCallbacks.add(config.suppressionCallback);
|
|
168
495
|
await ErrorNotificationData.ensureWatchingErrorsBrowser();
|
|
169
496
|
return () => {
|
|
170
|
-
ErrorNotificationData.
|
|
497
|
+
ErrorNotificationData.browserErrorCallbacks.delete(config.errorCallback);
|
|
498
|
+
ErrorNotificationData.browserSuppressionCallbacks.delete(config.suppressionCallback);
|
|
171
499
|
};
|
|
172
500
|
}
|
|
173
501
|
|
|
@@ -176,11 +504,17 @@ class ErrorNotificationData {
|
|
|
176
504
|
});
|
|
177
505
|
|
|
178
506
|
public async receiveErrorBrowser(datums: LogDatum[]) {
|
|
179
|
-
for (let callback of ErrorNotificationData.
|
|
507
|
+
for (let callback of ErrorNotificationData.browserErrorCallbacks) {
|
|
180
508
|
callback(datums);
|
|
181
509
|
}
|
|
182
510
|
}
|
|
183
511
|
|
|
512
|
+
public async receiveSuppressionBrowser(matches: SuppressionMatch[]) {
|
|
513
|
+
for (let callback of ErrorNotificationData.browserSuppressionCallbacks) {
|
|
514
|
+
callback(matches);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
184
518
|
private watchersHTTP = new Set<string>();
|
|
185
519
|
public async watchUnmatchedErrorsHTTP() {
|
|
186
520
|
let caller = SocketFunction.getCaller();
|
|
@@ -212,33 +546,53 @@ class ErrorNotificationData {
|
|
|
212
546
|
}
|
|
213
547
|
}
|
|
214
548
|
|
|
549
|
+
public async receiveSuppressionHTTP(matches: SuppressionMatch[]) {
|
|
550
|
+
for (let nodeId of this.watchersHTTP) {
|
|
551
|
+
void (async () => {
|
|
552
|
+
try {
|
|
553
|
+
await ErrorNotificationDataBase.nodes[nodeId].receiveSuppressionBrowser(matches);
|
|
554
|
+
} catch {
|
|
555
|
+
this.watchersHTTP.delete(nodeId);
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
215
561
|
public async getSuppressionEntries() {
|
|
216
|
-
|
|
562
|
+
let controllerNodeId = await getControllerNodeId(ErrorNotificationServiceBase);
|
|
563
|
+
if (!controllerNodeId) {
|
|
564
|
+
throw new Error(`Could not find node exposing controller ErrorNotificationServiceBase`);
|
|
565
|
+
}
|
|
566
|
+
return await ErrorNotificationServiceBase.nodes[controllerNodeId].getSuppressionEntries();
|
|
217
567
|
}
|
|
218
568
|
|
|
219
569
|
public async setSuppressionEntry(entry: SuppressionEntry) {
|
|
220
|
-
await
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
suppressionCache[prevEntry] = entry;
|
|
224
|
-
} else {
|
|
225
|
-
suppressionCache.push(entry);
|
|
570
|
+
let controllerNodeId = await getControllerNodeId(ErrorNotificationServiceBase);
|
|
571
|
+
if (!controllerNodeId) {
|
|
572
|
+
throw new Error(`Could not find node exposing controller ErrorNotificationServiceBase`);
|
|
226
573
|
}
|
|
227
|
-
|
|
574
|
+
await ErrorNotificationServiceBase.nodes[controllerNodeId].setSuppressionEntry(entry);
|
|
228
575
|
}
|
|
229
576
|
|
|
230
577
|
public async deleteSuppressionEntry(id: string) {
|
|
231
|
-
await
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
suppressionCache.splice(prevEntry, 1);
|
|
578
|
+
let controllerNodeId = await getControllerNodeId(ErrorNotificationServiceBase);
|
|
579
|
+
if (!controllerNodeId) {
|
|
580
|
+
throw new Error(`Could not find node exposing controller ErrorNotificationServiceBase`);
|
|
235
581
|
}
|
|
236
|
-
|
|
582
|
+
await ErrorNotificationServiceBase.nodes[controllerNodeId].deleteSuppressionEntry(id);
|
|
237
583
|
}
|
|
238
584
|
|
|
239
585
|
public async logTestError(errorMessage: string) {
|
|
240
586
|
console.error(errorMessage);
|
|
241
587
|
}
|
|
588
|
+
|
|
589
|
+
public async updateSuppressionNotes(id: string, notes: string | undefined) {
|
|
590
|
+
let controllerNodeId = await getControllerNodeId(ErrorNotificationServiceBase);
|
|
591
|
+
if (!controllerNodeId) {
|
|
592
|
+
throw new Error(`Could not find node exposing controller ErrorNotificationServiceBase`);
|
|
593
|
+
}
|
|
594
|
+
await ErrorNotificationServiceBase.nodes[controllerNodeId].updateSuppressionNotes(id, notes);
|
|
595
|
+
}
|
|
242
596
|
}
|
|
243
597
|
|
|
244
598
|
const ErrorNotificationDataBase = SocketFunction.register(
|
|
@@ -246,13 +600,17 @@ const ErrorNotificationDataBase = SocketFunction.register(
|
|
|
246
600
|
new ErrorNotificationData(),
|
|
247
601
|
() => ({
|
|
248
602
|
getData: {},
|
|
603
|
+
getUnmatchedErrorsLimited: {},
|
|
249
604
|
receiveErrorBrowser: {},
|
|
605
|
+
receiveSuppressionBrowser: {},
|
|
250
606
|
receiveErrorHTTP: {},
|
|
607
|
+
receiveSuppressionHTTP: {},
|
|
251
608
|
watchUnmatchedErrorsHTTP: {},
|
|
252
609
|
getSuppressionEntries: {},
|
|
253
610
|
setSuppressionEntry: {},
|
|
254
611
|
deleteSuppressionEntry: {},
|
|
255
612
|
logTestError: {},
|
|
613
|
+
updateSuppressionNotes: {},
|
|
256
614
|
}),
|
|
257
615
|
() => ({
|
|
258
616
|
hooks: [assertIsManagementUser],
|
|
@@ -261,9 +619,11 @@ const ErrorNotificationDataBase = SocketFunction.register(
|
|
|
261
619
|
export const ErrorNotificationsController = getSyncedController(ErrorNotificationDataBase);
|
|
262
620
|
|
|
263
621
|
export function watchUnmatchedErrors(config: {
|
|
264
|
-
|
|
622
|
+
errorCallback: (datum: LogDatum[]) => void;
|
|
623
|
+
suppressionCallback: (matches: SuppressionMatch[]) => void;
|
|
265
624
|
}) {
|
|
266
625
|
return ErrorNotificationData.watchUnmatchedErrors({
|
|
267
|
-
|
|
626
|
+
errorCallback: config.errorCallback,
|
|
627
|
+
suppressionCallback: config.suppressionCallback,
|
|
268
628
|
});
|
|
269
|
-
}
|
|
629
|
+
}
|