store: give ecosystem traces their own table
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.
This commit is contained in:
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,34 +10,12 @@ import (
|
||||
|
||||
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
|
||||
|
||||
func ecoTraces(t *testing.T, h *reactiveHandler) []map[string]any {
|
||||
func findTrace(t *testing.T, h *reactiveHandler, service, op string) *store.EcosystemTrace {
|
||||
t.Helper()
|
||||
facts, err := h.dataStore.RecentFacts(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read facts: %v", err)
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, f := range facts {
|
||||
if f.Source != "ecosystem:trace" {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(f.Value, "{")
|
||||
if i < 0 {
|
||||
t.Fatalf("trace fact carries no detail object: %q", f.Value)
|
||||
}
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal([]byte(f.Value[i:]), &d); err != nil {
|
||||
t.Fatalf("decode trace %q: %v", f.Value, err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findTrace(traces []map[string]any, service, op string) map[string]any {
|
||||
for _, d := range traces {
|
||||
if d["service"] == service && d["operation"] == op {
|
||||
return d
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
found := tr
|
||||
return &found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -58,7 +35,9 @@ func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if _, err := h.ecosystem.praxis.ListAttention(ctx, 5); err != nil {
|
||||
// A bare client call carries whatever the caller assigned. Entry points
|
||||
// assign the ID, the header layer only reads it, so mirror an action here.
|
||||
if _, err := h.ecosystem.praxis.ListAttention(withCorrelationID(ctx, newCorrelationID()), 5); err != nil {
|
||||
t.Fatalf("attention: %v", err)
|
||||
}
|
||||
|
||||
@@ -167,31 +146,69 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
traces := ecoTraces(t, h)
|
||||
var chain string
|
||||
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
|
||||
d := findTrace(traces, want[0], want[1])
|
||||
d := findTrace(t, h, want[0], want[1])
|
||||
if d == nil {
|
||||
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces)
|
||||
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces(t, h))
|
||||
}
|
||||
if d["status"] != traceOK {
|
||||
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d["status"])
|
||||
if d.Status != traceOK {
|
||||
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d.Status)
|
||||
}
|
||||
if _, ok := d["duration_ms"]; !ok {
|
||||
t.Errorf("%s %s trace has no timing", want[0], want[1])
|
||||
}
|
||||
if d["correlation_id"] == nil || d["correlation_id"] == "" {
|
||||
if d.CorrelationID == "" {
|
||||
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
|
||||
}
|
||||
if want[1] != "execute" {
|
||||
if chain == "" {
|
||||
chain = d.CorrelationID
|
||||
} else if d.CorrelationID != chain {
|
||||
t.Errorf("%s %s left the correlation chain: %s != %s", want[0], want[1], d.CorrelationID, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
exec := findTrace(traces, "hexis", "execute")
|
||||
if exec["causation_id"] == nil || exec["causation_id"] == "" {
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec.CausationID == "" {
|
||||
t.Error("execute trace must carry the causation id of the turn that caused it")
|
||||
}
|
||||
if exec["correlation_id"] == exec["causation_id"] {
|
||||
if exec.CorrelationID == exec.CausationID {
|
||||
t.Error("execute correlation and causation must be distinguishable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_OneCorrelationIDPerPraxisAction: a digest calls attention
|
||||
// once and surface once per item. All of it is one turn, so the far side must
|
||||
// see one ID and not N+1 unrelated ones.
|
||||
func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
map[string]any{"id": "item_2", "title": "backup is stale", "importance": 2.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
reqs := praxis.Requests()
|
||||
if len(reqs) < 3 {
|
||||
t.Fatalf("expected attention plus one surface per item, got %d requests", len(reqs))
|
||||
}
|
||||
first := reqs[0].Header.Get("X-Correlation-ID")
|
||||
if first == "" {
|
||||
t.Fatal("every ecosystem request must carry a correlation id")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if got := r.Header.Get("X-Correlation-ID"); got != first {
|
||||
t.Fatalf("%s %s carried %q, want the action's id %q", r.Method, r.Path, got, first)
|
||||
}
|
||||
}
|
||||
tr := findTrace(t, h, "praxis", "list_attention")
|
||||
if tr == nil || tr.CorrelationID != first {
|
||||
t.Fatalf("the trace must carry the id that was actually sent, got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
|
||||
// a failed hop is exactly the one worth having recorded.
|
||||
func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
@@ -203,18 +220,39 @@ func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(ecoTraces(t, h), "nexus", "resolve")
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("a failed resolve must still be traced")
|
||||
}
|
||||
if d["status"] != traceFailed {
|
||||
t.Errorf("status = %v, want failed", d["status"])
|
||||
if d.Status != traceRefused {
|
||||
t.Errorf("status = %v, want refused: the far side answered", d.Status)
|
||||
}
|
||||
if d["class"] != "unauthorized" {
|
||||
t.Errorf("class = %v, want unauthorized", d["class"])
|
||||
if d.Fields["class"] != "unauthorized" {
|
||||
t.Errorf("class = %v, want unauthorized", d.Fields["class"])
|
||||
}
|
||||
if d["http_status"] != float64(401) {
|
||||
t.Errorf("http_status = %v, want 401", d["http_status"])
|
||||
if d.HTTPStatus != 401 {
|
||||
t.Errorf("http_status = %v, want 401", d.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_UnreachableIsNotRefused: never got an answer and answered
|
||||
// with a refusal are different failures, and the trace must say which.
|
||||
func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("an unreachable resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceFailed {
|
||||
t.Errorf("status = %v, want failed", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unreachable" {
|
||||
t.Errorf("class = %v, want unreachable", d.Fields["class"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,28 +265,23 @@ func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
|
||||
facts, err := h.dataStore.RecentFacts(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read facts: %v", err)
|
||||
}
|
||||
var traced []store.Fact
|
||||
for _, f := range facts {
|
||||
if f.Source == "ecosystem:trace" {
|
||||
traced = append(traced, f)
|
||||
}
|
||||
if strings.Contains(f.Value, "кофемашину") && f.Source == "ecosystem:trace" {
|
||||
t.Fatalf("trace leaked the utterance: %q", f.Value)
|
||||
}
|
||||
}
|
||||
if len(traced) == 0 {
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("expected a not_found resolve trace")
|
||||
}
|
||||
d := findTrace(ecoTraces(t, h), "nexus", "resolve")
|
||||
if d["status"] != traceNotFound {
|
||||
t.Errorf("status = %v, want not_found", d["status"])
|
||||
for _, tr := range recorded {
|
||||
for k, v := range tr.Fields {
|
||||
if s, ok := v.(string); ok && strings.Contains(s, "кофемашину") {
|
||||
t.Fatalf("trace leaked the utterance in %s: %q", k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if d["subject"] != redactSubject("перезапусти кофемашину") {
|
||||
t.Errorf("subject = %v, want a redacted length", d["subject"])
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d.Status != traceNotFound {
|
||||
t.Errorf("status = %v, want not_found", d.Status)
|
||||
}
|
||||
if d.Fields["subject"] != redactSubject("перезапусти кофемашину") {
|
||||
t.Errorf("subject = %v, want a redacted length", d.Fields["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +296,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if d := findTrace(ecoTraces(t, h), "nexus", "resolve"); d == nil || d["status"] != traceAmbig {
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
|
||||
@@ -271,7 +304,13 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
if d := findTrace(ecoTraces(t, h2), "hexis", "confirmation"); d == nil || d["status"] != "pending" {
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
}
|
||||
// The confirmation hop is measured from the top of the action, not from
|
||||
// the instant it is recorded, which was always zero.
|
||||
if d.DurationMs == 0 {
|
||||
t.Error("the confirmation trace must report the time the action took to get there")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
|
||||
@@ -84,9 +86,13 @@ type ecoData struct {
|
||||
Nexus ecoPanel[ecoEntity]
|
||||
Praxis ecoPanel[ecoItem]
|
||||
Hexis ecoPanel[ecoCap]
|
||||
Calls ecoPanel[ipc.EcosystemTrace]
|
||||
}
|
||||
|
||||
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
||||
// handleEcosystem renders the three sibling panels plus Maven's own log of the
|
||||
// calls she made to them. The call log comes from core, not from the siblings:
|
||||
// it is what Maven saw, including the hops that never got an answer.
|
||||
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core ipc.CoreAPI) {
|
||||
ctx := r.Context()
|
||||
var d ecoData
|
||||
var wg sync.WaitGroup
|
||||
@@ -102,6 +108,15 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
||||
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
|
||||
wg.Wait()
|
||||
|
||||
if core == nil {
|
||||
d.Calls.Err = "not configured"
|
||||
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
|
||||
log.Printf("ecosystem traces: %v", err)
|
||||
d.Calls.Err = "core read failed"
|
||||
} else {
|
||||
d.Calls.Rows = rows
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ecosystemTmpl.Execute(w, d); err != nil {
|
||||
log.Printf("ecosystem render: %v", err)
|
||||
|
||||
@@ -41,11 +41,24 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class=card id=eco-calls>
|
||||
<div class=section-header>
|
||||
<h2>Calls <span class=card-sub>what Maven asked them</span></h2>
|
||||
</div>
|
||||
{{with .Calls}}
|
||||
{{if .Err}}<div class=empty>calls — {{.Err}}</div>
|
||||
{{else if not .Rows}}<div class=empty>no ecosystem calls yet.</div>
|
||||
{{else}}<div class=scroll><table class=mono><tr><th>when<th>service<th>operation<th>status<th>ms<th>http<th>correlation</tr>
|
||||
{{range .Rows}}<tr><td>{{ago .Ts}}<td><span class=badge>{{.Service}}</span><td class=en>{{.Operation}}<td>{{if eq .Status "ok"}}<span class="badge badge-ok">ok</span>{{else}}<span class="badge badge-warn">{{.Status}}</span>{{end}}<td>{{.DurationMs}}<td>{{if .HTTPStatus}}{{.HTTPStatus}}{{else}}—{{end}}<td class=key>{{.CorrelationID}}</tr>{{end}}
|
||||
</table></div>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
setInterval(() => fetch('/ecosystem').then(r => r.text()).then(html => {
|
||||
const d = new DOMParser().parseFromString(html, 'text/html');
|
||||
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis']) {
|
||||
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis', 'eco-calls']) {
|
||||
const old = document.getElementById(id), nu = d.getElementById(id);
|
||||
if (old && nu) old.replaceWith(nu);
|
||||
}
|
||||
|
||||
+1
-1
@@ -443,7 +443,7 @@ func main() {
|
||||
})
|
||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEcosystem(w, r, ecoURLsCfg)
|
||||
handleEcosystem(w, r, ecoURLsCfg, core)
|
||||
})
|
||||
// ----- passkey (WebAuthn) endpoints -----
|
||||
// Wired when both -core and a configured origin are present. The origin
|
||||
|
||||
@@ -24,6 +24,23 @@ type Fact struct {
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
// EcosystemTrace — one hop of a cross-service ecosystem call, read by the
|
||||
// monitoring surfaces. Traces live in their own store table, not in facts:
|
||||
// they are written at machine rate and would otherwise crowd every bounded
|
||||
// reader of facts.
|
||||
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,omitempty"`
|
||||
}
|
||||
|
||||
// Bucket — presence hysteresis state: "present" | "away".
|
||||
type Bucket string
|
||||
|
||||
@@ -588,6 +605,10 @@ type CoreAPI interface {
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
|
||||
@@ -64,6 +64,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodRecentFacts: true,
|
||||
MethodCalendarEvents: true,
|
||||
MethodRecentNudges: true,
|
||||
MethodRecentEcoTraces: true,
|
||||
MethodQueryNotes: true,
|
||||
MethodRecentNotes: true,
|
||||
MethodLookupTool: true,
|
||||
@@ -342,6 +343,14 @@ func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
var out []EcosystemTrace
|
||||
if err := c.call(ctx, MethodRecentEcoTraces, nReq{N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
var out []Nudge
|
||||
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
||||
|
||||
@@ -132,6 +132,22 @@ func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fa
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]EcosystemTrace, len(trs))
|
||||
for i, tr := range trs {
|
||||
out[i] = EcosystemTrace{
|
||||
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
||||
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
||||
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
ns, err := a.s.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
@@ -774,6 +790,16 @@ var methodTable = map[Method]handlerFunc{
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentEcoTraces: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]EcosystemTrace, error) {
|
||||
out, err := api.RecentEcosystemTraces(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []EcosystemTrace{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||
out, err := api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,6 +68,9 @@ func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Ti
|
||||
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
MethodRecentFacts Method = "recent_facts"
|
||||
MethodCalendarEvents Method = "calendar_events"
|
||||
MethodRecentNudges Method = "recent_nudges"
|
||||
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
||||
MethodWriteNote Method = "write_note"
|
||||
MethodQueryNotes Method = "query_notes"
|
||||
MethodRecentNotes Method = "recent_notes"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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()
|
||||
}
|
||||
@@ -160,6 +160,30 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
||||
|
||||
// #15 — ecosystem call traces (Vikunja #273). Deliberately NOT facts.
|
||||
// Traces are written at machine rate, one act turn produces three or four,
|
||||
// while facts are written at human rate. Sharing the facts table made every
|
||||
// bounded reader of facts (the habit profile's 2000-row window, memeval's
|
||||
// prompt snapshot, /dash's 50 and /history's 200) read mostly traces after
|
||||
// a day of ecosystem use, pushing the rows that matter out of range.
|
||||
// Retention is enforced on write (PruneEcosystemTraces) because nothing
|
||||
// here is an audit trail: a trace answers "did this hop work" for as long
|
||||
// as anyone is still asking.
|
||||
`CREATE TABLE IF NOT EXISTS ecosystem_traces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
correlation_id TEXT NOT NULL DEFAULT '',
|
||||
causation_id TEXT NOT NULL DEFAULT '',
|
||||
http_status INTEGER NOT NULL DEFAULT 0,
|
||||
fields TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_eco_traces_ts ON ecosystem_traces (ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_eco_traces_correlation ON ecosystem_traces (correlation_id);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
Reference in New Issue
Block a user