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.
@@ -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
- logStep,
6
- retry,
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 };