feat(traces): add optional coarse traces for agent runs (US-808)

TraceService:
- 4 coarse step types: init, execute, complete, error
- Trace ID = Attempt ID for natural correlation
- Traces stored as JSON files in .veritas-kanban/traces/
- Off by default (enable via POST /api/traces/enable)

API Endpoints:
- GET /api/traces/status — Check if tracing is enabled
- POST /api/traces/enable — Enable tracing
- POST /api/traces/disable — Disable tracing
- GET /api/traces/:attemptId — Get a specific trace
- GET /api/traces/task/:taskId — List all traces for a task

AgentService Integration:
- Automatically records trace steps when tracing is enabled
- init: worktree setup, prompt building
- execute: agent process running
- complete: post-run processing
- error: captures error details
This commit is contained in:
Brad Groux 2026-01-26 12:36:04 -06:00
parent b7c5b64649
commit e6faa846bf
4 changed files with 358 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import previewRoutes from './routes/preview.js';
import conflictRoutes from './routes/conflicts.js';
import telemetryRoutes from './routes/telemetry.js';
import metricsRoutes from './routes/metrics.js';
import tracesRoutes from './routes/traces.js';
import { getTelemetryService } from './services/telemetry-service.js';
import type { AgentOutput } from './services/agent-service.js';
@ -46,6 +47,7 @@ app.use('/api/preview', previewRoutes);
app.use('/api/conflicts', conflictRoutes);
app.use('/api/telemetry', telemetryRoutes);
app.use('/api/metrics', metricsRoutes);
app.use('/api/traces', tracesRoutes);
// Initialize telemetry service (runs retention cleanup)
getTelemetryService().init().catch((err) => {

View file

@ -0,0 +1,82 @@
import { Router, type Router as RouterType } from 'express';
import { getTraceService } from '../services/trace-service.js';
const router: RouterType = Router();
/**
* GET /api/traces/status
* Get tracing status (enabled/disabled)
*/
router.get('/status', async (_req, res, next) => {
try {
const traceService = getTraceService();
res.json({
enabled: traceService.isEnabled(),
});
} catch (error) {
next(error);
}
});
/**
* POST /api/traces/enable
* Enable tracing
*/
router.post('/enable', async (_req, res, next) => {
try {
const traceService = getTraceService();
traceService.setEnabled(true);
res.json({ enabled: true });
} catch (error) {
next(error);
}
});
/**
* POST /api/traces/disable
* Disable tracing
*/
router.post('/disable', async (_req, res, next) => {
try {
const traceService = getTraceService();
traceService.setEnabled(false);
res.json({ enabled: false });
} catch (error) {
next(error);
}
});
/**
* GET /api/traces/:attemptId
* Get a trace by attempt ID
*/
router.get('/:attemptId', async (req, res, next) => {
try {
const traceService = getTraceService();
const trace = await traceService.getTrace(req.params.attemptId);
if (!trace) {
return res.status(404).json({ error: 'Trace not found' });
}
res.json(trace);
} catch (error) {
next(error);
}
});
/**
* GET /api/traces/task/:taskId
* List all traces for a task
*/
router.get('/task/:taskId', async (req, res, next) => {
try {
const traceService = getTraceService();
const traces = await traceService.listTraces(req.params.taskId);
res.json(traces);
} catch (error) {
next(error);
}
});
export default router;

View file

@ -6,6 +6,7 @@ import { nanoid } from 'nanoid';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getTelemetryService, type TelemetryService } from './telemetry-service.js';
import { getTraceService, type TraceService } from './trace-service.js';
import type { Task, AgentType, TaskAttempt, AttemptStatus, RunTelemetryEvent } from '@veritas-kanban/shared';
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
@ -43,12 +44,14 @@ export class AgentService {
private configService: ConfigService;
private taskService: TaskService;
private telemetry: TelemetryService;
private traceService: TraceService;
private logsDir: string;
constructor() {
this.configService = new ConfigService();
this.taskService = new TaskService();
this.telemetry = getTelemetryService();
this.traceService = getTraceService();
this.logsDir = LOGS_DIR;
this.ensureLogsDir();
}
@ -99,6 +102,10 @@ export class AgentService {
const startedAt = new Date().toISOString();
const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`);
// Start trace (if enabled)
this.traceService.startTrace(attemptId, taskId, agent, task.project);
this.traceService.startStep(attemptId, 'init', { worktreePath: task.git.worktreePath });
// Build prompt from task
const prompt = this.buildPrompt(task);
@ -155,6 +162,10 @@ export class AgentService {
project: task.project,
});
// End init step, start execute step (tracing)
this.traceService.endStep(attemptId, 'init');
this.traceService.startStep(attemptId, 'execute', { pid: childProcess.pid });
// Handle stdout
childProcess.stdout?.on('data', async (data: Buffer) => {
const content = data.toString();
@ -216,6 +227,12 @@ export class AgentService {
success,
});
// End execute step, start complete step, then complete trace
this.traceService.endStep(attemptId, 'execute');
this.traceService.startStep(attemptId, 'complete', { exitCode: code });
this.traceService.endStep(attemptId, 'complete');
await this.traceService.completeTrace(attemptId, success ? 'completed' : 'failed');
// Emit completion
emitter.emit('complete', { code, signal, status });
@ -239,6 +256,11 @@ export class AgentService {
success: false,
});
// Record error step and complete trace with error status
this.traceService.startStep(attemptId, 'error', { error: error.message });
this.traceService.endStep(attemptId, 'error');
await this.traceService.completeTrace(attemptId, 'error');
emitter.emit('error', error);
await this.appendToLog(logPath, 'system', `\n---\nAgent error: ${error.message}`);
runningAgents.delete(taskId);

View file

@ -0,0 +1,252 @@
import fs from 'fs/promises';
import path from 'path';
import type { AgentType } from '@veritas-kanban/shared';
import { getTelemetryService } from './telemetry-service.js';
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
const TRACES_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'traces');
export type TraceStepType = 'init' | 'execute' | 'complete' | 'error';
export interface TraceStep {
type: TraceStepType;
startedAt: string;
endedAt?: string;
durationMs?: number;
metadata?: Record<string, unknown>;
}
export interface Trace {
traceId: string; // Same as attemptId
taskId: string;
agent: AgentType;
project?: string;
startedAt: string;
endedAt?: string;
totalDurationMs?: number;
steps: TraceStep[];
status: 'running' | 'completed' | 'failed' | 'error';
}
// In-memory store for active traces
const activeTraces = new Map<string, Trace>();
export class TraceService {
private tracesDir: string;
private enabled: boolean = false;
constructor() {
this.tracesDir = TRACES_DIR;
this.init();
}
private async init(): Promise<void> {
// Check if traces are enabled in telemetry config
const telemetry = getTelemetryService();
const config = telemetry.getConfig();
this.enabled = config.traces ?? false;
if (this.enabled) {
await fs.mkdir(this.tracesDir, { recursive: true });
}
}
/**
* Check if tracing is enabled
*/
isEnabled(): boolean {
return this.enabled;
}
/**
* Enable or disable tracing
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (enabled) {
fs.mkdir(this.tracesDir, { recursive: true }).catch(() => {});
}
}
/**
* Start a new trace for an agent run
*/
startTrace(
attemptId: string,
taskId: string,
agent: AgentType,
project?: string
): Trace | null {
if (!this.enabled) return null;
const trace: Trace = {
traceId: attemptId,
taskId,
agent,
project,
startedAt: new Date().toISOString(),
steps: [],
status: 'running',
};
activeTraces.set(attemptId, trace);
return trace;
}
/**
* Start a step within a trace
*/
startStep(
attemptId: string,
stepType: TraceStepType,
metadata?: Record<string, unknown>
): TraceStep | null {
if (!this.enabled) return null;
const trace = activeTraces.get(attemptId);
if (!trace) return null;
const step: TraceStep = {
type: stepType,
startedAt: new Date().toISOString(),
metadata,
};
trace.steps.push(step);
return step;
}
/**
* End the current step of a given type
*/
endStep(attemptId: string, stepType: TraceStepType): void {
if (!this.enabled) return;
const trace = activeTraces.get(attemptId);
if (!trace) return;
// Find the last step of this type that hasn't ended
const step = [...trace.steps]
.reverse()
.find(s => s.type === stepType && !s.endedAt);
if (step) {
step.endedAt = new Date().toISOString();
step.durationMs = new Date(step.endedAt).getTime() - new Date(step.startedAt).getTime();
}
}
/**
* Complete a trace (success or failure)
*/
async completeTrace(
attemptId: string,
status: 'completed' | 'failed' | 'error'
): Promise<Trace | null> {
if (!this.enabled) return null;
const trace = activeTraces.get(attemptId);
if (!trace) return null;
trace.endedAt = new Date().toISOString();
trace.totalDurationMs = new Date(trace.endedAt).getTime() - new Date(trace.startedAt).getTime();
trace.status = status;
// Close any open steps
for (const step of trace.steps) {
if (!step.endedAt) {
step.endedAt = trace.endedAt;
step.durationMs = new Date(step.endedAt).getTime() - new Date(step.startedAt).getTime();
}
}
// Save to disk
await this.saveTrace(trace);
// Remove from active traces
activeTraces.delete(attemptId);
return trace;
}
/**
* Get an active trace
*/
getActiveTrace(attemptId: string): Trace | null {
return activeTraces.get(attemptId) || null;
}
/**
* Get a completed trace from disk
*/
async getTrace(attemptId: string): Promise<Trace | null> {
// Check active traces first
const active = activeTraces.get(attemptId);
if (active) return active;
// Try to load from disk
try {
const filepath = path.join(this.tracesDir, `${attemptId}.json`);
const content = await fs.readFile(filepath, 'utf-8');
return JSON.parse(content) as Trace;
} catch {
return null;
}
}
/**
* List all traces for a task
*/
async listTraces(taskId: string): Promise<Trace[]> {
const traces: Trace[] = [];
// Add active traces for this task
for (const trace of activeTraces.values()) {
if (trace.taskId === taskId) {
traces.push(trace);
}
}
// Load completed traces from disk
try {
const files = await fs.readdir(this.tracesDir);
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const content = await fs.readFile(path.join(this.tracesDir, file), 'utf-8');
const trace = JSON.parse(content) as Trace;
if (trace.taskId === taskId && !activeTraces.has(trace.traceId)) {
traces.push(trace);
}
} catch {
// Skip invalid files
}
}
} catch {
// Directory might not exist
}
// Sort by startedAt descending
return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}
/**
* Save a trace to disk
*/
private async saveTrace(trace: Trace): Promise<void> {
await fs.mkdir(this.tracesDir, { recursive: true });
const filepath = path.join(this.tracesDir, `${trace.traceId}.json`);
await fs.writeFile(filepath, JSON.stringify(trace, null, 2), 'utf-8');
}
}
// Singleton instance
let instance: TraceService | null = null;
export function getTraceService(): TraceService {
if (!instance) {
instance = new TraceService();
}
return instance;
}