Skip to content

Commit 648c161

Browse files
committed
fix(diagnostics): expose traceable upload errors
1 parent c0a9173 commit 648c161

12 files changed

Lines changed: 460 additions & 53 deletions

File tree

packages/core/src/api.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,28 @@ export function handleApiError(errorData: unknown, httpStatus: number): ApiError
225225
);
226226
}
227227

228+
function sanitizeRequestUrlForError(value: string): string {
229+
try {
230+
const base = typeof location !== 'undefined' ? location.href : undefined;
231+
const parsed = base ? new URL(value, base) : new URL(value);
232+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
233+
return `${parsed.origin}${parsed.pathname}`;
234+
}
235+
return parsed.pathname;
236+
} catch {
237+
return value.split(/[?#]/, 1)[0] || '<invalid-url>';
238+
}
239+
}
240+
241+
function attachRequestContext(error: ApiError, fullUrl: string, options?: globalThis.RequestInit): ApiError {
242+
error.context = {
243+
...error.context,
244+
clientRequestUrl: sanitizeRequestUrlForError(fullUrl),
245+
requestMethod: (options?.method || 'GET').toUpperCase(),
246+
};
247+
return error;
248+
}
249+
228250
// ========== API 配置 ==========
229251

230252
/**
@@ -484,7 +506,7 @@ async function apiRequest<T = unknown>(
484506
} catch (error) {
485507
// 网络错误(fetch 失败)
486508
if (error instanceof TypeError && error.message.includes('fetch')) {
487-
throw new ApiError(
509+
throw attachRequestContext(new ApiError(
488510
'Network request failed',
489511
'Unable to connect to server, please check network connection',
490512
0,
@@ -493,24 +515,24 @@ async function apiRequest<T = unknown>(
493515
suggestions: ['Check network connection', 'Confirm server is running'],
494516
severity: 'error'
495517
}
496-
);
518+
), fullUrl, options);
497519
}
498520

499521
// 如果已经是 ApiError,直接抛出
500522
if (error instanceof ApiError) {
501-
throw error;
523+
throw attachRequestContext(error, fullUrl, options);
502524
}
503525

504526
// 其他未知错误
505-
throw new ApiError(
527+
throw attachRequestContext(new ApiError(
506528
error instanceof Error ? error.message : String(error),
507529
'An unknown error occurred, please try again later',
508530
500,
509531
{
510532
code: 'UNKNOWN_ERROR',
511533
severity: 'error'
512534
}
513-
);
535+
), fullUrl, options);
514536
}
515537
}
516538

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test } from 'node:test';
3+
import { api, ApiError } from '../src/api.js';
4+
5+
const originalFetch = globalThis.fetch;
6+
7+
afterEach(() => {
8+
globalThis.fetch = originalFetch;
9+
});
10+
11+
test('diagnostic API errors include the safe client request URL and method', async () => {
12+
globalThis.fetch = async () => Response.json({
13+
success: false,
14+
error: {
15+
code: 'DIAGNOSTIC_SERVICE_UNAVAILABLE',
16+
message: 'gateway failed',
17+
userMessage: 'try again',
18+
severity: 'error',
19+
context: {
20+
errorId: 'error-1',
21+
downstreamRequestUrl: 'https://gateway.example.test/v1/diagnostics/authorize',
22+
},
23+
},
24+
}, { status: 503 });
25+
26+
await assert.rejects(
27+
api.uploadDiagnosticLogs({ sourceId: 'server', fromMs: 1, toMs: 2 }, 'https://radio.example.test/api'),
28+
(error: unknown) => {
29+
assert.ok(error instanceof ApiError);
30+
assert.equal(error.context?.clientRequestUrl, 'https://radio.example.test/api/diagnostics/uploads');
31+
assert.equal(error.context?.requestMethod, 'POST');
32+
assert.equal(error.context?.errorId, 'error-1');
33+
assert.equal(error.context?.downstreamRequestUrl, 'https://gateway.example.test/v1/diagnostics/authorize');
34+
return true;
35+
},
36+
);
37+
});

packages/server/src/diagnostics/DiagnosticLogUploadService.ts

Lines changed: 137 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,50 @@ export type DiagnosticUploadErrorCode =
8787
| 'DIAGNOSTIC_SERVICE_UNAVAILABLE'
8888
| 'DIAGNOSTIC_UPLOAD_FAILED';
8989

90+
export type DiagnosticUploadStage =
91+
| 'log_discovery'
92+
| 'log_selection'
93+
| 'compression'
94+
| 'temporary_file'
95+
| 'gateway_authorization'
96+
| 'upload_authorization'
97+
| 'oss_upload'
98+
| 'gateway_completion';
99+
100+
interface DiagnosticUploadErrorOptions {
101+
cause?: unknown;
102+
stage?: DiagnosticUploadStage;
103+
requestUrl?: string;
104+
upstreamStatus?: number;
105+
}
106+
107+
export function sanitizeDiagnosticRequestUrl(value: string): string {
108+
try {
109+
const parsed = new URL(value);
110+
return `${parsed.origin}${parsed.pathname}`;
111+
} catch {
112+
return value.split(/[?#]/, 1)[0] || '<invalid-url>';
113+
}
114+
}
115+
90116
export class DiagnosticUploadError extends Error {
91117
public readonly cause?: unknown;
118+
public readonly stage?: DiagnosticUploadStage;
119+
public readonly requestUrl?: string;
120+
public readonly upstreamStatus?: number;
92121

93122
constructor(
94123
public readonly code: DiagnosticUploadErrorCode,
95124
message: string,
96125
public readonly statusCode: number,
97-
options?: { cause?: unknown },
126+
options?: DiagnosticUploadErrorOptions,
98127
) {
99128
super(message);
100129
this.name = 'DiagnosticUploadError';
101130
this.cause = options?.cause;
131+
this.stage = options?.stage;
132+
this.requestUrl = options?.requestUrl ? sanitizeDiagnosticRequestUrl(options.requestUrl) : undefined;
133+
this.upstreamStatus = options?.upstreamStatus;
102134
}
103135
}
104136

@@ -180,6 +212,7 @@ async function selectLogRange(
180212
'DIAGNOSTIC_RANGE_TOO_LARGE',
181213
'Selected logs exceed the uncompressed upload limit',
182214
413,
215+
{ stage: 'log_selection' },
183216
);
184217
}
185218
selected.push({ timestampMs: currentTimestamp, fileOrder, blockOrder, lines: currentLines, bytes });
@@ -204,7 +237,9 @@ async function selectLogRange(
204237
}
205238

206239
if (selected.length === 0) {
207-
throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'No log entries were found in the selected range', 404);
240+
throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'No log entries were found in the selected range', 404, {
241+
stage: 'log_selection',
242+
});
208243
}
209244

210245
selected.sort((left, right) => (
@@ -288,29 +323,49 @@ export class DiagnosticLogUploadService {
288323
}
289324

290325
async upload(request: CreateDiagnosticUploadRequest): Promise<DiagnosticUploadReceipt> {
291-
const definition = SOURCE_DEFINITIONS.find((source) => source.id === request.sourceId);
292-
if (!definition) throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'Unknown diagnostic source', 404);
293-
const files = await this.descriptorsFor(definition);
294-
if (files.length === 0) throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'The selected log source is unavailable', 404);
295-
296-
const selected = await selectLogRange(files, request.fromMs, request.toMs, this.options.maxUncompressedBytes);
297-
const uncompressed = Buffer.from(selected.content, 'utf8');
298-
if (uncompressed.length > this.options.maxUncompressedBytes) {
299-
throw new DiagnosticUploadError('DIAGNOSTIC_RANGE_TOO_LARGE', 'Selected logs exceed the uncompressed upload limit', 413);
300-
}
301-
const compressed = await gzip(uncompressed);
302-
if (compressed.length > this.options.maxCompressedBytes) {
303-
throw new DiagnosticUploadError('DIAGNOSTIC_RANGE_TOO_LARGE', 'Selected logs exceed the compressed upload limit', 413);
304-
}
305-
306-
const temporaryDir = await mkdtemp(join(this.options.temporaryRoot, 'tx5dr-diagnostic-'));
307-
const temporaryFile = join(temporaryDir, `${randomUUID()}.log.gz`);
326+
let stage: DiagnosticUploadStage = 'log_discovery';
308327
try {
309-
await writeFile(temporaryFile, compressed, { mode: 0o600 });
310-
const uploadBytes = await readFile(temporaryFile);
311-
return await this.sendToGateway(request, selected, uncompressed.length, uploadBytes);
312-
} finally {
313-
await rm(temporaryDir, { recursive: true, force: true }).catch(() => undefined);
328+
const definition = SOURCE_DEFINITIONS.find((source) => source.id === request.sourceId);
329+
if (!definition) {
330+
throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'Unknown diagnostic source', 404, { stage });
331+
}
332+
const files = await this.descriptorsFor(definition);
333+
if (files.length === 0) {
334+
throw new DiagnosticUploadError('DIAGNOSTIC_NO_LOGS', 'The selected log source is unavailable', 404, { stage });
335+
}
336+
337+
stage = 'log_selection';
338+
const selected = await selectLogRange(files, request.fromMs, request.toMs, this.options.maxUncompressedBytes);
339+
const uncompressed = Buffer.from(selected.content, 'utf8');
340+
if (uncompressed.length > this.options.maxUncompressedBytes) {
341+
throw new DiagnosticUploadError('DIAGNOSTIC_RANGE_TOO_LARGE', 'Selected logs exceed the uncompressed upload limit', 413, { stage });
342+
}
343+
344+
stage = 'compression';
345+
const compressed = await gzip(uncompressed);
346+
if (compressed.length > this.options.maxCompressedBytes) {
347+
throw new DiagnosticUploadError('DIAGNOSTIC_RANGE_TOO_LARGE', 'Selected logs exceed the compressed upload limit', 413, { stage });
348+
}
349+
350+
stage = 'temporary_file';
351+
const temporaryDir = await mkdtemp(join(this.options.temporaryRoot, 'tx5dr-diagnostic-'));
352+
const temporaryFile = join(temporaryDir, `${randomUUID()}.log.gz`);
353+
try {
354+
await writeFile(temporaryFile, compressed, { mode: 0o600 });
355+
const uploadBytes = await readFile(temporaryFile);
356+
stage = 'gateway_authorization';
357+
return await this.sendToGateway(request, selected, uncompressed.length, uploadBytes);
358+
} finally {
359+
await rm(temporaryDir, { recursive: true, force: true }).catch(() => undefined);
360+
}
361+
} catch (error) {
362+
if (error instanceof DiagnosticUploadError) throw error;
363+
throw new DiagnosticUploadError(
364+
'DIAGNOSTIC_UPLOAD_FAILED',
365+
`Diagnostic upload failed during ${stage}`,
366+
500,
367+
{ cause: error, stage },
368+
);
314369
}
315370
}
316371

@@ -320,17 +375,28 @@ export class DiagnosticLogUploadService {
320375
uncompressedBytes: number,
321376
compressed: Buffer,
322377
): Promise<DiagnosticUploadReceipt> {
323-
const context = await this.options.getGatewayContext();
378+
let context: DiagnosticGatewayContext;
379+
try {
380+
context = await this.options.getGatewayContext();
381+
} catch (error) {
382+
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic gateway context is unavailable', 503, {
383+
cause: error,
384+
stage: 'gateway_authorization',
385+
});
386+
}
324387
const authorization = await this.postJson<{
325388
diagnostics_token: string;
326389
}>(context, '/v1/diagnostics/authorize', {
327390
schema_version: 1,
328391
authorization_event_id: randomUUID(),
329392
installation_id: context.installationId,
330393
app: context.app,
331-
});
394+
}, undefined, 'gateway_authorization');
332395
if (typeof authorization.diagnostics_token !== 'string') {
333-
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic authorization response is invalid', 503);
396+
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic authorization response is invalid', 503, {
397+
stage: 'gateway_authorization',
398+
requestUrl: `${context.endpoint}/v1/diagnostics/authorize`,
399+
});
334400
}
335401

336402
const uploadId = randomUUID();
@@ -353,14 +419,26 @@ export class DiagnosticLogUploadService {
353419
compressed_bytes: compressed.length,
354420
sha256,
355421
is_test: false,
356-
}, authorization.diagnostics_token);
422+
}, authorization.diagnostics_token, 'upload_authorization');
357423
if (!grant.upload_url || !grant.upload_receipt || !grant.form_fields) {
358-
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic upload response is invalid', 503);
424+
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic upload response is invalid', 503, {
425+
stage: 'upload_authorization',
426+
requestUrl: `${context.endpoint}/v1/diagnostics/uploads`,
427+
});
359428
}
360429

361-
const form = new FormData();
362-
for (const [key, value] of Object.entries(grant.form_fields)) form.append(key, value);
363-
form.append('file', new Blob([new Uint8Array(compressed)], { type: 'application/gzip' }), basename('diagnostic.log.gz'));
430+
let form: FormData;
431+
try {
432+
form = new FormData();
433+
for (const [key, value] of Object.entries(grant.form_fields)) form.append(key, value);
434+
form.append('file', new Blob([new Uint8Array(compressed)], { type: 'application/gzip' }), basename('diagnostic.log.gz'));
435+
} catch (error) {
436+
throw new DiagnosticUploadError('DIAGNOSTIC_UPLOAD_FAILED', 'Unable to prepare the OSS upload form', 500, {
437+
cause: error,
438+
stage: 'oss_upload',
439+
requestUrl: grant.upload_url,
440+
});
441+
}
364442
let uploadResponse: Response;
365443
try {
366444
uploadResponse = await this.options.fetch(grant.upload_url, {
@@ -369,10 +447,18 @@ export class DiagnosticLogUploadService {
369447
signal: AbortSignal.timeout(60_000),
370448
});
371449
} catch (error) {
372-
throw new DiagnosticUploadError('DIAGNOSTIC_UPLOAD_FAILED', 'Unable to upload the diagnostic log', 502, { cause: error });
450+
throw new DiagnosticUploadError('DIAGNOSTIC_UPLOAD_FAILED', 'Unable to upload the diagnostic log', 502, {
451+
cause: error,
452+
stage: 'oss_upload',
453+
requestUrl: grant.upload_url,
454+
});
373455
}
374456
if (uploadResponse.status !== 204) {
375-
throw new DiagnosticUploadError('DIAGNOSTIC_UPLOAD_FAILED', `OSS upload failed with status ${uploadResponse.status}`, 502);
457+
throw new DiagnosticUploadError('DIAGNOSTIC_UPLOAD_FAILED', `OSS upload failed with status ${uploadResponse.status}`, 502, {
458+
stage: 'oss_upload',
459+
requestUrl: grant.upload_url,
460+
upstreamStatus: uploadResponse.status,
461+
});
376462
}
377463

378464
const completeBody = {
@@ -382,11 +468,11 @@ export class DiagnosticLogUploadService {
382468
};
383469
let complete: { accepted_at: string; retained_until: string };
384470
try {
385-
complete = await this.postJson(context, `/v1/diagnostics/uploads/${uploadId}/complete`, completeBody, authorization.diagnostics_token);
471+
complete = await this.postJson(context, `/v1/diagnostics/uploads/${uploadId}/complete`, completeBody, authorization.diagnostics_token, 'gateway_completion');
386472
} catch (error) {
387473
if (!(error instanceof DiagnosticUploadError) || error.code !== 'DIAGNOSTIC_SERVICE_UNAVAILABLE') throw error;
388474
await delay(250);
389-
complete = await this.postJson(context, `/v1/diagnostics/uploads/${uploadId}/complete`, completeBody, authorization.diagnostics_token);
475+
complete = await this.postJson(context, `/v1/diagnostics/uploads/${uploadId}/complete`, completeBody, authorization.diagnostics_token, 'gateway_completion');
390476
}
391477

392478
return {
@@ -409,10 +495,12 @@ export class DiagnosticLogUploadService {
409495
path: string,
410496
body: unknown,
411497
token?: string,
498+
stage?: DiagnosticUploadStage,
412499
): Promise<T> {
500+
const requestUrl = `${context.endpoint}${path}`;
413501
let response: Response;
414502
try {
415-
response = await this.options.fetch(`${context.endpoint}${path}`, {
503+
response = await this.options.fetch(requestUrl, {
416504
method: 'POST',
417505
headers: {
418506
'content-type': 'application/json',
@@ -422,7 +510,11 @@ export class DiagnosticLogUploadService {
422510
signal: AbortSignal.timeout(15_000),
423511
});
424512
} catch (error) {
425-
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic service is unreachable', 503, { cause: error });
513+
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic service is unreachable', 503, {
514+
cause: error,
515+
stage,
516+
requestUrl,
517+
});
426518
}
427519
if (!response.ok) {
428520
throw new DiagnosticUploadError(
@@ -431,12 +523,18 @@ export class DiagnosticLogUploadService {
431523
: 'DIAGNOSTIC_UPLOAD_FAILED',
432524
`Diagnostic gateway rejected ${path} with status ${response.status}`,
433525
response.status >= 500 || response.status === 429 ? 503 : 502,
526+
{ stage, requestUrl, upstreamStatus: response.status },
434527
);
435528
}
436529
try {
437530
return await response.json() as T;
438531
} catch (error) {
439-
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic service returned invalid JSON', 503, { cause: error });
532+
throw new DiagnosticUploadError('DIAGNOSTIC_SERVICE_UNAVAILABLE', 'Diagnostic service returned invalid JSON', 503, {
533+
cause: error,
534+
stage,
535+
requestUrl,
536+
upstreamStatus: response.status,
537+
});
440538
}
441539
}
442540
}

0 commit comments

Comments
 (0)