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.

Nghe bài viết này dưới dạng podcast

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! 🇻🇳

Chưa có bình luận

Để lại bình luận

Bình luận sẽ được phê duyệt trước khi hiển thị.

Nhận bài viết mới

Mình sẽ gửi email khi có bài mới. Không spam.