hub v0.43.0: remote app-log diagnostics — copyable issues + context + on-demand log tails + range/dismissal fixes
- 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
This commit is contained in:
@@ -304,6 +304,29 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// On-demand log tails (v0.43.0) — the controller ships these on the cycle after the ACK
|
||||
// requested them. Storing one clears its pending request (consume-once, in SaveAppLogTail)
|
||||
// so the next ACK stops advertising it. Backward-compatible: old controllers never send this.
|
||||
var tailPayload struct {
|
||||
LogTails []struct {
|
||||
App string `json:"app"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Lines []string `json:"lines"`
|
||||
} `json:"log_tails"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tailPayload); err == nil {
|
||||
for _, lt := range tailPayload.LogTails {
|
||||
if lt.App == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.store.SaveAppLogTail(payload.CustomerID, lt.App, lt.CollectedAt, lt.Lines); err != nil {
|
||||
h.logger.Printf("[WARN] Failed to save log tail %s/%s: %v", payload.CustomerID, lt.App, err)
|
||||
} else {
|
||||
h.logger.Printf("[INFO] Log tail received for %s/%s (%d lines)", payload.CustomerID, lt.App, len(lt.Lines))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
|
||||
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
|
||||
var drPayload struct {
|
||||
@@ -342,6 +365,13 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
resp["escrow"] = es
|
||||
}
|
||||
|
||||
// v0.43.0 — pending log-tail requests (same additive ACK-flag pattern as escrow): the
|
||||
// controller collects the named apps' tails and ships them on its NEXT report; the field
|
||||
// is omitted when nothing is pending. The hub never connects into the box.
|
||||
if apps, err := h.store.GetPendingLogTailRequests(payload.CustomerID); err == nil && len(apps) > 0 {
|
||||
resp["log_tail_requests"] = apps
|
||||
}
|
||||
|
||||
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
|
||||
// else global default) and the latest available version. The controller compares its current
|
||||
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user