Version, authenticate and fully trace ecosystem calls #84
@@ -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