backtrace-console 0.0.1 → 0.0.2
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/app.js +3 -2
- package/bin/backtrace-server.js +101 -0
- package/bin/www +3 -0
- package/lib/backtrace/constants.js +4 -0
- package/lib/backtrace/query-download.js +117 -0
- package/lib/backtrace/query-session.js +229 -0
- package/lib/backtrace/query.js +11 -445
- package/lib/backtrace/repair.js +35 -0
- package/lib/backtrace/tool.js +34 -3
- package/lib/feishu.js +66 -0
- package/lib/scheduler.js +126 -0
- package/package.json +10 -4
- package/public/chat-components.css +569 -0
- package/public/chat-core.js +635 -0
- package/public/chat-layout.css +290 -0
- package/public/chat-render.js +308 -0
- package/public/chat-send.js +230 -0
- package/public/chat.html +69 -0
- package/public/{__inline_check__.js → index-page.js} +107 -54
- package/public/index.html +1 -505
- package/routes/backtrace-chat.js +389 -0
- package/routes/backtrace-files.js +88 -0
- package/routes/backtrace-fix-plan.js +53 -0
- package/routes/backtrace-run.js +128 -0
- package/routes/backtrace-shared.js +202 -0
- package/routes/backtrace.js +7 -861
package/lib/backtrace/query.js
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
const fs = require("node:fs/promises");
|
|
2
|
-
const fsNative = require("node:fs");
|
|
3
2
|
const path = require("node:path");
|
|
4
|
-
const {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
ensureDirectory,
|
|
8
|
-
normalizeFingerprint,
|
|
9
|
-
getFingerprintLogsRoot,
|
|
10
|
-
} = require("./utils");
|
|
11
|
-
|
|
3
|
+
const { AuthenticationError, BacktraceSession, buildSiblingUrl, buildLoginUrl, withSessionToken } = require("./query-session");
|
|
4
|
+
const { downloadAttachment, downloadAttachmentWithRetry } = require("./query-download");
|
|
5
|
+
const { logStep, ensureDirectory, normalizeFingerprint, getFingerprintLogsRoot } = require("./utils");
|
|
12
6
|
let undiciModule = null;
|
|
13
|
-
|
|
14
7
|
const FINGERPRINT_GROUP_FIELDS = ["fingerprint"];
|
|
15
|
-
const FINGERPRINT_GROUP_FOLD = {
|
|
16
|
-
"error.message": [["head"]],
|
|
17
|
-
classifiers: [["head"]],
|
|
18
|
-
"fingerprint;first_seen": [["head"]],
|
|
19
|
-
"fingerprint;issues;state": [["head"]],
|
|
20
|
-
};
|
|
8
|
+
const FINGERPRINT_GROUP_FOLD = { "error.message": [["head"]], classifiers: [["head"]], "fingerprint;first_seen": [["head"]], "fingerprint;issues;state": [["head"]] };
|
|
21
9
|
const FINGERPRINT_GROUP_ORDER = [{ name: "fingerprint;first_seen;0", ordering: "descending" }];
|
|
22
|
-
|
|
23
10
|
function getUndici() {
|
|
24
11
|
if (!undiciModule) {
|
|
25
12
|
undiciModule = require("undici");
|
|
26
13
|
}
|
|
27
14
|
return undiciModule;
|
|
28
15
|
}
|
|
29
|
-
|
|
30
16
|
function createProxyDispatcher(proxyUrl) {
|
|
31
17
|
const normalizedProxy = String(proxyUrl || "").trim();
|
|
32
18
|
if (!normalizedProxy) {
|
|
@@ -39,29 +25,20 @@ function createProxyDispatcher(proxyUrl) {
|
|
|
39
25
|
throw new Error(`Proxy support requires dependency "undici": ${error instanceof Error ? error.message : String(error)}`);
|
|
40
26
|
}
|
|
41
27
|
}
|
|
42
|
-
|
|
43
28
|
function withDispatcher(options, dispatcher) {
|
|
44
29
|
if (!dispatcher) {
|
|
45
30
|
return options || {};
|
|
46
31
|
}
|
|
47
32
|
return { ...(options || {}), dispatcher };
|
|
48
33
|
}
|
|
49
|
-
|
|
50
|
-
function shouldUseFingerprintGroupQuery(options) {
|
|
51
|
-
return options.command === "fingerprint";
|
|
52
|
-
}
|
|
53
|
-
|
|
34
|
+
function shouldUseFingerprintGroupQuery(options) { return options.command === "fingerprint"; }
|
|
54
35
|
function getRequestedFingerprints(options) {
|
|
55
36
|
return String(options?.fingerprint || "")
|
|
56
37
|
.split(",")
|
|
57
38
|
.map((item) => String(item || "").trim())
|
|
58
39
|
.filter(Boolean);
|
|
59
40
|
}
|
|
60
|
-
|
|
61
|
-
// Backtrace 查询与下载辅助函数,
|
|
62
|
-
// 负责把本地选项转换成 HTTP 请求和磁盘文件产物。
|
|
63
41
|
function buildQueryBody(options, offset, limit) {
|
|
64
|
-
// 时间范围过滤始终生效;fingerprint 过滤只有在用户显式传入时才附加。
|
|
65
42
|
const filter = {
|
|
66
43
|
timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
|
|
67
44
|
};
|
|
@@ -86,7 +63,6 @@ function buildQueryBody(options, offset, limit) {
|
|
|
86
63
|
filter: [filter],
|
|
87
64
|
};
|
|
88
65
|
}
|
|
89
|
-
|
|
90
66
|
function buildFingerprintGroupQueryBody(options, offset, limit, fingerprintFilter) {
|
|
91
67
|
const filter = {
|
|
92
68
|
timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
|
|
@@ -103,7 +79,6 @@ function buildFingerprintGroupQueryBody(options, offset, limit, fingerprintFilte
|
|
|
103
79
|
filter: [filter],
|
|
104
80
|
};
|
|
105
81
|
}
|
|
106
|
-
|
|
107
82
|
function buildCompressedFingerprintQueryBody(options, fingerprint) {
|
|
108
83
|
return {
|
|
109
84
|
select: ["_compressed"],
|
|
@@ -116,7 +91,6 @@ function buildCompressedFingerprintQueryBody(options, fingerprint) {
|
|
|
116
91
|
}],
|
|
117
92
|
};
|
|
118
93
|
}
|
|
119
|
-
|
|
120
94
|
function buildFingerprintObjectsQueryBody(options, fingerprint, offset, limit) {
|
|
121
95
|
return {
|
|
122
96
|
select: ["timestamp"],
|
|
@@ -129,30 +103,6 @@ function buildFingerprintObjectsQueryBody(options, fingerprint, offset, limit) {
|
|
|
129
103
|
}],
|
|
130
104
|
};
|
|
131
105
|
}
|
|
132
|
-
|
|
133
|
-
// 复用原始主机地址,只切换到相邻的 API 路径。
|
|
134
|
-
function buildSiblingUrl(sourceUrl, pathname) {
|
|
135
|
-
const url = new URL(sourceUrl);
|
|
136
|
-
url.pathname = pathname;
|
|
137
|
-
return url;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// 登录接口与查询接口共用同一个 Backtrace 主机。
|
|
141
|
-
function buildLoginUrl(queryUrl) {
|
|
142
|
-
const url = new URL(queryUrl);
|
|
143
|
-
url.pathname = "/api/login";
|
|
144
|
-
url.search = "";
|
|
145
|
-
return url.toString();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 登录后的请求会把静态 token 替换成会话 token。
|
|
149
|
-
function withSessionToken(urlString, sessionToken) {
|
|
150
|
-
const url = new URL(urlString);
|
|
151
|
-
url.searchParams.set("token", sessionToken);
|
|
152
|
-
return url;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 把 object id 统一规范成小写十六进制字符串。
|
|
156
106
|
function toHexObjectId(value) {
|
|
157
107
|
if (typeof value === "string" && /^[0-9a-f]+$/i.test(value)) {
|
|
158
108
|
return value.toLowerCase();
|
|
@@ -163,8 +113,6 @@ function toHexObjectId(value) {
|
|
|
163
113
|
}
|
|
164
114
|
return numeric.toString(16);
|
|
165
115
|
}
|
|
166
|
-
|
|
167
|
-
// 从 Backtrace 查询结果中提取 object id 列表。
|
|
168
116
|
function extractObjectIds(objects) {
|
|
169
117
|
if (!Array.isArray(objects) || objects.length === 0) {
|
|
170
118
|
return [];
|
|
@@ -184,8 +132,6 @@ function extractObjectIds(objects) {
|
|
|
184
132
|
});
|
|
185
133
|
return values;
|
|
186
134
|
}
|
|
187
|
-
|
|
188
|
-
// 把 Backtrace 按列组织的值数组转换成按行组织的对象值。
|
|
189
135
|
function extractSelectedValues(values, selectFields) {
|
|
190
136
|
if (!Array.isArray(values) || values.length === 0) {
|
|
191
137
|
return [];
|
|
@@ -202,8 +148,6 @@ function extractSelectedValues(values, selectFields) {
|
|
|
202
148
|
return row;
|
|
203
149
|
});
|
|
204
150
|
}
|
|
205
|
-
|
|
206
|
-
// 把 object id 和选中字段拼成更易用的单条对象结构。
|
|
207
151
|
function mapResultsToItems(objectIds, selectedValues) {
|
|
208
152
|
return objectIds.map((objectId, index) => ({
|
|
209
153
|
index: index + 1,
|
|
@@ -212,83 +156,29 @@ function mapResultsToItems(objectIds, selectedValues) {
|
|
|
212
156
|
values: selectedValues[index] || {},
|
|
213
157
|
}));
|
|
214
158
|
}
|
|
215
|
-
|
|
216
|
-
// 对较大的结果集使用更大的分页大小,减少请求往返次数。
|
|
217
|
-
function optimizeLimit(totalRows, baseLimit) {
|
|
218
|
-
if (totalRows <= baseLimit) return baseLimit;
|
|
219
|
-
if (totalRows <= 100) return 50;
|
|
220
|
-
if (totalRows <= 500) return 100;
|
|
221
|
-
return 200;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function formatBytes(bytes) {
|
|
225
|
-
const value = Number(bytes || 0);
|
|
226
|
-
if (value >= 1024 * 1024) {
|
|
227
|
-
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
228
|
-
}
|
|
229
|
-
if (value >= 1024) {
|
|
230
|
-
return `${(value / 1024).toFixed(1)} KB`;
|
|
231
|
-
}
|
|
232
|
-
return `${value} B`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function formatAttachmentSpeed(bytes, elapsedMs) {
|
|
236
|
-
if (!elapsedMs || elapsedMs <= 0 || !bytes) {
|
|
237
|
-
return "0 B/s";
|
|
238
|
-
}
|
|
239
|
-
const bytesPerSecond = bytes / (elapsedMs / 1000);
|
|
240
|
-
if (bytesPerSecond >= 1024 * 1024) {
|
|
241
|
-
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
242
|
-
}
|
|
243
|
-
if (bytesPerSecond >= 1024) {
|
|
244
|
-
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
|
245
|
-
}
|
|
246
|
-
return `${Math.max(0, Math.round(bytesPerSecond))} B/s`;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function formatAttachmentProgress(downloadedBytes, totalBytes) {
|
|
250
|
-
if (!totalBytes || totalBytes <= 0) {
|
|
251
|
-
return "";
|
|
252
|
-
}
|
|
253
|
-
return `${Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))}%`;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function formatAttachmentStatus(downloadedBytes, totalBytes, elapsedMs) {
|
|
257
|
-
const progress = formatAttachmentProgress(downloadedBytes, totalBytes);
|
|
258
|
-
const transferred = totalBytes > 0
|
|
259
|
-
? `${formatBytes(downloadedBytes)}/${formatBytes(totalBytes)}`
|
|
260
|
-
: formatBytes(downloadedBytes);
|
|
261
|
-
return [progress, transferred, formatAttachmentSpeed(downloadedBytes, elapsedMs)]
|
|
262
|
-
.filter(Boolean)
|
|
263
|
-
.join(" ");
|
|
264
|
-
}
|
|
265
|
-
|
|
159
|
+
function optimizeLimit(totalRows, baseLimit) { if (totalRows <= baseLimit) return baseLimit; if (totalRows <= 100) return 50; if (totalRows <= 500) return 100; return 200; }
|
|
266
160
|
function unwrapValueCell(value) {
|
|
267
161
|
if (Array.isArray(value) && value.length === 1) {
|
|
268
162
|
return unwrapValueCell(value[0]);
|
|
269
163
|
}
|
|
270
164
|
return value;
|
|
271
165
|
}
|
|
272
|
-
|
|
273
166
|
function normalizeFieldName(value) {
|
|
274
167
|
return String(value || "")
|
|
275
168
|
.replace(/^head\((.+)\)$/i, "$1")
|
|
276
169
|
.replace(/;0$/i, "");
|
|
277
170
|
}
|
|
278
|
-
|
|
279
171
|
function extractGroupedFingerprintItems(response) {
|
|
280
172
|
const values = response?.values;
|
|
281
173
|
if (!Array.isArray(values) || values.length === 0) {
|
|
282
174
|
return [];
|
|
283
175
|
}
|
|
284
176
|
const rows = values;
|
|
285
|
-
|
|
286
177
|
const valueHeaders = Array.isArray(response?.columns_desc) && response.columns_desc.length > 0
|
|
287
178
|
? response.columns_desc.map((entry) => normalizeFieldName(entry?.name))
|
|
288
179
|
: Array.isArray(response?.columns)
|
|
289
180
|
? response.columns.map((entry) => normalizeFieldName(Array.isArray(entry) ? entry[0] : entry))
|
|
290
181
|
: [];
|
|
291
|
-
|
|
292
182
|
return rows.map((entry, index) => {
|
|
293
183
|
const rowValues = Array.isArray(entry) ? entry : [];
|
|
294
184
|
const fingerprint = unwrapValueCell(rowValues[0]);
|
|
@@ -311,176 +201,6 @@ function extractGroupedFingerprintItems(response) {
|
|
|
311
201
|
};
|
|
312
202
|
});
|
|
313
203
|
}
|
|
314
|
-
|
|
315
|
-
class AuthenticationError extends Error {
|
|
316
|
-
constructor(message) {
|
|
317
|
-
super(message);
|
|
318
|
-
this.name = "AuthenticationError";
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
class BacktraceSession {
|
|
323
|
-
constructor(options) {
|
|
324
|
-
this.queryUrl = options.queryUrl;
|
|
325
|
-
this.username = options.username;
|
|
326
|
-
this.password = options.password;
|
|
327
|
-
this.retries = options.retries;
|
|
328
|
-
this.proxy = String(options.proxy || "").trim();
|
|
329
|
-
this.dispatcher = createProxyDispatcher(this.proxy);
|
|
330
|
-
this.token = "";
|
|
331
|
-
this.loginUrl = buildLoginUrl(options.queryUrl);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async login() {
|
|
335
|
-
const form = new URLSearchParams();
|
|
336
|
-
form.set("username", this.username);
|
|
337
|
-
form.set("password", this.password);
|
|
338
|
-
logStep("auth", "logging in", { loginUrl: this.loginUrl, username: this.username });
|
|
339
|
-
|
|
340
|
-
const payload = await retry("backtrace login", this.retries, async () => {
|
|
341
|
-
const response = await fetch(this.loginUrl, {
|
|
342
|
-
method: "POST",
|
|
343
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
344
|
-
body: form.toString(),
|
|
345
|
-
...(this.dispatcher ? { dispatcher: this.dispatcher } : {}),
|
|
346
|
-
});
|
|
347
|
-
const text = await response.text();
|
|
348
|
-
let data = null;
|
|
349
|
-
try {
|
|
350
|
-
data = text ? JSON.parse(text) : null;
|
|
351
|
-
} catch (error) {
|
|
352
|
-
data = null;
|
|
353
|
-
}
|
|
354
|
-
if (!response.ok) {
|
|
355
|
-
throw new Error(`Login failed: ${response.status} ${response.statusText}`);
|
|
356
|
-
}
|
|
357
|
-
if (!data || !data.token) {
|
|
358
|
-
throw new Error("Login response did not contain a token");
|
|
359
|
-
}
|
|
360
|
-
return data;
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
this.token = payload.token;
|
|
364
|
-
logStep("auth", "login succeeded");
|
|
365
|
-
return this.token;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
async getToken() {
|
|
369
|
-
if (!this.token) {
|
|
370
|
-
await this.login();
|
|
371
|
-
}
|
|
372
|
-
return this.token;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async refreshToken() {
|
|
376
|
-
logStep("auth", "refreshing session token");
|
|
377
|
-
this.token = "";
|
|
378
|
-
return this.login();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
buildHeaders(extraHeaders, token) {
|
|
382
|
-
return { ...(extraHeaders || {}), Cookie: `token=${token}` };
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
parseResponseBody(text) {
|
|
386
|
-
try {
|
|
387
|
-
return text ? JSON.parse(text) : null;
|
|
388
|
-
} catch (error) {
|
|
389
|
-
return text;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
isAuthFailure(response, parsedBody) {
|
|
394
|
-
const normalizedBody = typeof parsedBody === "string"
|
|
395
|
-
? parsedBody.toLowerCase()
|
|
396
|
-
: JSON.stringify(parsedBody || {}).toLowerCase();
|
|
397
|
-
return response.status === 401
|
|
398
|
-
|| response.status === 403
|
|
399
|
-
|| normalizedBody.includes("invalid token")
|
|
400
|
-
|| normalizedBody.includes("authentication")
|
|
401
|
-
|| normalizedBody.includes("not authorized");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async request(urlString, options, label, mode) {
|
|
405
|
-
return retry(label, this.retries, async () => {
|
|
406
|
-
let token = await this.getToken();
|
|
407
|
-
let authAttempt = 0;
|
|
408
|
-
while (authAttempt < 2) {
|
|
409
|
-
const url = withSessionToken(urlString, token);
|
|
410
|
-
const response = await fetch(
|
|
411
|
-
url.toString(),
|
|
412
|
-
withDispatcher({ ...options, headers: this.buildHeaders(options?.headers, token) }, this.dispatcher),
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
if (mode === "buffer") {
|
|
416
|
-
if (response.ok) {
|
|
417
|
-
return Buffer.from(await response.arrayBuffer());
|
|
418
|
-
}
|
|
419
|
-
const text = await response.text();
|
|
420
|
-
const parsed = this.parseResponseBody(text);
|
|
421
|
-
if (this.isAuthFailure(response, parsed) && authAttempt === 0) {
|
|
422
|
-
await this.refreshToken();
|
|
423
|
-
token = await this.getToken();
|
|
424
|
-
authAttempt += 1;
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const text = await response.text();
|
|
431
|
-
const parsed = this.parseResponseBody(text);
|
|
432
|
-
if (response.ok) {
|
|
433
|
-
return parsed;
|
|
434
|
-
}
|
|
435
|
-
if (this.isAuthFailure(response, parsed) && authAttempt === 0) {
|
|
436
|
-
await this.refreshToken();
|
|
437
|
-
token = await this.getToken();
|
|
438
|
-
authAttempt += 1;
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
|
442
|
-
}
|
|
443
|
-
throw new AuthenticationError("Authentication failed after token refresh");
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
async requestJson(urlString, options, label) {
|
|
448
|
-
return this.request(urlString, options, label, "json");
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async requestBuffer(urlString, options, label) {
|
|
452
|
-
return this.request(urlString, options, label, "buffer");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async requestStream(urlString, options, label) {
|
|
456
|
-
return retry(label, this.retries, async () => {
|
|
457
|
-
let token = await this.getToken();
|
|
458
|
-
let authAttempt = 0;
|
|
459
|
-
while (authAttempt < 2) {
|
|
460
|
-
const url = withSessionToken(urlString, token);
|
|
461
|
-
const response = await fetch(
|
|
462
|
-
url.toString(),
|
|
463
|
-
withDispatcher({ ...options, headers: this.buildHeaders(options?.headers, token) }, this.dispatcher),
|
|
464
|
-
);
|
|
465
|
-
if (response.ok) {
|
|
466
|
-
return response;
|
|
467
|
-
}
|
|
468
|
-
const text = await response.text();
|
|
469
|
-
const parsed = this.parseResponseBody(text);
|
|
470
|
-
if (this.isAuthFailure(response, parsed) && authAttempt === 0) {
|
|
471
|
-
await this.refreshToken();
|
|
472
|
-
token = await this.getToken();
|
|
473
|
-
authAttempt += 1;
|
|
474
|
-
continue;
|
|
475
|
-
}
|
|
476
|
-
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
|
477
|
-
}
|
|
478
|
-
throw new AuthenticationError("Authentication failed after token refresh");
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// 拉取一页查询结果,并记录请求体方便排查问题。
|
|
484
204
|
async function fetchBacktracePage(session, queryUrl, queryBody) {
|
|
485
205
|
logStep("query", "sending query page", queryBody);
|
|
486
206
|
return session.requestJson(
|
|
@@ -489,7 +209,6 @@ async function fetchBacktracePage(session, queryUrl, queryBody) {
|
|
|
489
209
|
`query offset=${queryBody.offset} limit=${queryBody.limit}`,
|
|
490
210
|
);
|
|
491
211
|
}
|
|
492
|
-
|
|
493
212
|
async function fetchFingerprintProbe(session, options, fingerprint) {
|
|
494
213
|
const queryBody = buildCompressedFingerprintQueryBody(options, fingerprint);
|
|
495
214
|
const result = await fetchBacktracePage(session, options.queryUrl, queryBody);
|
|
@@ -499,7 +218,6 @@ async function fetchFingerprintProbe(session, options, fingerprint) {
|
|
|
499
218
|
payload: result,
|
|
500
219
|
};
|
|
501
220
|
}
|
|
502
|
-
|
|
503
221
|
async function queryFingerprintGroups(session, options, fingerprintFilter = "") {
|
|
504
222
|
const probeBody = buildFingerprintGroupQueryBody(options, options.offset, options.limit, fingerprintFilter);
|
|
505
223
|
const probeResult = await fetchBacktracePage(session, options.queryUrl, probeBody);
|
|
@@ -508,14 +226,12 @@ async function queryFingerprintGroups(session, options, fingerprintFilter = "")
|
|
|
508
226
|
?? probeResult?.response?.cardinalities?.initial?.groups
|
|
509
227
|
?? 0;
|
|
510
228
|
const pageLimit = optimizeLimit(totalRows, options.limit);
|
|
511
|
-
|
|
512
229
|
logStep("query", "fingerprint pagination decided", {
|
|
513
230
|
totalRows,
|
|
514
231
|
initialLimit: options.limit,
|
|
515
232
|
optimizedLimit: pageLimit,
|
|
516
233
|
fingerprint: fingerprintFilter || "all",
|
|
517
234
|
});
|
|
518
|
-
|
|
519
235
|
const items = [];
|
|
520
236
|
for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
|
|
521
237
|
/* eslint-disable no-await-in-loop */
|
|
@@ -530,7 +246,6 @@ async function queryFingerprintGroups(session, options, fingerprintFilter = "")
|
|
|
530
246
|
break;
|
|
531
247
|
}
|
|
532
248
|
}
|
|
533
|
-
|
|
534
249
|
return {
|
|
535
250
|
totalRows,
|
|
536
251
|
pageLimit,
|
|
@@ -540,7 +255,6 @@ async function queryFingerprintGroups(session, options, fingerprintFilter = "")
|
|
|
540
255
|
items,
|
|
541
256
|
};
|
|
542
257
|
}
|
|
543
|
-
|
|
544
258
|
async function fetchFingerprintObjects(session, options, fingerprint, knownTotalRows) {
|
|
545
259
|
const totalRows = Number.isFinite(knownTotalRows)
|
|
546
260
|
? knownTotalRows
|
|
@@ -549,7 +263,6 @@ async function fetchFingerprintObjects(session, options, fingerprint, knownTotal
|
|
|
549
263
|
})();
|
|
550
264
|
const pageLimit = optimizeLimit(totalRows, options.limit);
|
|
551
265
|
const objects = [];
|
|
552
|
-
|
|
553
266
|
for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
|
|
554
267
|
/* eslint-disable no-await-in-loop */
|
|
555
268
|
const result = await fetchBacktracePage(
|
|
@@ -571,7 +284,6 @@ async function fetchFingerprintObjects(session, options, fingerprint, knownTotal
|
|
|
571
284
|
break;
|
|
572
285
|
}
|
|
573
286
|
}
|
|
574
|
-
|
|
575
287
|
return {
|
|
576
288
|
fingerprint,
|
|
577
289
|
totalRows,
|
|
@@ -579,7 +291,6 @@ async function fetchFingerprintObjects(session, options, fingerprint, knownTotal
|
|
|
579
291
|
objects,
|
|
580
292
|
};
|
|
581
293
|
}
|
|
582
|
-
|
|
583
294
|
async function queryFingerprintDetail(session, options) {
|
|
584
295
|
const requestedFingerprints = getRequestedFingerprints(options);
|
|
585
296
|
const fingerprint = requestedFingerprints[0];
|
|
@@ -597,7 +308,6 @@ async function queryFingerprintDetail(session, options) {
|
|
|
597
308
|
};
|
|
598
309
|
const probe = await fetchFingerprintProbe(session, options, fingerprint);
|
|
599
310
|
const objectsResult = await fetchFingerprintObjects(session, options, fingerprint, probe.totalRows);
|
|
600
|
-
|
|
601
311
|
return {
|
|
602
312
|
fingerprint,
|
|
603
313
|
summary,
|
|
@@ -612,10 +322,7 @@ async function queryFingerprintDetail(session, options) {
|
|
|
612
322
|
},
|
|
613
323
|
};
|
|
614
324
|
}
|
|
615
|
-
|
|
616
|
-
// 先探测总行数,再用优化后的分页大小拉取全部结果页。
|
|
617
325
|
async function fetchAllResults(session, options) {
|
|
618
|
-
// 先做一次探测请求,拿到总行数后再决定真正的分页大小。
|
|
619
326
|
const probeBody = buildQueryBody(options, options.offset, options.limit);
|
|
620
327
|
const probeResult = await fetchBacktracePage(session, options.queryUrl, probeBody);
|
|
621
328
|
const totalRows = shouldUseFingerprintGroupQuery(options)
|
|
@@ -625,11 +332,8 @@ async function fetchAllResults(session, options) {
|
|
|
625
332
|
?? 0
|
|
626
333
|
: probeResult?._?.runtime?.filter?.rows ?? 0;
|
|
627
334
|
const pageLimit = optimizeLimit(totalRows, options.limit);
|
|
628
|
-
|
|
629
335
|
logStep("query", "query pagination decided", { totalRows, initialLimit: options.limit, optimizedLimit: pageLimit });
|
|
630
|
-
|
|
631
336
|
const pages = [];
|
|
632
|
-
// 即使结果为空,也保留一页请求,保持空结果场景的行为一致。
|
|
633
337
|
for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
|
|
634
338
|
pages.push({ offset, limit: pageLimit });
|
|
635
339
|
if (totalRows === 0) break;
|
|
@@ -637,16 +341,13 @@ async function fetchAllResults(session, options) {
|
|
|
637
341
|
if (pages.length === 0) {
|
|
638
342
|
pages.push({ offset: options.offset, limit: pageLimit });
|
|
639
343
|
}
|
|
640
|
-
|
|
641
344
|
const allObjectIds = [];
|
|
642
345
|
const allSelectedValues = [];
|
|
643
|
-
// 顺序拉取每一页,避免一次性并发查询过多导致远端接口不稳定。
|
|
644
346
|
for (const page of pages) {
|
|
645
347
|
/* eslint-disable no-await-in-loop */
|
|
646
348
|
const result = await fetchBacktracePage(session, options.queryUrl, buildQueryBody(options, page.offset, page.limit));
|
|
647
349
|
/* eslint-enable no-await-in-loop */
|
|
648
350
|
if (shouldUseFingerprintGroupQuery(options)) {
|
|
649
|
-
// 指纹聚合查询不依赖 object id,后续直接从 values 中还原每一行。
|
|
650
351
|
const groupedItems = extractGroupedFingerprintItems(result?.response);
|
|
651
352
|
allObjectIds.push(...groupedItems.map((item) => item.fingerprint || ""));
|
|
652
353
|
allSelectedValues.push(...groupedItems.map((item) => item.values));
|
|
@@ -655,124 +356,22 @@ async function fetchAllResults(session, options) {
|
|
|
655
356
|
allSelectedValues.push(...extractSelectedValues(result?.response?.values, options.select));
|
|
656
357
|
}
|
|
657
358
|
}
|
|
658
|
-
|
|
659
359
|
return { totalRows, pageLimit, listedCount: allObjectIds.length, objectIds: allObjectIds, selectedValues: allSelectedValues };
|
|
660
360
|
}
|
|
661
|
-
|
|
662
|
-
// 列出某个 Backtrace 对象可下载的附件。
|
|
663
361
|
async function fetchAttachmentList(session, queryUrl, objectHexId) {
|
|
362
|
+
const source = new URL(queryUrl);
|
|
664
363
|
const url = buildSiblingUrl(queryUrl, "/api/list");
|
|
364
|
+
const project = source.searchParams.get("project");
|
|
365
|
+
if (project) url.searchParams.set("project", project);
|
|
665
366
|
url.searchParams.set("object", objectHexId);
|
|
666
367
|
logStep("attachments", "requesting attachment list", url.toString());
|
|
667
368
|
return session.requestJson(url.toString(), { method: "GET" }, `attachment list ${objectHexId}`);
|
|
668
369
|
}
|
|
669
|
-
|
|
670
|
-
// 下载单个附件到本地;如果本地已存在则直接复用。
|
|
671
|
-
async function downloadAttachment(session, queryUrl, objectHexId, targetDir, attachment) {
|
|
672
|
-
const savedPath = path.join(targetDir, attachment.name);
|
|
673
|
-
const existing = await fs.stat(savedPath).catch(() => null);
|
|
674
|
-
if (existing && existing.isFile()) {
|
|
675
|
-
logStep("download", "attachment already exists, skipping", savedPath);
|
|
676
|
-
return { ...attachment, savedPath, skipped: true, sizeBytes: existing.size || 0 };
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
const url = buildSiblingUrl(queryUrl, "/api/get");
|
|
680
|
-
// 下载接口需要同时带上 object 和 attachment_id 才能定位到具体附件。
|
|
681
|
-
url.searchParams.set("object", objectHexId);
|
|
682
|
-
url.searchParams.set("attachment_id", attachment.id);
|
|
683
|
-
url.searchParams.set("attachment_inline", "false");
|
|
684
|
-
logStep("download", "downloading attachment", { objectId: objectHexId, attachment: attachment.name });
|
|
685
|
-
const response = await session.requestStream(url.toString(), {}, `download ${objectHexId}/${attachment.name}`);
|
|
686
|
-
const totalBytes = Number(response.headers.get("content-length") || attachment.size || 0);
|
|
687
|
-
const fileStream = fsNative.createWriteStream(savedPath);
|
|
688
|
-
const startedAt = Date.now();
|
|
689
|
-
let downloadedBytes = 0;
|
|
690
|
-
let progressTimer = null;
|
|
691
|
-
const printProgress = () => {
|
|
692
|
-
const elapsedMs = Math.max(1, Date.now() - startedAt);
|
|
693
|
-
logStep("download", `file ${formatAttachmentStatus(downloadedBytes, totalBytes, elapsedMs)}`, {
|
|
694
|
-
objectId: objectHexId,
|
|
695
|
-
attachment: attachment.name,
|
|
696
|
-
});
|
|
697
|
-
};
|
|
698
|
-
try {
|
|
699
|
-
printProgress();
|
|
700
|
-
progressTimer = setInterval(() => {
|
|
701
|
-
printProgress();
|
|
702
|
-
}, 1000);
|
|
703
|
-
|
|
704
|
-
for await (const chunk of response.body) {
|
|
705
|
-
downloadedBytes += chunk.length;
|
|
706
|
-
if (!fileStream.write(chunk)) {
|
|
707
|
-
/* eslint-disable no-await-in-loop */
|
|
708
|
-
await new Promise((resolve) => {
|
|
709
|
-
fileStream.once("drain", resolve);
|
|
710
|
-
});
|
|
711
|
-
/* eslint-enable no-await-in-loop */
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
await new Promise((resolve, reject) => {
|
|
715
|
-
fileStream.end((error) => {
|
|
716
|
-
if (error) {
|
|
717
|
-
reject(error);
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
resolve();
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
} catch (error) {
|
|
724
|
-
if (progressTimer) {
|
|
725
|
-
clearInterval(progressTimer);
|
|
726
|
-
progressTimer = null;
|
|
727
|
-
}
|
|
728
|
-
fileStream.destroy();
|
|
729
|
-
await fs.unlink(savedPath).catch(() => {});
|
|
730
|
-
throw error;
|
|
731
|
-
}
|
|
732
|
-
if (progressTimer) {
|
|
733
|
-
clearInterval(progressTimer);
|
|
734
|
-
progressTimer = null;
|
|
735
|
-
}
|
|
736
|
-
printProgress();
|
|
737
|
-
logStep("download", "attachment saved", savedPath);
|
|
738
|
-
return { ...attachment, savedPath, sizeBytes: downloadedBytes };
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
async function downloadAttachmentWithRetry(session, queryUrl, objectHexId, targetDir, attachment, maxAttempts = 5) {
|
|
742
|
-
let lastError = null;
|
|
743
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
744
|
-
try {
|
|
745
|
-
/* eslint-disable no-await-in-loop */
|
|
746
|
-
return await downloadAttachment(session, queryUrl, objectHexId, targetDir, attachment);
|
|
747
|
-
/* eslint-enable no-await-in-loop */
|
|
748
|
-
} catch (error) {
|
|
749
|
-
lastError = error;
|
|
750
|
-
logStep("download", "attachment download failed", {
|
|
751
|
-
objectId: objectHexId,
|
|
752
|
-
attachment: attachment.name,
|
|
753
|
-
attempt,
|
|
754
|
-
maxAttempts,
|
|
755
|
-
message: error instanceof Error ? error.message : String(error),
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
logStep("download", "attachment skipped after retries", {
|
|
761
|
-
objectId: objectHexId,
|
|
762
|
-
attachment: attachment.name,
|
|
763
|
-
maxAttempts,
|
|
764
|
-
message: lastError instanceof Error ? lastError.message : String(lastError || ""),
|
|
765
|
-
});
|
|
766
|
-
return { ...attachment, savedPath: "", skipped: true, failed: true };
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// 查询所有匹配对象,并同时返回原始查询摘要和映射后的对象列表。
|
|
770
370
|
async function queryAllItems(session, options) {
|
|
771
371
|
if (options.command === "collect-all") {
|
|
772
372
|
const requestedFingerprints = getRequestedFingerprints(options);
|
|
773
373
|
const fingerprintItems = [];
|
|
774
374
|
let fingerprintPageLimit = 0;
|
|
775
|
-
|
|
776
375
|
if (requestedFingerprints.length === 0) {
|
|
777
376
|
const fingerprintResult = await queryFingerprintGroups(session, options);
|
|
778
377
|
fingerprintItems.push(...fingerprintResult.items);
|
|
@@ -786,27 +385,23 @@ async function queryAllItems(session, options) {
|
|
|
786
385
|
/* eslint-enable no-await-in-loop */
|
|
787
386
|
}
|
|
788
387
|
}
|
|
789
|
-
|
|
790
388
|
const uniqueFingerprintItems = Array.from(
|
|
791
389
|
new Map(fingerprintItems.map((item) => [String(item.fingerprint || item.values.fingerprint || "").trim(), item])).values(),
|
|
792
390
|
).filter((item) => String(item.fingerprint || item.values.fingerprint || "").trim());
|
|
793
391
|
const items = [];
|
|
794
392
|
let totalRows = 0;
|
|
795
393
|
let pageLimit = fingerprintPageLimit;
|
|
796
|
-
|
|
797
394
|
for (const fingerprintItem of uniqueFingerprintItems) {
|
|
798
395
|
const fingerprint = String(fingerprintItem.fingerprint || fingerprintItem.values.fingerprint || "").trim();
|
|
799
396
|
if (!fingerprint) {
|
|
800
397
|
continue;
|
|
801
398
|
}
|
|
802
|
-
|
|
803
399
|
/* eslint-disable no-await-in-loop */
|
|
804
400
|
const probe = await fetchFingerprintProbe(session, options, fingerprint);
|
|
805
401
|
if (probe.totalRows === 0) {
|
|
806
402
|
logStep("query", "fingerprint probe returned no rows", { fingerprint });
|
|
807
403
|
continue;
|
|
808
404
|
}
|
|
809
|
-
|
|
810
405
|
const objectResult = await fetchFingerprintObjects(session, options, fingerprint, probe.totalRows);
|
|
811
406
|
totalRows += objectResult.totalRows;
|
|
812
407
|
pageLimit = Math.max(pageLimit, objectResult.pageLimit);
|
|
@@ -829,13 +424,11 @@ async function queryAllItems(session, options) {
|
|
|
829
424
|
});
|
|
830
425
|
/* eslint-enable no-await-in-loop */
|
|
831
426
|
}
|
|
832
|
-
|
|
833
427
|
logStep("query", "loaded query results", {
|
|
834
428
|
fingerprintCount: uniqueFingerprintItems.length,
|
|
835
429
|
fetchedRows: totalRows,
|
|
836
430
|
objectCount: items.length,
|
|
837
431
|
});
|
|
838
|
-
|
|
839
432
|
return {
|
|
840
433
|
totalRows,
|
|
841
434
|
pageLimit,
|
|
@@ -846,7 +439,6 @@ async function queryAllItems(session, options) {
|
|
|
846
439
|
fingerprints: uniqueFingerprintItems,
|
|
847
440
|
};
|
|
848
441
|
}
|
|
849
|
-
|
|
850
442
|
const result = await fetchAllResults(session, options);
|
|
851
443
|
const items = result.selectedValues.map((values, index) => ({
|
|
852
444
|
index: index + 1,
|
|
@@ -858,8 +450,6 @@ async function queryAllItems(session, options) {
|
|
|
858
450
|
logStep("query", "loaded query results", { fetchedRows: result.listedCount, objectCount: items.length });
|
|
859
451
|
return { ...result, items };
|
|
860
452
|
}
|
|
861
|
-
|
|
862
|
-
// 把单个崩溃对象的全部附件下载到对应 fingerprint/object 目录下。
|
|
863
453
|
async function downloadItemLogs(session, item, options) {
|
|
864
454
|
const objectHexId = item.objectIdHex;
|
|
865
455
|
const fingerprint = normalizeFingerprint(item.fingerprint || item.values.fingerprint || options.fingerprint);
|
|
@@ -894,12 +484,10 @@ async function downloadItemLogs(session, item, options) {
|
|
|
894
484
|
const attachments = Array.isArray(attachmentList.attachments) ? attachmentList.attachments : [];
|
|
895
485
|
const targetDir = path.join(getFingerprintLogsRoot(options.storageDir, fingerprint), objectHexId);
|
|
896
486
|
await ensureDirectory(targetDir);
|
|
897
|
-
|
|
898
487
|
const downloadedFiles = [];
|
|
899
|
-
// 单对象内部仍按顺序下载,便于日志追踪,也避免附件下载把会话压垮。
|
|
900
488
|
for (const attachment of attachments) {
|
|
901
489
|
/* eslint-disable no-await-in-loop */
|
|
902
|
-
const downloadedFile = await downloadAttachmentWithRetry(session, options.queryUrl, objectHexId, targetDir, attachment, 5);
|
|
490
|
+
const downloadedFile = await downloadAttachmentWithRetry(session, options.queryUrl, objectHexId, targetDir, attachment, buildSiblingUrl, 5);
|
|
903
491
|
downloadedFiles.push(downloadedFile);
|
|
904
492
|
if (typeof options.onAttachmentDownloaded === "function") {
|
|
905
493
|
options.onAttachmentDownloaded({
|
|
@@ -912,29 +500,7 @@ async function downloadItemLogs(session, item, options) {
|
|
|
912
500
|
}
|
|
913
501
|
/* eslint-enable no-await-in-loop */
|
|
914
502
|
}
|
|
915
|
-
|
|
916
503
|
logStep("download", "object download completed", { objectId: objectHexId, fileCount: downloadedFiles.length, targetDir, fingerprint });
|
|
917
504
|
return { ...item, values: { ...item.values, fingerprint }, fingerprint, targetDir, downloadedFiles };
|
|
918
505
|
}
|
|
919
|
-
|
|
920
|
-
module.exports = {
|
|
921
|
-
AuthenticationError,
|
|
922
|
-
BacktraceSession,
|
|
923
|
-
buildQueryBody,
|
|
924
|
-
buildSiblingUrl,
|
|
925
|
-
buildLoginUrl,
|
|
926
|
-
withSessionToken,
|
|
927
|
-
toHexObjectId,
|
|
928
|
-
extractObjectIds,
|
|
929
|
-
extractSelectedValues,
|
|
930
|
-
mapResultsToItems,
|
|
931
|
-
optimizeLimit,
|
|
932
|
-
fetchBacktracePage,
|
|
933
|
-
fetchAllResults,
|
|
934
|
-
queryFingerprintGroups,
|
|
935
|
-
queryFingerprintDetail,
|
|
936
|
-
fetchAttachmentList,
|
|
937
|
-
downloadAttachment,
|
|
938
|
-
queryAllItems,
|
|
939
|
-
downloadItemLogs,
|
|
940
|
-
};
|
|
506
|
+
module.exports = { AuthenticationError, BacktraceSession, buildQueryBody, buildSiblingUrl, buildLoginUrl, withSessionToken, toHexObjectId, extractObjectIds, extractSelectedValues, mapResultsToItems, optimizeLimit, fetchBacktracePage, fetchAllResults, queryFingerprintGroups, queryFingerprintDetail, fetchAttachmentList, downloadAttachment, queryAllItems, downloadItemLogs };
|