c084046af0
- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app) - api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow) - web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event - tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
92 lines
3.4 KiB
Go
92 lines
3.4 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var longAgoAPI = time.Now().Add(-365 * 24 * time.Hour)
|
|
|
|
// Part D (API half) — the full pull-based round-trip on /report:
|
|
// 1. no pending request → the ACK omits log_tail_requests entirely
|
|
// 2. operator requests a tail → the ACK advertises it
|
|
// 3. the controller's next report carries log_tails → stored + request cleared
|
|
// 4. the FOLLOWING report's ACK omits the field again (consume-once)
|
|
func TestReportACK_LogTailRoundTrip(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
|
|
reportBody := `{"customer_id":"cust-a"}`
|
|
|
|
// 1. baseline ACK — no field
|
|
rr := do(h, http.MethodPost, "/report", globalKey, reportBody)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var ack map[string]json.RawMessage
|
|
json.Unmarshal(rr.Body.Bytes(), &ack)
|
|
if _, present := ack["log_tail_requests"]; present {
|
|
t.Fatalf("baseline ACK must omit log_tail_requests: %s", rr.Body.String())
|
|
}
|
|
|
|
// 2. operator requests a tail → advertised
|
|
if err := st.RequestLogTail("cust-a", "gokapi"); err != nil {
|
|
t.Fatalf("RequestLogTail: %v", err)
|
|
}
|
|
rr = do(h, http.MethodPost, "/report", globalKey, reportBody)
|
|
ack = map[string]json.RawMessage{}
|
|
json.Unmarshal(rr.Body.Bytes(), &ack)
|
|
var apps []string
|
|
json.Unmarshal(ack["log_tail_requests"], &apps)
|
|
if len(apps) != 1 || apps[0] != "gokapi" {
|
|
t.Fatalf("ACK log_tail_requests = %v, want [gokapi] (%s)", apps, rr.Body.String())
|
|
}
|
|
|
|
// 3. the controller ships the tail on its next report → stored + consumed
|
|
tailReport := `{"customer_id":"cust-a","log_tails":[{"app":"gokapi","collected_at":"2026-07-10T10:00:00Z","lines":["l1","l2 password=[REDACTED]","l3"]}]}`
|
|
rr = do(h, http.MethodPost, "/report", globalKey, tailReport)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("tail report POST = %d", rr.Code)
|
|
}
|
|
tails, err := st.GetCustomerLogTails("cust-a")
|
|
if err != nil || len(tails) != 1 {
|
|
t.Fatalf("stored tails = %d/%v, want 1", len(tails), err)
|
|
}
|
|
if len(tails[0].Lines) != 3 || tails[0].Lines[1] != "l2 password=[REDACTED]" {
|
|
t.Fatalf("tail lines not stored ordered/verbatim: %#v", tails[0].Lines)
|
|
}
|
|
|
|
// 4. consume-once: the FOLLOWING ACK omits the field
|
|
rr = do(h, http.MethodPost, "/report", globalKey, reportBody)
|
|
ack = map[string]json.RawMessage{}
|
|
json.Unmarshal(rr.Body.Bytes(), &ack)
|
|
if _, present := ack["log_tail_requests"]; present {
|
|
t.Fatalf("request survived fulfillment — the controller would ship tails every cycle: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// A report whose telemetry issues carry context lands it in app_log_issues (the C wire leg).
|
|
func TestReport_IssueContextStored(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
|
|
body := `{"customer_id":"cust-a","app_telemetry":[{"app_name":"cwa","issues":[
|
|
{"severity":"error","message":"ERROR: nfs gone","count":2,"last_seen":"2026-07-10T09:00:00Z",
|
|
"context":["before","ERROR: nfs gone","after"]}]}]}`
|
|
rr := do(h, http.MethodPost, "/report", globalKey, body)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
issues, err := st.GetAppIssues("cwa", longAgoAPI, false, 10)
|
|
if err != nil || len(issues) != 1 {
|
|
t.Fatalf("issues = %d/%v, want 1", len(issues), err)
|
|
}
|
|
is := issues[0]
|
|
if len(is.Context) != 3 || is.Context[1] != "ERROR: nfs gone" {
|
|
t.Fatalf("context not stored from the wire: %#v", is.Context)
|
|
}
|
|
if is.ContextCustomer != "cust-a" {
|
|
t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer)
|
|
}
|
|
}
|