Skip to content

Commit 9c2443c

Browse files
committed
feat(tracing): add LaminarService skeleton and minimal task.step span
- Introduce LaminarService (lightweight, no-op if disabled)\n- Start span in Task.submitUserMessage() and end in attemptCompletionTool()\n- Add types shim at src/types/lmnr.d.ts to avoid missing module types\n- Tests pass (cd src && npx vitest run)\n- Lint passes (pnpm run lint)\n\nNote: groundwork for porting cline/cline#5862; pending clarification on full scope.
1 parent 87d50a7 commit 9c2443c

File tree

4 files changed

+259
-0
lines changed

4 files changed

+259
-0
lines changed

src/core/task/Task.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
114114
import { MessageQueueService } from "../message-queue/MessageQueueService"
115115

116116
import { AutoApprovalHandler } from "./AutoApprovalHandler"
117+
import { laminar } from "../../services/laminar/LaminarService"
117118

118119
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
119120
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@@ -298,6 +299,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
298299
private tokenUsageSnapshot?: TokenUsage
299300
private tokenUsageSnapshotAt?: number
300301

302+
// Tracing
303+
private laminarSpanId?: string
304+
301305
constructor({
302306
provider,
303307
apiConfiguration,
@@ -960,6 +964,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
960964
await provider.setProviderProfile(providerProfile)
961965
}
962966

967+
// Start a tracing span for this user turn
968+
try {
969+
this.laminarSpanId = laminar.startSpan("task.step", { taskId: this.taskId })
970+
} catch {}
971+
963972
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
964973

965974
provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })

src/core/tools/attemptCompletionTool.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "../../shared/tools"
1818
import { formatResponse } from "../prompts/responses"
1919
import { Package } from "../../shared/package"
20+
import { laminar } from "../../services/laminar/LaminarService"
2021

2122
export async function attemptCompletionTool(
2223
cline: Task,
@@ -72,6 +73,14 @@ export async function attemptCompletionTool(
7273
TelemetryService.instance.captureTaskCompleted(cline.taskId)
7374
cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage)
7475

76+
// End current task.step span if active
77+
try {
78+
if ((cline as any).laminarSpanId) {
79+
laminar.endSpan((cline as any).laminarSpanId)
80+
;(cline as any).laminarSpanId = undefined
81+
}
82+
} catch {}
83+
7584
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
7685
}
7786
} else {
@@ -95,6 +104,14 @@ export async function attemptCompletionTool(
95104
TelemetryService.instance.captureTaskCompleted(cline.taskId)
96105
cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage)
97106

107+
// End current task.step span if active
108+
try {
109+
if ((cline as any).laminarSpanId) {
110+
laminar.endSpan((cline as any).laminarSpanId)
111+
;(cline as any).laminarSpanId = undefined
112+
}
113+
} catch {}
114+
98115
if (cline.parentTask) {
99116
const didApprove = await askFinishSubTaskApproval()
100117

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* Minimal Laminar tracing integration for Roo Code.
3+
*
4+
* This service is a safe, no-op wrapper when disabled or missing configuration.
5+
* It can later be wired to @lmnr-ai/lmnr SDK without breaking callers.
6+
*/
7+
8+
type Attributes = Record<string, any>
9+
10+
type InternalSpan = {
11+
id: string
12+
name: string
13+
startTime: number
14+
endTime?: number
15+
attributes: Attributes
16+
error?: { message: string; stack?: string; type?: string }
17+
children: InternalSpan[]
18+
parentId?: string
19+
}
20+
21+
export class LaminarService {
22+
private static _instance: LaminarService | undefined
23+
public static get instance(): LaminarService {
24+
if (!this._instance) this._instance = new LaminarService()
25+
return this._instance
26+
}
27+
28+
private enabled = false
29+
private projectKey?: string
30+
private userId?: string
31+
private machineId?: string
32+
private sessionId?: string
33+
34+
// Simple in-memory span store and active stack
35+
private spans = new Map<string, InternalSpan>()
36+
private activeStack: string[] = []
37+
38+
// Optional real SDK client (loaded dynamically when available)
39+
private sdk: any | undefined
40+
41+
/**
42+
* Initialize tracing service. Safe to call multiple times.
43+
*/
44+
public async initialize(
45+
config: {
46+
apiKey?: string
47+
userId?: string
48+
machineId?: string
49+
sessionId?: string
50+
enabled?: boolean
51+
} = {},
52+
): Promise<void> {
53+
try {
54+
const { apiKey, userId, machineId, sessionId, enabled } = config
55+
this.projectKey = apiKey
56+
this.userId = userId
57+
this.machineId = machineId
58+
this.sessionId = sessionId
59+
60+
// Enable only if explicitly enabled and apiKey is present
61+
this.enabled = Boolean(enabled && apiKey)
62+
63+
// Try to dynamically load the SDK if enabled
64+
if (this.enabled) {
65+
try {
66+
const mod = await import("@lmnr-ai/lmnr")
67+
this.sdk = mod
68+
// Potential real SDK init could go here in the future
69+
// await this.sdk?.init?.({ apiKey })
70+
this.debug("[Laminar] Initialized")
71+
} catch (e) {
72+
// If SDK not available, remain in no-op mode but keep enabled to collect local spans
73+
this.sdk = undefined
74+
this.debug(`[Laminar] SDK not found; running in lightweight mode`)
75+
}
76+
} else {
77+
this.debug("[Laminar] Disabled (no apiKey or enabled flag false)")
78+
}
79+
} catch (e) {
80+
this.enabled = false
81+
this.debug(`[Laminar] initialize() failed: ${e instanceof Error ? e.message : String(e)}`)
82+
}
83+
}
84+
85+
/**
86+
* Start a span. Returns a spanId that must be passed to endSpan().
87+
* Safe no-op when disabled; still returns an id for balanced calls.
88+
*/
89+
public startSpan(name: string, attributes: Attributes = {}): string {
90+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
91+
const parentId = this.activeStack[this.activeStack.length - 1]
92+
93+
const span: InternalSpan = {
94+
id,
95+
name,
96+
startTime: Date.now(),
97+
attributes: {
98+
...attributes,
99+
"roo.sessionId": this.sessionId,
100+
"roo.userId": this.userId,
101+
"roo.machineId": this.machineId,
102+
},
103+
children: [],
104+
parentId,
105+
}
106+
107+
if (parentId) {
108+
const parent = this.spans.get(parentId)
109+
if (parent) parent.children.push(span)
110+
}
111+
112+
this.spans.set(id, span)
113+
this.activeStack.push(id)
114+
115+
// If real SDK exists, this is where we'd call sdk.startSpan(name, attributes)
116+
return id
117+
}
118+
119+
/**
120+
* Add or overwrite attributes on a span.
121+
*/
122+
public addAttributesToSpan(spanId: string, attrs: Attributes): void {
123+
const span = this.spans.get(spanId)
124+
if (!span) return
125+
Object.assign(span.attributes, attrs)
126+
// Real SDK would add attributes here as well
127+
}
128+
129+
/**
130+
* Record an exception onto a span without ending it.
131+
*/
132+
public recordExceptionOnSpan(spanId: string, error: unknown): void {
133+
const span = this.spans.get(spanId)
134+
if (!span) return
135+
136+
const err =
137+
error instanceof Error
138+
? { message: error.message, stack: error.stack, type: error.name }
139+
: { message: String(error) }
140+
141+
span.error = err
142+
// Real SDK would record exception here
143+
}
144+
145+
/**
146+
* End a span and optionally attach final attributes (e.g., usage metrics).
147+
*/
148+
public endSpan(spanId: string, finalAttributes: Attributes = {}): void {
149+
const span = this.spans.get(spanId)
150+
if (!span) return
151+
152+
span.endTime = Date.now()
153+
Object.assign(span.attributes, finalAttributes)
154+
155+
// Pop from active stack if it is the current top
156+
if (this.activeStack[this.activeStack.length - 1] === spanId) {
157+
this.activeStack.pop()
158+
} else {
159+
// Remove from stack if found deeper (defensive)
160+
const idx = this.activeStack.indexOf(spanId)
161+
if (idx !== -1) this.activeStack.splice(idx, 1)
162+
}
163+
164+
// Real SDK would end the span here
165+
this.debugSpan(span)
166+
}
167+
168+
/**
169+
* Helper to run a function within a span.
170+
*/
171+
public async withSpan<T>(name: string, attributes: Attributes, fn: () => Promise<T>): Promise<T> {
172+
const id = this.startSpan(name, attributes)
173+
try {
174+
const result = await fn()
175+
return result
176+
} catch (e) {
177+
this.recordExceptionOnSpan(id, e)
178+
throw e
179+
} finally {
180+
this.endSpan(id)
181+
}
182+
}
183+
184+
/**
185+
* Decorator factory for class methods to auto-instrument them.
186+
* Usage:
187+
* const observed = LaminarService.instance.observeDecorator('MyClass.method')
188+
* class MyClass {
189+
* @observed
190+
* commit() { ... }
191+
* }
192+
*/
193+
public observeDecorator(spanName: string, attributes?: Attributes) {
194+
return function (_target: object, _propertyKey: string, descriptor: PropertyDescriptor) {
195+
const original = descriptor.value
196+
descriptor.value = async function (...args: any[]) {
197+
const id = LaminarService.instance.startSpan(spanName, attributes ?? {})
198+
try {
199+
const out = await original.apply(this, args)
200+
return out
201+
} catch (e) {
202+
LaminarService.instance.recordExceptionOnSpan(id, e)
203+
throw e
204+
} finally {
205+
LaminarService.instance.endSpan(id)
206+
}
207+
}
208+
return descriptor
209+
}
210+
}
211+
212+
private debug(msg: string) {
213+
// Keep logs minimal to avoid noise
214+
try {
215+
console.log(msg)
216+
} catch {}
217+
}
218+
219+
private debugSpan(span: InternalSpan) {
220+
// Lightweight end-span debug; avoid logging large attributes
221+
const durationMs = span.endTime! - span.startTime
222+
this.debug(
223+
`[Laminar] span '${span.name}' (${span.id})${span.parentId ? ` child of ${span.parentId}` : ""} duration=${durationMs}ms`,
224+
)
225+
}
226+
}
227+
228+
// Export a singleton instance for convenience
229+
export const laminar = LaminarService.instance

src/types/lmnr.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module "@lmnr-ai/lmnr" {
2+
const mod: any
3+
export default mod
4+
}

0 commit comments

Comments
 (0)