@@ -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+
90116export 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