feat: complete agent monitoring - hook, UI, and backend filter

- Add event_type and framework filters to events query endpoint
- Add /agents SPA route to web-ui server
- Add Agents nav link and route in frontend
- Add agents page CSS (timeline, VM pills, stats panel)
- Build VM status strip, activity timeline, and real-time stats
- Add agentmon hook for OpenClaw (HOOK.md + handler.ts)
- Add docker-compose, Dockerfile, and supporting infra files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-03-14 00:26:42 -07:00
parent 1927ec6622
commit 3434db3c59
29 changed files with 6228 additions and 231 deletions
+211
View File
@@ -0,0 +1,211 @@
# Agentmon SDK
The agentmon SDK provides a Go client for sending telemetry events to the agentmon backend.
## Installation
```bash
go get agentmon/internal/sdk
```
## Quick Start
```go
package main
import (
"context"
"log"
"agentmon/internal/sdk"
)
func main() {
emitter, err := sdk.NewEmitter(sdk.Config{
ServerURL: "http://localhost:8080",
Framework: "my-agent",
ClientID: "my-client-001",
Host: "localhost",
BufferSize: 100,
})
if err != nil {
log.Fatal(err)
}
defer emitter.Close(context.Background())
ctx := context.Background()
sessionID := "session-123"
// Start a session
sessionStart := sdk.NewSessionStart(sessionID, sdk.WithSource(emitter))
if err := emitter.Emit(ctx, sessionStart); err != nil {
log.Printf("Error: %v", err)
}
// ... do work ...
// End the session
sessionEnd := sdk.NewSessionEnd(sessionID, sdk.WithSource(emitter))
if err := emitter.Emit(ctx, sessionEnd); err != nil {
log.Printf("Error: %v", err)
}
// Flush buffered events
if err := emitter.Flush(ctx); err != nil {
log.Printf("Error: %v", err)
}
}
```
## Configuration
The `Config` struct configures the emitter:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `ServerURL` | string | Yes | - | URL of the ingest gateway (e.g., `http://localhost:8080`) |
| `APIKey` | string | No | - | Optional API key for authentication |
| `Framework` | string | Yes | - | Name of the framework (e.g., `opencode`, `claude-code`) |
| `ClientID` | string | Yes | - | Stable identifier for this emitter instance |
| `Host` | string | No | `localhost` | Hostname where events originate |
| `BufferSize` | int | No | `100` | Max number of events to buffer before flushing |
| `UseWebSocket` | bool | No | `false` | Enable WebSocket streaming mode |
| `EnableLogging` | bool | No | `false` | Enable debug logging |
## Event Types
### Session Events
```go
// Start a session
sessionStart := sdk.NewSessionStart(sessionID,
sdk.WithSource(emitter),
sdk.WithAttributes(map[string]any{
"cwd": "/home/user/project",
"repo": "myrepo",
"branch": "main",
}),
)
// End a session
sessionEnd := sdk.NewSessionEnd(sessionID,
sdk.WithSource(emitter),
)
```
### Run Events
```go
// Start a run
runStart := sdk.NewRunStart(sessionID, runID,
sdk.WithSource(emitter),
sdk.WithAttributes(map[string]any{
"command": "my-command",
"agent": "my-agent",
}),
)
// End a run
runEnd := sdk.NewRunEnd(sessionID, runID, "success", 60000,
sdk.WithSource(emitter),
sdk.WithLLMUsage("claude-3-opus", 1000, 500, 0.015),
)
```
### Span Events
```go
// Start a span
spanStart := sdk.NewSpanStart(sessionID, runID, traceID, spanID,
sdk.WithSource(emitter),
sdk.WithSpanKind("tool"),
sdk.WithName("Bash"),
sdk.WithAttributes(map[string]any{
"command": "echo hello",
}),
)
// End a span
spanEnd := sdk.NewSpanEnd(sessionID, runID, traceID, spanID, "success", 1000,
sdk.WithSource(emitter),
sdk.WithSpanKind("tool"),
sdk.WithName("Bash"),
)
```
### Error Events
```go
errEvent := sdk.NewError(sessionID, runID, traceID, spanID,
"validation", "invalid input",
sdk.WithSource(emitter),
sdk.WithErrorDetails("VAL001", false),
)
```
### Metric Snapshots
```go
metrics := sdk.NewMetricSnapshot(sessionID, runID, map[string]any{
"tokens_in": 1000.0,
"tokens_out": 500.0,
"cost_usd": 0.015,
"latency_ms": 300.0,
"error_count": 0,
})
```
## Event Options
Event options are functions that modify events before sending:
- `WithSource(emitter)` - Add source information (framework, client_id, host)
- `WithAttributes(attrs)` - Add arbitrary attributes
- `WithSpanKind(kind)` - Set the span_kind attribute (`llm`, `tool`, `skill`, `internal`)
- `WithName(name)` - Set the name attribute
- `WithParentSpanID(parentID)` - Set the parent span ID
- `WithPayload(payload)` - Set custom payload
- `WithSeq(seq)` - Set sequence number (for WebSocket mode)
- `WithLLMUsage(model, inTokens, outTokens, costUSD)` - Add LLM usage to run.end or span.end
- `WithErrorDetails(code, retryable)` - Add error details
## Span Kinds
Common span kinds:
- `llm` - LLM API calls
- `tool` - Tool/function calls
- `skill` - Skill execution
- `internal` - Internal operations
## WebSocket Mode
For real-time streaming, enable WebSocket mode:
```go
emitter, err := sdk.NewEmitter(sdk.Config{
ServerURL: "http://localhost:8080",
Framework: "my-agent",
ClientID: "my-client-001",
Host: "localhost",
UseWebSocket: true,
})
```
In WebSocket mode, events are sent immediately rather than buffered.
## Example
See `examples/sdk-example/main.go` for a complete example.
## Testing
Run tests with:
```bash
go test ./internal/sdk/...
```
## License
Same license as the agentmon project.
+351
View File
@@ -0,0 +1,351 @@
// Package sdk provides the agentmon emitter SDK for sending telemetry events.
package sdk
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
schemaName = "agentmon.event"
schemaVersion = 1
)
// Emitter is the main client for sending agentmon events.
type Emitter struct {
config Config
httpClient *http.Client
wsClient *WSClient
buffer []Event
bufferSize int
mu sync.Mutex
closed bool
}
// Config holds emitter configuration.
type Config struct {
// ServerURL is the base URL of the ingest gateway (e.g., "http://localhost:8080")
ServerURL string
// APIKey is optional authentication key
APIKey string
// Framework is the name of the agent framework (e.g., "opencode", "claude-code")
Framework string
// ClientID is a stable identifier for this emitter instance
ClientID string
// Host is the hostname where events originate
Host string
// BufferSize is the max number of events to buffer before flushing
BufferSize int
// UseWebSocket enables WebSocket streaming mode
UseWebSocket bool
// EnableLogging enables debug logging
EnableLogging bool
}
// Event represents a complete agentmon event.
type Event map[string]any
// WSClient handles WebSocket communication with the ingest gateway.
type WSClient struct {
conn *websocket.Conn
sendChan chan []byte
ackChan chan int
mu sync.Mutex
closed bool
}
// NewWSClient creates a new WebSocket client.
func NewWSClient(url string) (*WSClient, error) {
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return nil, err
}
return &WSClient{
conn: conn,
sendChan: make(chan []byte, 100),
ackChan: make(chan int, 1),
}, nil
}
// Run starts the WebSocket client's main loop.
func (w *WSClient) Run(ctx context.Context) {
defer w.Close()
go w.readMessages()
w.writeMessages()
}
// Send queues an event to be sent via WebSocket.
func (w *WSClient) Send(data []byte) error {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return fmt.Errorf("WebSocket client is closed")
}
w.mu.Unlock()
select {
case w.sendChan <- data:
return nil
default:
return fmt.Errorf("send buffer full")
}
}
// Close closes the WebSocket connection.
func (w *WSClient) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
if w.conn != nil {
_ = w.conn.Close()
}
close(w.sendChan)
return nil
}
func (w *WSClient) readMessages() {
for {
_, message, err := w.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("WebSocket read error: %v", err)
}
return
}
var ack map[string]any
if err := json.Unmarshal(message, &ack); err != nil {
log.Printf("Failed to unmarshal ack: %v", err)
continue
}
if seq, ok := ack["ack"].(map[string]any)["up_to_seq"].(float64); ok {
select {
case w.ackChan <- int(seq):
default:
}
}
}
}
func (w *WSClient) writeMessages() {
for data := range w.sendChan {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return
}
err := w.conn.WriteMessage(websocket.TextMessage, data)
w.mu.Unlock()
if err != nil {
log.Printf("WebSocket write error: %v", err)
return
}
}
}
// NewEmitter creates a new emitter with the given configuration.
func NewEmitter(cfg Config) (*Emitter, error) {
if cfg.ServerURL == "" {
return nil, fmt.Errorf("ServerURL is required")
}
if cfg.Framework == "" {
return nil, fmt.Errorf("Framework is required")
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("ClientID is required")
}
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.BufferSize <= 0 {
cfg.BufferSize = 100
}
e := &Emitter{
config: cfg,
httpClient: &http.Client{Timeout: 30 * time.Second},
buffer: make([]Event, 0, cfg.BufferSize),
bufferSize: cfg.BufferSize,
}
if cfg.UseWebSocket {
wsURL := wsURLFromHTTP(cfg.ServerURL)
wsClient, err := NewWSClient(wsURL)
if err != nil {
return nil, fmt.Errorf("failed to create WebSocket client: %w", err)
}
e.wsClient = wsClient
go e.wsClient.Run(context.Background())
}
return e, nil
}
// Emit sends a single event.
func (e *Emitter) Emit(ctx context.Context, event Event) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return fmt.Errorf("emitter is closed")
}
if e.config.UseWebSocket {
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
return e.wsClient.Send(data)
}
e.buffer = append(e.buffer, event)
if len(e.buffer) >= e.bufferSize {
return e.Flush(ctx)
}
return nil
}
// Flush sends all buffered events to the server.
func (e *Emitter) Flush(ctx context.Context) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return fmt.Errorf("emitter is closed")
}
if len(e.buffer) == 0 {
return nil
}
if e.config.EnableLogging {
log.Printf("Flushing %d events", len(e.buffer))
}
events := make([]map[string]any, len(e.buffer))
for i, ev := range e.buffer {
events[i] = ev
}
resp, err := e.sendEvents(ctx, events)
if err != nil {
return fmt.Errorf("failed to send events: %w", err)
}
e.buffer = e.buffer[:0]
if resp.Rejected > 0 && e.config.EnableLogging {
log.Printf("Rejected %d events", resp.Rejected)
if len(resp.Errors) > 0 {
log.Printf("Errors: %v", resp.Errors)
}
}
return nil
}
// Close flushes any remaining events and closes the emitter.
func (e *Emitter) Close(ctx context.Context) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil
}
e.closed = true
if e.wsClient != nil {
_ = e.wsClient.Close()
}
if len(e.buffer) > 0 {
_ = e.Flush(ctx)
}
return nil
}
type sendResponse struct {
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Errors []struct {
Error string `json:"error"`
} `json:"errors,omitempty"`
}
func (e *Emitter) sendEvents(ctx context.Context, events []map[string]any) (*sendResponse, error) {
body, err := json.Marshal(events)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", e.config.ServerURL+"/v1/events", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if e.config.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.config.APIKey)
}
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result sendResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
func wsURLFromHTTP(httpURL string) string {
switch {
case len(httpURL) >= 8 && httpURL[:8] == "https://":
return "wss://" + httpURL[8:] + "/v1/ws"
case len(httpURL) >= 7 && httpURL[:7] == "http://":
return "ws://" + httpURL[7:] + "/v1/ws"
default:
return httpURL + "/v1/ws"
}
}
// generateID creates a new UUID-like identifier.
func generateID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
log.Printf("Failed to generate random ID: %v", err)
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
+348
View File
@@ -0,0 +1,348 @@
package sdk
import (
"context"
"testing"
)
func TestNewEmitter(t *testing.T) {
tests := []struct {
name string
config Config
wantErr bool
}{
{
name: "valid config",
config: Config{
ServerURL: "http://localhost:8080",
Framework: "test-framework",
ClientID: "test-client",
Host: "test-host",
},
wantErr: false,
},
{
name: "missing server URL",
config: Config{
Framework: "test-framework",
ClientID: "test-client",
},
wantErr: true,
},
{
name: "missing framework",
config: Config{
ServerURL: "http://localhost:8080",
ClientID: "test-client",
},
wantErr: true,
},
{
name: "missing client ID",
config: Config{
ServerURL: "http://localhost:8080",
Framework: "test-framework",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
emitter, err := NewEmitter(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("NewEmitter() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
_ = emitter.Close(context.Background())
}
})
}
}
func TestGenerateID(t *testing.T) {
id1 := generateID()
id2 := generateID()
if id1 == id2 {
t.Errorf("generateID() should produce unique IDs, got duplicate: %s", id1)
}
if len(id1) == 0 {
t.Error("generateID() should return a non-empty string")
}
}
func TestNewSessionStart(t *testing.T) {
sessionID := "test-session-001"
event := NewSessionStart(sessionID)
if event["event"].(map[string]any)["type"] != "session.start" {
t.Error("NewSessionStart() should create session.start event")
}
if event["correlation"].(map[string]any)["session_id"] != sessionID {
t.Error("NewSessionStart() should set session_id in correlation")
}
if event["schema"].(map[string]any)["name"] != schemaName {
t.Error("NewSessionStart() should set correct schema name")
}
}
func TestNewRunStart(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
event := NewRunStart(sessionID, runID)
if event["event"].(map[string]any)["type"] != "run.start" {
t.Error("NewRunStart() should create run.start event")
}
if event["correlation"].(map[string]any)["session_id"] != sessionID {
t.Error("NewRunStart() should set session_id in correlation")
}
if event["correlation"].(map[string]any)["run_id"] != runID {
t.Error("NewRunStart() should set run_id in correlation")
}
}
func TestNewSpanStart(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
traceID := "test-trace-001"
spanID := "test-span-001"
event := NewSpanStart(sessionID, runID, traceID, spanID)
if event["event"].(map[string]any)["type"] != "span.start" {
t.Error("NewSpanStart() should create span.start event")
}
if event["correlation"].(map[string]any)["span_id"] != spanID {
t.Error("NewSpanStart() should set span_id in correlation")
}
if event["correlation"].(map[string]any)["trace_id"] != traceID {
t.Error("NewSpanStart() should set trace_id in correlation")
}
}
func TestNewRunEnd(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
status := "success"
durationMs := int64(60000)
event := NewRunEnd(sessionID, runID, status, durationMs)
if event["event"].(map[string]any)["type"] != "run.end" {
t.Error("NewRunEnd() should create run.end event")
}
payload := event["payload"].(map[string]any)
if payload["status"] != status {
t.Errorf("NewRunEnd() should set status to %s", status)
}
if payload["duration_ms"] != durationMs {
t.Errorf("NewRunEnd() should set duration_ms to %d", durationMs)
}
}
func TestNewSpanEnd(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
traceID := "test-trace-001"
spanID := "test-span-001"
status := "success"
durationMs := int64(1000)
event := NewSpanEnd(sessionID, runID, traceID, spanID, status, durationMs)
if event["event"].(map[string]any)["type"] != "span.end" {
t.Error("NewSpanEnd() should create span.end event")
}
payload := event["payload"].(map[string]any)
if payload["status"] != status {
t.Errorf("NewSpanEnd() should set status to %s", status)
}
if payload["duration_ms"] != durationMs {
t.Errorf("NewSpanEnd() should set duration_ms to %d", durationMs)
}
}
func TestNewError(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
traceID := "test-trace-001"
spanID := "test-span-001"
errType := "validation"
message := "invalid input"
event := NewError(sessionID, runID, traceID, spanID, errType, message)
if event["event"].(map[string]any)["type"] != "error" {
t.Error("NewError() should create error event")
}
payload := event["payload"].(map[string]any)
err := payload["error"].(map[string]any)
if err["type"] != errType {
t.Errorf("NewError() should set error type to %s", errType)
}
if err["message"] != message {
t.Errorf("NewError() should set error message to %s", message)
}
}
func TestNewMetricSnapshot(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
metrics := map[string]any{
"tokens_in": 1000.0,
"tokens_out": 500.0,
"cost_usd": 0.002,
}
event := NewMetricSnapshot(sessionID, runID, metrics)
if event["event"].(map[string]any)["type"] != "metric.snapshot" {
t.Error("NewMetricSnapshot() should create metric.snapshot event")
}
payload := event["payload"].(map[string]any)
payloadMetrics := payload["metrics"].(map[string]any)
if len(payloadMetrics) != len(metrics) {
t.Error("NewMetricSnapshot() should include all metrics")
}
}
func TestEventOptions(t *testing.T) {
emitter, err := NewEmitter(Config{
ServerURL: "http://localhost:8080",
Framework: "test-framework",
ClientID: "test-client",
Host: "test-host",
})
if err != nil {
t.Fatalf("NewEmitter() error = %v", err)
}
defer emitter.Close(context.Background())
sessionID := "test-session-001"
event := NewSessionStart(sessionID,
WithSource(emitter),
WithAttributes(map[string]any{"cwd": "/home/user"}),
WithSeq(1),
)
if _, ok := event["event"].(map[string]any)["source"]; !ok {
t.Error("WithSource() should set source")
}
if _, ok := event["attributes"]; !ok {
t.Error("WithAttributes() should set attributes")
}
if event["event"].(map[string]any)["seq"] != 1 {
t.Error("WithSeq() should set sequence number")
}
}
func TestWithSpanKind(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
traceID := "test-trace-001"
spanID := "test-span-001"
event := NewSpanStart(sessionID, runID, traceID, spanID, WithSpanKind("tool"))
attrs := event["attributes"].(map[string]any)
if attrs["span_kind"] != "tool" {
t.Error("WithSpanKind() should set span_kind attribute")
}
}
func TestWithLLMUsage(t *testing.T) {
sessionID := "test-session-001"
runID := "test-run-001"
event := NewRunEnd(sessionID, runID, "success", 60000,
WithLLMUsage("claude-3-opus", 1000, 500, 0.015),
)
payload := event["payload"].(map[string]any)
llm := payload["llm"].(map[string]any)
if llm["model"] != "claude-3-opus" {
t.Error("WithLLMUsage() should set model")
}
usage := llm["usage"].(map[string]any)
if usage["input_tokens"] != 1000 {
t.Error("WithLLMUsage() should set input_tokens")
}
if usage["output_tokens"] != 500 {
t.Error("WithLLMUsage() should set output_tokens")
}
cost := llm["cost"].(map[string]any)
if cost["total_usd"] != 0.015 {
t.Error("WithLLMUsage() should set total_usd")
}
}
func TestEmit(t *testing.T) {
emitter, err := NewEmitter(Config{
ServerURL: "http://localhost:9999",
Framework: "test-framework",
ClientID: "test-client",
Host: "test-host",
BufferSize: 10,
})
if err != nil {
t.Fatalf("NewEmitter() error = %v", err)
}
sessionID := "test-session-001"
event := NewSessionStart(sessionID, WithSource(emitter))
ctx := context.Background()
err = emitter.Emit(ctx, event)
if err != nil {
t.Errorf("Emit() error = %v", err)
}
err = emitter.Emit(ctx, NewSessionStart(sessionID+"-2", WithSource(emitter)))
if err != nil {
t.Errorf("Emit() error = %v", err)
}
emitter.mu.Lock()
buffered := len(emitter.buffer)
emitter.mu.Unlock()
if buffered != 2 {
t.Errorf("Expected 2 buffered events, got %d", buffered)
}
}
func TestWsURLFromHTTP(t *testing.T) {
tests := []struct {
httpURL string
want string
}{
{"http://localhost:8080", "ws://localhost:8080/v1/ws"},
{"https://example.com", "wss://example.com/v1/ws"},
{"http://example.com:8080", "ws://example.com:8080/v1/ws"},
}
for _, tt := range tests {
t.Run(tt.httpURL, func(t *testing.T) {
if got := wsURLFromHTTP(tt.httpURL); got != tt.want {
t.Errorf("wsURLFromHTTP() = %v, want %v", got, tt.want)
}
})
}
}
+332
View File
@@ -0,0 +1,332 @@
package sdk
import (
"time"
)
// NewSessionStart creates a session.start event.
func NewSessionStart(sessionID string, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "session.start",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewSessionEnd creates a session.end event.
func NewSessionEnd(sessionID string, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "session.end",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewRunStart creates a run.start event.
func NewRunStart(sessionID, runID string, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "run.start",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewRunEnd creates a run.end event.
func NewRunEnd(sessionID, runID string, status string, durationMs int64, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "run.end",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
},
"payload": map[string]any{
"status": status,
"duration_ms": durationMs,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewSpanStart creates a span.start event.
func NewSpanStart(sessionID, runID, traceID, spanID string, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "span.start",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
"trace_id": traceID,
"span_id": spanID,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewSpanEnd creates a span.end event.
func NewSpanEnd(sessionID, runID, traceID, spanID string, status string, durationMs int64, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "span.end",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
"trace_id": traceID,
"span_id": spanID,
},
"payload": map[string]any{
"status": status,
"duration_ms": durationMs,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewError creates an error event.
func NewError(sessionID, runID, traceID, spanID string, errType, message string, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "error",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
"trace_id": traceID,
"span_id": spanID,
},
"payload": map[string]any{
"error": map[string]any{
"type": errType,
"message": message,
},
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// NewMetricSnapshot creates a metric.snapshot event.
func NewMetricSnapshot(sessionID, runID string, metrics map[string]any, opts ...EventOption) Event {
now := time.Now()
event := map[string]any{
"schema": map[string]any{
"name": schemaName,
"version": schemaVersion,
},
"event": map[string]any{
"id": generateID(),
"type": "metric.snapshot",
"ts": now.UTC().Format(time.RFC3339Nano),
},
"correlation": map[string]any{
"session_id": sessionID,
"run_id": runID,
},
"payload": map[string]any{
"metrics": metrics,
},
}
for _, opt := range opts {
opt(event)
}
return event
}
// EventOption is a function that modifies an event.
type EventOption func(Event)
// WithSource sets the source information on an event.
func WithSource(emitter *Emitter) EventOption {
return func(e Event) {
if event, ok := e["event"].(map[string]any); ok {
event["source"] = map[string]any{
"framework": emitter.config.Framework,
"client_id": emitter.config.ClientID,
"host": emitter.config.Host,
}
}
}
}
// WithAttributes adds attributes to an event.
func WithAttributes(attrs map[string]any) EventOption {
return func(e Event) {
if _, ok := e["attributes"]; !ok {
e["attributes"] = make(map[string]any)
}
if attrsMap, ok := e["attributes"].(map[string]any); ok {
for k, v := range attrs {
attrsMap[k] = v
}
}
}
}
// WithSpanKind sets the span_kind attribute.
func WithSpanKind(kind string) EventOption {
return WithAttributes(map[string]any{"span_kind": kind})
}
// WithName sets the name attribute.
func WithName(name string) EventOption {
return WithAttributes(map[string]any{"name": name})
}
// WithParentSpanID sets the parent_span_id in correlation.
func WithParentSpanID(parentID string) EventOption {
return func(e Event) {
if corr, ok := e["correlation"].(map[string]any); ok {
corr["parent_span_id"] = parentID
}
}
}
// WithPayload sets the payload on an event.
func WithPayload(payload map[string]any) EventOption {
return func(e Event) {
e["payload"] = payload
}
}
// WithSeq sets the sequence number on an event.
func WithSeq(seq int) EventOption {
return func(e Event) {
if event, ok := e["event"].(map[string]any); ok {
event["seq"] = seq
}
}
}
// WithLLMUsage adds LLM usage information to a span.end or run.end payload.
func WithLLMUsage(model string, inputTokens, outputTokens int, costUSD float64) EventOption {
return func(e Event) {
if payload, ok := e["payload"].(map[string]any); ok {
if _, ok := payload["llm"]; !ok {
payload["llm"] = make(map[string]any)
}
if llm, ok := payload["llm"].(map[string]any); ok {
llm["model"] = model
llm["usage"] = map[string]any{
"input_tokens": inputTokens,
"output_tokens": outputTokens,
}
llm["cost"] = map[string]any{
"total_usd": costUSD,
}
}
}
}
}
// WithErrorDetails adds error details to a payload.
func WithErrorDetails(code string, retryable bool) EventOption {
return func(e Event) {
if payload, ok := e["payload"].(map[string]any); ok {
if err, ok := payload["error"].(map[string]any); ok {
err["code"] = code
err["retryable"] = retryable
}
}
}
}