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:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user