flare-agent Bài 9: Observability & Tracing — Biết agent đang làm gì

Xây dựng @flare-agent/observability — OpenTelemetry-compatible tracing cho agent loop, workflow steps và multi-agent calls. Debug dễ dàng, không còn black box.

7 phút đọc
Tập này đang được chuẩn bị, quay lại sau nhé.

flare-agent Bài 9: Observability & Tracing

Series: Build Your Own AI Agent Framework trên Cloudflare Bài: 9 / 9 — @flare-agent/observability


Tại sao cần Tracing?

Agent loop là black box — bạn gọi agent.run() và nhận output, nhưng không biết:

  • Mất bao lâu ở mỗi bước?
  • Tool nào được gọi với args gì?
  • LLM trả về gì trước khi gọi tool?
  • Iteration nào tốn nhiều tokens nhất?
  • Lỗi xảy ra ở đâu trong multi-agent flow?

Tracing giải quyết tất cả — biến black box thành glass box.


Thiết kế đơn giản, không over-engineer

Thay vì tích hợp full OpenTelemetry SDK (nặng, không phù hợp Workers), build lightweight tracer tương thích OTel format — export được sang Jaeger, Grafana, hoặc Cloudflare Workers Analytics.

packages/observability/
  src/
    types.ts        # Span, Trace interfaces
    Tracer.ts       # Core tracer
    SpanContext.ts  # Context propagation
    exporters/
      console.ts    # Dev: log ra console
      d1.ts         # Prod: lưu vào D1
      otel.ts       # Export sang OTel collector
    index.ts

Types

// src/types.ts

export type SpanStatus = 'ok' | 'error' | 'running';

export interface Span {
  traceId: string;      // ID của toàn bộ request
  spanId: string;       // ID của span này
  parentSpanId?: string; // ID của span cha
  name: string;         // Tên operation
  startTime: number;    // Unix ms
  endTime?: number;
  durationMs?: number;
  status: SpanStatus;
  attributes: Record<string, unknown>; // metadata
  events: SpanEvent[];  // logs trong span
  error?: string;
}

export interface SpanEvent {
  name: string;
  timestamp: number;
  attributes?: Record<string, unknown>;
}

export interface Trace {
  traceId: string;
  spans: Span[];
  startTime: number;
  endTime?: number;
  totalDurationMs?: number;
}

export interface SpanExporter {
  export(spans: Span[]): Promise<void>;
}

Tracer

// src/Tracer.ts
import type { Span, SpanEvent, SpanExporter, SpanStatus } from './types';

export class Tracer {
  private spans = new Map<string, Span>();
  private exporters: SpanExporter[] = [];

  constructor(private traceId = crypto.randomUUID()) {}

  addExporter(exporter: SpanExporter): this {
    this.exporters.push(exporter);
    return this;
  }

  // Bắt đầu 1 span
  startSpan(
    name: string,
    attributes: Record<string, unknown> = {},
    parentSpanId?: string
  ): string {
    const spanId = crypto.randomUUID();
    const span: Span = {
      traceId: this.traceId,
      spanId,
      parentSpanId,
      name,
      startTime: Date.now(),
      status: 'running',
      attributes,
      events: [],
    };
    this.spans.set(spanId, span);
    return spanId;
  }

  // Kết thúc span
  endSpan(spanId: string, status: SpanStatus = 'ok', error?: string): void {
    const span = this.spans.get(spanId);
    if (!span) return;

    span.endTime = Date.now();
    span.durationMs = span.endTime - span.startTime;
    span.status = status;
    if (error) span.error = error;

    // Export ngay khi span kết thúc
    this.exporters.forEach((e) => e.export([span]));
  }

  // Thêm event vào span đang chạy
  addEvent(
    spanId: string,
    name: string,
    attributes?: Record<string, unknown>
  ): void {
    const span = this.spans.get(spanId);
    if (!span) return;
    span.events.push({
      name,
      timestamp: Date.now(),
      attributes,
    });
  }

  // Set attribute
  setAttribute(
    spanId: string,
    key: string,
    value: unknown
  ): void {
    const span = this.spans.get(spanId);
    if (!span) return;
    span.attributes[key] = value;
  }

  // Helper: wrap async function trong span
  async trace<T>(
    name: string,
    fn: (spanId: string) => Promise<T>,
    attributes: Record<string, unknown> = {},
    parentSpanId?: string
  ): Promise<T> {
    const spanId = this.startSpan(name, attributes, parentSpanId);
    try {
      const result = await fn(spanId);
      this.endSpan(spanId, 'ok');
      return result;
    } catch (err) {
      this.endSpan(
        spanId,
        'error',
        err instanceof Error ? err.message : String(err)
      );
      throw err;
    }
  }

  getTrace() {
    const spans = [...this.spans.values()];
    return {
      traceId: this.traceId,
      spans,
      startTime: Math.min(...spans.map((s) => s.startTime)),
      endTime: Math.max(...spans.map((s) => s.endTime ?? Date.now())),
    };
  }
}

Exporters

Console Exporter — dev

// src/exporters/console.ts
import type { Span, SpanExporter } from '../types';

export class ConsoleExporter implements SpanExporter {
  async export(spans: Span[]): Promise<void> {
    for (const span of spans) {
      const status = span.status === 'error' ? '❌' : '✅';
      const duration = span.durationMs ? `${span.durationMs}ms` : 'running';

      console.log(
        `${status} [${span.name}] ${duration}`,
        span.attributes
      );

      if (span.error) {
        console.error(`   Error: ${span.error}`);
      }

      if (span.events.length) {
        span.events.forEach((e) =>
          console.log(`   Event: ${e.name}`, e.attributes)
        );
      }
    }
  }
}

D1 Exporter — production

// src/exporters/d1.ts
import type { Span, SpanExporter } from '../types';

export class D1Exporter implements SpanExporter {
  constructor(private db: D1Database) {}

  async export(spans: Span[]): Promise<void> {
    // Batch insert
    const stmts = spans.map((span) =>
      this.db
        .prepare(
          `INSERT INTO agent_traces
           (trace_id, span_id, parent_span_id, name,
            start_time, end_time, duration_ms,
            status, attributes, error)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        )
        .bind(
          span.traceId,
          span.spanId,
          span.parentSpanId ?? null,
          span.name,
          span.startTime,
          span.endTime ?? null,
          span.durationMs ?? null,
          span.status,
          JSON.stringify(span.attributes),
          span.error ?? null
        )
    );

    await this.db.batch(stmts);
  }
}

// Migration
export const TRACES_MIGRATION = `
CREATE TABLE IF NOT EXISTS agent_traces (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  trace_id     TEXT NOT NULL,
  span_id      TEXT NOT NULL UNIQUE,
  parent_span_id TEXT,
  name         TEXT NOT NULL,
  start_time   INTEGER NOT NULL,
  end_time     INTEGER,
  duration_ms  INTEGER,
  status       TEXT NOT NULL,
  attributes   TEXT, -- JSON
  error        TEXT,
  created_at   DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_trace ON agent_traces(trace_id);
`;

Tích hợp vào Agent Loop

Thêm tracer vào AgentLoop — inject qua config:

// packages/core/src/AgentLoop.ts — updated

interface LoopConfig {
  systemPrompt: string;
  provider: LLMProvider;
  memory: MemoryManager;
  tools: ToolRegistry;
  maxIterations: number;
  tracer?: Tracer; // ← optional, không bắt buộc
}

export class AgentLoop {
  async run(userMessage: string, ctx: AgentContext): Promise<AgentResult> {
    const { tracer } = this.config;

    // Span cho toàn bộ agent run
    const runSpanId = tracer?.startSpan('agent.run', {
      'agent.input': userMessage,
      'agent.sessionId': ctx.sessionId,
      'agent.userId': ctx.userId,
    });

    try {
      await this.config.memory.addMessage(ctx.sessionId, {
        role: 'user',
        content: userMessage,
      });

      for (let i = 0; i < this.config.maxIterations; i++) {
        const iterSpanId = tracer?.startSpan(
          'agent.iteration',
          { 'iteration.index': i },
          runSpanId
        );

        const messages = await this.config.memory.getMessages(ctx.sessionId);
        const toolSchemas = this.config.tools.getSchemas();

        // Span cho LLM call
        const llmSpanId = tracer?.startSpan(
          'llm.chat',
          {
            'llm.messageCount': messages.length,
            'llm.toolCount': toolSchemas.length,
          },
          iterSpanId
        );

        const response = await this.config.provider.chat(
          [{ role: 'system', content: this.config.systemPrompt }, ...messages],
          toolSchemas
        );

        tracer?.setAttribute(
          llmSpanId!,
          'llm.responseType',
          response.type
        );
        tracer?.endSpan(llmSpanId!);

        if (response.type === 'tool_call' && response.toolCall) {
          const { id, name, args } = response.toolCall;

          // Span cho tool execution
          const toolSpanId = tracer?.startSpan(
            `tool.${name}`,
            { 'tool.name': name, 'tool.args': JSON.stringify(args) },
            iterSpanId
          );

          let result: unknown;
          try {
            result = await this.config.tools.execute(name, args, ctx);
            tracer?.setAttribute(
              toolSpanId!,
              'tool.result',
              JSON.stringify(result)
            );
            tracer?.endSpan(toolSpanId!, 'ok');
          } catch (err) {
            tracer?.endSpan(
              toolSpanId!,
              'error',
              err instanceof Error ? err.message : String(err)
            );
            result = { error: String(err) };
          }

          await this.config.memory.addMessage(ctx.sessionId, {
            role: 'tool',
            content: JSON.stringify(result),
            toolCallId: id,
          });

          tracer?.endSpan(iterSpanId!);
          continue;
        }

        // Final response
        const output = response.content ?? '';
        await this.config.memory.addMessage(ctx.sessionId, {
          role: 'assistant',
          content: output,
        });

        tracer?.setAttribute(runSpanId!, 'agent.output', output);
        tracer?.setAttribute(runSpanId!, 'agent.iterations', i + 1);
        tracer?.endSpan(iterSpanId!);
        tracer?.endSpan(runSpanId!, 'ok');

        return { output, iterations: i + 1, toolCalls: [] };
      }

      throw new Error('Max iterations exceeded');
    } catch (err) {
      tracer?.endSpan(
        runSpanId!,
        'error',
        err instanceof Error ? err.message : String(err)
      );
      throw err;
    }
  }
}

Dùng trong Worker

import { Tracer } from '@flare-agent/observability';
import { ConsoleExporter, D1Exporter } from '@flare-agent/observability';

app.post('/api/chat', async (c) => {
  const { input, sessionId, userId } = await c.req.json();

  // Tạo tracer cho request này
  const tracer = new Tracer()
    .addExporter(new ConsoleExporter())        // log dev
    .addExporter(new D1Exporter(c.env.DB));   // persist prod

  const result = await vocabularyAgent.run(input, {
    sessionId,
    userId,
    env: { ...c.env, tracer }, // inject tracer qua env
  });

  // Trả về trace cùng result nếu cần debug
  return c.json({
    result,
    traceId: tracer.getTrace().traceId,
  });
});

// Debug endpoint — xem trace của 1 request
app.get('/api/traces/:traceId', async (c) => {
  const { traceId } = c.req.param();
  const { results } = await c.env.DB
    .prepare('SELECT * FROM agent_traces WHERE trace_id = ? ORDER BY start_time ASC')
    .bind(traceId)
    .all();
  return c.json({ spans: results });
});

Output khi chạy

✅ [agent.run] 1243ms { agent.input: 'giải thích từ serendipity', agent.iterations: 3 }
  ✅ [agent.iteration] 423ms { iteration.index: 0 }
    ✅ [llm.chat] 380ms { llm.messageCount: 2, llm.responseType: 'tool_call' }
    ✅ [tool.get_vocabulary] 42ms { tool.name: 'get_vocabulary', tool.args: '{"word":"serendipity"}' }
  ✅ [agent.iteration] 401ms { iteration.index: 1 }
    ✅ [llm.chat] 390ms { llm.messageCount: 4, llm.responseType: 'tool_call' }
    ✅ [tool.save_progress] 10ms { tool.name: 'save_progress' }
  ✅ [agent.iteration] 419ms { iteration.index: 2 }
    ✅ [llm.chat] 415ms { llm.messageCount: 6, llm.responseType: 'text' }

Nhìn vào là biết ngay: 3 iterations, LLM call nào chậm, tool nào được gọi.


Checklist

  • Tạo packages/observability/
  • Chạy migration thêm bảng agent_traces
  • Inject tracer vào AgentLoop
  • Verify trace output đúng trong console
  • Test D1Exporter persist được

Tổng kết Series

9 bài, 9 packages — bạn đã có full AI Agent Framework chạy native trên Cloudflare:

@flare-agent/types         ← interfaces
@flare-agent/providers     ← Groq, WorkersAI, Ollama
@flare-agent/memory        ← KV, D1, Vectorize
@flare-agent/core          ← Agent Loop, Tool Registry
@flare-agent/workflow      ← Graph-based Workflow
@flare-agent/multi-agent   ← Agent Network, Handoff
@flare-agent/observability ← Tracing, Debugging

Không phụ thuộc Mastra. Không phụ thuộc LangChain. Hiểu 100% internals.

Series hoàn tất. Nếu bài viết hữu ích, hãy chia sẻ với cộng đồng developer Việt Nam! 🇻🇳