Feature: 016-activity-log-backend Date: 2025-12-26
Based on the spec and technical context, the following areas required investigation:
- BBolt key design for time-ordered activity queries
- Response truncation strategy for large payloads
- Retention policy implementation (time + count based)
- Export format best practices for streaming
- Integration points for activity recording
Use {timestamp_ns}_{activity_id} format for natural reverse-chronological ordering.
BBolt stores keys in byte-sorted order. By using nanosecond timestamps as prefix:
- Cursor iteration from end gives newest-first ordering
- Range queries by time are efficient (seek to timestamp)
- Unique suffix prevents collisions for concurrent writes
// Key format: 20-digit nanosecond timestamp + underscore + ULID
// Example: "17035123456789012345_01HQWX1Y2Z3A4B5C6D7E8F9G0H"
func activityKey(timestamp time.Time, id string) []byte {
return []byte(fmt.Sprintf("%020d_%s", timestamp.UnixNano(), id))
}| Alternative | Rejected Because |
|---|---|
| ULID only | ULIDs are time-ordered but not easily range-queryable |
| UUID | Random order, poor for time-based queries |
| Auto-increment | Requires counter management, not timestamp-queryable |
Truncate response bodies exceeding configurable limit (default 64KB), store truncation indicator.
- Large LLM responses can exceed 100KB
- Storage efficiency matters at 100K records
- Users need to know if data was truncated
- Full response available via separate mechanism if needed
type ActivityRecord struct {
// ... other fields
Response string `json:"response"` // Potentially truncated
ResponseTruncated bool `json:"response_truncated"` // True if truncated
}
const DefaultMaxResponseSize = 64 * 1024 // 64KB
func truncateResponse(response string, maxSize int) (string, bool) {
if len(response) <= maxSize {
return response, false
}
return response[:maxSize] + "...[truncated]", true
}{
"activity_max_response_size": 65536
}| Alternative | Rejected Because |
|---|---|
| No truncation | Storage bloat, 100K records × 100KB = 10GB |
| Compress instead | CPU overhead, complexity |
| Store separately | Additional file management, complexity |
Delete records when either:
- Record age exceeds retention period (default 90 days), OR
- Total record count exceeds limit (default 100,000)
- Time-based ensures old data is purged for compliance
- Count-based ensures storage doesn't grow unbounded
- Background goroutine avoids blocking operations
- Uses existing
AsyncManagerpattern from storage
type RetentionConfig struct {
MaxAgeDays int `json:"activity_retention_days"` // Default: 90
MaxRecords int `json:"activity_max_records"` // Default: 100000
CleanupInterval time.Duration // Default: 1 hour
}
func (m *Manager) runActivityRetentionLoop(ctx context.Context) {
ticker := time.NewTicker(m.retentionConfig.CleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.pruneOldActivities()
m.pruneExcessActivities()
}
}
}- Time-based pruning: Delete all records older than
MaxAgeDays - Count-based pruning: If count >
MaxRecords, delete oldest until at 90% capacity
| Alternative | Rejected Because |
|---|---|
| On-write pruning | Blocks tool calls, violates FR-019 |
| Manual cleanup only | Users forget, storage grows unbounded |
| Separate database | Complexity, another file to manage |
Support both formats with streaming to handle large exports without memory issues.
- JSON Lines (JSONL) is easy to parse, one record per line
- CSV is universal for spreadsheets/compliance tools
- Streaming prevents OOM for 100K record exports
- Content-Disposition header triggers download
JSON Lines Export:
func (s *Server) handleExportActivityJSONL(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Content-Disposition", "attachment; filename=activity.jsonl")
encoder := json.NewEncoder(w)
for record := range s.controller.StreamActivities(ctx, filters) {
encoder.Encode(record)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
}CSV Export:
func (s *Server) handleExportActivityCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=activity.csv")
writer := csv.NewWriter(w)
writer.Write([]string{"id", "type", "server", "tool", "status", "timestamp"})
for record := range s.controller.StreamActivities(ctx, filters) {
writer.Write(recordToRow(record))
}
writer.Flush()
}| Alternative | Rejected Because |
|---|---|
| Full JSON array | Memory issues for large exports |
| Zip compression | Added complexity, streaming still possible |
| Background job | Overcomplicated for this use case |
Record activities by:
- Emitting events from tool call handler
- Runtime receives and stores asynchronously
- SSE automatically propagates to clients
- Non-blocking (FR-019) - tool calls don't wait for storage
- Decoupled - MCP handler doesn't know about storage
- Consistent with existing event bus pattern
- SSE "for free" via existing infrastructure
1. Tool Call Recording (internal/server/mcp.go):
func (p *MCPProxyServer) handleCallTool(...) {
// Emit start event
p.runtime.EmitActivity(ActivityToolCallStarted, map[string]any{
"activity_id": activityID,
"server": serverName,
"tool": toolName,
"arguments": args,
})
// Execute tool call
result, err := p.executeToolCall(...)
// Emit completion event
p.runtime.EmitActivity(ActivityToolCallCompleted, map[string]any{
"activity_id": activityID,
"status": status,
"duration_ms": duration,
"response": truncatedResponse,
})
}2. Policy Decision Recording (internal/runtime/lifecycle.go):
func (r *Runtime) checkToolPolicy(serverName, toolName string) error {
if blocked := r.policyEngine.Check(...); blocked {
r.EmitActivity(ActivityPolicyDecision, map[string]any{
"server": serverName,
"tool": toolName,
"reason": "blocked by policy",
})
return ErrPolicyBlocked
}
return nil
}3. Quarantine Events (already exists, extend):
func (r *Runtime) QuarantineServer(serverName string, quarantined bool) error {
// ... existing logic
r.EmitActivity(ActivityQuarantineChange, map[string]any{
"server": serverName,
"quarantined": quarantined,
})
}Tool Call → MCP Handler → EmitActivity() → Event Bus → Runtime Subscriber
↓
┌─────────────┴─────────────┐
↓ ↓
StorageManager SSE Broadcast
(async write) (to clients)
| Alternative | Rejected Because |
|---|---|
| Direct storage call | Blocks tool execution |
| Middleware approach | Doesn't capture policy decisions |
| Separate activity service | Unnecessary abstraction |
All research questions resolved. Key decisions:
- BBolt Keys:
{timestamp_ns}_{id}for natural time ordering - Truncation: 64KB default with indicator flag
- Retention: Dual-trigger (90 days OR 100K records) with background cleanup
- Export: Streaming JSONL and CSV formats
- Integration: Event-driven via existing event bus
No NEEDS CLARIFICATION items remain.