package store import ( "context" "encoding/json" "fmt" "time" ) // ecosystemTraceRetention is how many trace rows are kept. Traces are // diagnostics with a short useful life, and they arrive at machine rate, so // the table is bounded rather than append-only. The facts table is the audit // trail; this one is not. const ecosystemTraceRetention = 5000 // EcosystemTrace is one hop of a cross-service call: which service, which // operation, how it ended, how long it took, and the ids that stitch the hops // of one turn together. Fields carries the hop-specific detail (entity id, // capability, failure class) as a JSON object. type EcosystemTrace struct { ID int64 `json:"id"` Ts time.Time `json:"ts"` Service string `json:"service"` Operation string `json:"operation"` Status string `json:"status"` DurationMs int64 `json:"duration_ms"` CorrelationID string `json:"correlation_id"` CausationID string `json:"causation_id"` HTTPStatus int `json:"http_status"` Fields map[string]any `json:"fields"` } // WriteEcosystemTrace appends one trace row and keeps the table bounded. func (s *Store) WriteEcosystemTrace(ctx context.Context, tr EcosystemTrace) (int64, error) { fields := "{}" if len(tr.Fields) > 0 { b, err := json.Marshal(tr.Fields) if err != nil { return 0, fmt.Errorf("marshal trace fields: %w", err) } fields = string(b) } res, err := s.db.ExecContext(ctx, ` INSERT INTO ecosystem_traces (ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields) VALUES (?,?,?,?,?,?,?,?,?)`, tr.Ts.UnixMilli(), tr.Service, tr.Operation, tr.Status, tr.DurationMs, tr.CorrelationID, tr.CausationID, tr.HTTPStatus, fields) if err != nil { return 0, fmt.Errorf("write ecosystem trace: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("last insert id: %w", err) } // Prune rarely: the cost of the delete is not worth paying on every hop, // and the bound is a ceiling, not an exact size. if id%256 == 0 { if err := s.PruneEcosystemTraces(ctx, ecosystemTraceRetention); err != nil { return id, err } } return id, nil } // PruneEcosystemTraces drops all but the newest keep rows. func (s *Store) PruneEcosystemTraces(ctx context.Context, keep int) error { if keep <= 0 { return nil } _, err := s.db.ExecContext(ctx, ` DELETE FROM ecosystem_traces WHERE id <= (SELECT MAX(id) FROM ecosystem_traces) - ?`, keep) if err != nil { return fmt.Errorf("prune ecosystem traces: %w", err) } return nil } // RecentEcosystemTraces returns the newest n traces, newest first. func (s *Store) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields FROM ecosystem_traces ORDER BY id DESC LIMIT ?`, n) if err != nil { return nil, fmt.Errorf("recent ecosystem traces: %w", err) } defer rows.Close() var out []EcosystemTrace for rows.Next() { var tr EcosystemTrace var tsMilli int64 var fields string if err := rows.Scan(&tr.ID, &tsMilli, &tr.Service, &tr.Operation, &tr.Status, &tr.DurationMs, &tr.CorrelationID, &tr.CausationID, &tr.HTTPStatus, &fields); err != nil { return nil, err } tr.Ts = time.UnixMilli(tsMilli).UTC() if fields != "" { _ = json.Unmarshal([]byte(fields), &tr.Fields) } out = append(out, tr) } return out, rows.Err() }