summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-27 21:55:44 +0200
committerPaul Buetow <paul@buetow.org>2026-02-27 21:55:44 +0200
commit89e864669d62a88f337c02d2097de4afb37e6333 (patch)
tree38d0034de8a3bca44a2d8af616e58c19f4ddf02a
parent281a433b9ff39c0b290adfc901bdf47cc486491f (diff)
flamegraph: add live field-order toggle and reconfigure API
-rw-r--r--README.md1
-rw-r--r--internal/flamegraph/livehtml.go83
-rw-r--r--internal/flamegraph/livehtml_browser_test.go9
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go87
-rw-r--r--internal/flamegraph/liveserver.go40
-rw-r--r--internal/flamegraph/liveserver_test.go77
-rw-r--r--internal/flamegraph/livetrie.go84
-rw-r--r--internal/flamegraph/livetrie_test.go38
8 files changed, 407 insertions, 12 deletions
diff --git a/README.md b/README.md
index 08775aa..4679401 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,7 @@ Live controls:
- `Escape`: reset zoom and search highlighting.
- `r`: reset baseline (clears all live aggregated stats on the server and restarts from zero).
- `Reset Baseline` button: same behavior as `r`.
+- `Order: ...` toggle button: cycles stack order presets on the fly and re-baselines live aggregation for the new order.
## TUI Hotkeys
diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go
index e136a18..90a6d3d 100644
--- a/internal/flamegraph/livehtml.go
+++ b/internal/flamegraph/livehtml.go
@@ -57,6 +57,11 @@ const liveHTML = `<!doctype html>
background: var(--fg-btn-hover);
}
+ #controls .order-toggle {
+ min-width: 220px;
+ text-align: left;
+ }
+
#status {
margin-left: 8px;
font-size: 12px;
@@ -111,6 +116,7 @@ const liveHTML = `<!doctype html>
<button id="btn-undo-zoom" type="button">Undo Zoom</button>
<button id="btn-reset-zoom" type="button">Reset Zoom</button>
<button id="btn-reset-baseline" type="button">Reset Baseline</button>
+ <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > path > tracepoint</button>
<span id="status">LIVE</span>
</div>
@@ -138,6 +144,14 @@ const liveHTML = `<!doctype html>
undoZoomBtn: document.getElementById('btn-undo-zoom'),
resetZoomBtn: document.getElementById('btn-reset-zoom'),
resetBaselineBtn: document.getElementById('btn-reset-baseline'),
+ toggleOrderBtn: document.getElementById('btn-toggle-order'),
+ orderPresets: [
+ 'comm,path,tracepoint',
+ 'path,tracepoint,comm',
+ 'tracepoint,comm,path',
+ 'pid,path,tracepoint'
+ ],
+ orderIndex: 0,
cfg: {
baseWidth: 1200,
baseFrameHeight: 16,
@@ -297,6 +311,27 @@ const liveHTML = `<!doctype html>
fg.status.textContent = suffix ? (prefix + ' | ' + suffix) : prefix;
}
+ function fgOrderLabel(csv) {
+ return String(csv || '').split(',').join(' > ');
+ }
+
+ function fgOrderFields(csv) {
+ return String(csv || '').split(',').filter(function (s) { return s; });
+ }
+
+ function fgSetOrderIndexByCSV(csv) {
+ for (var i = 0; i < fg.orderPresets.length; i++) {
+ if (fg.orderPresets[i] === csv) {
+ fg.orderIndex = i;
+ return;
+ }
+ }
+ }
+
+ function fgUpdateOrderButton() {
+ fg.toggleOrderBtn.textContent = 'Order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || '');
+ }
+
function fgHover(frame) {
var title = frame.querySelector('title');
fgSetStatus(title ? title.textContent : '');
@@ -661,6 +696,51 @@ const liveHTML = `<!doctype html>
fgApplySearch();
}
+ function fgApplyOrder(csv, expectedIndex) {
+ if (fg.resetting) {
+ return;
+ }
+ fg.resetting = true;
+ fgSetStatus('changing order...');
+ fetch('/order', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fields: fgOrderFields(csv) })
+ })
+ .then(function (resp) {
+ if (!resp.ok) {
+ throw new Error('order change failed');
+ }
+ return resp.json();
+ })
+ .then(function (payload) {
+ fg.orderIndex = expectedIndex;
+ if (payload && Array.isArray(payload.fields)) {
+ fgSetOrderIndexByCSV(payload.fields.join(','));
+ }
+ fgUpdateOrderButton();
+ fgClearLocalState();
+ if (payload && payload.snapshot) {
+ fg.lastTreeData = payload.snapshot;
+ fgRender(payload.snapshot);
+ } else {
+ fgProcessUpdate('{"n":"","v":0,"t":0}');
+ }
+ fgSetStatus('order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || csv));
+ })
+ .catch(function () {
+ fgSetStatus('order change failed');
+ })
+ .then(function () {
+ fg.resetting = false;
+ });
+ }
+
+ function fgToggleOrder() {
+ var nextIndex = (fg.orderIndex + 1) % fg.orderPresets.length;
+ fgApplyOrder(fg.orderPresets[nextIndex], nextIndex);
+ }
+
function fgConnect() {
fg.eventSource = new EventSource('/events');
fg.eventSource.onmessage = function (e) {
@@ -731,9 +811,11 @@ const liveHTML = `<!doctype html>
fg.undoZoomBtn.addEventListener('click', fgUndoZoom);
fg.resetZoomBtn.addEventListener('click', fgResetZoom);
fg.resetBaselineBtn.addEventListener('click', fgResetBaseline);
+ fg.toggleOrderBtn.addEventListener('click', fgToggleOrder);
document.addEventListener('keydown', fgHandleKeydown);
window.addEventListener('resize', fgHandleResize);
+ fgUpdateOrderButton();
fgSetStatus('');
fgConnect();
@@ -750,6 +832,7 @@ const liveHTML = `<!doctype html>
window.fgResetSearch = fgResetSearch;
window.fgTogglePause = fgTogglePause;
window.fgResetBaseline = fgResetBaseline;
+ window.fgToggleOrder = fgToggleOrder;
window.liveFlamegraphState = fg;
})();
</script>
diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go
index 4105453..c7a16c7 100644
--- a/internal/flamegraph/livehtml_browser_test.go
+++ b/internal/flamegraph/livehtml_browser_test.go
@@ -148,7 +148,7 @@ function makeElement(id) {
}
const elements = {};
-["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => {
+["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => {
elements[id] = makeElement(id);
});
elements["body"] = makeElement("body");
@@ -163,6 +163,13 @@ global.document = {
};
global.window = global;
global.prompt = function(){ return ""; };
+global.fetch = function() {
+ return Promise.resolve({
+ ok: true,
+ json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); },
+ text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); }
+ });
+};
global.requestAnimationFrame = function(cb){ cb(); };
global.EventSource = function() {
this.onmessage = null;
diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go
index 675b03f..0de1466 100644
--- a/internal/flamegraph/livehtml_interaction_test.go
+++ b/internal/flamegraph/livehtml_interaction_test.go
@@ -40,6 +40,12 @@ type resetBaselineResult struct {
ResetCallsValid bool `json:"resetCallsValid"`
}
+type orderToggleResult struct {
+ OrderButtonUpdated bool `json:"orderButtonUpdated"`
+ OrderCallValid bool `json:"orderCallValid"`
+ OrderSnapshotShown bool `json:"orderSnapshotShown"`
+}
+
func TestLiveHTMLJSZoomSearchStatePreservedAcrossUpdates(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node not available")
@@ -389,6 +395,78 @@ setTimeout(function() {
}
}
+func TestLiveHTMLJSOrderToggle(t *testing.T) {
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node not available")
+ }
+
+ snippet := `
+const fg = liveFlamegraphState;
+const orderCalls = [];
+fetch = function(url, opts) {
+ orderCalls.push({
+ url: url,
+ method: (opts && opts.method) || "GET",
+ body: (opts && opts.body) || ""
+ });
+ return Promise.resolve({
+ ok: true,
+ json: function() {
+ return Promise.resolve({
+ fields: ["path", "tracepoint", "comm"],
+ snapshot: {
+ n: "",
+ v: 0,
+ t: 1,
+ c: [{ n: "/tmp", v: 1, t: 1 }]
+ }
+ });
+ }
+ });
+};
+
+document.getElementById("btn-toggle-order").listeners.click();
+
+setTimeout(function() {
+ const orderButtonUpdated = document.getElementById("btn-toggle-order").textContent.indexOf("path > tracepoint > comm") >= 0;
+ const orderSnapshotShown = fg.svg.innerHTML.indexOf('data-name="/tmp"') >= 0;
+ const req = orderCalls[0] || {};
+ let bodyFields = [];
+ try {
+ bodyFields = JSON.parse(req.body || "{}").fields || [];
+ } catch (err) {
+ bodyFields = [];
+ }
+ const orderCallValid = orderCalls.length === 1 &&
+ req.url === "/order" &&
+ req.method === "POST" &&
+ JSON.stringify(bodyFields) === JSON.stringify(["path", "tracepoint", "comm"]);
+
+ console.log(JSON.stringify({
+ orderButtonUpdated,
+ orderCallValid,
+ orderSnapshotShown
+ }));
+}, 0);
+`
+
+ out := runLiveHTMLNodeSnippet(t, snippet)
+ var got orderToggleResult
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("decode node result: %v\nraw:\n%s", err, out)
+ }
+
+ if !got.OrderButtonUpdated {
+ t.Fatalf("expected toggle button label to update to next order")
+ }
+ if !got.OrderCallValid {
+ t.Fatalf("expected toggle to POST /order with next preset fields")
+ }
+ if !got.OrderSnapshotShown {
+ t.Fatalf("expected returned order snapshot to render immediately")
+ }
+}
+
func runLiveHTMLNodeSnippet(t *testing.T, snippet string) string {
t.Helper()
@@ -477,7 +555,7 @@ function makeFrame(name, path, depth, x, w) {
}
const elements = {};
-["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => {
+["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => {
elements[id] = makeElement(id);
});
elements["body"] = makeElement("body");
@@ -493,6 +571,13 @@ global.document = {
};
global.window = global;
global.prompt = function(){ return ""; };
+global.fetch = function() {
+ return Promise.resolve({
+ ok: true,
+ json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); },
+ text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); }
+ });
+};
global.requestAnimationFrame = function(cb){ cb(); };
global.EventSource = function() {
this.onmessage = null;
diff --git a/internal/flamegraph/liveserver.go b/internal/flamegraph/liveserver.go
index 5790cb0..1cbb1d4 100644
--- a/internal/flamegraph/liveserver.go
+++ b/internal/flamegraph/liveserver.go
@@ -2,6 +2,7 @@ package flamegraph
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"time"
@@ -13,6 +14,7 @@ func ServeLive(ctx context.Context, lt *LiveTrie, interval time.Duration) error
mux.HandleFunc("/", handleLivePage())
mux.HandleFunc("/events", handleSSE(lt, interval))
mux.HandleFunc("/reset", handleReset(lt))
+ mux.HandleFunc("/order", handleOrder(lt))
srv := &http.Server{Handler: mux}
listener, err := listenRandomPort()
@@ -112,6 +114,44 @@ func handleReset(lt *LiveTrie) http.HandlerFunc {
}
}
+type orderRequest struct {
+ Fields []string `json:"fields"`
+}
+
+type orderResponse struct {
+ Fields []string `json:"fields"`
+ Snapshot json.RawMessage `json:"snapshot,omitempty"`
+}
+
+func handleOrder(lt *LiveTrie) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(orderResponse{Fields: lt.Fields()})
+ case http.MethodPost:
+ var req orderRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid json body", http.StatusBadRequest)
+ return
+ }
+ if err := lt.Reconfigure(req.Fields); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ snap, _ := lt.SnapshotJSON()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(orderResponse{
+ Fields: lt.Fields(),
+ Snapshot: snap,
+ })
+ default:
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
func sendSnapshot(w http.ResponseWriter, flusher http.Flusher, lt *LiveTrie, lastVersion uint64) (uint64, error) {
payload, version := lt.SnapshotJSON()
if version == lastVersion {
diff --git a/internal/flamegraph/liveserver_test.go b/internal/flamegraph/liveserver_test.go
index 15e8f37..59a3782 100644
--- a/internal/flamegraph/liveserver_test.go
+++ b/internal/flamegraph/liveserver_test.go
@@ -179,6 +179,83 @@ func TestHandleResetClearsTrieAndReturnsEmptySnapshot(t *testing.T) {
}
}
+func TestHandleOrderGetReturnsCurrentFields(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ req := httptest.NewRequest(http.MethodGet, "/order", nil)
+ rec := httptest.NewRecorder()
+ handleOrder(lt).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ var resp orderResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if strings.Join(resp.Fields, ",") != "comm,path,tracepoint" {
+ t.Fatalf("fields = %v, want [comm path tracepoint]", resp.Fields)
+ }
+}
+
+func TestHandleOrderPostReconfiguresAndResets(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1))
+
+ req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["path","tracepoint","comm"]}`))
+ rec := httptest.NewRecorder()
+ handleOrder(lt).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ var resp orderResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if strings.Join(resp.Fields, ",") != "path,tracepoint,comm" {
+ t.Fatalf("fields = %v, want [path tracepoint comm]", resp.Fields)
+ }
+ var snap trieSnapshot
+ if err := json.Unmarshal(resp.Snapshot, &snap); err != nil {
+ t.Fatalf("decode snapshot: %v", err)
+ }
+ if snap.Total != 0 {
+ t.Fatalf("snapshot total after reconfigure = %d, want 0", snap.Total)
+ }
+}
+
+func TestHandleOrderPostRejectsInvalidRequest(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm"}, "count")
+
+ req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["comm","bogus"]}`))
+ rec := httptest.NewRecorder()
+ handleOrder(lt).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":[}`))
+ rec = httptest.NewRecorder()
+ handleOrder(lt).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+}
+
+func TestHandleOrderRequiresGetOrPost(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm"}, "count")
+ req := httptest.NewRequest(http.MethodPut, "/order", nil)
+ rec := httptest.NewRecorder()
+ handleOrder(lt).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
+ }
+ if allow := rec.Header().Get("Allow"); allow != http.MethodGet+", "+http.MethodPost {
+ t.Fatalf("allow = %q, want %q", allow, http.MethodGet+", "+http.MethodPost)
+ }
+}
+
func TestServeLivePrintsURLAndStopsOnCancel(t *testing.T) {
lt := NewLiveTrie([]string{"comm"}, "count")
ctx, cancel := context.WithCancel(context.Background())
diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go
index 6ca9bc3..100e03b 100644
--- a/internal/flamegraph/livetrie.go
+++ b/internal/flamegraph/livetrie.go
@@ -2,6 +2,7 @@ package flamegraph
import (
"encoding/json"
+ "fmt"
"ior/internal/event"
"slices"
"sort"
@@ -68,13 +69,28 @@ func (lt *LiveTrie) addLocked(frames []string, value uint64) {
}
}
+func (lt *LiveTrie) resetLocked() {
+ lt.root = &trieNode{
+ childMap: make(map[string]*trieNode),
+ }
+ lt.maxDepth = 0
+ lt.version.Add(1)
+}
+
+func (lt *LiveTrie) invalidateCache() {
+ lt.cacheMu.Lock()
+ lt.cacheVersion = 0
+ lt.cacheJSON = nil
+ lt.cacheMu.Unlock()
+}
+
// Ingest adds one event pair into the live trie and recycles the pair.
func (lt *LiveTrie) Ingest(ep *event.Pair) {
record := eventPairToRecord(ep)
- frames := lt.buildFrames(record)
value := record.Cnt.ValueByName(lt.countField)
lt.mu.Lock()
+ frames := lt.buildFrames(record)
lt.addLocked(frames, value)
lt.version.Add(1)
lt.mu.Unlock()
@@ -85,17 +101,32 @@ func (lt *LiveTrie) Ingest(ep *event.Pair) {
// Reset clears the trie so live snapshots start from a new baseline.
func (lt *LiveTrie) Reset() {
lt.mu.Lock()
- lt.root = &trieNode{
- childMap: make(map[string]*trieNode),
- }
- lt.maxDepth = 0
- lt.version.Add(1)
+ lt.resetLocked()
lt.mu.Unlock()
+ lt.invalidateCache()
+}
- lt.cacheMu.Lock()
- lt.cacheVersion = 0
- lt.cacheJSON = nil
- lt.cacheMu.Unlock()
+// Fields returns the currently configured frame fields in stack order.
+func (lt *LiveTrie) Fields() []string {
+ lt.mu.RLock()
+ out := slices.Clone(lt.fields)
+ lt.mu.RUnlock()
+ return out
+}
+
+// Reconfigure changes frame fields and clears accumulated data for a new baseline.
+func (lt *LiveTrie) Reconfigure(fields []string) error {
+ normalized, err := normalizeLiveTrieFields(fields)
+ if err != nil {
+ return err
+ }
+
+ lt.mu.Lock()
+ lt.fields = slices.Clone(normalized)
+ lt.resetLocked()
+ lt.mu.Unlock()
+ lt.invalidateCache()
+ return nil
}
// Version returns the current ingest version of the trie.
@@ -166,6 +197,39 @@ func (lt *LiveTrie) buildFrames(record IterRecord) []string {
return frames
}
+func normalizeLiveTrieFields(fields []string) ([]string, error) {
+ if len(fields) == 0 {
+ return nil, fmt.Errorf("fields cannot be empty")
+ }
+
+ normalized := make([]string, 0, len(fields))
+ seen := make(map[string]struct{}, len(fields))
+ for _, raw := range fields {
+ field := strings.TrimSpace(raw)
+ if field == "" {
+ return nil, fmt.Errorf("fields cannot contain empty values")
+ }
+ if !isLiveTrieField(field) {
+ return nil, fmt.Errorf("invalid field %q", field)
+ }
+ if _, exists := seen[field]; exists {
+ return nil, fmt.Errorf("duplicate field %q", field)
+ }
+ seen[field] = struct{}{}
+ normalized = append(normalized, field)
+ }
+ return normalized, nil
+}
+
+func isLiveTrieField(field string) bool {
+ switch field {
+ case "path", "comm", "tracepoint", "pid", "tid", "flags":
+ return true
+ default:
+ return false
+ }
+}
+
func subtreeTotal(node *trieNode) uint64 {
total := node.value
for _, child := range node.children {
diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go
index 9bd16c2..1315c71 100644
--- a/internal/flamegraph/livetrie_test.go
+++ b/internal/flamegraph/livetrie_test.go
@@ -87,6 +87,44 @@ func TestLiveTrieResetClearsDataAndAdvancesVersion(t *testing.T) {
}
}
+func TestLiveTrieReconfigureChangesOrderAndResets(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "pid"}, "count")
+ lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1))
+
+ if err := lt.Reconfigure([]string{"path", "comm"}); err != nil {
+ t.Fatalf("reconfigure: %v", err)
+ }
+ if got := lt.Fields(); len(got) != 2 || got[0] != "path" || got[1] != "comm" {
+ t.Fatalf("fields after reconfigure = %v, want [path comm]", got)
+ }
+
+ empty := decodeLiveSnapshot(t, lt)
+ if empty.Total != 0 {
+ t.Fatalf("snapshot total after reconfigure = %d, want 0", empty.Total)
+ }
+
+ lt.Ingest(newTestPair("svc", 42, 1002, "/tmp/a", 1, 1, 1))
+ snap := decodeLiveSnapshot(t, lt)
+ findSnapshotPath(t, &snap, "/tmp", "/a", "svc")
+}
+
+func TestLiveTrieReconfigureRejectsInvalidFields(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm"}, "count")
+
+ cases := [][]string{
+ nil,
+ {},
+ {"comm", "comm"},
+ {"comm", ""},
+ {"comm", "bogus"},
+ }
+ for _, tc := range cases {
+ if err := lt.Reconfigure(tc); err == nil {
+ t.Fatalf("expected error for fields=%v", tc)
+ }
+ }
+}
+
func TestLiveTrieSnapshotJSONCaching(t *testing.T) {
lt := NewLiveTrie([]string{"comm"}, "count")
lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1))