diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2dd9e55c0b..a4248b588c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -114,6 +114,7 @@ import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" import { MessageQueueService } from "../message-queue/MessageQueueService" import { AutoApprovalHandler } from "./AutoApprovalHandler" +import { laminar } from "../../services/laminar/LaminarService" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -298,6 +299,9 @@ export class Task extends EventEmitter implements TaskLike { private tokenUsageSnapshot?: TokenUsage private tokenUsageSnapshotAt?: number + // Tracing + private laminarSpanId?: string + constructor({ provider, apiConfiguration, @@ -960,6 +964,11 @@ export class Task extends EventEmitter implements TaskLike { await provider.setProviderProfile(providerProfile) } + // Start a tracing span for this user turn + try { + this.laminarSpanId = laminar.startSpan("task.step", { taskId: this.taskId }) + } catch {} + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index 5074d7f4e8..8ed1c0c597 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -17,6 +17,7 @@ import { } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { Package } from "../../shared/package" +import { laminar } from "../../services/laminar/LaminarService" export async function attemptCompletionTool( cline: Task, @@ -72,6 +73,14 @@ export async function attemptCompletionTool( TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + // End current task.step span if active + try { + if ((cline as any).laminarSpanId) { + laminar.endSpan((cline as any).laminarSpanId) + ;(cline as any).laminarSpanId = undefined + } + } catch {} + await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) } } else { @@ -95,6 +104,14 @@ export async function attemptCompletionTool( TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + // End current task.step span if active + try { + if ((cline as any).laminarSpanId) { + laminar.endSpan((cline as any).laminarSpanId) + ;(cline as any).laminarSpanId = undefined + } + } catch {} + if (cline.parentTask) { const didApprove = await askFinishSubTaskApproval() diff --git a/src/services/laminar/LaminarService.ts b/src/services/laminar/LaminarService.ts new file mode 100644 index 0000000000..6efdc6eee5 --- /dev/null +++ b/src/services/laminar/LaminarService.ts @@ -0,0 +1,229 @@ +/** + * Minimal Laminar tracing integration for Roo Code. + * + * This service is a safe, no-op wrapper when disabled or missing configuration. + * It can later be wired to @lmnr-ai/lmnr SDK without breaking callers. + */ + +type Attributes = Record + +type InternalSpan = { + id: string + name: string + startTime: number + endTime?: number + attributes: Attributes + error?: { message: string; stack?: string; type?: string } + children: InternalSpan[] + parentId?: string +} + +export class LaminarService { + private static _instance: LaminarService | undefined + public static get instance(): LaminarService { + if (!this._instance) this._instance = new LaminarService() + return this._instance + } + + private enabled = false + private projectKey?: string + private userId?: string + private machineId?: string + private sessionId?: string + + // Simple in-memory span store and active stack + private spans = new Map() + private activeStack: string[] = [] + + // Optional real SDK client (loaded dynamically when available) + private sdk: any | undefined + + /** + * Initialize tracing service. Safe to call multiple times. + */ + public async initialize( + config: { + apiKey?: string + userId?: string + machineId?: string + sessionId?: string + enabled?: boolean + } = {}, + ): Promise { + try { + const { apiKey, userId, machineId, sessionId, enabled } = config + this.projectKey = apiKey + this.userId = userId + this.machineId = machineId + this.sessionId = sessionId + + // Enable only if explicitly enabled and apiKey is present + this.enabled = Boolean(enabled && apiKey) + + // Try to dynamically load the SDK if enabled + if (this.enabled) { + try { + const mod = await import("@lmnr-ai/lmnr") + this.sdk = mod + // Potential real SDK init could go here in the future + // await this.sdk?.init?.({ apiKey }) + this.debug("[Laminar] Initialized") + } catch (e) { + // If SDK not available, remain in no-op mode but keep enabled to collect local spans + this.sdk = undefined + this.debug(`[Laminar] SDK not found; running in lightweight mode`) + } + } else { + this.debug("[Laminar] Disabled (no apiKey or enabled flag false)") + } + } catch (e) { + this.enabled = false + this.debug(`[Laminar] initialize() failed: ${e instanceof Error ? e.message : String(e)}`) + } + } + + /** + * Start a span. Returns a spanId that must be passed to endSpan(). + * Safe no-op when disabled; still returns an id for balanced calls. + */ + public startSpan(name: string, attributes: Attributes = {}): string { + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + const parentId = this.activeStack[this.activeStack.length - 1] + + const span: InternalSpan = { + id, + name, + startTime: Date.now(), + attributes: { + ...attributes, + "roo.sessionId": this.sessionId, + "roo.userId": this.userId, + "roo.machineId": this.machineId, + }, + children: [], + parentId, + } + + if (parentId) { + const parent = this.spans.get(parentId) + if (parent) parent.children.push(span) + } + + this.spans.set(id, span) + this.activeStack.push(id) + + // If real SDK exists, this is where we'd call sdk.startSpan(name, attributes) + return id + } + + /** + * Add or overwrite attributes on a span. + */ + public addAttributesToSpan(spanId: string, attrs: Attributes): void { + const span = this.spans.get(spanId) + if (!span) return + Object.assign(span.attributes, attrs) + // Real SDK would add attributes here as well + } + + /** + * Record an exception onto a span without ending it. + */ + public recordExceptionOnSpan(spanId: string, error: unknown): void { + const span = this.spans.get(spanId) + if (!span) return + + const err = + error instanceof Error + ? { message: error.message, stack: error.stack, type: error.name } + : { message: String(error) } + + span.error = err + // Real SDK would record exception here + } + + /** + * End a span and optionally attach final attributes (e.g., usage metrics). + */ + public endSpan(spanId: string, finalAttributes: Attributes = {}): void { + const span = this.spans.get(spanId) + if (!span) return + + span.endTime = Date.now() + Object.assign(span.attributes, finalAttributes) + + // Pop from active stack if it is the current top + if (this.activeStack[this.activeStack.length - 1] === spanId) { + this.activeStack.pop() + } else { + // Remove from stack if found deeper (defensive) + const idx = this.activeStack.indexOf(spanId) + if (idx !== -1) this.activeStack.splice(idx, 1) + } + + // Real SDK would end the span here + this.debugSpan(span) + } + + /** + * Helper to run a function within a span. + */ + public async withSpan(name: string, attributes: Attributes, fn: () => Promise): Promise { + const id = this.startSpan(name, attributes) + try { + const result = await fn() + return result + } catch (e) { + this.recordExceptionOnSpan(id, e) + throw e + } finally { + this.endSpan(id) + } + } + + /** + * Decorator factory for class methods to auto-instrument them. + * Usage: + * const observed = LaminarService.instance.observeDecorator('MyClass.method') + * class MyClass { + * @observed + * commit() { ... } + * } + */ + public observeDecorator(spanName: string, attributes?: Attributes) { + return function (_target: object, _propertyKey: string, descriptor: PropertyDescriptor) { + const original = descriptor.value + descriptor.value = async function (...args: any[]) { + const id = LaminarService.instance.startSpan(spanName, attributes ?? {}) + try { + const out = await original.apply(this, args) + return out + } catch (e) { + LaminarService.instance.recordExceptionOnSpan(id, e) + throw e + } finally { + LaminarService.instance.endSpan(id) + } + } + return descriptor + } + } + + private debug(msg: string) { + // Keep logs minimal to avoid noise + try { + console.log(msg) + } catch {} + } + + private debugSpan(span: InternalSpan) { + // Lightweight end-span debug; avoid logging large attributes + const durationMs = span.endTime! - span.startTime + this.debug( + `[Laminar] span '${span.name}' (${span.id})${span.parentId ? ` child of ${span.parentId}` : ""} duration=${durationMs}ms`, + ) + } +} + +// Export a singleton instance for convenience +export const laminar = LaminarService.instance diff --git a/src/types/lmnr.d.ts b/src/types/lmnr.d.ts new file mode 100644 index 0000000000..53499a025d --- /dev/null +++ b/src/types/lmnr.d.ts @@ -0,0 +1,4 @@ +declare module "@lmnr-ai/lmnr" { + const mod: any + export default mod +}