@narumitw/pi-analytics 0.46.0 → 0.49.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.
package/src/menu.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { stripVTControlCharacters } from "node:util";
1
2
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
3
+ import type { MenuDefinition } from "@narumitw/pi-tui-kit";
4
+ import type { ClearAnalyticsResult } from "./storage/files.js";
3
5
  import type {
4
6
  AnalyticsSnapshot,
5
7
  SkillStats,
@@ -16,7 +18,7 @@ export type AnalyticsLoadResult =
16
18
  export interface AnalyticsMenuDataSource {
17
19
  path: string;
18
20
  load(range: TimeRange, signal: AbortSignal): Promise<AnalyticsLoadResult>;
19
- clearAll(signal: AbortSignal): Promise<number>;
21
+ clearAll(signal: AbortSignal): Promise<ClearAnalyticsResult>;
20
22
  }
21
23
 
22
24
  export interface AnalyticsMenuState {
@@ -48,7 +50,7 @@ export function createAnalyticsMenu(source: AnalyticsMenuDataSource, now: () =>
48
50
  };
49
51
  const getState = ({ signal }: { signal: AbortSignal }): Promise<AnalyticsMenuState> =>
50
52
  loadState(signal);
51
- const menu = defineMenu<AnalyticsMenuState, Screen, Action>({
53
+ const menu: MenuDefinition<AnalyticsMenuState, Screen, Action> = {
52
54
  start: "main",
53
55
  screens: {
54
56
  main: ({ state }) => ({
@@ -120,21 +122,27 @@ export function createAnalyticsMenu(source: AnalyticsMenuDataSource, now: () =>
120
122
  const count = state.result.snapshot.overview.responseCycles;
121
123
  const confirmed = await ctx.ui.confirm(
122
124
  "Delete analytics data?",
123
- `This will delete ${count} response cycles and their tool, skill, and reliability records from:\n\n${state.path}\n\nOther running Pi processes may add new records afterward.`,
125
+ `This will clear all local analytics history from:\n\n${safeDisplayText(state.path)}\n\nThe selected range currently shows ${count} response cycles. Other running Pi processes may add new records afterward.`,
124
126
  { signal },
125
127
  );
126
128
  if (!confirmed || signal.aborted) return { kind: "stay" };
127
- const deleted = await source.clearAll(signal);
129
+ const result = await source.clearAll(signal);
128
130
  cachedState = undefined;
129
131
  try {
130
- ctx.ui.notify(`Deleted ${deleted} response cycles from local analytics.`, "info");
132
+ ctx.ui.notify("Cleared local analytics data.", "info");
133
+ if (result.cleanupIncomplete) {
134
+ ctx.ui.notify(
135
+ "Some obsolete analytics files are still in use. Stop other Pi processes and clear again to remove them.",
136
+ "warning",
137
+ );
138
+ }
131
139
  } catch {
132
- // Session replacement can invalidate the UI after the database commit.
140
+ // Session replacement can invalidate the UI after the generation switch.
133
141
  }
134
142
  return signal.aborted ? { kind: "close" } : { kind: "to", screen: "main" };
135
143
  },
136
144
  },
137
- });
145
+ };
138
146
  return {
139
147
  menu,
140
148
  getState,
@@ -150,6 +158,8 @@ export async function showAnalyticsMenu(
150
158
  source: AnalyticsMenuDataSource,
151
159
  options: { signal: AbortSignal; isCurrent: () => boolean },
152
160
  ): Promise<void> {
161
+ const { runMenu, runTask } = await import("@narumitw/pi-tui-kit");
162
+ if (options.signal.aborted || !options.isCurrent()) return;
153
163
  const controller = createAnalyticsMenu(source);
154
164
  const loading = await runTask(ctx, {
155
165
  label: "Loading local analytics…",
@@ -233,7 +243,7 @@ function skillsScreen(result: AnalyticsLoadResult) {
233
243
  function skillItem(skill: SkillStats) {
234
244
  return {
235
245
  id: skill.name,
236
- label: skill.name,
246
+ label: safeDisplayText(skill.name),
237
247
  statusText: `${skill.count} · ${skill.modelInitiated} model / ${skill.userInitiated} user`,
238
248
  searchText: skill.models.map(modelLabel).join(" "),
239
249
  details: [
@@ -275,7 +285,7 @@ function toolsScreen(result: AnalyticsLoadResult) {
275
285
  function toolItem(tool: ToolStats) {
276
286
  return {
277
287
  id: tool.name,
278
- label: tool.name,
288
+ label: safeDisplayText(tool.name),
279
289
  statusText: `${tool.count} · ${tool.errors} errors`,
280
290
  searchText: tool.models.map(modelLabel).join(" "),
281
291
  details: [
@@ -333,14 +343,14 @@ function responseLines(result: AnalyticsLoadResult): string[] {
333
343
 
334
344
  function privacyLines(state: AnalyticsMenuState): string[] {
335
345
  return [
336
- "Local database:",
337
- state.path,
346
+ "Local analytics files:",
347
+ safeDisplayText(state.path),
338
348
  "",
339
- "Stored: timestamps, model/provider IDs, thinking level, tool and skill names, durations, counts, HTTP statuses, and classified errors.",
349
+ "Stored: timestamps, extension-generated record IDs, model/provider IDs, thinking level, tool and skill names, durations, counts, HTTP statuses, and classified errors.",
340
350
  "Not stored: prompts, responses, thinking, tool arguments/results, raw errors, headers, cwd/file paths, session identity, or credentials.",
341
351
  "",
342
- "No Turso Cloud connection or other remote telemetry is used.",
343
- "Turso Database is pre-1.0; analytics are non-critical derived metadata.",
352
+ "No database server, cloud connection, or other remote telemetry is used.",
353
+ "Analytics are non-critical derived metadata; a failed local write may be dropped.",
344
354
  ];
345
355
  }
346
356
 
@@ -360,7 +370,14 @@ function formatTimestamp(value: number): string {
360
370
 
361
371
  function modelLabel(model: { provider?: string; model?: string }): string {
362
372
  if (!model.provider && !model.model) return "unknown";
363
- return `${model.provider ?? "unknown"}/${model.model ?? "unknown"}`;
373
+ return safeDisplayText(`${model.provider ?? "unknown"}/${model.model ?? "unknown"}`);
374
+ }
375
+
376
+ function safeDisplayText(value: unknown): string {
377
+ return Array.from(stripVTControlCharacters(String(value)), (character) => {
378
+ const codePoint = character.codePointAt(0) ?? 0;
379
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character;
380
+ }).join("");
364
381
  }
365
382
 
366
383
  function isRangeId(value: string): value is TimeRangeId {
package/src/skills.ts CHANGED
@@ -22,7 +22,10 @@ export class SkillTracker {
22
22
  private readonly skillByPath = new Map<string, string>();
23
23
  private readonly availableNames = new Set<string>();
24
24
 
25
- constructor(private readonly cwd: string) {}
25
+ constructor(
26
+ private readonly cwd: string,
27
+ private readonly canonicalize: (filePath: string) => Promise<string> = realpath,
28
+ ) {}
26
29
 
27
30
  observeInput(text: string, source: InputSource, now: number): void {
28
31
  if (source === "extension") return;
@@ -52,7 +55,7 @@ export class SkillTracker {
52
55
  if (seenNames.has(skill.name)) continue;
53
56
  seenNames.add(skill.name);
54
57
  this.availableNames.add(skill.name);
55
- const canonical = await canonicalPath(skill.filePath).catch(() =>
58
+ const canonical = await this.canonicalize(skill.filePath).catch(() =>
56
59
  path.resolve(this.cwd, skill.filePath),
57
60
  );
58
61
  this.skillByPath.set(canonical, skill.name);
@@ -69,15 +72,11 @@ export class SkillTracker {
69
72
  if (typeof rawPath !== "string" || rawPath.length === 0) return undefined;
70
73
  const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
71
74
  const absolute = path.resolve(this.cwd, normalized);
72
- const canonical = await canonicalPath(absolute).catch(() => absolute);
75
+ const canonical = await this.canonicalize(absolute).catch(() => absolute);
73
76
  return this.skillByPath.get(canonical);
74
77
  }
75
78
  }
76
79
 
77
- function canonicalPath(filePath: string): Promise<string> {
78
- return realpath(filePath);
79
- }
80
-
81
80
  function isRecord(value: unknown): value is Record<string, unknown> {
82
81
  return typeof value === "object" && value !== null && !Array.isArray(value);
83
82
  }
@@ -0,0 +1,434 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream, type Dirent } from "node:fs";
3
+ import {
4
+ chmod,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ readFile,
10
+ rename,
11
+ rm,
12
+ rmdir,
13
+ unlink,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import path from "node:path";
17
+ import type { SettledRun } from "../types.js";
18
+ import {
19
+ AnalyticsStorageFormatError,
20
+ decodeStoredRun,
21
+ encodeStoredRun,
22
+ MAX_STORED_RUN_BYTES,
23
+ } from "./format.js";
24
+
25
+ const GENERATION_PATTERN =
26
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
27
+ const DEFAULT_WRITE_TIMEOUT_MS = 500;
28
+ const YIELD_EVERY_RECORDS = 100;
29
+
30
+ export interface ClearAnalyticsResult {
31
+ cleanupIncomplete: boolean;
32
+ }
33
+
34
+ export class AnalyticsGenerationChangedError extends Error {
35
+ constructor() {
36
+ super("The active analytics generation changed during the read.");
37
+ this.name = "AnalyticsGenerationChangedError";
38
+ }
39
+ }
40
+
41
+ export class AnalyticsRunFiles {
42
+ private readonly createId: () => string;
43
+ private readonly writeTimeoutMs: number;
44
+ private readonly beforeAppend?: (generation: string, signal: AbortSignal) => Promise<void>;
45
+ private readonly beforeCleanupEntry?: (signal?: AbortSignal) => Promise<void>;
46
+ private readonly beforeReadFile?: (signal?: AbortSignal) => Promise<void>;
47
+ private writerId: string;
48
+ private mutationTail: Promise<void> = Promise.resolve();
49
+ private readonly lifecycle = new AbortController();
50
+ private closed = false;
51
+
52
+ constructor(
53
+ readonly path: string,
54
+ options: {
55
+ createId?: () => string;
56
+ writeTimeoutMs?: number;
57
+ beforeAppend?: (generation: string, signal: AbortSignal) => Promise<void>;
58
+ beforeCleanupEntry?: (signal?: AbortSignal) => Promise<void>;
59
+ beforeReadFile?: (signal?: AbortSignal) => Promise<void>;
60
+ } = {},
61
+ ) {
62
+ this.createId = options.createId ?? randomUUID;
63
+ this.writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
64
+ this.beforeAppend = options.beforeAppend;
65
+ this.beforeCleanupEntry = options.beforeCleanupEntry;
66
+ this.beforeReadFile = options.beforeReadFile;
67
+ this.writerId = validCreatedId(this.createId());
68
+ }
69
+
70
+ append(run: SettledRun, signal?: AbortSignal): Promise<void> {
71
+ if (this.closed) return Promise.reject(new Error("Analytics storage is closed."));
72
+ const frame = encodeStoredRun(run);
73
+ return this.enqueueMutation(() =>
74
+ withDeadline(signal, this.lifecycle.signal, this.writeTimeoutMs, (operationSignal) =>
75
+ this.appendFrame(frame, operationSignal),
76
+ ),
77
+ );
78
+ }
79
+
80
+ async *read(signal?: AbortSignal): AsyncIterable<SettledRun> {
81
+ throwIfAborted(signal);
82
+ const generation = await this.readOrCreateGeneration(signal);
83
+ const directory = this.generationPath(generation);
84
+ let entries: Dirent[];
85
+ try {
86
+ entries = await readdir(directory, { withFileTypes: true });
87
+ } catch (error) {
88
+ if (isNodeError(error) && error.code === "ENOENT") {
89
+ await this.assertGenerationUnchanged(generation, signal);
90
+ throw new AnalyticsGenerationChangedError();
91
+ }
92
+ throw error;
93
+ }
94
+ let count = 0;
95
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
96
+ throwIfAborted(signal);
97
+ if (!entry.name.endsWith(".jsonl")) continue;
98
+ const filePath = path.join(directory, entry.name);
99
+ try {
100
+ await this.beforeReadFile?.(signal);
101
+ throwIfAborted(signal);
102
+ await assertPrivateRegularFile(filePath);
103
+ for await (const run of readFrames(filePath, signal)) {
104
+ yield run;
105
+ count += 1;
106
+ if (count % YIELD_EVERY_RECORDS === 0) await yieldToEventLoop(signal);
107
+ }
108
+ } catch (error) {
109
+ if (isNodeError(error) && error.code === "ENOENT") {
110
+ await this.assertGenerationUnchanged(generation, signal);
111
+ throw new AnalyticsGenerationChangedError();
112
+ }
113
+ throw error;
114
+ }
115
+ }
116
+ await this.assertGenerationUnchanged(generation, signal);
117
+ }
118
+
119
+ clear(signal?: AbortSignal): Promise<ClearAnalyticsResult> {
120
+ if (this.closed) return Promise.reject(new Error("Analytics storage is closed."));
121
+ let result: ClearAnalyticsResult = { cleanupIncomplete: false };
122
+ return this.enqueueMutation(() =>
123
+ withLinkedSignals(signal, this.lifecycle.signal, async (operationSignal) => {
124
+ throwIfAborted(operationSignal);
125
+ await this.readOrCreateGeneration(operationSignal);
126
+ const next = validCreatedId(this.createId());
127
+ await this.publishGeneration(next);
128
+ this.writerId = validCreatedId(this.createId());
129
+ const current = await readGenerationMarker(path.join(this.path, "current"));
130
+ await ensurePrivateDirectory(this.generationPath(current));
131
+ if (!(await this.cleanupObsoleteGenerations(operationSignal))) {
132
+ result = { cleanupIncomplete: true };
133
+ }
134
+ }),
135
+ ).then(() => result);
136
+ }
137
+
138
+ async close(): Promise<void> {
139
+ if (this.closed) return;
140
+ this.closed = true;
141
+ this.lifecycle.abort(new DOMException("Analytics storage closed", "AbortError"));
142
+ await this.mutationTail.catch(() => undefined);
143
+ }
144
+
145
+ private enqueueMutation(operation: () => Promise<void>): Promise<void> {
146
+ const result = this.mutationTail.then(operation);
147
+ this.mutationTail = result.catch(() => undefined);
148
+ return result;
149
+ }
150
+
151
+ private async appendFrame(frame: string, signal: AbortSignal): Promise<void> {
152
+ throwIfAborted(signal);
153
+ let obsoleteDirectory: string | undefined;
154
+ for (let attempt = 0; attempt < 3; attempt += 1) {
155
+ const generation = await this.readOrCreateGeneration(signal);
156
+ const directory = this.generationPath(generation);
157
+ await ensurePrivateDirectory(directory);
158
+ const filePath = path.join(directory, `${this.writerId}.jsonl`);
159
+ await assertOptionalPrivateRegularFile(filePath);
160
+ throwIfAborted(signal);
161
+ await this.beforeAppend?.(generation, signal);
162
+ throwIfAborted(signal);
163
+ try {
164
+ await writeFile(filePath, frame, {
165
+ encoding: "utf8",
166
+ flag: "a",
167
+ mode: 0o600,
168
+ signal,
169
+ });
170
+ if (process.platform !== "win32") await chmod(filePath, 0o600);
171
+ const current = await readGenerationMarker(path.join(this.path, "current"), signal);
172
+ if (current === generation) {
173
+ if (obsoleteDirectory) {
174
+ await cleanupGeneration(obsoleteDirectory, signal).catch(() => undefined);
175
+ }
176
+ return;
177
+ }
178
+ obsoleteDirectory = directory;
179
+ } catch (error) {
180
+ this.writerId = validCreatedId(this.createId());
181
+ throwIfAborted(signal);
182
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
183
+ }
184
+ }
185
+ throw new AnalyticsGenerationChangedError();
186
+ }
187
+
188
+ private async readOrCreateGeneration(signal?: AbortSignal): Promise<string> {
189
+ throwIfAborted(signal);
190
+ await ensurePrivateDirectory(this.path);
191
+ await ensurePrivateDirectory(path.join(this.path, "generations"));
192
+ const markerPath = path.join(this.path, "current");
193
+ for (let attempt = 0; attempt < 3; attempt += 1) {
194
+ let generation: string;
195
+ try {
196
+ generation = await readGenerationMarker(markerPath, signal);
197
+ } catch (error) {
198
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
199
+ generation = validCreatedId(this.createId());
200
+ throwIfAborted(signal);
201
+ try {
202
+ await createPrivateFile(markerPath, `${generation}\n`);
203
+ } catch (createError) {
204
+ if (!isNodeError(createError) || createError.code !== "EEXIST") throw createError;
205
+ generation = await readGenerationMarker(markerPath, signal);
206
+ }
207
+ }
208
+ await ensurePrivateDirectory(this.generationPath(generation));
209
+ const current = await readGenerationMarker(markerPath, signal);
210
+ if (current === generation) return generation;
211
+ }
212
+ throw new AnalyticsGenerationChangedError();
213
+ }
214
+
215
+ private async publishGeneration(generation: string): Promise<void> {
216
+ const markerPath = path.join(this.path, "current");
217
+ const temporaryPath = path.join(this.path, `.current.${validCreatedId(this.createId())}.tmp`);
218
+ await createPrivateFile(temporaryPath, `${generation}\n`);
219
+ try {
220
+ await rename(temporaryPath, markerPath);
221
+ if (process.platform !== "win32") await chmod(markerPath, 0o600);
222
+ } catch (error) {
223
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ private async cleanupObsoleteGenerations(signal?: AbortSignal): Promise<boolean> {
229
+ let complete = true;
230
+ const root = path.join(this.path, "generations");
231
+ for (const entry of await readdir(root, { withFileTypes: true })) {
232
+ const active = await readGenerationMarker(path.join(this.path, "current"));
233
+ if (entry.name === active) continue;
234
+ if (!entry.isDirectory() || !GENERATION_PATTERN.test(entry.name)) {
235
+ complete = false;
236
+ continue;
237
+ }
238
+ try {
239
+ await cleanupGeneration(path.join(root, entry.name), signal, this.beforeCleanupEntry);
240
+ } catch {
241
+ complete = false;
242
+ if (signal?.aborted) break;
243
+ }
244
+ }
245
+ const active = await readGenerationMarker(path.join(this.path, "current"));
246
+ const remaining = await readdir(root, { withFileTypes: true });
247
+ return complete && remaining.every((entry) => entry.isDirectory() && entry.name === active);
248
+ }
249
+
250
+ private async assertGenerationUnchanged(generation: string, signal?: AbortSignal): Promise<void> {
251
+ const current = await readGenerationMarker(path.join(this.path, "current"), signal);
252
+ if (current !== generation) throw new AnalyticsGenerationChangedError();
253
+ }
254
+
255
+ private generationPath(generation: string): string {
256
+ return path.join(this.path, "generations", generation);
257
+ }
258
+ }
259
+
260
+ async function* readFrames(filePath: string, signal?: AbortSignal): AsyncIterable<SettledRun> {
261
+ let pending = "";
262
+ const stream = createReadStream(filePath, { encoding: "utf8", signal });
263
+ for await (const chunk of stream) {
264
+ throwIfAborted(signal);
265
+ pending += String(chunk);
266
+ while (true) {
267
+ const newline = pending.indexOf("\n");
268
+ if (newline < 0) break;
269
+ const line = pending.slice(0, newline);
270
+ pending = pending.slice(newline + 1);
271
+ if (!line) continue;
272
+ yield decodeStoredRun(line);
273
+ }
274
+ if (Buffer.byteLength(pending) > MAX_STORED_RUN_BYTES) {
275
+ throw new AnalyticsStorageFormatError("Analytics record is too large to read safely.");
276
+ }
277
+ }
278
+ // A writer always terminates complete frames with a newline. An unterminated tail is a
279
+ // crash residue and is ignored; this writer file will not be reused after its process exits.
280
+ }
281
+
282
+ async function readGenerationMarker(markerPath: string, signal?: AbortSignal): Promise<string> {
283
+ await assertPrivateRegularFile(markerPath);
284
+ throwIfAborted(signal);
285
+ const value = (await readFile(markerPath, { encoding: "utf8", signal })).trim();
286
+ if (!GENERATION_PATTERN.test(value)) {
287
+ throw new AnalyticsStorageFormatError("Analytics generation marker is invalid.");
288
+ }
289
+ return value;
290
+ }
291
+
292
+ async function ensurePrivateDirectory(directoryPath: string): Promise<void> {
293
+ try {
294
+ const metadata = await lstat(directoryPath);
295
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
296
+ throw new Error("Analytics storage paths must be regular directories, not links.");
297
+ }
298
+ } catch (error) {
299
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
300
+ await mkdir(directoryPath, { recursive: true, mode: 0o700 });
301
+ const metadata = await lstat(directoryPath);
302
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
303
+ throw new Error("Analytics storage paths must be regular directories, not links.");
304
+ }
305
+ }
306
+ if (process.platform !== "win32") await chmod(directoryPath, 0o700);
307
+ }
308
+
309
+ async function assertOptionalPrivateRegularFile(filePath: string): Promise<void> {
310
+ try {
311
+ await assertPrivateRegularFile(filePath);
312
+ } catch (error) {
313
+ if (isNodeError(error) && error.code === "ENOENT") return;
314
+ throw error;
315
+ }
316
+ }
317
+
318
+ async function assertPrivateRegularFile(filePath: string): Promise<void> {
319
+ const metadata = await lstat(filePath);
320
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
321
+ throw new Error("Analytics storage files must be regular files, not links.");
322
+ }
323
+ if (process.platform !== "win32") await chmod(filePath, 0o600);
324
+ }
325
+
326
+ async function createPrivateFile(filePath: string, content: string): Promise<void> {
327
+ const handle = await open(filePath, "wx", 0o600);
328
+ try {
329
+ await handle.writeFile(content, "utf8");
330
+ await handle.sync();
331
+ } finally {
332
+ await handle.close();
333
+ }
334
+ }
335
+
336
+ async function cleanupGeneration(
337
+ directoryPath: string,
338
+ signal?: AbortSignal,
339
+ beforeEntry?: (signal?: AbortSignal) => Promise<void>,
340
+ ): Promise<void> {
341
+ throwIfAborted(signal);
342
+ let entries: Dirent[];
343
+ try {
344
+ entries = await readdir(directoryPath, { withFileTypes: true });
345
+ } catch (error) {
346
+ if (isNodeError(error) && error.code === "ENOENT") return;
347
+ throw error;
348
+ }
349
+ for (const entry of entries) {
350
+ throwIfAborted(signal);
351
+ await beforeEntry?.(signal);
352
+ throwIfAborted(signal);
353
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
354
+ throw new Error("Analytics generation contains an unexpected storage entry.");
355
+ }
356
+ await unlink(path.join(directoryPath, entry.name));
357
+ }
358
+ throwIfAborted(signal);
359
+ await rmdir(directoryPath);
360
+ }
361
+
362
+ async function withLinkedSignals<T>(
363
+ callerSignal: AbortSignal | undefined,
364
+ lifecycleSignal: AbortSignal,
365
+ operation: (signal: AbortSignal) => Promise<T>,
366
+ ): Promise<T> {
367
+ throwIfAborted(callerSignal);
368
+ throwIfAborted(lifecycleSignal);
369
+ const controller = new AbortController();
370
+ const abort = (signal: AbortSignal) =>
371
+ controller.abort(
372
+ signal.reason ?? new DOMException("Analytics operation aborted", "AbortError"),
373
+ );
374
+ const callerAbort = () => callerSignal && abort(callerSignal);
375
+ const lifecycleAbort = () => abort(lifecycleSignal);
376
+ callerSignal?.addEventListener("abort", callerAbort, { once: true });
377
+ lifecycleSignal.addEventListener("abort", lifecycleAbort, { once: true });
378
+ try {
379
+ return await operation(controller.signal);
380
+ } finally {
381
+ callerSignal?.removeEventListener("abort", callerAbort);
382
+ lifecycleSignal.removeEventListener("abort", lifecycleAbort);
383
+ }
384
+ }
385
+
386
+ async function withDeadline<T>(
387
+ callerSignal: AbortSignal | undefined,
388
+ lifecycleSignal: AbortSignal,
389
+ timeoutMs: number,
390
+ operation: (signal: AbortSignal) => Promise<T>,
391
+ ): Promise<T> {
392
+ throwIfAborted(callerSignal);
393
+ throwIfAborted(lifecycleSignal);
394
+ const controller = new AbortController();
395
+ const abort = (signal: AbortSignal) =>
396
+ controller.abort(
397
+ signal.reason ?? new DOMException("Analytics operation aborted", "AbortError"),
398
+ );
399
+ const callerAbort = () => callerSignal && abort(callerSignal);
400
+ const lifecycleAbort = () => abort(lifecycleSignal);
401
+ callerSignal?.addEventListener("abort", callerAbort, { once: true });
402
+ lifecycleSignal.addEventListener("abort", lifecycleAbort, { once: true });
403
+ const timer = setTimeout(
404
+ () => controller.abort(new DOMException("Analytics write timed out", "TimeoutError")),
405
+ Math.max(1, timeoutMs),
406
+ );
407
+ try {
408
+ return await operation(controller.signal);
409
+ } finally {
410
+ clearTimeout(timer);
411
+ callerSignal?.removeEventListener("abort", callerAbort);
412
+ lifecycleSignal.removeEventListener("abort", lifecycleAbort);
413
+ }
414
+ }
415
+
416
+ async function yieldToEventLoop(signal?: AbortSignal): Promise<void> {
417
+ await new Promise<void>((resolve) => setImmediate(resolve));
418
+ throwIfAborted(signal);
419
+ }
420
+
421
+ function validCreatedId(value: string): string {
422
+ if (!GENERATION_PATTERN.test(value)) throw new Error("Analytics storage received an invalid ID.");
423
+ return value;
424
+ }
425
+
426
+ function throwIfAborted(signal?: AbortSignal): void {
427
+ if (signal?.aborted) {
428
+ throw signal.reason ?? new DOMException("Analytics operation aborted", "AbortError");
429
+ }
430
+ }
431
+
432
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
433
+ return error instanceof Error && "code" in error;
434
+ }