Skip to content

Commit 4487553

Browse files
author
Sentience Dev
committed
Merge pull request #86 from SentienceAPI/trace_status
set or infer final trace status - close upload gaps
2 parents cab088f + aa07ea9 commit 4487553

File tree

8 files changed

+952
-73
lines changed

8 files changed

+952
-73
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sentienceapi",
3-
"version": "0.90.17",
3+
"version": "0.90.19",
44
"description": "TypeScript SDK for Sentience AI Agent Browser Automation",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/tracing/cloud-sink.ts

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class CloudTraceSink extends TraceSink {
7979
private traceFileSizeBytes: number = 0;
8080
private screenshotTotalSizeBytes: number = 0;
8181
private screenshotCount: number = 0; // Track number of screenshots extracted
82+
private indexFileSizeBytes: number = 0; // Track index file size
8283

8384
// Upload success flag
8485
private uploadSuccessful: boolean = false;
@@ -332,7 +333,164 @@ export class CloudTraceSink extends TraceSink {
332333
}
333334

334335
/**
335-
* Call /v1/traces/complete to report file sizes to gateway.
336+
* Infer final status from trace events by reading the trace file.
337+
* @returns Final status: "success", "failure", "partial", or "unknown"
338+
*/
339+
private _inferFinalStatusFromTrace(): string {
340+
try {
341+
// Read trace file to analyze events
342+
const traceContent = fs.readFileSync(this.tempFilePath, 'utf-8');
343+
const lines = traceContent.split('\n').filter(line => line.trim());
344+
const events: any[] = [];
345+
346+
for (const line of lines) {
347+
try {
348+
const event = JSON.parse(line);
349+
events.push(event);
350+
} catch {
351+
continue;
352+
}
353+
}
354+
355+
if (events.length === 0) {
356+
return 'unknown';
357+
}
358+
359+
// Check for run_end event with status
360+
for (let i = events.length - 1; i >= 0; i--) {
361+
const event = events[i];
362+
if (event.type === 'run_end') {
363+
const status = event.data?.status;
364+
if (['success', 'failure', 'partial', 'unknown'].includes(status)) {
365+
return status;
366+
}
367+
}
368+
}
369+
370+
// Infer from error events
371+
const hasErrors = events.some(e => e.type === 'error');
372+
if (hasErrors) {
373+
// Check if there are successful steps too (partial success)
374+
const stepEnds = events.filter(e => e.type === 'step_end');
375+
if (stepEnds.length > 0) {
376+
return 'partial';
377+
}
378+
return 'failure';
379+
}
380+
381+
// If we have step_end events and no errors, likely success
382+
const stepEnds = events.filter(e => e.type === 'step_end');
383+
if (stepEnds.length > 0) {
384+
return 'success';
385+
}
386+
387+
return 'unknown';
388+
} catch {
389+
// If we can't read the trace, default to unknown
390+
return 'unknown';
391+
}
392+
}
393+
394+
/**
395+
* Extract execution statistics from trace file.
396+
* @returns Dictionary with stats fields for /v1/traces/complete
397+
*/
398+
private _extractStatsFromTrace(): Record<string, any> {
399+
try {
400+
// Read trace file to extract stats
401+
const traceContent = fs.readFileSync(this.tempFilePath, 'utf-8');
402+
const lines = traceContent.split('\n').filter(line => line.trim());
403+
const events: any[] = [];
404+
405+
for (const line of lines) {
406+
try {
407+
const event = JSON.parse(line);
408+
events.push(event);
409+
} catch {
410+
continue;
411+
}
412+
}
413+
414+
if (events.length === 0) {
415+
return {
416+
total_steps: 0,
417+
total_events: 0,
418+
duration_ms: null,
419+
final_status: 'unknown',
420+
started_at: null,
421+
ended_at: null,
422+
};
423+
}
424+
425+
// Find run_start and run_end events
426+
const runStart = events.find(e => e.type === 'run_start');
427+
const runEnd = events.find(e => e.type === 'run_end');
428+
429+
// Extract timestamps
430+
const startedAt = runStart?.ts || null;
431+
const endedAt = runEnd?.ts || null;
432+
433+
// Calculate duration
434+
let durationMs: number | null = null;
435+
if (startedAt && endedAt) {
436+
try {
437+
const startDt = new Date(startedAt);
438+
const endDt = new Date(endedAt);
439+
durationMs = endDt.getTime() - startDt.getTime();
440+
} catch {
441+
// Ignore parse errors
442+
}
443+
}
444+
445+
// Count steps (from step_start events, only first attempt)
446+
const stepIndices = new Set<number>();
447+
for (const event of events) {
448+
if (event.type === 'step_start') {
449+
const stepIndex = event.data?.step_index;
450+
if (stepIndex !== undefined) {
451+
stepIndices.add(stepIndex);
452+
}
453+
}
454+
}
455+
let totalSteps = stepIndices.size;
456+
457+
// If run_end has steps count, use that (more accurate)
458+
if (runEnd) {
459+
const stepsFromEnd = runEnd.data?.steps;
460+
if (stepsFromEnd !== undefined) {
461+
totalSteps = Math.max(totalSteps, stepsFromEnd);
462+
}
463+
}
464+
465+
// Count total events
466+
const totalEvents = events.length;
467+
468+
// Infer final status
469+
const finalStatus = this._inferFinalStatusFromTrace();
470+
471+
return {
472+
total_steps: totalSteps,
473+
total_events: totalEvents,
474+
duration_ms: durationMs,
475+
final_status: finalStatus,
476+
started_at: startedAt,
477+
ended_at: endedAt,
478+
};
479+
} catch (error: any) {
480+
this.logger?.warn(`Error extracting stats from trace: ${error.message}`);
481+
return {
482+
total_steps: 0,
483+
total_events: 0,
484+
duration_ms: null,
485+
final_status: 'unknown',
486+
started_at: null,
487+
ended_at: null,
488+
};
489+
}
490+
}
491+
492+
/**
493+
* Call /v1/traces/complete to report file sizes and stats to gateway.
336494
*
337495
* This is a best-effort call - failures are logged but don't affect upload success.
338496
*/
@@ -346,13 +504,21 @@ export class CloudTraceSink extends TraceSink {
346504
const url = new URL(`${this.apiUrl}/v1/traces/complete`);
347505
const protocol = url.protocol === 'https:' ? https : http;
348506

507+
// Extract stats from trace file
508+
const stats = this._extractStatsFromTrace();
509+
510+
// Add file size fields
511+
const completeStats = {
512+
...stats,
513+
trace_file_size_bytes: this.traceFileSizeBytes,
514+
screenshot_total_size_bytes: this.screenshotTotalSizeBytes,
515+
screenshot_count: this.screenshotCount,
516+
index_file_size_bytes: this.indexFileSizeBytes,
517+
};
518+
349519
const body = JSON.stringify({
350520
run_id: this.runId,
351-
stats: {
352-
trace_file_size_bytes: this.traceFileSizeBytes,
353-
screenshot_total_size_bytes: this.screenshotTotalSizeBytes,
354-
screenshot_count: this.screenshotCount,
355-
},
521+
stats: completeStats,
356522
});
357523

358524
const options = {
@@ -447,6 +613,7 @@ export class CloudTraceSink extends TraceSink {
447613
const indexData = await fsPromises.readFile(indexPath);
448614
const compressedIndex = zlib.gzipSync(indexData);
449615
const indexSize = compressedIndex.length;
616+
this.indexFileSizeBytes = indexSize; // Track index file size
450617

451618
this.logger?.info(`Index file size: ${(indexSize / 1024).toFixed(2)} KB`);
452619
if (this.logger) {

src/tracing/jsonl-sink.ts

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,43 @@ export class JsonlTraceSink extends TraceSink {
115115
stream.removeAllListeners('error');
116116

117117
return new Promise<void>((resolve) => {
118+
// Check if stream is already closed
119+
if (stream.destroyed || !stream.writable) {
120+
// Stream already closed, generate index and resolve immediately
121+
this.generateIndex();
122+
resolve();
123+
return;
124+
}
125+
126+
let resolved = false;
127+
const doResolve = () => {
128+
if (!resolved) {
129+
resolved = true;
130+
clearTimeout(timeout);
131+
// Generate index after closing file
132+
this.generateIndex();
133+
resolve();
134+
}
135+
};
136+
137+
// Fallback timeout in case 'close' event doesn't fire (shouldn't happen, but safety)
138+
const timeout = setTimeout(() => {
139+
if (!resolved) {
140+
doResolve();
141+
}
142+
}, 500);
143+
144+
// Wait for stream to fully close (Windows needs this)
145+
// The 'close' event fires after all data is flushed and file handle is released
146+
stream.once('close', doResolve);
147+
118148
stream.end((err?: Error | null) => {
119149
if (err) {
120150
// Silently ignore close errors in production
121151
// (they're logged during stream lifetime if needed)
122152
}
123-
124-
// Generate index after closing file
125-
this.generateIndex();
126-
127-
// Always resolve, don't reject on close errors
128-
resolve();
153+
// Note: 'close' event will fire after end() completes
154+
// Don't resolve here - wait for 'close' event
129155
});
130156
});
131157
}
@@ -163,4 +189,124 @@ export class JsonlTraceSink extends TraceSink {
163189
isClosed(): boolean {
164190
return this.closed;
165191
}
192+
193+
/**
194+
* Extract execution statistics from trace file (for local traces).
195+
* @returns Dictionary with stats fields (same format as Tracer.getStats())
196+
*/
197+
getStats(): Record<string, any> {
198+
try {
199+
// Read trace file to extract stats
200+
const traceContent = fs.readFileSync(this.path, 'utf-8');
201+
const lines = traceContent.split('\n').filter(line => line.trim());
202+
const events: any[] = [];
203+
204+
for (const line of lines) {
205+
try {
206+
const event = JSON.parse(line);
207+
events.push(event);
208+
} catch {
209+
continue;
210+
}
211+
}
212+
213+
if (events.length === 0) {
214+
return {
215+
total_steps: 0,
216+
total_events: 0,
217+
duration_ms: null,
218+
final_status: 'unknown',
219+
started_at: null,
220+
ended_at: null,
221+
};
222+
}
223+
224+
// Find run_start and run_end events
225+
const runStart = events.find(e => e.type === 'run_start');
226+
const runEnd = events.find(e => e.type === 'run_end');
227+
228+
// Extract timestamps
229+
const startedAt = runStart?.ts || null;
230+
const endedAt = runEnd?.ts || null;
231+
232+
// Calculate duration
233+
let durationMs: number | null = null;
234+
if (startedAt && endedAt) {
235+
try {
236+
const startDt = new Date(startedAt);
237+
const endDt = new Date(endedAt);
238+
durationMs = endDt.getTime() - startDt.getTime();
239+
} catch {
240+
// Ignore parse errors
241+
}
242+
}
243+
244+
// Count steps (from step_start events, only first attempt)
245+
const stepIndices = new Set<number>();
246+
for (const event of events) {
247+
if (event.type === 'step_start') {
248+
const stepIndex = event.data?.step_index;
249+
if (stepIndex !== undefined) {
250+
stepIndices.add(stepIndex);
251+
}
252+
}
253+
}
254+
let totalSteps = stepIndices.size;
255+
256+
// If run_end has steps count, use that (more accurate)
257+
if (runEnd) {
258+
const stepsFromEnd = runEnd.data?.steps;
259+
if (stepsFromEnd !== undefined) {
260+
totalSteps = Math.max(totalSteps, stepsFromEnd);
261+
}
262+
}
263+
264+
// Count total events
265+
const totalEvents = events.length;
266+
267+
// Infer final status
268+
let finalStatus = 'unknown';
269+
// Check for run_end event with status
270+
if (runEnd) {
271+
const status = runEnd.data?.status;
272+
if (['success', 'failure', 'partial', 'unknown'].includes(status)) {
273+
finalStatus = status;
274+
}
275+
} else {
276+
// Infer from error events
277+
const hasErrors = events.some(e => e.type === 'error');
278+
if (hasErrors) {
279+
const stepEnds = events.filter(e => e.type === 'step_end');
280+
if (stepEnds.length > 0) {
281+
finalStatus = 'partial';
282+
} else {
283+
finalStatus = 'failure';
284+
}
285+
} else {
286+
const stepEnds = events.filter(e => e.type === 'step_end');
287+
if (stepEnds.length > 0) {
288+
finalStatus = 'success';
289+
}
290+
}
291+
}
292+
293+
return {
294+
total_steps: totalSteps,
295+
total_events: totalEvents,
296+
duration_ms: durationMs,
297+
final_status: finalStatus,
298+
started_at: startedAt,
299+
ended_at: endedAt,
300+
};
301+
} catch {
302+
return {
303+
total_steps: 0,
304+
total_events: 0,
305+
duration_ms: null,
306+
final_status: 'unknown',
307+
started_at: null,
308+
ended_at: null,
309+
};
310+
}
311+
}
166312
}

0 commit comments

Comments
 (0)