@narumitw/pi-analytics 0.45.0 → 0.48.1

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.
@@ -0,0 +1,281 @@
1
+ import type {
2
+ GenerationOutcome,
3
+ GenerationRecord,
4
+ ProviderErrorCategory,
5
+ ProviderErrorRecord,
6
+ ProviderResponseRecord,
7
+ RunOutcome,
8
+ SettledRun,
9
+ SkillActivationRecord,
10
+ ToolCallRecord,
11
+ } from "../types.js";
12
+
13
+ export const MAX_STORED_RUN_BYTES = 1024 * 1024;
14
+ const MAX_STRING_LENGTH = 4096;
15
+ const MAX_NESTED_RECORDS = 20_000;
16
+ const TRIGGER_SOURCES = ["interactive", "rpc", "extension", "unknown"] as const;
17
+ const RUN_OUTCOMES = [
18
+ "success",
19
+ "recovered_success",
20
+ "error",
21
+ "aborted",
22
+ "length",
23
+ "interrupted",
24
+ ] as const;
25
+ const GENERATION_OUTCOMES = [
26
+ "pending",
27
+ "stop",
28
+ "tool_use",
29
+ "error",
30
+ "aborted",
31
+ "length",
32
+ "interrupted",
33
+ ] as const;
34
+ const ERROR_CATEGORIES = [
35
+ "dns",
36
+ "timeout",
37
+ "connection_refused",
38
+ "connection_reset",
39
+ "tls",
40
+ "network_other",
41
+ "provider_other",
42
+ ] as const;
43
+
44
+ export class AnalyticsStorageFormatError extends Error {
45
+ constructor(message: string, options: { cause?: unknown } = {}) {
46
+ super(message, options);
47
+ this.name = "AnalyticsStorageFormatError";
48
+ }
49
+ }
50
+
51
+ export function encodeStoredRun(run: SettledRun): string {
52
+ const encoded = `${JSON.stringify({
53
+ formatVersion: 1,
54
+ run: parseRun(run, { remaining: MAX_NESTED_RECORDS }),
55
+ })}\n`;
56
+ if (Buffer.byteLength(encoded) > MAX_STORED_RUN_BYTES) {
57
+ throw new AnalyticsStorageFormatError("Analytics record is too large to store safely.");
58
+ }
59
+ return encoded;
60
+ }
61
+
62
+ export function decodeStoredRun(line: string): SettledRun {
63
+ if (Buffer.byteLength(line) > MAX_STORED_RUN_BYTES) {
64
+ throw new AnalyticsStorageFormatError("Analytics record is too large to read safely.");
65
+ }
66
+ let value: unknown;
67
+ try {
68
+ value = JSON.parse(line);
69
+ } catch (error) {
70
+ throw new AnalyticsStorageFormatError("Analytics record contains invalid JSON.", {
71
+ cause: error,
72
+ });
73
+ }
74
+ const envelope = asRecord(value, "analytics record");
75
+ if (envelope.formatVersion !== 1) {
76
+ throw new AnalyticsStorageFormatError("Analytics record uses an unsupported format version.");
77
+ }
78
+ return parseRun(envelope.run, { remaining: MAX_NESTED_RECORDS });
79
+ }
80
+
81
+ interface ParseBudget {
82
+ remaining: number;
83
+ }
84
+
85
+ function parseRun(value: unknown, budget: ParseBudget): SettledRun {
86
+ const run = asRecord(value, "run");
87
+ return {
88
+ id: requiredString(run.id, "run.id"),
89
+ startedAtMs: timestampValue(run.startedAtMs, "run.startedAtMs"),
90
+ finishedAtMs: timestampValue(run.finishedAtMs, "run.finishedAtMs"),
91
+ durationMs: durationValue(run.durationMs, "run.durationMs"),
92
+ triggerSource: enumValue(run.triggerSource, TRIGGER_SOURCES, "run.triggerSource"),
93
+ ...optionalProperty(
94
+ "initialProvider",
95
+ optionalString(run.initialProvider, "run.initialProvider"),
96
+ ),
97
+ ...optionalProperty("initialModel", optionalString(run.initialModel, "run.initialModel")),
98
+ outcome: enumValue(run.outcome, RUN_OUTCOMES, "run.outcome") as RunOutcome,
99
+ attemptCount: boundedCount(run.attemptCount, "run.attemptCount"),
100
+ generations: boundedArray(run.generations, "run.generations", budget).map((item, index) =>
101
+ parseGeneration(item, index, budget),
102
+ ),
103
+ tools: boundedArray(run.tools, "run.tools", budget).map(parseTool),
104
+ skills: boundedArray(run.skills, "run.skills", budget).map(parseSkill),
105
+ providerErrors: boundedArray(run.providerErrors, "run.providerErrors", budget).map(
106
+ parseProviderError,
107
+ ),
108
+ toolErrorCount: boundedCount(run.toolErrorCount, "run.toolErrorCount"),
109
+ providerErrorCount: boundedCount(run.providerErrorCount, "run.providerErrorCount"),
110
+ recoveredErrorCount: boundedCount(run.recoveredErrorCount, "run.recoveredErrorCount"),
111
+ };
112
+ }
113
+
114
+ function parseGeneration(value: unknown, index: number, budget: ParseBudget): GenerationRecord {
115
+ const item = asRecord(value, `run.generations[${index}]`);
116
+ const prefix = `run.generations[${index}]`;
117
+ return {
118
+ id: requiredString(item.id, `${prefix}.id`),
119
+ ordinal: boundedCount(item.ordinal, `${prefix}.ordinal`),
120
+ ...optionalProperty("provider", optionalString(item.provider, `${prefix}.provider`)),
121
+ ...optionalProperty("model", optionalString(item.model, `${prefix}.model`)),
122
+ ...optionalProperty(
123
+ "thinkingLevel",
124
+ optionalString(item.thinkingLevel, `${prefix}.thinkingLevel`),
125
+ ),
126
+ startedAtMs: timestampValue(item.startedAtMs, `${prefix}.startedAtMs`),
127
+ ...optionalProperty(
128
+ "finishedAtMs",
129
+ optionalTimestamp(item.finishedAtMs, `${prefix}.finishedAtMs`),
130
+ ),
131
+ ...optionalProperty("durationMs", optionalDuration(item.durationMs, `${prefix}.durationMs`)),
132
+ ...optionalProperty("stopReason", optionalString(item.stopReason, `${prefix}.stopReason`)),
133
+ outcome: enumValue(item.outcome, GENERATION_OUTCOMES, `${prefix}.outcome`) as GenerationOutcome,
134
+ responses: boundedArray(item.responses, `${prefix}.responses`, budget).map(
135
+ parseProviderResponse,
136
+ ),
137
+ };
138
+ }
139
+
140
+ function parseProviderResponse(value: unknown, index: number): ProviderResponseRecord {
141
+ const item = asRecord(value, `provider response ${index}`);
142
+ return {
143
+ ordinal: boundedCount(item.ordinal, "providerResponse.ordinal"),
144
+ occurredAtMs: timestampValue(item.occurredAtMs, "providerResponse.occurredAtMs"),
145
+ status: boundedInteger(item.status, "providerResponse.status", 999),
146
+ };
147
+ }
148
+
149
+ function parseTool(value: unknown, index: number): ToolCallRecord {
150
+ const item = asRecord(value, `run.tools[${index}]`);
151
+ const prefix = `run.tools[${index}]`;
152
+ const ordinal = boundedCount(item.ordinal, `${prefix}.ordinal`);
153
+ return {
154
+ id: `tool-${ordinal}`,
155
+ ordinal,
156
+ name: requiredString(item.name, `${prefix}.name`),
157
+ ...optionalProperty("provider", optionalString(item.provider, `${prefix}.provider`)),
158
+ ...optionalProperty("model", optionalString(item.model, `${prefix}.model`)),
159
+ startedAtMs: timestampValue(item.startedAtMs, `${prefix}.startedAtMs`),
160
+ ...optionalProperty(
161
+ "finishedAtMs",
162
+ optionalTimestamp(item.finishedAtMs, `${prefix}.finishedAtMs`),
163
+ ),
164
+ ...optionalProperty("durationMs", optionalDuration(item.durationMs, `${prefix}.durationMs`)),
165
+ isError: booleanValue(item.isError, `${prefix}.isError`),
166
+ completionState: enumValue(
167
+ item.completionState,
168
+ ["running", "finished", "interrupted"] as const,
169
+ `${prefix}.completionState`,
170
+ ),
171
+ };
172
+ }
173
+
174
+ function parseSkill(value: unknown, index: number): SkillActivationRecord {
175
+ const item = asRecord(value, `run.skills[${index}]`);
176
+ const prefix = `run.skills[${index}]`;
177
+ return {
178
+ id: requiredString(item.id, `${prefix}.id`),
179
+ name: requiredString(item.name, `${prefix}.name`),
180
+ initiatedBy: enumValue(item.initiatedBy, ["user", "model"] as const, `${prefix}.initiatedBy`),
181
+ occurredAtMs: timestampValue(item.occurredAtMs, `${prefix}.occurredAtMs`),
182
+ ...optionalProperty("provider", optionalString(item.provider, `${prefix}.provider`)),
183
+ ...optionalProperty("model", optionalString(item.model, `${prefix}.model`)),
184
+ };
185
+ }
186
+
187
+ function parseProviderError(value: unknown, index: number): ProviderErrorRecord {
188
+ const item = asRecord(value, `run.providerErrors[${index}]`);
189
+ const prefix = `run.providerErrors[${index}]`;
190
+ return {
191
+ id: requiredString(item.id, `${prefix}.id`),
192
+ ...optionalProperty(
193
+ "generationId",
194
+ optionalString(item.generationId, `${prefix}.generationId`),
195
+ ),
196
+ occurredAtMs: timestampValue(item.occurredAtMs, `${prefix}.occurredAtMs`),
197
+ ...optionalProperty("provider", optionalString(item.provider, `${prefix}.provider`)),
198
+ ...optionalProperty("model", optionalString(item.model, `${prefix}.model`)),
199
+ category: enumValue(
200
+ item.category,
201
+ ERROR_CATEGORIES,
202
+ `${prefix}.category`,
203
+ ) as ProviderErrorCategory,
204
+ recovered: booleanValue(item.recovered, `${prefix}.recovered`),
205
+ terminal: booleanValue(item.terminal, `${prefix}.terminal`),
206
+ };
207
+ }
208
+
209
+ function asRecord(value: unknown, name: string): Record<string, unknown> {
210
+ if (typeof value !== "object" || value === null || Array.isArray(value)) invalid(name);
211
+ return value as Record<string, unknown>;
212
+ }
213
+
214
+ function boundedArray(value: unknown, name: string, budget: ParseBudget): unknown[] {
215
+ if (!Array.isArray(value)) invalid(name);
216
+ budget.remaining -= value.length;
217
+ if (budget.remaining < 0) {
218
+ throw new AnalyticsStorageFormatError("Analytics record is too large to process safely.");
219
+ }
220
+ return value;
221
+ }
222
+
223
+ function requiredString(value: unknown, name: string): string {
224
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_STRING_LENGTH) {
225
+ invalid(name);
226
+ }
227
+ return value;
228
+ }
229
+
230
+ function optionalString(value: unknown, name: string): string | undefined {
231
+ return value === undefined ? undefined : requiredString(value, name);
232
+ }
233
+
234
+ function boundedInteger(value: unknown, name: string, maximum: number): number {
235
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) {
236
+ invalid(name);
237
+ }
238
+ return value;
239
+ }
240
+
241
+ function timestampValue(value: unknown, name: string): number {
242
+ return boundedInteger(value, name, Number.MAX_SAFE_INTEGER);
243
+ }
244
+
245
+ function optionalTimestamp(value: unknown, name: string): number | undefined {
246
+ return value === undefined ? undefined : timestampValue(value, name);
247
+ }
248
+
249
+ function durationValue(value: unknown, name: string): number {
250
+ return boundedInteger(value, name, Math.floor(Number.MAX_SAFE_INTEGER / MAX_NESTED_RECORDS));
251
+ }
252
+
253
+ function optionalDuration(value: unknown, name: string): number | undefined {
254
+ return value === undefined ? undefined : durationValue(value, name);
255
+ }
256
+
257
+ function boundedCount(value: unknown, name: string): number {
258
+ return boundedInteger(value, name, MAX_NESTED_RECORDS);
259
+ }
260
+
261
+ function booleanValue(value: unknown, name: string): boolean {
262
+ if (typeof value !== "boolean") invalid(name);
263
+ return value;
264
+ }
265
+
266
+ function enumValue<const T extends readonly string[]>(
267
+ value: unknown,
268
+ values: T,
269
+ name: string,
270
+ ): T[number] {
271
+ if (typeof value !== "string" || !values.includes(value)) invalid(name);
272
+ return value as T[number];
273
+ }
274
+
275
+ function optionalProperty<K extends string, T>(key: K, value: T | undefined): { [P in K]?: T } {
276
+ return value === undefined ? {} : ({ [key]: value } as { [P in K]?: T });
277
+ }
278
+
279
+ function invalid(name: string): never {
280
+ throw new AnalyticsStorageFormatError(`Analytics record has an invalid ${name}.`);
281
+ }
@@ -1,5 +1,4 @@
1
- import type { Database } from "@tursodatabase/database";
2
- import type { ProviderErrorCategory } from "../types.js";
1
+ import type { ProviderErrorCategory, SettledRun } from "../types.js";
3
2
 
4
3
  export type TimeRangeId = "today" | "7d" | "30d" | "all";
5
4
  export interface TimeRange {
@@ -92,168 +91,120 @@ export function resolveTimeRange(id: TimeRangeId, now = Date.now()): TimeRange {
92
91
  }
93
92
 
94
93
  export async function querySnapshot(
95
- database: Database,
94
+ runs: AsyncIterable<SettledRun> | Iterable<SettledRun>,
96
95
  range: TimeRange,
96
+ signal?: AbortSignal,
97
97
  ): Promise<AnalyticsSnapshot> {
98
- const [runs, skillRows, toolRows, categoryRows, statusRows] = await Promise.all([
99
- database.all(
100
- `SELECT generation_count, tool_call_count, tool_error_count,
101
- skill_activation_count, provider_error_count, recovered_error_count
102
- FROM response_runs
103
- WHERE started_at_ms >= ? AND started_at_ms < ?
104
- ORDER BY generation_count`,
105
- range.fromMs,
106
- range.toMs,
107
- ),
108
- database.all(
109
- `SELECT s.skill_name, s.initiated_by, s.provider, s.model,
110
- COUNT(*) AS count, MAX(s.occurred_at_ms) AS last_at
111
- FROM skill_activations s
112
- JOIN response_runs r ON r.id = s.run_id
113
- WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
114
- GROUP BY s.skill_name, s.initiated_by, s.provider, s.model`,
115
- range.fromMs,
116
- range.toMs,
117
- ),
118
- database.all(
119
- `SELECT t.tool_name, t.provider, t.model, COUNT(*) AS count,
120
- SUM(t.is_error) AS errors, AVG(COALESCE(t.duration_ms, 0)) AS average_duration,
121
- MAX(t.started_at_ms) AS last_at
122
- FROM tool_calls t
123
- JOIN response_runs r ON r.id = t.run_id
124
- WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
125
- GROUP BY t.tool_name, t.provider, t.model`,
126
- range.fromMs,
127
- range.toMs,
128
- ),
129
- database.all(
130
- `SELECT e.category, COUNT(*) AS count, SUM(e.terminal) AS terminal
131
- FROM provider_errors e
132
- JOIN response_runs r ON r.id = e.run_id
133
- WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
134
- GROUP BY e.category`,
135
- range.fromMs,
136
- range.toMs,
137
- ),
138
- database.all(
139
- `SELECT p.status, COUNT(*) AS count
140
- FROM provider_responses p
141
- JOIN model_generations g ON g.id = p.generation_id
142
- JOIN response_runs r ON r.id = g.run_id
143
- WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
144
- GROUP BY p.status`,
145
- range.fromMs,
146
- range.toMs,
147
- ),
148
- ]);
149
-
150
- const generationCounts = runs.map((row) => numberValue(row.generation_count));
151
- const responseStats = responseStatistics(generationCounts);
152
- const overview: OverviewStats = {
153
- responseCycles: runs.length,
154
- llmCalls: sum(generationCounts),
155
- callsPerResponse: responseStats.average,
156
- p95CallsPerResponse: responseStats.p95,
157
- toolCalls: sum(runs.map((row) => numberValue(row.tool_call_count))),
158
- toolErrors: sum(runs.map((row) => numberValue(row.tool_error_count))),
159
- skillActivations: sum(runs.map((row) => numberValue(row.skill_activation_count))),
160
- providerErrors: sum(runs.map((row) => numberValue(row.provider_error_count))),
161
- recoveredErrors: sum(runs.map((row) => numberValue(row.recovered_error_count))),
162
- };
163
-
98
+ const generationCounts: number[] = [];
99
+ const seenRunIds = new Set<string>();
100
+ const skills = new Map<string, SkillStats>();
101
+ const tools = new Map<string, ToolStats & { totalDurationMs: number }>();
164
102
  const categories = Object.fromEntries(
165
103
  ERROR_CATEGORIES.map((category) => [category, 0]),
166
104
  ) as Record<ProviderErrorCategory, number>;
167
- let terminal = 0;
168
- for (const row of categoryRows) {
169
- const category = String(row.category) as ProviderErrorCategory;
170
- if (category in categories) categories[category] = numberValue(row.count);
171
- terminal += numberValue(row.terminal);
172
- }
105
+ let toolErrors = 0;
106
+ let providerErrors = 0;
107
+ let recoveredErrors = 0;
173
108
  let http429 = 0;
174
109
  let http5xx = 0;
175
- for (const row of statusRows) {
176
- const status = numberValue(row.status);
177
- if (status === 429) http429 += numberValue(row.count);
178
- if (status >= 500 && status < 600) http5xx += numberValue(row.count);
110
+ let terminal = 0;
111
+
112
+ for await (const run of runs) {
113
+ throwIfAborted(signal);
114
+ if (seenRunIds.has(run.id)) continue;
115
+ seenRunIds.add(run.id);
116
+ if (run.startedAtMs < range.fromMs || run.startedAtMs >= range.toMs) continue;
117
+ generationCounts.push(run.generations.length);
118
+ toolErrors += run.toolErrorCount;
119
+ providerErrors += run.providerErrorCount;
120
+ recoveredErrors += run.recoveredErrorCount;
121
+
122
+ for (const skill of run.skills) {
123
+ const item = skills.get(skill.name) ?? {
124
+ name: skill.name,
125
+ count: 0,
126
+ modelInitiated: 0,
127
+ userInitiated: 0,
128
+ lastOccurredAtMs: 0,
129
+ models: [],
130
+ };
131
+ item.count += 1;
132
+ if (skill.initiatedBy === "user") item.userInitiated += 1;
133
+ else item.modelInitiated += 1;
134
+ item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, skill.occurredAtMs);
135
+ mergeModelCount(item.models, {
136
+ provider: skill.provider,
137
+ model: skill.model,
138
+ count: 1,
139
+ });
140
+ skills.set(skill.name, item);
141
+ }
142
+
143
+ for (const tool of run.tools) {
144
+ const item = tools.get(tool.name) ?? {
145
+ name: tool.name,
146
+ count: 0,
147
+ errors: 0,
148
+ averageDurationMs: 0,
149
+ totalDurationMs: 0,
150
+ lastOccurredAtMs: 0,
151
+ models: [],
152
+ };
153
+ item.count += 1;
154
+ item.errors += tool.isError ? 1 : 0;
155
+ item.totalDurationMs += tool.durationMs ?? 0;
156
+ item.averageDurationMs = item.totalDurationMs / item.count;
157
+ item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, tool.startedAtMs);
158
+ mergeModelCount(item.models, {
159
+ provider: tool.provider,
160
+ model: tool.model,
161
+ count: 1,
162
+ });
163
+ tools.set(tool.name, item);
164
+ }
165
+
166
+ for (const error of run.providerErrors) {
167
+ categories[error.category] += 1;
168
+ terminal += error.terminal ? 1 : 0;
169
+ }
170
+ for (const generation of run.generations) {
171
+ for (const response of generation.responses) {
172
+ if (response.status === 429) http429 += 1;
173
+ if (response.status >= 500 && response.status < 600) http5xx += 1;
174
+ }
175
+ }
179
176
  }
180
177
 
178
+ const responses = responseStatistics(generationCounts);
181
179
  return {
182
- overview,
183
- skills: foldSkills(skillRows),
184
- tools: foldTools(toolRows),
180
+ overview: {
181
+ responseCycles: responses.count,
182
+ llmCalls: responses.llmCalls,
183
+ callsPerResponse: responses.average,
184
+ p95CallsPerResponse: responses.p95,
185
+ toolCalls: sum([...tools.values()].map(({ count }) => count)),
186
+ toolErrors,
187
+ skillActivations: sum([...skills.values()].map(({ count }) => count)),
188
+ providerErrors,
189
+ recoveredErrors,
190
+ },
191
+ skills: [...skills.values()]
192
+ .map((item) => ({ ...item, models: sortModels(item.models) }))
193
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
194
+ tools: [...tools.values()]
195
+ .map(({ totalDurationMs: _, ...item }) => ({ ...item, models: sortModels(item.models) }))
196
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
185
197
  reliability: {
186
198
  http429,
187
199
  http5xx,
188
- recovered: overview.recoveredErrors,
200
+ recovered: recoveredErrors,
189
201
  terminal,
190
202
  categories,
191
203
  },
192
- responses: responseStats,
204
+ responses,
193
205
  };
194
206
  }
195
207
 
196
- function foldSkills(rows: Array<Record<string, unknown>>): SkillStats[] {
197
- const result = new Map<string, SkillStats>();
198
- for (const row of rows) {
199
- const name = String(row.skill_name);
200
- const count = numberValue(row.count);
201
- const item = result.get(name) ?? {
202
- name,
203
- count: 0,
204
- modelInitiated: 0,
205
- userInitiated: 0,
206
- lastOccurredAtMs: 0,
207
- models: [],
208
- };
209
- item.count += count;
210
- if (row.initiated_by === "user") item.userInitiated += count;
211
- else item.modelInitiated += count;
212
- item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
213
- mergeModelCount(item.models, {
214
- provider: optionalString(row.provider),
215
- model: optionalString(row.model),
216
- count,
217
- });
218
- result.set(name, item);
219
- }
220
- return [...result.values()]
221
- .map((item) => ({ ...item, models: sortModels(item.models) }))
222
- .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
223
- }
224
-
225
- function foldTools(rows: Array<Record<string, unknown>>): ToolStats[] {
226
- const result = new Map<string, ToolStats & { weightedDuration: number }>();
227
- for (const row of rows) {
228
- const name = String(row.tool_name);
229
- const count = numberValue(row.count);
230
- const average = numberValue(row.average_duration);
231
- const item = result.get(name) ?? {
232
- name,
233
- count: 0,
234
- errors: 0,
235
- averageDurationMs: 0,
236
- weightedDuration: 0,
237
- lastOccurredAtMs: 0,
238
- models: [],
239
- };
240
- item.count += count;
241
- item.errors += numberValue(row.errors);
242
- item.weightedDuration += average * count;
243
- item.averageDurationMs = item.count > 0 ? item.weightedDuration / item.count : 0;
244
- item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
245
- mergeModelCount(item.models, {
246
- provider: optionalString(row.provider),
247
- model: optionalString(row.model),
248
- count,
249
- });
250
- result.set(name, item);
251
- }
252
- return [...result.values()]
253
- .map(({ weightedDuration: _, ...item }) => ({ ...item, models: sortModels(item.models) }))
254
- .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
255
- }
256
-
257
208
  function responseStatistics(generationCounts: number[]): ResponseStats {
258
209
  const sorted = [...generationCounts].sort((left, right) => left - right);
259
210
  const count = sorted.length;
@@ -300,12 +251,9 @@ function sortModels(models: ModelCount[]): ModelCount[] {
300
251
  );
301
252
  }
302
253
 
303
- function optionalString(value: unknown): string | undefined {
304
- return typeof value === "string" ? value : undefined;
305
- }
306
-
307
- function numberValue(value: unknown): number {
308
- return typeof value === "number" && Number.isFinite(value) ? value : Number(value) || 0;
254
+ function throwIfAborted(signal?: AbortSignal): void {
255
+ if (signal?.aborted)
256
+ throw signal.reason ?? new DOMException("Analytics query aborted", "AbortError");
309
257
  }
310
258
 
311
259
  function sum(values: readonly number[]): number {