@@ -284,7 +284,10 @@ export class CloudTraceSink extends TraceSink {
284284 if ( statusCode === 200 ) {
285285 console . log ( '✅ [Sentience] Trace uploaded successfully' ) ;
286286
287- // Call /v1/traces/complete to report file sizes (NEW)
287+ // Upload trace index file
288+ await this . _uploadIndex ( ) ;
289+
290+ // Call /v1/traces/complete to report file sizes
288291 await this . _completeTrace ( ) ;
289292
290293 // 4. Delete temp file on success
@@ -381,6 +384,170 @@ export class CloudTraceSink extends TraceSink {
381384 }
382385 }
383386
387+ /**
388+ * Upload trace index file to cloud storage.
389+ *
390+ * Called after successful trace upload to provide fast timeline rendering.
391+ * The index file enables O(1) step lookups without parsing the entire trace.
392+ */
393+ private async _uploadIndex ( ) : Promise < void > {
394+ // Construct index file path (same as trace file with .index.json extension)
395+ const indexPath = this . tempFilePath . replace ( '.jsonl' , '.index.json' ) ;
396+
397+ try {
398+ // Check if index file exists
399+ await fsPromises . access ( indexPath ) ;
400+ } catch {
401+ this . logger ?. warn ( 'Index file not found, skipping index upload' ) ;
402+ return ;
403+ }
404+
405+ try {
406+ // Request index upload URL from API
407+ if ( ! this . apiKey ) {
408+ this . logger ?. info ( 'No API key provided, skipping index upload' ) ;
409+ return ;
410+ }
411+
412+ const uploadUrlResponse = await this . _requestIndexUploadUrl ( ) ;
413+ if ( ! uploadUrlResponse ) {
414+ return ;
415+ }
416+
417+ // Read and compress index file
418+ const indexData = await fsPromises . readFile ( indexPath ) ;
419+ const compressedIndex = zlib . gzipSync ( indexData ) ;
420+ const indexSize = compressedIndex . length ;
421+
422+ this . logger ?. info ( `Index file size: ${ ( indexSize / 1024 ) . toFixed ( 2 ) } KB` ) ;
423+
424+ console . log ( `📤 [Sentience] Uploading trace index (${ indexSize } bytes)...` ) ;
425+
426+ // Upload index to cloud storage
427+ const statusCode = await this . _uploadIndexToCloud ( uploadUrlResponse , compressedIndex ) ;
428+
429+ if ( statusCode === 200 ) {
430+ console . log ( '✅ [Sentience] Trace index uploaded successfully' ) ;
431+
432+ // Delete local index file after successful upload
433+ try {
434+ await fsPromises . unlink ( indexPath ) ;
435+ } catch {
436+ // Ignore cleanup errors
437+ }
438+ } else {
439+ this . logger ?. warn ( `Index upload failed: HTTP ${ statusCode } ` ) ;
440+ console . log ( `⚠️ [Sentience] Index upload failed: HTTP ${ statusCode } ` ) ;
441+ }
442+ } catch ( error : any ) {
443+ // Non-fatal: log but don't crash
444+ this . logger ?. warn ( `Error uploading trace index: ${ error . message } ` ) ;
445+ console . log ( `⚠️ [Sentience] Error uploading trace index: ${ error . message } ` ) ;
446+ }
447+ }
448+
449+ /**
450+ * Request index upload URL from Sentience API
451+ */
452+ private async _requestIndexUploadUrl ( ) : Promise < string | null > {
453+ return new Promise ( ( resolve ) => {
454+ const url = new URL ( `${ this . apiUrl } /v1/traces/index_upload` ) ;
455+ const protocol = url . protocol === 'https:' ? https : http ;
456+
457+ const body = JSON . stringify ( { run_id : this . runId } ) ;
458+
459+ const options = {
460+ hostname : url . hostname ,
461+ port : url . port || ( url . protocol === 'https:' ? 443 : 80 ) ,
462+ path : url . pathname + url . search ,
463+ method : 'POST' ,
464+ headers : {
465+ 'Content-Type' : 'application/json' ,
466+ 'Content-Length' : Buffer . byteLength ( body ) ,
467+ Authorization : `Bearer ${ this . apiKey } ` ,
468+ } ,
469+ timeout : 10000 ,
470+ } ;
471+
472+ const req = protocol . request ( options , ( res ) => {
473+ let data = '' ;
474+ res . on ( 'data' , ( chunk ) => {
475+ data += chunk ;
476+ } ) ;
477+ res . on ( 'end' , ( ) => {
478+ if ( res . statusCode === 200 ) {
479+ try {
480+ const response = JSON . parse ( data ) ;
481+ resolve ( response . upload_url || null ) ;
482+ } catch {
483+ this . logger ?. warn ( 'Failed to parse index upload URL response' ) ;
484+ resolve ( null ) ;
485+ }
486+ } else {
487+ this . logger ?. warn ( `Failed to get index upload URL: HTTP ${ res . statusCode } ` ) ;
488+ resolve ( null ) ;
489+ }
490+ } ) ;
491+ } ) ;
492+
493+ req . on ( 'error' , ( error ) => {
494+ this . logger ?. warn ( `Error requesting index upload URL: ${ error . message } ` ) ;
495+ resolve ( null ) ;
496+ } ) ;
497+
498+ req . on ( 'timeout' , ( ) => {
499+ req . destroy ( ) ;
500+ this . logger ?. warn ( 'Index upload URL request timeout' ) ;
501+ resolve ( null ) ;
502+ } ) ;
503+
504+ req . write ( body ) ;
505+ req . end ( ) ;
506+ } ) ;
507+ }
508+
509+ /**
510+ * Upload index data to cloud using pre-signed URL
511+ */
512+ private async _uploadIndexToCloud ( uploadUrl : string , data : Buffer ) : Promise < number > {
513+ return new Promise ( ( resolve , reject ) => {
514+ const url = new URL ( uploadUrl ) ;
515+ const protocol = url . protocol === 'https:' ? https : http ;
516+
517+ const options = {
518+ hostname : url . hostname ,
519+ port : url . port || ( url . protocol === 'https:' ? 443 : 80 ) ,
520+ path : url . pathname + url . search ,
521+ method : 'PUT' ,
522+ headers : {
523+ 'Content-Type' : 'application/json' ,
524+ 'Content-Encoding' : 'gzip' ,
525+ 'Content-Length' : data . length ,
526+ } ,
527+ timeout : 30000 , // 30 second timeout
528+ } ;
529+
530+ const req = protocol . request ( options , ( res ) => {
531+ res . on ( 'data' , ( ) => { } ) ;
532+ res . on ( 'end' , ( ) => {
533+ resolve ( res . statusCode || 500 ) ;
534+ } ) ;
535+ } ) ;
536+
537+ req . on ( 'error' , ( error ) => {
538+ reject ( error ) ;
539+ } ) ;
540+
541+ req . on ( 'timeout' , ( ) => {
542+ req . destroy ( ) ;
543+ reject ( new Error ( 'Index upload timeout' ) ) ;
544+ } ) ;
545+
546+ req . write ( data ) ;
547+ req . end ( ) ;
548+ } ) ;
549+ }
550+
384551 /**
385552 * Get unique identifier for this sink
386553 */
0 commit comments