package audit import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" ) // LokiCentral pushes capture audit records to a Grafana Loki instance via // its push API, and probes readiness via /ready. It is the central audit // substrate behind DegradingSink. type LokiCentral struct { baseURL string labels map[string]string http *http.Client } // NewLokiCentral constructs a LokiCentral for the given base URL (e.g. // http://loki:3100). Returns nil when baseURL is empty so callers can // treat missing config as "no central sink" with a single nil check. func NewLokiCentral(baseURL string) *LokiCentral { if baseURL == "" { return nil } return &LokiCentral{ baseURL: strings.TrimRight(baseURL, "/"), labels: map[string]string{"service": "brain-capture", "kind": "audit"}, http: &http.Client{Timeout: 10 * time.Second}, } } // Ready probes Loki's readiness endpoint. func (l *LokiCentral) Ready(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.baseURL+"/ready", nil) if err != nil { return err } resp, err := l.http.Do(req) if err != nil { return fmt.Errorf("loki not ready: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("loki not ready: status %d", resp.StatusCode) } return nil } // pushPayload is the Loki push API body: one stream, one entry whose line // is the JSON-encoded audit record. type pushPayload struct { Streams []lokiStream `json:"streams"` } type lokiStream struct { Stream map[string]string `json:"stream"` Values [][2]string `json:"values"` } // Push writes one audit record to Loki as a structured log line. func (l *LokiCentral) Push(ctx context.Context, e capture.AuditEntry) error { line, err := json.Marshal(e) if err != nil { return fmt.Errorf("marshal audit entry: %w", err) } ts := e.Timestamp if ts.IsZero() { ts = time.Now() } body, err := json.Marshal(pushPayload{Streams: []lokiStream{{ Stream: l.labels, Values: [][2]string{{strconv.FormatInt(ts.UTC().UnixNano(), 10), string(line)}}, }}}) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.baseURL+"/loki/api/v1/push", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := l.http.Do(req) if err != nil { return fmt.Errorf("loki push: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("loki push: status %d", resp.StatusCode) } return nil }