5aaecd2a53
Traces were written as facts. A single Praxis action wrote several of them, so machine-rate rows crowded out the bounded fact readers that humans and evaluation consume. The habit profile window of 2000 facts and the memeval snapshot both filled with call records instead of what Maven learned about the owner. Traces now go to ecosystem_traces, with correlation, causation, duration and HTTP status as columns, pruned to the most recent 5000. The new reader is exposed over IPC and rendered as the Calls card on the ecosystem page, so it is a table someone actually looks at. Found in review of #84.
108 lines
3.5 KiB
Go
108 lines
3.5 KiB
Go
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()
|
|
}
|