/** * Stream Tracker — Unified SSE stream monitoring * * Tracks token counts, latency, and errors during streaming responses. * Emits periodic progress callbacks for real-time monitoring. * * @module shared/utils/streamTracker */ export interface StreamMetrics { startTime: number; firstTokenTime: number; totalTokens: number; totalChunks: number; elapsedMs: number; tokensPerSecond: number; complete: boolean; error: string | null; finishReason: string | null; } interface StreamTrackerOptions { onProgress?: (metrics: StreamMetrics) => void; progressIntervalMs?: number; } export class StreamTracker { private _onProgress: ((metrics: StreamMetrics) => void) | null; private _progressIntervalMs: number; private _startTime: number; private _firstTokenTime: number; private _totalTokens: number; private _totalChunks: number; private _complete: boolean; private _error: string | null; private _finishReason: string | null; private _lastProgressAt: number; private _buffer: string; constructor(options: StreamTrackerOptions = {}) { this._onProgress = options.onProgress || null; this._progressIntervalMs = options.progressIntervalMs || 500; this._startTime = Date.now(); this._firstTokenTime = 0; this._totalTokens = 0; this._totalChunks = 0; this._complete = false; this._error = null; this._finishReason = null; this._lastProgressAt = 0; this._buffer = ""; } /** * Record an incoming SSE chunk. * @param {string|Object} chunk - Raw SSE text or parsed data */ onChunk(chunk) { this._totalChunks++; if (this._totalChunks === 1) { this._firstTokenTime = Date.now() - this._startTime; } // Try to extract token count from chunk let data = chunk; if (typeof chunk === "string") { // Parse SSE if formatted if (chunk.startsWith("data: ")) { const payload = chunk.slice(6).trim(); if (payload === "[DONE]") { this._complete = true; this._emitProgress(); return; } try { data = JSON.parse(payload); } catch { data = null; } } } if (data && typeof data === "object") { // OpenAI format: choices[0].delta.content const content = data.choices?.[0]?.delta?.content; if (content) { // Rough token estimate (~4 chars per token) this._totalTokens += Math.ceil(content.length / 4); } // Check for finish reason const reason = data.choices?.[0]?.finish_reason; if (reason) { this._finishReason = reason; } // Usage in final chunk (OpenAI includes this) if (data.usage?.completion_tokens) { this._totalTokens = data.usage.completion_tokens; } } this._maybeEmitProgress(); } /** * Mark stream as errored. * @param {string|Error} error */ onError(error) { this._error = typeof error === "string" ? error : error.message; this._complete = true; this._emitProgress(); } /** Mark stream as complete. */ onComplete() { this._complete = true; this._emitProgress(); } /** @returns {StreamMetrics} Current metrics */ getMetrics() { const elapsedMs = Date.now() - this._startTime; const tokensPerSecond = elapsedMs > 0 ? this._totalTokens / (elapsedMs / 1000) : 0; return { startTime: this._startTime, firstTokenTime: this._firstTokenTime, totalTokens: this._totalTokens, totalChunks: this._totalChunks, elapsedMs, tokensPerSecond: Math.round(tokensPerSecond * 10) / 10, complete: this._complete, error: this._error, finishReason: this._finishReason, }; } /** @private */ _maybeEmitProgress() { const now = Date.now(); if (now - this._lastProgressAt >= this._progressIntervalMs) { this._emitProgress(); } } /** @private */ _emitProgress() { this._lastProgressAt = Date.now(); if (this._onProgress) { this._onProgress(this.getMetrics()); } } } /** * Create a TransformStream that tracks SSE progress. * * @param {{ onProgress?: (metrics: StreamMetrics) => void }} [options={}] * @returns {{ stream: TransformStream, tracker: StreamTracker }} */ export function createStreamTracker(options = {}) { const tracker = new StreamTracker(options); const stream = new TransformStream({ transform(chunk, controller) { const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); const lines = text.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { tracker.onChunk(line); } } controller.enqueue(chunk); }, flush() { tracker.onComplete(); }, }); return { stream, tracker }; }