agharsallah commited on
Commit
e1959d5
Β·
1 Parent(s): bd9568a

feat: Enhance telemetry store with revision tracking and optimize UI refresh logic

Browse files
src/observability/store.py CHANGED
@@ -50,20 +50,26 @@ class TelemetryStore:
50
  self._spans: deque[SpanRecord] = deque(maxlen=capacity)
51
  self._metrics: deque[MetricPoint] = deque(maxlen=capacity * 4)
52
  self._counters: dict[tuple, float] = {}
 
 
 
53
 
54
  # ── ingest ──────────────────────────────────────────────────────────────
55
 
56
  def add_log(self, record: dict) -> None:
57
  with self._lock:
58
  self._logs.append(record)
 
59
 
60
  def add_span(self, span: SpanRecord) -> None:
61
  with self._lock:
62
  self._spans.append(span)
 
63
 
64
  def add_metric(self, point: MetricPoint, *, counter: bool = False) -> None:
65
  with self._lock:
66
  self._metrics.append(point)
 
67
  if counter:
68
  key = (point.name, tuple(sorted(point.labels.items())))
69
  self._counters[key] = self._counters.get(key, 0.0) + point.value
@@ -78,10 +84,30 @@ class TelemetryStore:
78
  with self._lock:
79
  return list(self._spans)[-n:]
80
 
81
- def metric_points(self, name: str | None = None) -> list[MetricPoint]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  with self._lock:
83
- points = list(self._metrics)
84
- return [p for p in points if name is None or p.name == name]
85
 
86
  def counter_totals(self) -> dict[str, float]:
87
  """Cumulative total per metric name, summed across label sets."""
@@ -102,6 +128,7 @@ class TelemetryStore:
102
  self._spans.clear()
103
  self._metrics.clear()
104
  self._counters.clear()
 
105
 
106
 
107
  def now_ts() -> float:
 
50
  self._spans: deque[SpanRecord] = deque(maxlen=capacity)
51
  self._metrics: deque[MetricPoint] = deque(maxlen=capacity * 4)
52
  self._counters: dict[tuple, float] = {}
53
+ # Monotonic ingest counter β€” a cheap "has anything changed since I last looked?"
54
+ # signal so the Telemetry tab can skip recomputing/repainting on an idle tick.
55
+ self._rev = 0
56
 
57
  # ── ingest ──────────────────────────────────────────────────────────────
58
 
59
  def add_log(self, record: dict) -> None:
60
  with self._lock:
61
  self._logs.append(record)
62
+ self._rev += 1
63
 
64
  def add_span(self, span: SpanRecord) -> None:
65
  with self._lock:
66
  self._spans.append(span)
67
+ self._rev += 1
68
 
69
  def add_metric(self, point: MetricPoint, *, counter: bool = False) -> None:
70
  with self._lock:
71
  self._metrics.append(point)
72
+ self._rev += 1
73
  if counter:
74
  key = (point.name, tuple(sorted(point.labels.items())))
75
  self._counters[key] = self._counters.get(key, 0.0) + point.value
 
84
  with self._lock:
85
  return list(self._spans)[-n:]
86
 
87
+ def metric_points(self, name: str | None = None, limit: int | None = None) -> list[MetricPoint]:
88
+ """Recorded points, optionally filtered by ``name``.
89
+
90
+ With ``limit`` set, only the most recent ``limit`` matching points are returned
91
+ (still chronological) β€” this bounds both the scan and the chart payload for
92
+ high-frequency metrics like agent-turn latency, which would otherwise grow until
93
+ the buffer is full.
94
+ """
95
+ with self._lock:
96
+ if limit is None:
97
+ return [p for p in self._metrics if name is None or p.name == name]
98
+ out: list[MetricPoint] = []
99
+ for p in reversed(self._metrics): # newest-first, stop once we have `limit`
100
+ if name is None or p.name == name:
101
+ out.append(p)
102
+ if len(out) >= limit:
103
+ break
104
+ out.reverse()
105
+ return out
106
+
107
+ def revision(self) -> int:
108
+ """Monotonic ingest counter β€” bumped on every log/span/metric and on ``clear``."""
109
  with self._lock:
110
+ return self._rev
 
111
 
112
  def counter_totals(self) -> dict[str, float]:
113
  """Cumulative total per metric name, summed across label sets."""
 
128
  self._spans.clear()
129
  self._metrics.clear()
130
  self._counters.clear()
131
+ self._rev += 1 # a clear is a change too β€” let the UI repaint to empty
132
 
133
 
134
  def now_ts() -> float:
src/ui/fishbowl/app.py CHANGED
@@ -276,9 +276,12 @@ except Exception: # pragma: no cover
276
  def build_telemetry() -> None:
277
  """The Telemetry tab: structured log feed + metric charts + per-trace timeline.
278
 
279
- Reads live from the in-memory telemetry store (ADR-0024); a manual Refresh, a
280
- 3s auto-tick, and the filter dropdowns all repaint it. Clicking a span in the
281
- timeline reveals the prompt + memory that agent saw.
 
 
 
282
  """
283
  try:
284
  from src.ui.fishbowl.render import telemetry as t
@@ -306,24 +309,62 @@ def build_telemetry() -> None:
306
  row_count=(12, "dynamic"),
307
  column_count=(5, "fixed"),
308
  )
309
- traces = gr.HTML(t.traces_html())
 
 
 
 
 
 
 
310
  timer = gr.Timer(3.0)
311
 
312
- def _refresh(level, layer):
313
- return (
314
- t.kpi_markdown(),
315
- t.log_rows(level, layer),
316
- t.calls_frame(),
317
- t.tokens_frame(),
318
- t.latency_frame(),
319
- t.traces_html(),
320
- )
321
 
322
- outs = [kpi, feed, calls_plot, tokens_plot, latency_plot, traces]
323
- refresh.click(_refresh, [level_dd, layer_dd], outs)
324
- timer.tick(_refresh, [level_dd, layer_dd], outs)
325
- level_dd.change(_refresh, [level_dd, layer_dd], outs)
326
- layer_dd.change(_refresh, [level_dd, layer_dd], outs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
 
329
  # ── scenario registry (assembled from config/, not hardcoded) ───────────────────
 
276
  def build_telemetry() -> None:
277
  """The Telemetry tab: structured log feed + metric charts + per-trace timeline.
278
 
279
+ Reads live from the in-memory telemetry store (ADR-0024). The 3s auto-tick is
280
+ *non-blocking and idle-cheap*: it gates on the store's revision and returns
281
+ ``gr.skip()`` (no recompute, no repaint, ``show_progress='hidden'`` so no overlay)
282
+ whenever nothing was recorded since the last paint. The filter dropdowns and ⟳
283
+ Refresh force a repaint; "show more traces" pages older traces into the timeline.
284
+ Clicking a span reveals the prompt + memory that agent saw.
285
  """
286
  try:
287
  from src.ui.fishbowl.render import telemetry as t
 
309
  row_count=(12, "dynamic"),
310
  column_count=(5, "fixed"),
311
  )
312
+ _traces0, _shown0, _total0 = t.render_traces(t.DEFAULT_TRACES)
313
+ traces = gr.HTML(_traces0)
314
+ load_more = gr.Button(t.more_label(_shown0, _total0), interactive=_total0 > _shown0, size="sm")
315
+
316
+ # Per-user view state: the signature of the last paint (so an idle tick can skip the
317
+ # work) and how many traces the timeline currently shows ("show more" grows it).
318
+ sig_state = gr.State(None)
319
+ trace_limit_state = gr.State(t.DEFAULT_TRACES)
320
  timer = gr.Timer(3.0)
321
 
322
+ outs = [kpi, feed, calls_plot, tokens_plot, latency_plot, traces, load_more]
 
 
 
 
 
 
 
 
323
 
324
+ def _values(level, layer, trace_limit):
325
+ """Recompute all panel outputs in one store pass (single counter read)."""
326
+ snap = t.snapshot(level, layer, trace_limit)
327
+ more = gr.update(
328
+ value=t.more_label(snap["trace_shown"], snap["trace_total"]),
329
+ interactive=snap["trace_total"] > snap["trace_shown"],
330
+ )
331
+ return (snap["kpi"], snap["feed"], snap["calls"], snap["tokens"], snap["latency"], snap["traces"], more)
332
+
333
+ def _sig(level, layer, trace_limit):
334
+ # Read the revision BEFORE rendering: if data lands mid-render the stored sig
335
+ # stays behind, so the next tick repaints to catch up (never a missed update).
336
+ return (t.revision(), level, layer, int(trace_limit))
337
+
338
+ def _tick(level, layer, trace_limit, last_sig):
339
+ """Auto-refresh: repaint only when the store advanced or a filter/limit changed."""
340
+ sig = _sig(level, layer, trace_limit)
341
+ if sig == last_sig:
342
+ return (gr.skip(),) * (len(outs) + 1) # idle β†’ leave every output (and sig) as-is
343
+ return _values(level, layer, trace_limit) + (sig,)
344
+
345
+ def _force(level, layer, trace_limit):
346
+ """Manual Refresh / filter change: always repaint with the current filters."""
347
+ sig = _sig(level, layer, trace_limit)
348
+ return _values(level, layer, trace_limit) + (sig,)
349
+
350
+ def _load_more(level, layer, trace_limit):
351
+ """Grow the timeline by one page and repaint (carries the new limit back to state)."""
352
+ new_limit = int(trace_limit) + t.TRACE_PAGE
353
+ sig = _sig(level, layer, new_limit)
354
+ return _values(level, layer, new_limit) + (sig, new_limit)
355
+
356
+ # Auto-tick is hidden (no progress overlay β†’ no 3s flicker); user-driven actions show
357
+ # a minimal indicator so the click registers.
358
+ timer.tick(_tick, [level_dd, layer_dd, trace_limit_state, sig_state], outs + [sig_state], show_progress="hidden")
359
+ refresh.click(_force, [level_dd, layer_dd, trace_limit_state], outs + [sig_state], show_progress="minimal")
360
+ level_dd.change(_force, [level_dd, layer_dd, trace_limit_state], outs + [sig_state], show_progress="minimal")
361
+ layer_dd.change(_force, [level_dd, layer_dd, trace_limit_state], outs + [sig_state], show_progress="minimal")
362
+ load_more.click(
363
+ _load_more,
364
+ [level_dd, layer_dd, trace_limit_state],
365
+ outs + [sig_state, trace_limit_state],
366
+ show_progress="minimal",
367
+ )
368
 
369
 
370
  # ── scenario registry (assembled from config/, not hardcoded) ───────────────────
src/ui/fishbowl/render/telemetry.py CHANGED
@@ -71,9 +71,13 @@ def log_rows(level: str = "all", layer: str = "all", limit: int = 250) -> list[l
71
  # ── metrics (chart data) ────────────────────────────────────────────────────
72
 
73
 
74
- def kpi_markdown() -> str:
75
- """A one-line headline of the key counters."""
76
- c = obs.telemetry_store().counter_totals()
 
 
 
 
77
  calls = int(c.get("llm.calls", 0))
78
  tin, tout = int(c.get("llm.tokens.input", 0)), int(c.get("llm.tokens.output", 0))
79
  cost = c.get("llm.cost_usd", 0.0)
@@ -95,9 +99,9 @@ def _df(rows: list[dict], columns: list[str]):
95
  return pd.DataFrame(rows or [{c: None for c in columns}][:0], columns=columns)
96
 
97
 
98
- def calls_frame():
99
  """Counts by metric (calls / tool calls / events / trips) for a bar chart."""
100
- c = obs.telemetry_store().counter_totals()
101
  keep = {
102
  "llm.calls": "llm calls",
103
  "tool.calls": "tool calls",
@@ -108,9 +112,9 @@ def calls_frame():
108
  return _df(rows, ["metric", "count"])
109
 
110
 
111
- def tokens_frame():
112
  """Input vs output tokens for a bar chart."""
113
- c = obs.telemetry_store().counter_totals()
114
  rows = [
115
  {"kind": "input", "tokens": float(c.get("llm.tokens.input", 0))},
116
  {"kind": "output", "tokens": float(c.get("llm.tokens.output", 0))},
@@ -118,9 +122,14 @@ def tokens_frame():
118
  return _df(rows, ["kind", "tokens"])
119
 
120
 
 
 
 
 
 
121
  def latency_frame():
122
- """Agent-turn latency observations over time (seq index β†’ seconds, by agent)."""
123
- points = obs.telemetry_store().metric_points("agent.turn.seconds")
124
  rows = [
125
  {"n": i, "seconds": round(p.value, 4), "agent": str(p.labels.get("agent", "?"))} for i, p in enumerate(points)
126
  ]
@@ -132,26 +141,36 @@ def latency_frame():
132
  _PROMPT_KEYS = ("llm.prompt", "agent.prompt", "memory.query")
133
  _OUTPUT_KEYS = ("llm.completion", "memory.visible_count")
134
 
 
 
 
 
 
 
135
 
136
- def traces_html(limit_traces: int = 8) -> str:
137
- """Recent spans grouped by trace, rendered as an indented timeline.
138
 
139
- Each span shows name + duration + status; spans that carry a prompt or memory
140
- (``llm.prompt`` / ``agent`` / ``memory.*`` attributes) expand to reveal exactly
141
- what the agent sent and saw β€” the heart of the 'what did each agent do' view.
 
 
142
  """
143
- spans = obs.telemetry_store().recent_spans(3000)
144
  if not spans:
145
- return "<div class='tele-empty'>No traces yet β€” run the show to populate the timeline.</div>"
146
 
147
  by_trace: dict[str, list] = {}
148
  for sp in spans:
149
  by_trace.setdefault(sp.trace_id, []).append(sp)
150
  # Most recent traces first (by max end time within the trace).
151
  ordered = sorted(by_trace.items(), key=lambda kv: max(s.end_ms for s in kv[1]), reverse=True)
 
 
152
 
153
  out: list[str] = []
154
- for trace_id, group in ordered[:limit_traces]:
155
  depth = _depth_map(group)
156
  root_dur = max(s.end_ms for s in group) - min(s.start_ms for s in group)
157
  out.append(
@@ -161,7 +180,12 @@ def traces_html(limit_traces: int = 8) -> str:
161
  for sp in sorted(group, key=lambda s: s.start_ms):
162
  out.append(_span_html(sp, depth.get(sp.span_id, 0)))
163
  out.append("</div>")
164
- return "\n".join(out)
 
 
 
 
 
165
 
166
 
167
  def _depth_map(group: list) -> dict[str, int]:
@@ -210,6 +234,42 @@ def _span_html(sp, depth: int) -> str:
210
  return head + (f"<div class='tele-span-body'>{body}</div>" if body else "") + "</div>"
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  TELEMETRY_CSS = """
214
  .tele-trace { border:1px solid #2e3d25; border-radius:8px; margin:8px 0; padding:8px; background:#0b0f0a; }
215
  .tele-trace-hd { color:#5fd0d0; font-weight:700; margin-bottom:6px; }
 
71
  # ── metrics (chart data) ────────────────────────────────────────────────────
72
 
73
 
74
+ def kpi_markdown(counters: dict | None = None) -> str:
75
+ """A one-line headline of the key counters.
76
+
77
+ Pass a pre-read ``counters`` snapshot (see :func:`snapshot`) to avoid re-locking the
78
+ store; omit it and one is fetched.
79
+ """
80
+ c = counters if counters is not None else obs.telemetry_store().counter_totals()
81
  calls = int(c.get("llm.calls", 0))
82
  tin, tout = int(c.get("llm.tokens.input", 0)), int(c.get("llm.tokens.output", 0))
83
  cost = c.get("llm.cost_usd", 0.0)
 
99
  return pd.DataFrame(rows or [{c: None for c in columns}][:0], columns=columns)
100
 
101
 
102
+ def calls_frame(counters: dict | None = None):
103
  """Counts by metric (calls / tool calls / events / trips) for a bar chart."""
104
+ c = counters if counters is not None else obs.telemetry_store().counter_totals()
105
  keep = {
106
  "llm.calls": "llm calls",
107
  "tool.calls": "tool calls",
 
112
  return _df(rows, ["metric", "count"])
113
 
114
 
115
+ def tokens_frame(counters: dict | None = None):
116
  """Input vs output tokens for a bar chart."""
117
+ c = counters if counters is not None else obs.telemetry_store().counter_totals()
118
  rows = [
119
  {"kind": "input", "tokens": float(c.get("llm.tokens.input", 0))},
120
  {"kind": "output", "tokens": float(c.get("llm.tokens.output", 0))},
 
122
  return _df(rows, ["kind", "tokens"])
123
 
124
 
125
+ #: Cap on how many latency observations the line chart plots β€” a rolling window that
126
+ #: keeps both the store scan and the browser-side chart light on long runs.
127
+ _LATENCY_POINTS = 300
128
+
129
+
130
  def latency_frame():
131
+ """Agent-turn latency over time (rolling window: seq index β†’ seconds, by agent)."""
132
+ points = obs.telemetry_store().metric_points("agent.turn.seconds", limit=_LATENCY_POINTS)
133
  rows = [
134
  {"n": i, "seconds": round(p.value, 4), "agent": str(p.labels.get("agent", "?"))} for i, p in enumerate(points)
135
  ]
 
141
  _PROMPT_KEYS = ("llm.prompt", "agent.prompt", "memory.query")
142
  _OUTPUT_KEYS = ("llm.completion", "memory.visible_count")
143
 
144
+ #: How many traces the timeline shows at first, and how many more each "show more" adds.
145
+ DEFAULT_TRACES = 8
146
+ TRACE_PAGE = 8
147
+ #: Span buffer slice scanned to assemble the timeline (≀ store capacity).
148
+ _SPAN_SCAN = 3000
149
+
150
 
151
+ def render_traces(limit_traces: int = DEFAULT_TRACES) -> tuple[str, int, int]:
152
+ """Recent spans grouped by trace, newest first; render the first ``limit_traces``.
153
 
154
+ Returns ``(html, shown, total)``. Each span shows name + duration + status; spans
155
+ that carry a prompt or memory (``llm.prompt`` / ``agent`` / ``memory.*`` attributes)
156
+ expand to reveal exactly what the agent sent and saw β€” the heart of the 'what did
157
+ each agent do' view. Only ``limit_traces`` traces are turned into HTML, so the
158
+ payload stays bounded while ``total`` lets the caller offer a "show more" control.
159
  """
160
+ spans = obs.telemetry_store().recent_spans(_SPAN_SCAN)
161
  if not spans:
162
+ return "<div class='tele-empty'>No traces yet β€” run the show to populate the timeline.</div>", 0, 0
163
 
164
  by_trace: dict[str, list] = {}
165
  for sp in spans:
166
  by_trace.setdefault(sp.trace_id, []).append(sp)
167
  # Most recent traces first (by max end time within the trace).
168
  ordered = sorted(by_trace.items(), key=lambda kv: max(s.end_ms for s in kv[1]), reverse=True)
169
+ total = len(ordered)
170
+ limit = max(0, int(limit_traces))
171
 
172
  out: list[str] = []
173
+ for trace_id, group in ordered[:limit]:
174
  depth = _depth_map(group)
175
  root_dur = max(s.end_ms for s in group) - min(s.start_ms for s in group)
176
  out.append(
 
180
  for sp in sorted(group, key=lambda s: s.start_ms):
181
  out.append(_span_html(sp, depth.get(sp.span_id, 0)))
182
  out.append("</div>")
183
+ return "\n".join(out), min(limit, total), total
184
+
185
+
186
+ def traces_html(limit_traces: int = DEFAULT_TRACES) -> str:
187
+ """The trace timeline HTML alone (no counts) β€” for callers that don't paginate."""
188
+ return render_traces(limit_traces)[0]
189
 
190
 
191
  def _depth_map(group: list) -> dict[str, int]:
 
234
  return head + (f"<div class='tele-span-body'>{body}</div>" if body else "") + "</div>"
235
 
236
 
237
+ # ── panel orchestration ─────────────────────────────────────────────────────
238
+ # One entry point the Telemetry tab drives: a single store read per repaint, plus a
239
+ # cheap change signal so an idle auto-tick can skip the work entirely (see app.py).
240
+
241
+
242
+ def revision() -> int:
243
+ """Cheap 'has anything been recorded since I last painted?' signal for the UI."""
244
+ return obs.telemetry_store().revision()
245
+
246
+
247
+ def more_label(shown: int, total: int) -> str:
248
+ """Label for the 'show more traces' button given how many are shown vs. available."""
249
+ return f"↓ Show more traces Β· {shown}/{total}" if total > shown else f"All {total} traces shown"
250
+
251
+
252
+ def snapshot(level: str = "all", layer: str = "all", trace_limit: int = DEFAULT_TRACES) -> dict:
253
+ """Every Telemetry output computed in one pass β€” one counter read, one span scan.
254
+
255
+ Centralising the reads here means the metric panels share a single
256
+ ``counter_totals()`` snapshot (instead of three separate locks) and the auto-refresh
257
+ can recompute the whole panel only when :func:`revision` says the store advanced.
258
+ """
259
+ counters = obs.telemetry_store().counter_totals()
260
+ traces, shown, total = render_traces(trace_limit)
261
+ return {
262
+ "kpi": kpi_markdown(counters),
263
+ "feed": log_rows(level, layer),
264
+ "calls": calls_frame(counters),
265
+ "tokens": tokens_frame(counters),
266
+ "latency": latency_frame(),
267
+ "traces": traces,
268
+ "trace_shown": shown,
269
+ "trace_total": total,
270
+ }
271
+
272
+
273
  TELEMETRY_CSS = """
274
  .tele-trace { border:1px solid #2e3d25; border-radius:8px; margin:8px 0; padding:8px; background:#0b0f0a; }
275
  .tele-trace-hd { color:#5fd0d0; font-weight:700; margin-bottom:6px; }