talon-agent 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,80 +1,168 @@
1
- // src/util/metrics.ts lightweight in-process metrics
1
+ // Session-backed metrics aggregation. Writes for chat turns live on
2
+ // storage/sessions.ts; this module keeps the public read shape used by
3
+ // /metrics and a small compatibility sink for non-chat legacy counters.
2
4
 
3
- const counters = new Map<string, number>();
4
- const MAX_METRIC_KEYS = 500; // cap to prevent unbounded growth from high-cardinality names
5
+ import {
6
+ getAllSessions,
7
+ resetAllSessionMetrics,
8
+ todayUtc,
9
+ type MetricsGrain,
10
+ type MetricsLatencyAgg,
11
+ } from "../storage/sessions.js";
5
12
 
6
- // Ring buffer for histograms — O(1) insert, avoids splice overhead
7
- interface RingBuffer {
8
- data: number[];
9
- head: number; // next write position
10
- size: number; // current number of valid entries (≤ capacity)
11
- }
13
+ const legacyCounters = new Map<string, number>();
12
14
 
13
- const histograms = new Map<string, RingBuffer>();
14
- const MAX_HISTOGRAM_SIZE = 1000;
15
+ export type MetricsSnapshot = {
16
+ counters: Record<string, number>;
17
+ histograms: Record<
18
+ string,
19
+ { count: number; avg: number; min: number; max: number }
20
+ >;
21
+ };
15
22
 
16
- function createRingBuffer(capacity: number): RingBuffer {
17
- return { data: new Array<number>(capacity), head: 0, size: 0 };
23
+ export function incrementCounter(name: string, amount = 1): void {
24
+ legacyCounters.set(name, (legacyCounters.get(name) ?? 0) + amount);
18
25
  }
19
26
 
20
- function ringPush(buf: RingBuffer, value: number): void {
21
- buf.data[buf.head] = value;
22
- buf.head = (buf.head + 1) % buf.data.length;
23
- if (buf.size < buf.data.length) buf.size++;
27
+ export function recordHistogram(_name: string, _value: number): void {
28
+ // The old global histogram ring is intentionally gone. Chat-turn
29
+ // histograms are now latency aggregates on SessionMetrics.
24
30
  }
25
31
 
26
- function ringValues(buf: RingBuffer): number[] {
27
- if (buf.size < buf.data.length) return buf.data.slice(0, buf.size);
28
- // Full ring — read from head (oldest) wrapping around
29
- return [...buf.data.slice(buf.head), ...buf.data.slice(0, buf.head)];
32
+ function addCounter(
33
+ counters: Record<string, number>,
34
+ name: string,
35
+ amount: number | undefined,
36
+ ): void {
37
+ if (typeof amount === "number" && Number.isFinite(amount) && amount !== 0) {
38
+ counters[name] = (counters[name] ?? 0) + amount;
39
+ }
30
40
  }
31
41
 
32
- export function incrementCounter(name: string, amount = 1): void {
33
- // Only allow new keys up to the cap — existing keys always pass
34
- if (!counters.has(name) && counters.size >= MAX_METRIC_KEYS) return;
35
- counters.set(name, (counters.get(name) ?? 0) + amount);
42
+ function mergeAgg(target: MetricsLatencyAgg, source: MetricsLatencyAgg): void {
43
+ if (!source.count) return;
44
+ target.count += source.count;
45
+ target.sumMs += source.sumMs;
46
+ target.minMs = Math.min(target.minMs, source.minMs);
47
+ target.maxMs = Math.max(target.maxMs, source.maxMs);
36
48
  }
37
49
 
38
- export function recordHistogram(name: string, value: number): void {
39
- if (!Number.isFinite(value)) return; // drop NaN/Infinity/invalid samples
40
- let buf = histograms.get(name);
41
- if (!buf) {
42
- if (histograms.size >= MAX_METRIC_KEYS) return; // cap key count
43
- buf = createRingBuffer(MAX_HISTOGRAM_SIZE);
44
- histograms.set(name, buf);
45
- }
46
- ringPush(buf, value);
50
+ function emptyAgg(): MetricsLatencyAgg {
51
+ return { count: 0, sumMs: 0, minMs: Infinity, maxMs: 0 };
47
52
  }
48
53
 
49
- export function getMetrics(): {
50
- counters: Record<string, number>;
51
- histograms: Record<
52
- string,
53
- { count: number; p50: number; p95: number; p99: number; avg: number }
54
- >;
55
- } {
56
- const result: ReturnType<typeof getMetrics> = {
57
- counters: {},
58
- histograms: {},
54
+ function snapshotAgg(agg: MetricsLatencyAgg) {
55
+ return {
56
+ count: agg.count,
57
+ avg: Math.round(agg.sumMs / agg.count),
58
+ min: agg.minMs,
59
+ max: agg.maxMs,
59
60
  };
60
- for (const [k, v] of counters) result.counters[k] = v;
61
- for (const [k, buf] of histograms) {
62
- if (buf.size === 0) continue;
63
- const values = ringValues(buf);
64
- const sorted = [...values].sort((a, b) => a - b);
65
- const len = sorted.length;
66
- result.histograms[k] = {
67
- count: len,
68
- p50: sorted[Math.floor(len * 0.5)],
69
- p95: sorted[Math.floor(len * 0.95)],
70
- p99: sorted[Math.floor(len * 0.99)],
71
- avg: Math.round(values.reduce((a, b) => a + b, 0) / len),
72
- };
61
+ }
62
+
63
+ function buildSnapshot(
64
+ grains: MetricsGrain[],
65
+ counters: Record<string, number>,
66
+ ): MetricsSnapshot {
67
+ const responseLatency = emptyAgg();
68
+ const toolCallsPerTurn = emptyAgg();
69
+ const apiCallsPerTurn = emptyAgg();
70
+ const cacheHitPercent = emptyAgg();
71
+ const backendLatency = new Map<string, MetricsLatencyAgg>();
72
+
73
+ for (const grain of grains) {
74
+ const c = grain.counters;
75
+ addCounter(counters, "queries_total", c.queries);
76
+ addCounter(counters, "turns_with_tools_total", c.turnsWithTools);
77
+ addCounter(counters, "api_calls_total", c.apiCalls);
78
+ addCounter(counters, "tokens.input_total", c.inputTokens);
79
+ addCounter(counters, "tokens.output_total", c.outputTokens);
80
+ addCounter(counters, "tokens.cache_read_total", c.cacheReadTokens);
81
+ addCounter(counters, "tokens.cache_write_total", c.cacheWriteTokens);
82
+ addCounter(
83
+ counters,
84
+ "scratchpad.trailing_text_dropped",
85
+ c.trailingTextDropped,
86
+ );
87
+ addCounter(
88
+ counters,
89
+ "scratchpad.flow_violation_retried",
90
+ c.flowViolationRetries,
91
+ );
92
+ addCounter(
93
+ counters,
94
+ "scratchpad.flow_violation_cap_exhausted",
95
+ c.flowViolationCapExhausted,
96
+ );
97
+ for (const [name, count] of Object.entries(grain.toolCallsByName)) {
98
+ addCounter(counters, `tool_calls.${name}`, count);
99
+ }
100
+ for (const [backend, bc] of Object.entries(grain.backend)) {
101
+ addCounter(counters, `backend.${backend}.queries`, bc.queries);
102
+ addCounter(counters, `backend.${backend}.tool_calls`, bc.toolCalls);
103
+ addCounter(counters, `backend.${backend}.turn_failed`, bc.failedTurns);
104
+ addCounter(counters, `backend.${backend}.tokens.input`, bc.inputTokens);
105
+ addCounter(counters, `backend.${backend}.tokens.output`, bc.outputTokens);
106
+ addCounter(
107
+ counters,
108
+ `backend.${backend}.tokens.cache_read`,
109
+ bc.cacheReadTokens,
110
+ );
111
+ addCounter(
112
+ counters,
113
+ `backend.${backend}.tokens.cache_write`,
114
+ bc.cacheWriteTokens,
115
+ );
116
+ const agg = backendLatency.get(backend) ?? emptyAgg();
117
+ mergeAgg(agg, bc.latency);
118
+ backendLatency.set(backend, agg);
119
+ }
120
+ mergeAgg(responseLatency, grain.latency);
121
+ mergeAgg(toolCallsPerTurn, grain.toolCallsPerTurn);
122
+ mergeAgg(apiCallsPerTurn, grain.apiCallsPerTurn);
123
+ mergeAgg(cacheHitPercent, grain.cacheHitPercent);
124
+ }
125
+
126
+ const histograms: MetricsSnapshot["histograms"] = {};
127
+ if (responseLatency.count)
128
+ histograms.response_latency_ms = snapshotAgg(responseLatency);
129
+ if (toolCallsPerTurn.count)
130
+ histograms.tool_calls_per_turn = snapshotAgg(toolCallsPerTurn);
131
+ if (apiCallsPerTurn.count)
132
+ histograms.api_calls_per_turn = snapshotAgg(apiCallsPerTurn);
133
+ if (cacheHitPercent.count)
134
+ histograms.cache_hit_percent = snapshotAgg(cacheHitPercent);
135
+ for (const [backend, agg] of backendLatency) {
136
+ if (agg.count)
137
+ histograms[`backend.${backend}.response_latency_ms`] = snapshotAgg(agg);
73
138
  }
74
- return result;
139
+
140
+ return { counters, histograms };
141
+ }
142
+
143
+ /** Lifetime fleet snapshot: all sessions' cumulative metrics plus the
144
+ * in-process legacy counters. */
145
+ export function getMetrics(): MetricsSnapshot {
146
+ const counters: Record<string, number> = {};
147
+ for (const [key, value] of legacyCounters) addCounter(counters, key, value);
148
+ return buildSnapshot(
149
+ getAllSessions().map(({ info }) => info.metrics.lifetime),
150
+ counters,
151
+ );
152
+ }
153
+
154
+ /** Today's (UTC) fleet snapshot, aggregated from the sessions' daily
155
+ * rollup buckets. Legacy counters are process-lifetime, not daily, so
156
+ * they are excluded here. */
157
+ export function getTodayMetrics(): MetricsSnapshot {
158
+ const day = todayUtc();
159
+ return buildSnapshot(
160
+ getAllSessions().flatMap(({ info }) => info.metrics.buckets[day] ?? []),
161
+ {},
162
+ );
75
163
  }
76
164
 
77
165
  export function resetMetrics(): void {
78
- counters.clear();
79
- histograms.clear();
166
+ legacyCounters.clear();
167
+ resetAllSessionMetrics();
80
168
  }