Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -298,6 +299,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private tokenUsageSnapshot?: TokenUsage
private tokenUsageSnapshotAt?: number

// Tracing
private laminarSpanId?: string

constructor({
provider,
apiConfiguration,
Expand Down Expand Up @@ -960,6 +964,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await provider.setProviderProfile(providerProfile)
}

// Start a tracing span for this user turn
try {
this.laminarSpanId = laminar.startSpan("task.step", { taskId: this.taskId })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Span starts here but ends in attemptCompletionTool. If control flow exits early or errors differently, spans can remain open. Consider centralizing lifecycle in Task (e.g., try/finally) or using a Task event hook to end the span reliably.

} catch {}

this.emit(RooCodeEventName.TaskUserMessage, this.taskId)

provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
Expand Down
17 changes: 17 additions & 0 deletions src/core/tools/attemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Accessing a private field via (cline as any).laminarSpanId bypasses types and couples modules. Prefer a Task instance method to end the active span (or encapsulate span lifecycle within Task).

laminar.endSpan((cline as any).laminarSpanId)
;(cline as any).laminarSpanId = undefined
}
} catch {}

await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
}
} else {
Expand All @@ -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()

Expand Down
229 changes: 229 additions & 0 deletions src/services/laminar/LaminarService.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>

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<string, InternalSpan>()
private activeStack: string[] = []
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: activeStack is process-global; concurrent tasks may interleave and corrupt parent/child relationships. Use AsyncLocalStorage for per-async-context stacks or maintain stacks keyed by sessionId/taskId.


// 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<void> {
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")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Dynamic import likely needs default handling. If the SDK exports default, future calls like this.sdk.init may fail. Normalize the module:

Suggested change
const mod = await import("@lmnr-ai/lmnr")
const mod = await import("@lmnr-ai/lmnr");
this.sdk = (mod as any).default ?? mod;

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)}`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Not a true no-op when disabled. startSpan/endSpan record spans and debugSpan logs even if initialize() never enables tracing. Gate behavior behind enabled (bail early in startSpan/endSpan; guard debug/debugSpan) to avoid console noise and memory overhead.

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<T>(name: string, attributes: Attributes, fn: () => Promise<T>): Promise<T> {
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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: console.log in debug() adds noise and may violate lint rules. Prefer console.debug or a central logger, and gate logs behind an opt-in debug flag and/or enabled.

} 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
4 changes: 4 additions & 0 deletions src/types/lmnr.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "@lmnr-ai/lmnr" {
const mod: any
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Type shim assumes a default export; if the SDK migrates to named exports this could mislead consumers. Consider exporting both default and named to keep flexibility.

export default mod
}
Loading