feat(daemon): add background usage monitor with HTTP/SSE API
Implement a long-running daemon service that continuously polls Claude Code session logs and exposes usage data via local HTTP endpoints. Architecture: - internal/daemon/service.go: Core Service struct managing poll loop, snapshot computation, event buffering, and HTTP handlers - cmd/daemon.go: Cobra commands for start/status/stop with detach mode HTTP Endpoints (default 127.0.0.1:8787): - GET /healthz - Liveness probe for orchestration - GET /v1/status - Current aggregate snapshot + daemon runtime info - GET /v1/events - Recent event buffer as JSON array - GET /v1/stream - Server-Sent Events for real-time updates Snapshot model captures: - Session/prompt/API call counts - Token totals and estimated cost - Cache hit rate - Rolling daily averages (cost/day, tokens/day, sessions/day) Delta detection emits events only when usage actually changes, keeping the event stream lean for downstream consumers. Detach mode (-d, --detach): - Forks a child process with stdout/stderr redirected to log file - Writes PID file for process management - Parent exits after confirming child is running This daemon serves as the foundation for planned capabilities like incident replay, runaway detection, and session classification. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
344
cmd/daemon.go
Normal file
344
cmd/daemon.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/theirongolddev/cburn/internal/daemon"
|
||||
"github.com/theirongolddev/cburn/internal/pipeline"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type daemonRuntimeState struct {
|
||||
PID int `json:"pid"`
|
||||
Addr string `json:"addr"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
DataDir string `json:"data_dir"`
|
||||
}
|
||||
|
||||
var (
|
||||
flagDaemonAddr string
|
||||
flagDaemonInterval time.Duration
|
||||
flagDaemonDetach bool
|
||||
flagDaemonPIDFile string
|
||||
flagDaemonLogFile string
|
||||
flagDaemonEventsBuffer int
|
||||
flagDaemonChild bool
|
||||
)
|
||||
|
||||
var daemonCmd = &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run a background usage daemon with HTTP/SSE endpoints",
|
||||
RunE: runDaemon,
|
||||
}
|
||||
|
||||
var daemonStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon process and API status",
|
||||
RunE: runDaemonStatus,
|
||||
}
|
||||
|
||||
var daemonStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the running daemon",
|
||||
RunE: runDaemonStop,
|
||||
}
|
||||
|
||||
func init() {
|
||||
defaultPID := filepath.Join(pipeline.CacheDir(), "cburnd.pid")
|
||||
defaultLog := filepath.Join(pipeline.CacheDir(), "cburnd.log")
|
||||
|
||||
daemonCmd.PersistentFlags().StringVar(&flagDaemonAddr, "addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
daemonCmd.PersistentFlags().DurationVar(&flagDaemonInterval, "interval", 15*time.Second, "Polling interval")
|
||||
daemonCmd.PersistentFlags().StringVar(&flagDaemonPIDFile, "pid-file", defaultPID, "PID file path")
|
||||
daemonCmd.PersistentFlags().StringVar(&flagDaemonLogFile, "log-file", defaultLog, "Log file path for detached mode")
|
||||
daemonCmd.PersistentFlags().IntVar(&flagDaemonEventsBuffer, "events-buffer", 200, "Max in-memory events retained")
|
||||
|
||||
daemonCmd.Flags().BoolVar(&flagDaemonDetach, "detach", false, "Run daemon as a background process")
|
||||
daemonCmd.Flags().BoolVar(&flagDaemonChild, "child", false, "Internal: mark detached child process")
|
||||
_ = daemonCmd.Flags().MarkHidden("child")
|
||||
|
||||
daemonCmd.AddCommand(daemonStatusCmd)
|
||||
daemonCmd.AddCommand(daemonStopCmd)
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
}
|
||||
|
||||
func runDaemon(_ *cobra.Command, _ []string) error {
|
||||
if flagDaemonDetach && flagDaemonChild {
|
||||
return errors.New("invalid daemon launch mode")
|
||||
}
|
||||
|
||||
if flagDaemonDetach {
|
||||
return startDaemonDetached()
|
||||
}
|
||||
|
||||
return runDaemonForeground()
|
||||
}
|
||||
|
||||
func startDaemonDetached() error {
|
||||
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable: %w", err)
|
||||
}
|
||||
|
||||
args := filterDetachArg(os.Args[1:])
|
||||
args = append(args, "--child")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||
return fmt.Errorf("create daemon directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(flagDaemonLogFile), 0o750); err != nil {
|
||||
return fmt.Errorf("create daemon log directory: %w", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // daemon log path is configured by the local user
|
||||
logf, err := os.OpenFile(flagDaemonLogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open daemon log file: %w", err)
|
||||
}
|
||||
defer func() { _ = logf.Close() }()
|
||||
|
||||
cmd := exec.Command(exe, args...) //nolint:gosec // exe/args come from current process invocation
|
||||
cmd.Stdout = logf
|
||||
cmd.Stderr = logf
|
||||
cmd.Stdin = nil
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start detached daemon: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Started daemon (pid %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf(" PID file: %s\n", flagDaemonPIDFile)
|
||||
fmt.Printf(" API: http://%s/v1/status\n", flagDaemonAddr)
|
||||
fmt.Printf(" Log: %s\n", flagDaemonLogFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemonForeground() error {
|
||||
if err := ensureDaemonNotRunning(flagDaemonPIDFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(flagDaemonPIDFile), 0o750); err != nil {
|
||||
return fmt.Errorf("create daemon directory: %w", err)
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
if err := writePID(flagDaemonPIDFile, pid); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.Remove(flagDaemonPIDFile) }()
|
||||
|
||||
state := daemonRuntimeState{
|
||||
PID: pid,
|
||||
Addr: flagDaemonAddr,
|
||||
StartedAt: time.Now(),
|
||||
DataDir: flagDataDir,
|
||||
}
|
||||
_ = writeState(statePath(flagDaemonPIDFile), state)
|
||||
defer func() { _ = os.Remove(statePath(flagDaemonPIDFile)) }()
|
||||
|
||||
cfg := daemon.Config{
|
||||
DataDir: flagDataDir,
|
||||
Days: flagDays,
|
||||
ProjectFilter: flagProject,
|
||||
ModelFilter: flagModel,
|
||||
IncludeSubagents: !flagNoSubagents,
|
||||
UseCache: !flagNoCache,
|
||||
Interval: flagDaemonInterval,
|
||||
Addr: flagDaemonAddr,
|
||||
EventsBuffer: flagDaemonEventsBuffer,
|
||||
}
|
||||
svc := daemon.New(cfg)
|
||||
|
||||
fmt.Printf(" cburn daemon listening on http://%s\n", flagDaemonAddr)
|
||||
fmt.Printf(" Polling every %s from %s\n", flagDaemonInterval, flagDataDir)
|
||||
fmt.Printf(" Stop with: cburn daemon stop --pid-file %s\n", flagDaemonPIDFile)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemonStatus(_ *cobra.Command, _ []string) error {
|
||||
pid, err := readPID(flagDaemonPIDFile)
|
||||
if err != nil {
|
||||
fmt.Printf(" Daemon: not running (pid file not found)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
alive := processAlive(pid)
|
||||
if !alive {
|
||||
fmt.Printf(" Daemon: stale pid file (pid %d not alive)\n", pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := flagDaemonAddr
|
||||
if st, err := readState(statePath(flagDaemonPIDFile)); err == nil && st.Addr != "" {
|
||||
addr = st.Addr
|
||||
}
|
||||
|
||||
fmt.Printf(" Daemon PID: %d\n", pid)
|
||||
fmt.Printf(" Address: http://%s\n", addr)
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get("http://" + addr + "/v1/status") //nolint:noctx // short status probe
|
||||
if err != nil {
|
||||
fmt.Printf(" API status: unreachable (%v)\n", err)
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf(" API status: HTTP %d\n", resp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
var st daemon.Status
|
||||
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
||||
fmt.Printf(" API status: malformed response (%v)\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if st.LastPollAt.IsZero() {
|
||||
fmt.Printf(" Last poll: pending\n")
|
||||
} else {
|
||||
fmt.Printf(" Last poll: %s\n", st.LastPollAt.Local().Format(time.RFC3339))
|
||||
}
|
||||
fmt.Printf(" Poll count: %d\n", st.PollCount)
|
||||
fmt.Printf(" Sessions: %d\n", st.Summary.Sessions)
|
||||
fmt.Printf(" Tokens: %d\n", st.Summary.Tokens)
|
||||
fmt.Printf(" Cost: $%.2f\n", st.Summary.EstimatedCostUSD)
|
||||
if st.LastError != "" {
|
||||
fmt.Printf(" Last error: %s\n", st.LastError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemonStop(_ *cobra.Command, _ []string) error {
|
||||
pid, err := readPID(flagDaemonPIDFile)
|
||||
if err != nil {
|
||||
return errors.New("daemon is not running")
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find daemon process: %w", err)
|
||||
}
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("signal daemon process: %w", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
_ = os.Remove(flagDaemonPIDFile)
|
||||
_ = os.Remove(statePath(flagDaemonPIDFile))
|
||||
fmt.Printf(" Stopped daemon (pid %d)\n", pid)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Errorf("daemon (pid %d) did not exit in time", pid)
|
||||
}
|
||||
|
||||
func filterDetachArg(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
if a == "--detach" || strings.HasPrefix(a, "--detach=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureDaemonNotRunning(pidFile string) error {
|
||||
pid, err := readPID(pidFile)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if processAlive(pid) {
|
||||
return fmt.Errorf("daemon already running (pid %d)", pid)
|
||||
}
|
||||
_ = os.Remove(pidFile)
|
||||
_ = os.Remove(statePath(pidFile))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePID(path string, pid int) error {
|
||||
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
|
||||
}
|
||||
|
||||
func readPID(path string) (int, error) {
|
||||
//nolint:gosec // daemon pid path is configured by the local user
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, fmt.Errorf("invalid pid in %s", path)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
func statePath(pidFile string) string {
|
||||
return pidFile + ".json"
|
||||
}
|
||||
|
||||
func writeState(path string, st daemonRuntimeState) error {
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func readState(path string) (daemonRuntimeState, error) {
|
||||
var st daemonRuntimeState
|
||||
//nolint:gosec // daemon state path is configured by the local user
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return st, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
Reference in New Issue
Block a user