Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/support-dump-zip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"ftw": patch
---

One button, one file when asking for help. The plan card's "Something looks
wrong?" button now downloads `ftw-help-<stamp>.zip` — the help report as
`00-help-report.md`, sorted first, with the redacted config, driver health,
recent logs and an hour of telemetry behind it.

Before this there were two downloads and the user had to guess which one we
wanted: the report from the plan card, the log bundle from a driver's Diagnose
modal. They would send one and we would ask for the other.

The archive is a zip rather than a `.tar.gz` because its whole purpose is to be
handed to somebody else, and Windows and every chat client open a zip without a
second tool. Around 10 kB on a two-driver install.

`GET /api/support/report` still returns the bare Markdown for anyone who wants
only the text.

The report also now carries the slot's energy books — what the plan asked for,
what the batteries actually moved, and what the energy-allocation path thinks
it delivered — plus a finding when a slot is a quarter of the way through and
delivery is under half the rate the plan needs. That is the shape of the
reports that keep arriving: a plan card reading "charge 4.5 kW, now", a live
target of 0 W, and nothing in between to show whether the plan reached
dispatch at all.
57 changes: 35 additions & 22 deletions go/internal/api/api_drivers_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
package api

import (
"archive/tar"
"compress/gzip"
"archive/zip"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -318,48 +317,62 @@ func (s *Server) handleGlobalLogs(w http.ResponseWriter, r *http.Request) {
})
}

// GET /api/support/dump — gzipped tarball with everything a developer
// needs to triage a support incident: redacted config, full driver
// health JSON, recent global + per-driver logs, last 1 h of TS samples
// per (driver, metric), and a manifest. SQLite is NOT included; the
// dump is intended to be small enough to email or paste-link.
// GET /api/support/dump — zip archive with everything a developer needs
// to triage a support incident: redacted config, full driver health JSON,
// recent global + per-driver logs, last 1 h of TS samples per
// (driver, metric), and a manifest. SQLite is NOT included; the dump is
// intended to be small enough to attach to a chat message — measured at
// ~6 kB on a two-driver install.
//
// Zip rather than tar.gz because this file's whole purpose is to be
// handed to somebody else. Windows and every chat client open a zip
// without a second tool; a .tar.gz asks the person you need help from to
// go find one first.
func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) {
if s.deps.LogRing == nil {
writeJSON(w, 503, map[string]string{"error": "log ring not configured"})
return
}
now := time.Now().UTC()
stamp := now.Format("20060102-150405")
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.tar.gz"`)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.zip"`)
w.Header().Set("Cache-Control", "no-store")

gz := gzip.NewWriter(w)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
zw := zip.NewWriter(w)
defer zw.Close()

addFile := func(name string, body []byte) {
hdr := &tar.Header{
Name: "ftw-support-" + stamp + "/" + name,
Mode: 0o644,
Size: int64(len(body)),
ModTime: now,
hdr := &zip.FileHeader{
Name: "ftw-support-" + stamp + "/" + name,
Method: zip.Deflate,
Modified: now,
}
hdr.SetMode(0o644)
f, err := zw.CreateHeader(hdr)
if err != nil {
return
}
_ = tw.WriteHeader(hdr)
_, _ = tw.Write(body)
_, _ = f.Write(body)
}

// Manifest first so a curious recipient can `tar -xOzf … manifest.json`
// and see what they've got without unpacking the whole bundle.
// The help report goes in first and is named to sort first, because it
// is the only file in here most recipients need to read. Everything
// below it is what you reach for when the report does not settle the
// question. Shipping them together means the person asking for help
// sends one file and never has to pick the right one.
addFile("00-help-report.md", []byte(s.buildSupportReport(r.Context(), time.Now())))

manifest := map[string]any{
"generated_at": now.Format(time.RFC3339),
"version": s.deps.Version,
"go_version": runtime.Version(),
"goos": runtime.GOOS,
"goarch": runtime.GOARCH,
"hostname": hostnameOrEmpty(),
"read_first": "00-help-report.md",
"contents": []string{
"00-help-report.md",
"config.redacted.yaml",
"drivers.json",
"logs/global.log",
Expand Down
86 changes: 86 additions & 0 deletions go/internal/api/api_support_dump_zip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package api

import (
"archive/zip"
"bytes"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// The dump exists to be handed to somebody else, so the archive has to
// open with no extra tooling. This asserts it is a real zip, not just a
// renamed one.
func TestSupportDumpIsAReadableZip(t *testing.T) {
tel := telemetry.NewStore()
tel.DriverHealthMut("meter").RecordSuccess()
ring := telemetry.NewLogRing()
ring.Append(telemetry.LogEntry{Level: "WARN", Msg: "something to log"})
srv := New(&Deps{
Ctrl: control.NewState(0, 50, "meter"), CtrlMu: &sync.Mutex{},
Tel: tel, LogRing: ring, Version: "test",
})

req := httptest.NewRequest(http.MethodGet, "/api/support/dump", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("GET /api/support/dump = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Errorf("Content-Type = %q, want application/zip", ct)
}
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, ".zip") {
t.Errorf("Content-Disposition = %q, want a .zip filename", cd)
}

body := rec.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("archive does not open as a zip: %v", err)
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
rc, err := f.Open()
if err != nil {
t.Errorf("entry %s will not open: %v", f.Name, err)
continue
}
rc.Close()
}
joined := strings.Join(names, " ")
// The report is the point of the archive for most recipients, so it
// has to be in there and has to sort first.
if len(zr.File) == 0 || !strings.HasSuffix(zr.File[0].Name, "00-help-report.md") {
t.Errorf("first entry is %q, want the help report", names[0])
}
var reportBody string
for _, f := range zr.File {
if strings.HasSuffix(f.Name, "00-help-report.md") {
rc, _ := f.Open()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(rc)
rc.Close()
reportBody = buf.String()
}
}
if !strings.Contains(reportBody, "# FTW help report") {
t.Error("the embedded report is not a help report")
}
if !strings.Contains(reportBody, "## Findings") {
t.Error("the embedded report is missing Findings")
}

for _, want := range []string{"00-help-report.md", "manifest.json", "drivers.json", "logs/global.log"} {
if !strings.Contains(joined, want) {
t.Errorf("archive is missing %s; has %v", want, names)
}
}
}
106 changes: 104 additions & 2 deletions go/internal/api/api_support_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ const forecastMissRatio = 1.5
// the comparison to mean anything.
const forecastMissFloorW = 500

// slotEnergyIdleWh matches the dispatcher's own idle gate: below this the
// slot is not asking the battery for anything and cannot fall behind.
const slotEnergyIdleWh = 50

// slotPaceMinElapsed is how much of a slot must have passed before its
// delivery rate says anything. A slot is allowed to start slowly and catch
// up; a quarter of the way through is not.
const slotPaceMinElapsed = 0.25

// slotPaceFloor is the fraction of the required rate below which delivery
// counts as not happening. Half is generous — a slot tracking its plan at
// all clears it easily — so what trips this is a slot doing essentially
// nothing, or moving energy the wrong way.
const slotPaceFloor = 0.5

// loadmodelWarmBucketsForTrust is the point past which the weekly load
// pattern is filled in enough that its predictions carry the plan. Below
// it the planner is guessing, which is worth saying out loud before
Expand Down Expand Up @@ -163,6 +178,7 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string {
s.deps.CtrlMu.Lock()
ctrl := *s.deps.Ctrl
targets := append([]control.DispatchTarget{}, s.deps.Ctrl.LastTargets...)
slotEnergy := s.deps.Ctrl.SlotEnergy()
s.deps.CtrlMu.Unlock()

snap := s.liveNow(ctrl, now)
Expand All @@ -178,12 +194,12 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string {
}

health := s.deps.Tel.AllHealth()
findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, now)
findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, slotEnergy, now)

var b strings.Builder
writeReportHeader(&b, s.deps.Version, now)
writeFindings(&b, findings)
writeRightNow(&b, ctrl, snap, activeSlot, targets, now)
writeRightNow(&b, ctrl, snap, activeSlot, targets, slotEnergy, now)
writePlanSection(&b, plan, lastReplanAt, lastReplanReason, now)
writeForecastSection(&b, s, snap, activeSlot, now)
writeDeviceSection(&b, health, now)
Expand Down Expand Up @@ -232,6 +248,7 @@ func writeRightNow(
snap liveSnapshot,
activeSlot *mpc.Action,
targets []control.DispatchTarget,
slotEnergy control.SlotEnergySnapshot,
now time.Time,
) {
b.WriteString("## Right now\n\n")
Expand Down Expand Up @@ -308,6 +325,26 @@ func writeRightNow(
"the log for clamp messages.\n\n")
}

// The slot's energy books. Without these a report can show a plan
// asking for 4.5 kW and a target of 0 W and give no way to tell
// whether the plan never reached dispatch, or dispatch already
// finished the slot's energy early and is coasting.
if slotEnergy.HasSlot && !slotEnergy.SlotEnd.IsZero() {
elapsed := now.Sub(slotEnergy.SlotStart)
remaining := slotEnergy.SlotEnd.Sub(now)
fmt.Fprintf(b, "Energy booked for this slot: plan asked for **%s**, "+
"the batteries have moved **%s** so far, %s elapsed and %s left.\n\n",
fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh),
fmtReportAge(elapsed), fmtReportAge(remaining))
if slotEnergy.EnergyPathWh != 0 || slotEnergy.PlannedWh != 0 {
fmt.Fprintf(b, "The energy-allocation path counts %s delivered. "+
"That figure only moves while that path is executing, so a "+
"real plan figure beside a zero here means the slot is being "+
"run by a reactive path instead.\n\n",
fmtReportWh(slotEnergy.EnergyPathWh))
}
}

st := ctrl.SlotDeliveryStats
if st.OverDeliveryCount+st.UnderDeliveryCount+st.SignMismatchCount > 0 {
fmt.Fprintf(b, "Slot delivery misses since start: %d over, %d under, "+
Expand Down Expand Up @@ -598,6 +635,7 @@ func (s *Server) collectFindings(
activeSlot *mpc.Action,
targets []control.DispatchTarget,
health map[string]telemetry.DriverHealth,
slotEnergy control.SlotEnergySnapshot,
now time.Time,
) []finding {
var out []finding
Expand Down Expand Up @@ -674,6 +712,26 @@ func (s *Server) collectFindings(
}
}

// The slot is asking for energy and the batteries are not moving it.
// This is the shape of the reports that keep arriving: a plan card
// reading "charge 4.5 kW, now", a live target of 0 W, and no way from
// the outside to tell whether the plan reached dispatch at all.
if pace, ok := slotPaceShortfall(slotEnergy, now); ok {
detail := fmt.Sprintf("This slot asked for %s and the batteries have "+
"moved %s with %s of it gone — about %.0f%% of the rate the plan "+
"needs.",
fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh),
fmtReportAge(now.Sub(slotEnergy.SlotStart)), pace*100)
if slotEnergy.EnergyPathWh == 0 && slotEnergy.PlannedWh != 0 {
detail += " The energy-allocation path has delivered nothing this " +
"slot, so a reactive path is driving instead of the plan."
}
detail += " Safety limits, a charge ceiling and a device that cannot " +
"follow the command all look like this from here — the dispatch " +
"table and the log below separate them."
out = append(out, finding{sevProblem, "The slot's energy is not being delivered", detail})
}

var clamped []string
for _, t := range targets {
if t.Clamped {
Expand Down Expand Up @@ -801,8 +859,52 @@ func forecastMiss(predicted, actual float64) bool {
return hi/lo >= forecastMissRatio
}

// slotPaceShortfall reports how far behind the slot's required rate the
// batteries actually are, as a fraction of it, and whether that is worth
// saying. Returns (pace, true) only when the slot has a real energy ask,
// enough of it has passed to judge, and delivery is meaningfully behind.
//
// Pace rather than a plain energy comparison, because a slot is allowed
// to be behind early and catch up. What is not normal is being a quarter
// of the way through having moved almost nothing.
func slotPaceShortfall(e control.SlotEnergySnapshot, now time.Time) (float64, bool) {
if !e.HasSlot || e.SlotEnd.IsZero() {
return 0, false
}
if math.Abs(e.PlannedWh) < slotEnergyIdleWh {
return 0, false // an idle slot cannot fall behind
}
total := e.SlotEnd.Sub(e.SlotStart)
elapsed := now.Sub(e.SlotStart)
if total <= 0 || elapsed <= 0 || elapsed > total {
return 0, false
}
fraction := elapsed.Seconds() / total.Seconds()
if fraction < slotPaceMinElapsed {
return 0, false // too early to tell
}
expected := e.PlannedWh * fraction
if expected == 0 {
return 0, false
}
// Signed ratio: wrong-direction delivery lands negative and is
// therefore always a shortfall, which is what it should be.
pace := e.ActualWh / expected
if pace >= slotPaceFloor {
return 0, false
}
return pace, true
}

// ---- helpers ----

func fmtReportWh(v float64) string {
if math.Abs(v) >= 1000 {
return fmt.Sprintf("%.2f kWh", v/1000)
}
return fmt.Sprintf("%.0f Wh", v)
}

func activeAction(plan *mpc.Plan, now time.Time) *mpc.Action {
if plan == nil {
return nil
Expand Down
Loading