backtrace-console 0.0.1

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.
@@ -0,0 +1,940 @@
1
+ const fs = require("node:fs/promises");
2
+ const fsNative = require("node:fs");
3
+ const path = require("node:path");
4
+ const {
5
+ logStep,
6
+ retry,
7
+ ensureDirectory,
8
+ normalizeFingerprint,
9
+ getFingerprintLogsRoot,
10
+ } = require("./utils");
11
+
12
+ let undiciModule = null;
13
+
14
+ 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
+ };
21
+ const FINGERPRINT_GROUP_ORDER = [{ name: "fingerprint;first_seen;0", ordering: "descending" }];
22
+
23
+ function getUndici() {
24
+ if (!undiciModule) {
25
+ undiciModule = require("undici");
26
+ }
27
+ return undiciModule;
28
+ }
29
+
30
+ function createProxyDispatcher(proxyUrl) {
31
+ const normalizedProxy = String(proxyUrl || "").trim();
32
+ if (!normalizedProxy) {
33
+ return null;
34
+ }
35
+ try {
36
+ const { ProxyAgent } = getUndici();
37
+ return new ProxyAgent(normalizedProxy);
38
+ } catch (error) {
39
+ throw new Error(`Proxy support requires dependency "undici": ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ }
42
+
43
+ function withDispatcher(options, dispatcher) {
44
+ if (!dispatcher) {
45
+ return options || {};
46
+ }
47
+ return { ...(options || {}), dispatcher };
48
+ }
49
+
50
+ function shouldUseFingerprintGroupQuery(options) {
51
+ return options.command === "fingerprint";
52
+ }
53
+
54
+ function getRequestedFingerprints(options) {
55
+ return String(options?.fingerprint || "")
56
+ .split(",")
57
+ .map((item) => String(item || "").trim())
58
+ .filter(Boolean);
59
+ }
60
+
61
+ // Backtrace 查询与下载辅助函数,
62
+ // 负责把本地选项转换成 HTTP 请求和磁盘文件产物。
63
+ function buildQueryBody(options, offset, limit) {
64
+ // 时间范围过滤始终生效;fingerprint 过滤只有在用户显式传入时才附加。
65
+ const filter = {
66
+ timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
67
+ };
68
+ if (options.fingerprint) {
69
+ filter.fingerprint = [["contains", String(options.fingerprint)]];
70
+ }
71
+ if (shouldUseFingerprintGroupQuery(options)) {
72
+ return {
73
+ group: FINGERPRINT_GROUP_FIELDS,
74
+ fold: FINGERPRINT_GROUP_FOLD,
75
+ order: FINGERPRINT_GROUP_ORDER,
76
+ offset,
77
+ limit,
78
+ filter: [filter],
79
+ };
80
+ }
81
+ return {
82
+ select: options.select,
83
+ order: [{ name: "timestamp", ordering: "descending" }],
84
+ offset,
85
+ limit,
86
+ filter: [filter],
87
+ };
88
+ }
89
+
90
+ function buildFingerprintGroupQueryBody(options, offset, limit, fingerprintFilter) {
91
+ const filter = {
92
+ timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
93
+ };
94
+ if (fingerprintFilter) {
95
+ filter.fingerprint = [["contains", String(fingerprintFilter)]];
96
+ }
97
+ return {
98
+ group: FINGERPRINT_GROUP_FIELDS,
99
+ fold: FINGERPRINT_GROUP_FOLD,
100
+ order: FINGERPRINT_GROUP_ORDER,
101
+ offset,
102
+ limit,
103
+ filter: [filter],
104
+ };
105
+ }
106
+
107
+ function buildCompressedFingerprintQueryBody(options, fingerprint) {
108
+ return {
109
+ select: ["_compressed"],
110
+ order: [{ name: "timestamp", ordering: "descending" }],
111
+ offset: 0,
112
+ limit: 1,
113
+ filter: [{
114
+ fingerprint: [["contains", String(fingerprint)]],
115
+ timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
116
+ }],
117
+ };
118
+ }
119
+
120
+ function buildFingerprintObjectsQueryBody(options, fingerprint, offset, limit) {
121
+ return {
122
+ select: ["timestamp"],
123
+ order: [{ name: "timestamp", ordering: "descending" }],
124
+ offset,
125
+ limit,
126
+ filter: [{
127
+ fingerprint: [["contains", String(fingerprint)]],
128
+ timestamp: [["at-most", String(options.to)], ["at-least", String(options.from)]],
129
+ }],
130
+ };
131
+ }
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
+ function toHexObjectId(value) {
157
+ if (typeof value === "string" && /^[0-9a-f]+$/i.test(value)) {
158
+ return value.toLowerCase();
159
+ }
160
+ const numeric = Number(value);
161
+ if (!Number.isFinite(numeric)) {
162
+ return String(value);
163
+ }
164
+ return numeric.toString(16);
165
+ }
166
+
167
+ // 从 Backtrace 查询结果中提取 object id 列表。
168
+ function extractObjectIds(objects) {
169
+ if (!Array.isArray(objects) || objects.length === 0) {
170
+ return [];
171
+ }
172
+ const values = [];
173
+ objects.forEach((group) => {
174
+ const [, rows] = group || [];
175
+ if (!Array.isArray(rows)) {
176
+ return;
177
+ }
178
+ rows.forEach((row) => {
179
+ const value = Array.isArray(row) ? row[0] : null;
180
+ if (value !== null && value !== undefined) {
181
+ values.push(value);
182
+ }
183
+ });
184
+ });
185
+ return values;
186
+ }
187
+
188
+ // 把 Backtrace 按列组织的值数组转换成按行组织的对象值。
189
+ function extractSelectedValues(values, selectFields) {
190
+ if (!Array.isArray(values) || values.length === 0) {
191
+ return [];
192
+ }
193
+ const [, ...entries] = values[0];
194
+ return entries.map((entry) => {
195
+ const row = {};
196
+ selectFields.forEach((field, index) => {
197
+ row[field] = Array.isArray(entry) ? entry[index] : undefined;
198
+ });
199
+ if (Array.isArray(entry) && entry.length > selectFields.length) {
200
+ row._count = entry[entry.length - 1];
201
+ }
202
+ return row;
203
+ });
204
+ }
205
+
206
+ // 把 object id 和选中字段拼成更易用的单条对象结构。
207
+ function mapResultsToItems(objectIds, selectedValues) {
208
+ return objectIds.map((objectId, index) => ({
209
+ index: index + 1,
210
+ objectIdDecimal: objectId,
211
+ objectIdHex: toHexObjectId(objectId),
212
+ values: selectedValues[index] || {},
213
+ }));
214
+ }
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
+
266
+ function unwrapValueCell(value) {
267
+ if (Array.isArray(value) && value.length === 1) {
268
+ return unwrapValueCell(value[0]);
269
+ }
270
+ return value;
271
+ }
272
+
273
+ function normalizeFieldName(value) {
274
+ return String(value || "")
275
+ .replace(/^head\((.+)\)$/i, "$1")
276
+ .replace(/;0$/i, "");
277
+ }
278
+
279
+ function extractGroupedFingerprintItems(response) {
280
+ const values = response?.values;
281
+ if (!Array.isArray(values) || values.length === 0) {
282
+ return [];
283
+ }
284
+ const rows = values;
285
+
286
+ const valueHeaders = Array.isArray(response?.columns_desc) && response.columns_desc.length > 0
287
+ ? response.columns_desc.map((entry) => normalizeFieldName(entry?.name))
288
+ : Array.isArray(response?.columns)
289
+ ? response.columns.map((entry) => normalizeFieldName(Array.isArray(entry) ? entry[0] : entry))
290
+ : [];
291
+
292
+ return rows.map((entry, index) => {
293
+ const rowValues = Array.isArray(entry) ? entry : [];
294
+ const fingerprint = unwrapValueCell(rowValues[0]);
295
+ const groupedColumns = Array.isArray(rowValues[1]) ? rowValues[1] : [];
296
+ const count = rowValues[2];
297
+ const mappedValues = {};
298
+ mappedValues.fingerprint = String(fingerprint || "").trim();
299
+ valueHeaders.forEach((field, fieldIndex) => {
300
+ mappedValues[field] = unwrapValueCell(groupedColumns[fieldIndex]);
301
+ });
302
+ if (count !== undefined) {
303
+ mappedValues._count = unwrapValueCell(count);
304
+ }
305
+ return {
306
+ index: index + 1,
307
+ objectIdDecimal: "",
308
+ objectIdHex: "",
309
+ fingerprint: mappedValues.fingerprint,
310
+ values: mappedValues,
311
+ };
312
+ });
313
+ }
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
+ async function fetchBacktracePage(session, queryUrl, queryBody) {
485
+ logStep("query", "sending query page", queryBody);
486
+ return session.requestJson(
487
+ queryUrl,
488
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(queryBody) },
489
+ `query offset=${queryBody.offset} limit=${queryBody.limit}`,
490
+ );
491
+ }
492
+
493
+ async function fetchFingerprintProbe(session, options, fingerprint) {
494
+ const queryBody = buildCompressedFingerprintQueryBody(options, fingerprint);
495
+ const result = await fetchBacktracePage(session, options.queryUrl, queryBody);
496
+ return {
497
+ fingerprint,
498
+ totalRows: result?._?.runtime?.filter?.rows ?? 0,
499
+ payload: result,
500
+ };
501
+ }
502
+
503
+ async function queryFingerprintGroups(session, options, fingerprintFilter = "") {
504
+ const probeBody = buildFingerprintGroupQueryBody(options, options.offset, options.limit, fingerprintFilter);
505
+ const probeResult = await fetchBacktracePage(session, options.queryUrl, probeBody);
506
+ const totalRows = probeResult?.response?.cardinalities?.pagination?.groups
507
+ ?? probeResult?.response?.cardinalities?.having?.groups
508
+ ?? probeResult?.response?.cardinalities?.initial?.groups
509
+ ?? 0;
510
+ const pageLimit = optimizeLimit(totalRows, options.limit);
511
+
512
+ logStep("query", "fingerprint pagination decided", {
513
+ totalRows,
514
+ initialLimit: options.limit,
515
+ optimizedLimit: pageLimit,
516
+ fingerprint: fingerprintFilter || "all",
517
+ });
518
+
519
+ const items = [];
520
+ for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
521
+ /* eslint-disable no-await-in-loop */
522
+ const result = await fetchBacktracePage(
523
+ session,
524
+ options.queryUrl,
525
+ buildFingerprintGroupQueryBody(options, offset, pageLimit, fingerprintFilter),
526
+ );
527
+ /* eslint-enable no-await-in-loop */
528
+ items.push(...extractGroupedFingerprintItems(result?.response));
529
+ if (totalRows === 0) {
530
+ break;
531
+ }
532
+ }
533
+
534
+ return {
535
+ totalRows,
536
+ pageLimit,
537
+ listedCount: items.length,
538
+ objectIds: items.map((item) => item.fingerprint || ""),
539
+ selectedValues: items.map((item) => item.values || {}),
540
+ items,
541
+ };
542
+ }
543
+
544
+ async function fetchFingerprintObjects(session, options, fingerprint, knownTotalRows) {
545
+ const totalRows = Number.isFinite(knownTotalRows)
546
+ ? knownTotalRows
547
+ : (() => {
548
+ throw new Error("fetchFingerprintObjects requires a known total row count");
549
+ })();
550
+ const pageLimit = optimizeLimit(totalRows, options.limit);
551
+ const objects = [];
552
+
553
+ for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
554
+ /* eslint-disable no-await-in-loop */
555
+ const result = await fetchBacktracePage(
556
+ session,
557
+ options.queryUrl,
558
+ buildFingerprintObjectsQueryBody(options, fingerprint, offset, pageLimit),
559
+ );
560
+ /* eslint-enable no-await-in-loop */
561
+ const objectIds = extractObjectIds(result?.response?.objects);
562
+ const selectedValues = extractSelectedValues(result?.response?.values, ["timestamp"]);
563
+ objectIds.forEach((objectId, index) => {
564
+ objects.push({
565
+ objectIdDecimal: objectId,
566
+ objectIdHex: toHexObjectId(objectId),
567
+ timestamp: selectedValues[index]?.timestamp || "",
568
+ });
569
+ });
570
+ if (totalRows === 0) {
571
+ break;
572
+ }
573
+ }
574
+
575
+ return {
576
+ fingerprint,
577
+ totalRows,
578
+ pageLimit,
579
+ objects,
580
+ };
581
+ }
582
+
583
+ async function queryFingerprintDetail(session, options) {
584
+ const requestedFingerprints = getRequestedFingerprints(options);
585
+ const fingerprint = requestedFingerprints[0];
586
+ const groupResult = await queryFingerprintGroups(session, options, fingerprint);
587
+ const summary = groupResult.items[0] || {
588
+ fingerprint,
589
+ values: {
590
+ fingerprint,
591
+ "error.message": "",
592
+ classifiers: "",
593
+ "fingerprint;first_seen": "",
594
+ "fingerprint;issues;state": "",
595
+ _count: 0,
596
+ },
597
+ };
598
+ const probe = await fetchFingerprintProbe(session, options, fingerprint);
599
+ const objectsResult = await fetchFingerprintObjects(session, options, fingerprint, probe.totalRows);
600
+
601
+ return {
602
+ fingerprint,
603
+ summary,
604
+ objects: objectsResult.objects,
605
+ querySummary: {
606
+ totalRows: objectsResult.totalRows,
607
+ fetchedRows: objectsResult.objects.length,
608
+ objectCount: objectsResult.objects.length,
609
+ pageLimit: objectsResult.pageLimit,
610
+ objectIds: objectsResult.objects.map((item) => item.objectIdHex),
611
+ selectedValues: objectsResult.objects.map((item) => ({ timestamp: item.timestamp })),
612
+ },
613
+ };
614
+ }
615
+
616
+ // 先探测总行数,再用优化后的分页大小拉取全部结果页。
617
+ async function fetchAllResults(session, options) {
618
+ // 先做一次探测请求,拿到总行数后再决定真正的分页大小。
619
+ const probeBody = buildQueryBody(options, options.offset, options.limit);
620
+ const probeResult = await fetchBacktracePage(session, options.queryUrl, probeBody);
621
+ const totalRows = shouldUseFingerprintGroupQuery(options)
622
+ ? probeResult?.response?.cardinalities?.pagination?.groups
623
+ ?? probeResult?.response?.cardinalities?.having?.groups
624
+ ?? probeResult?.response?.cardinalities?.initial?.groups
625
+ ?? 0
626
+ : probeResult?._?.runtime?.filter?.rows ?? 0;
627
+ const pageLimit = optimizeLimit(totalRows, options.limit);
628
+
629
+ logStep("query", "query pagination decided", { totalRows, initialLimit: options.limit, optimizedLimit: pageLimit });
630
+
631
+ const pages = [];
632
+ // 即使结果为空,也保留一页请求,保持空结果场景的行为一致。
633
+ for (let offset = options.offset; offset < totalRows + options.offset || (offset === options.offset && totalRows === 0); offset += pageLimit) {
634
+ pages.push({ offset, limit: pageLimit });
635
+ if (totalRows === 0) break;
636
+ }
637
+ if (pages.length === 0) {
638
+ pages.push({ offset: options.offset, limit: pageLimit });
639
+ }
640
+
641
+ const allObjectIds = [];
642
+ const allSelectedValues = [];
643
+ // 顺序拉取每一页,避免一次性并发查询过多导致远端接口不稳定。
644
+ for (const page of pages) {
645
+ /* eslint-disable no-await-in-loop */
646
+ const result = await fetchBacktracePage(session, options.queryUrl, buildQueryBody(options, page.offset, page.limit));
647
+ /* eslint-enable no-await-in-loop */
648
+ if (shouldUseFingerprintGroupQuery(options)) {
649
+ // 指纹聚合查询不依赖 object id,后续直接从 values 中还原每一行。
650
+ const groupedItems = extractGroupedFingerprintItems(result?.response);
651
+ allObjectIds.push(...groupedItems.map((item) => item.fingerprint || ""));
652
+ allSelectedValues.push(...groupedItems.map((item) => item.values));
653
+ } else {
654
+ allObjectIds.push(...extractObjectIds(result?.response?.objects));
655
+ allSelectedValues.push(...extractSelectedValues(result?.response?.values, options.select));
656
+ }
657
+ }
658
+
659
+ return { totalRows, pageLimit, listedCount: allObjectIds.length, objectIds: allObjectIds, selectedValues: allSelectedValues };
660
+ }
661
+
662
+ // 列出某个 Backtrace 对象可下载的附件。
663
+ async function fetchAttachmentList(session, queryUrl, objectHexId) {
664
+ const url = buildSiblingUrl(queryUrl, "/api/list");
665
+ url.searchParams.set("object", objectHexId);
666
+ logStep("attachments", "requesting attachment list", url.toString());
667
+ return session.requestJson(url.toString(), { method: "GET" }, `attachment list ${objectHexId}`);
668
+ }
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
+ async function queryAllItems(session, options) {
771
+ if (options.command === "collect-all") {
772
+ const requestedFingerprints = getRequestedFingerprints(options);
773
+ const fingerprintItems = [];
774
+ let fingerprintPageLimit = 0;
775
+
776
+ if (requestedFingerprints.length === 0) {
777
+ const fingerprintResult = await queryFingerprintGroups(session, options);
778
+ fingerprintItems.push(...fingerprintResult.items);
779
+ fingerprintPageLimit = fingerprintResult.pageLimit;
780
+ } else {
781
+ for (const requestedFingerprint of requestedFingerprints) {
782
+ /* eslint-disable no-await-in-loop */
783
+ const fingerprintResult = await queryFingerprintGroups(session, options, requestedFingerprint);
784
+ fingerprintItems.push(...fingerprintResult.items);
785
+ fingerprintPageLimit = Math.max(fingerprintPageLimit, fingerprintResult.pageLimit);
786
+ /* eslint-enable no-await-in-loop */
787
+ }
788
+ }
789
+
790
+ const uniqueFingerprintItems = Array.from(
791
+ new Map(fingerprintItems.map((item) => [String(item.fingerprint || item.values.fingerprint || "").trim(), item])).values(),
792
+ ).filter((item) => String(item.fingerprint || item.values.fingerprint || "").trim());
793
+ const items = [];
794
+ let totalRows = 0;
795
+ let pageLimit = fingerprintPageLimit;
796
+
797
+ for (const fingerprintItem of uniqueFingerprintItems) {
798
+ const fingerprint = String(fingerprintItem.fingerprint || fingerprintItem.values.fingerprint || "").trim();
799
+ if (!fingerprint) {
800
+ continue;
801
+ }
802
+
803
+ /* eslint-disable no-await-in-loop */
804
+ const probe = await fetchFingerprintProbe(session, options, fingerprint);
805
+ if (probe.totalRows === 0) {
806
+ logStep("query", "fingerprint probe returned no rows", { fingerprint });
807
+ continue;
808
+ }
809
+
810
+ const objectResult = await fetchFingerprintObjects(session, options, fingerprint, probe.totalRows);
811
+ totalRows += objectResult.totalRows;
812
+ pageLimit = Math.max(pageLimit, objectResult.pageLimit);
813
+ objectResult.objects.forEach((objectItem) => {
814
+ items.push({
815
+ index: items.length + 1,
816
+ objectIdDecimal: objectItem.objectIdDecimal,
817
+ objectIdHex: objectItem.objectIdHex,
818
+ fingerprint,
819
+ values: {
820
+ fingerprint,
821
+ timestamp: objectItem.timestamp,
822
+ "error.message": fingerprintItem.values["error.message"] || "",
823
+ classifiers: fingerprintItem.values.classifiers || "",
824
+ "fingerprint;first_seen": fingerprintItem.values["fingerprint;first_seen"] || "",
825
+ "fingerprint;issues;state": fingerprintItem.values["fingerprint;issues;state"] || "",
826
+ _count: fingerprintItem.values._count || 0,
827
+ },
828
+ });
829
+ });
830
+ /* eslint-enable no-await-in-loop */
831
+ }
832
+
833
+ logStep("query", "loaded query results", {
834
+ fingerprintCount: uniqueFingerprintItems.length,
835
+ fetchedRows: totalRows,
836
+ objectCount: items.length,
837
+ });
838
+
839
+ return {
840
+ totalRows,
841
+ pageLimit,
842
+ listedCount: items.length,
843
+ objectIds: items.map((item) => item.objectIdHex),
844
+ selectedValues: items.map((item) => item.values),
845
+ items,
846
+ fingerprints: uniqueFingerprintItems,
847
+ };
848
+ }
849
+
850
+ const result = await fetchAllResults(session, options);
851
+ const items = result.selectedValues.map((values, index) => ({
852
+ index: index + 1,
853
+ objectIdDecimal: "",
854
+ objectIdHex: "",
855
+ fingerprint: String(values?.fingerprint || "").trim(),
856
+ values: values || {},
857
+ }));
858
+ logStep("query", "loaded query results", { fetchedRows: result.listedCount, objectCount: items.length });
859
+ return { ...result, items };
860
+ }
861
+
862
+ // 把单个崩溃对象的全部附件下载到对应 fingerprint/object 目录下。
863
+ async function downloadItemLogs(session, item, options) {
864
+ const objectHexId = item.objectIdHex;
865
+ const fingerprint = normalizeFingerprint(item.fingerprint || item.values.fingerprint || options.fingerprint);
866
+ logStep("download", "preparing object download", { objectId: objectHexId, from: options.from, to: options.to, fingerprint });
867
+ let attachmentList = null;
868
+ let attachmentListError = null;
869
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
870
+ try {
871
+ /* eslint-disable no-await-in-loop */
872
+ attachmentList = await fetchAttachmentList(session, options.queryUrl, objectHexId);
873
+ /* eslint-enable no-await-in-loop */
874
+ attachmentListError = null;
875
+ break;
876
+ } catch (error) {
877
+ attachmentListError = error;
878
+ logStep("attachments", "attachment list failed", {
879
+ objectId: objectHexId,
880
+ attempt,
881
+ maxAttempts: 5,
882
+ message: error instanceof Error ? error.message : String(error),
883
+ });
884
+ }
885
+ }
886
+ if (!attachmentList) {
887
+ logStep("download", "object skipped after attachment list retries", {
888
+ objectId: objectHexId,
889
+ fingerprint,
890
+ message: attachmentListError instanceof Error ? attachmentListError.message : String(attachmentListError || ""),
891
+ });
892
+ return { ...item, values: { ...item.values, fingerprint }, fingerprint, targetDir: "", downloadedFiles: [], skipped: true, failed: true };
893
+ }
894
+ const attachments = Array.isArray(attachmentList.attachments) ? attachmentList.attachments : [];
895
+ const targetDir = path.join(getFingerprintLogsRoot(options.storageDir, fingerprint), objectHexId);
896
+ await ensureDirectory(targetDir);
897
+
898
+ const downloadedFiles = [];
899
+ // 单对象内部仍按顺序下载,便于日志追踪,也避免附件下载把会话压垮。
900
+ for (const attachment of attachments) {
901
+ /* eslint-disable no-await-in-loop */
902
+ const downloadedFile = await downloadAttachmentWithRetry(session, options.queryUrl, objectHexId, targetDir, attachment, 5);
903
+ downloadedFiles.push(downloadedFile);
904
+ if (typeof options.onAttachmentDownloaded === "function") {
905
+ options.onAttachmentDownloaded({
906
+ item,
907
+ objectId: objectHexId,
908
+ fingerprint,
909
+ attachmentName: attachment.name,
910
+ downloadedFile,
911
+ });
912
+ }
913
+ /* eslint-enable no-await-in-loop */
914
+ }
915
+
916
+ logStep("download", "object download completed", { objectId: objectHexId, fileCount: downloadedFiles.length, targetDir, fingerprint });
917
+ return { ...item, values: { ...item.values, fingerprint }, fingerprint, targetDir, downloadedFiles };
918
+ }
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
+ };