@vaultcompass/vault-guard-telemetry 1.4.1 → 1.4.2

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/README.md CHANGED
@@ -8,7 +8,7 @@ Opt-in, **local-only** store for [Vault Guard](https://github.com/vaultcompasshq
8
8
  npm install @vaultcompass/vault-guard-telemetry
9
9
  ```
10
10
 
11
- Requires **Node.js 22+** and native `better-sqlite3` bindings (rebuilt automatically on `npm install`).
11
+ Requires **Node.js 22+**. Native `better-sqlite3` bindings are rebuilt automatically on `npm install` where a prebuilt binary or a working compiler toolchain is available, but they are an **optional dependency**: if the install cannot produce them (for example a Windows machine without the Visual Studio build tools, or an `--ignore-scripts` install), the package still installs and still works. See "Graceful degradation" below.
12
12
 
13
13
  ## Quickstart
14
14
 
@@ -38,26 +38,22 @@ const status = store.getStatuslinePayload();
38
38
 
39
39
  ## Graceful degradation
40
40
 
41
- If `better-sqlite3` bindings are missing (e.g. install with `--ignore-scripts`), catch `TelemetryUnavailableError` and skip telemetry features:
41
+ `new TelemetryStore()` never throws, even when `better-sqlite3` native bindings are missing or incompatible. In that case the store quietly becomes a no-op: every `record*` call does nothing, and every `get*`/`export*`/`suggestModel` call returns an empty or zeroed result of the normal shape (an empty array, a statusline payload with every count at zero, a suggestion with `suggested_model: null`) instead of raising an error. There is no exception to catch and no per-call special case to write:
42
42
 
43
43
  ```typescript
44
- import {
45
- TelemetryStore,
46
- TelemetryUnavailableError,
47
- } from '@vaultcompass/vault-guard-telemetry';
48
-
49
- try {
50
- const store = new TelemetryStore();
51
- console.log(store.getStatuslinePayload());
52
- } catch (err) {
53
- if (err instanceof TelemetryUnavailableError) {
54
- // Telemetry optional; continue without it
55
- } else {
56
- throw err;
57
- }
58
- }
44
+ import { TelemetryStore } from '@vaultcompass/vault-guard-telemetry';
45
+
46
+ const store = new TelemetryStore();
47
+ store.recordUsage({ model: 'claude-sonnet-4-20250514', inputTokens: 1200, outputTokens: 340 });
48
+ console.log(store.getStatuslinePayload());
49
+ // Works identically whether or not better-sqlite3 loaded; with it missing,
50
+ // recordUsage recorded nothing and getStatuslinePayload reports all zeros.
59
51
  ```
60
52
 
53
+ Call `store.isAvailable()` when you specifically need to distinguish "telemetry is working" from "telemetry degraded to a no-op" (the CLI's `data status` and `data export` commands do this, since their whole purpose is inspecting telemetry and an all-zero result would otherwise look identical to "no usage yet"). `store.getUnavailableReason()` returns the underlying reason as a string, or `null` when available. `TelemetryUnavailableError` stays exported for callers that inject their own loader (tests, or a factory such as the MCP server's `telemetryFactory`) and want to signal the same failure mode themselves; the store no longer throws it internally.
54
+
55
+ A missing native binding is noted at most once per process, and only when `VG_DEBUG=1` is set in the environment. Telemetry is opt-in local tooling, so it must never print a warning on every command (this matters most for `statusline`, which an editor can invoke every few seconds).
56
+
61
57
  ## CLI usage (recommended for end users)
62
58
 
63
59
  Most users interact with telemetry through the main CLI, not this package directly:
package/dist/store.d.ts CHANGED
@@ -1,30 +1,16 @@
1
1
  /**
2
- * Thrown when `better-sqlite3` native bindings are missing or incompatible.
3
- *
4
- * This happens when:
2
+ * Thrown internally when `better-sqlite3` native bindings are missing or
3
+ * incompatible. This happens when:
5
4
  * - The package was installed with `--ignore-scripts` (skips node-gyp compile)
6
5
  * - The Node.js ABI changed after install (e.g. nvm version switch)
7
- * - The pre-built binary is missing for the current platform/arch
8
- *
9
- * Callers that don't strictly need telemetry should catch this and degrade
10
- * gracefully (e.g. `statusline` and `suggest-model`). The `proxy` command
11
- * intentionally lets this propagate — it is the primary telemetry writer and
12
- * should fail loudly rather than silently discard usage data.
6
+ * - The pre-built binary is missing for the current platform/arch (e.g. a
7
+ * Windows install where node-gyp could not find a Visual Studio toolchain)
13
8
  *
14
- * @example
15
- * ```ts
16
- * try {
17
- * const store = new TelemetryStore();
18
- * const payload = store.getStatuslinePayload();
19
- * console.log(payload);
20
- * } catch (err) {
21
- * if (err instanceof TelemetryUnavailableError) {
22
- * console.error('Telemetry unavailable:', err.message);
23
- * } else {
24
- * throw err;
25
- * }
26
- * }
27
- * ```
9
+ * {@link TelemetryStore} catches this internally and degrades to a no-op
10
+ * store rather than letting it escape the constructor. See "Graceful
11
+ * degradation" below. It stays exported for callers that want to distinguish
12
+ * this failure mode from a genuine bug when they inject their own loader
13
+ * (tests, or a factory such as the MCP server's `telemetryFactory`).
28
14
  */
29
15
  export declare class TelemetryUnavailableError extends Error {
30
16
  constructor(cause: unknown);
@@ -169,8 +155,35 @@ export declare class TelemetryStore {
169
155
  private readonly db;
170
156
  private readonly counter;
171
157
  private readonly saltBuf;
158
+ private readonly available;
159
+ private readonly unavailableMessage;
172
160
  private lastRetentionPurgeMs;
161
+ /**
162
+ * @param dbPath Defaults to `~/.vault-guard/usage.sqlite`.
163
+ *
164
+ * Never throws, even when `better-sqlite3` native bindings are missing or
165
+ * incompatible: in that case the store degrades to a no-op (see
166
+ * {@link isAvailable}) instead of raising {@link TelemetryUnavailableError}.
167
+ */
173
168
  constructor(dbPath?: string);
169
+ /** True when `better-sqlite3` loaded and this store is backed by a real DB. */
170
+ isAvailable(): boolean;
171
+ /**
172
+ * Human-readable reason telemetry is unavailable, or `null` when
173
+ * {@link isAvailable} is true. Lets callers that are specifically about
174
+ * inspecting telemetry (`vault-guard data status`, `data export`) report
175
+ * "unavailable" explicitly rather than silently show all-zero results.
176
+ */
177
+ getUnavailableReason(): string | null;
178
+ /**
179
+ * Non-null accessor for the handful of private methods that only ever run
180
+ * from inside `if (this.db)` (constructor init) or after an
181
+ * `isAvailable`/`this.db` guard in a public method. Never reachable while
182
+ * degraded; exists so those method bodies don't each repeat the guard.
183
+ */
184
+ private requireDb;
185
+ /** Same invariant as {@link requireDb}: only ever reached while available, where saltBuf is always set alongside db. */
186
+ private requireSalt;
174
187
  /**
175
188
  * One-time migration (pragma `user_version` < 2): replace plaintext `cwd`
176
189
  * values with HMAC-SHA256 hex digests using the current salt file.
@@ -179,9 +192,11 @@ export declare class TelemetryStore {
179
192
  /**
180
193
  * Delete rows older than {@link getTelemetryRetentionDays}. Throttled to at
181
194
  * most once per hour per process to avoid hammering SQLite on hot paths.
195
+ * No-op when the store is unavailable.
182
196
  */
183
197
  private maybePurgeStaleRows;
184
198
  private initSchema;
199
+ /** No-op when the store is unavailable: there is no handle to close. */
185
200
  close(): void;
186
201
  /**
187
202
  * Force a WAL checkpoint (TRUNCATE) and close the database.
@@ -194,17 +209,22 @@ export declare class TelemetryStore {
194
209
  * with no recovery surprises.
195
210
  *
196
211
  * Best-effort: if the pragma fails (e.g. handle already closed by another
197
- * shutdown path) we still attempt to close the underlying handle.
212
+ * shutdown path) we still attempt to close the underlying handle. No-op
213
+ * when the store is unavailable.
198
214
  */
199
215
  closeAndCheckpoint(): void;
216
+ /** No-op (records nothing) when the store is unavailable. */
200
217
  recordUsage(input: UsageRecordInput): void;
218
+ /** No-op (records nothing) when the store is unavailable. */
201
219
  recordSession(input: SessionRecordInput): void;
202
- /** Count session events that represent blocked secrets today (UTC date). */
220
+ /** Count session events that represent blocked secrets today (UTC date). Returns 0 when unavailable. */
203
221
  secretsBlockedToday(day?: string): number;
222
+ /** Returns a zeroed payload (never throws) when the store is unavailable. */
204
223
  getStatuslinePayload(now?: Date): StatuslineJson;
205
224
  /**
206
225
  * Heuristic model hint from the last 7 days of session + usage data.
207
226
  * Prefer models with more usage and lower revert_rate when session metrics exist.
227
+ * Returns an empty suggestion (never throws) when the store is unavailable.
208
228
  */
209
229
  suggestModel(opts?: {
210
230
  cwd?: string;
@@ -214,14 +234,16 @@ export declare class TelemetryStore {
214
234
  * Read all rows from `usage_events` ordered by `id ASC`.
215
235
  *
216
236
  * Intended for `vault-guard data export`. Returns raw `cwd` strings — see
217
- * {@link DataStatusJson} for the privacy-respecting alternative.
237
+ * {@link DataStatusJson} for the privacy-respecting alternative. Returns an
238
+ * empty array (never throws) when the store is unavailable.
218
239
  */
219
240
  exportUsageEvents(): UsageEventRow[];
220
241
  /**
221
242
  * Read all rows from `session_events` ordered by `id ASC`.
222
243
  *
223
244
  * Intended for `vault-guard data export`. Returns raw `cwd` strings and
224
- * the `extra_json` payload as stored.
245
+ * the `extra_json` payload as stored. Returns an empty array (never
246
+ * throws) when the store is unavailable.
225
247
  */
226
248
  exportSessionEvents(): SessionEventRow[];
227
249
  /**
package/dist/store.js CHANGED
@@ -23,32 +23,18 @@ const _require = (0, module_1.createRequire)(__filename);
23
23
  // TelemetryUnavailableError
24
24
  // ---------------------------------------------------------------------------
25
25
  /**
26
- * Thrown when `better-sqlite3` native bindings are missing or incompatible.
27
- *
28
- * This happens when:
26
+ * Thrown internally when `better-sqlite3` native bindings are missing or
27
+ * incompatible. This happens when:
29
28
  * - The package was installed with `--ignore-scripts` (skips node-gyp compile)
30
29
  * - The Node.js ABI changed after install (e.g. nvm version switch)
31
- * - The pre-built binary is missing for the current platform/arch
32
- *
33
- * Callers that don't strictly need telemetry should catch this and degrade
34
- * gracefully (e.g. `statusline` and `suggest-model`). The `proxy` command
35
- * intentionally lets this propagate — it is the primary telemetry writer and
36
- * should fail loudly rather than silently discard usage data.
30
+ * - The pre-built binary is missing for the current platform/arch (e.g. a
31
+ * Windows install where node-gyp could not find a Visual Studio toolchain)
37
32
  *
38
- * @example
39
- * ```ts
40
- * try {
41
- * const store = new TelemetryStore();
42
- * const payload = store.getStatuslinePayload();
43
- * console.log(payload);
44
- * } catch (err) {
45
- * if (err instanceof TelemetryUnavailableError) {
46
- * console.error('Telemetry unavailable:', err.message);
47
- * } else {
48
- * throw err;
49
- * }
50
- * }
51
- * ```
33
+ * {@link TelemetryStore} catches this internally and degrades to a no-op
34
+ * store rather than letting it escape the constructor. See "Graceful
35
+ * degradation" below. It stays exported for callers that want to distinguish
36
+ * this failure mode from a genuine bug when they inject their own loader
37
+ * (tests, or a factory such as the MCP server's `telemetryFactory`).
52
38
  */
53
39
  class TelemetryUnavailableError extends Error {
54
40
  constructor(cause) {
@@ -60,6 +46,27 @@ class TelemetryUnavailableError extends Error {
60
46
  }
61
47
  }
62
48
  exports.TelemetryUnavailableError = TelemetryUnavailableError;
49
+ // ---------------------------------------------------------------------------
50
+ // Once-per-process "unavailable" notice
51
+ // ---------------------------------------------------------------------------
52
+ let hasNotedUnavailable = false;
53
+ /**
54
+ * Graceful degradation, in one place: when the native binding can't load,
55
+ * every {@link TelemetryStore} entry point (record*, get*, export*) becomes a
56
+ * safe no-op instead of throwing. This function logs that fact **at most
57
+ * once per process**, and only when `VG_DEBUG=1` is set. Telemetry is
58
+ * opt-in local tooling, so a missing native binding must never print a
59
+ * warning on every command (`statusline` in particular can be invoked by an
60
+ * editor every few seconds).
61
+ */
62
+ function noteTelemetryUnavailable(cause) {
63
+ if (hasNotedUnavailable)
64
+ return;
65
+ hasNotedUnavailable = true;
66
+ if (process.env.VG_DEBUG === '1') {
67
+ process.stderr.write(`vault-guard telemetry: native bindings unavailable, recording nothing this run: ${String(cause)}\n`);
68
+ }
69
+ }
63
70
  let _DbClass = null;
64
71
  function getDbClass() {
65
72
  if (_DbClass)
@@ -153,53 +160,116 @@ class TelemetryStore {
153
160
  db;
154
161
  counter = new vault_guard_core_1.TokenCounter();
155
162
  saltBuf;
163
+ available;
164
+ unavailableMessage;
156
165
  lastRetentionPurgeMs = 0;
166
+ /**
167
+ * @param dbPath Defaults to `~/.vault-guard/usage.sqlite`.
168
+ *
169
+ * Never throws, even when `better-sqlite3` native bindings are missing or
170
+ * incompatible: in that case the store degrades to a no-op (see
171
+ * {@link isAvailable}) instead of raising {@link TelemetryUnavailableError}.
172
+ */
157
173
  constructor(dbPath) {
158
174
  const resolved = dbPath ?? defaultDbPath();
159
- const dir = path_1.default.dirname(resolved);
160
- fs_1.default.mkdirSync(dir, { recursive: true });
161
- this.saltBuf = getOrCreateTelemetrySalt();
162
- // getDbClass() throws TelemetryUnavailableError if bindings missing.
163
- this.db = new (getDbClass())(resolved);
164
- this.db.pragma('journal_mode = WAL');
165
- this.initSchema();
166
- this.applyTelemetryMigrations();
167
- this.maybePurgeStaleRows();
175
+ let db = null;
176
+ let salt = null;
177
+ let unavailableMessage = null;
178
+ try {
179
+ const DbCtor = getDbClass(); // throws TelemetryUnavailableError if bindings missing.
180
+ const dir = path_1.default.dirname(resolved);
181
+ fs_1.default.mkdirSync(dir, { recursive: true });
182
+ salt = getOrCreateTelemetrySalt();
183
+ const opened = new DbCtor(resolved);
184
+ opened.pragma('journal_mode = WAL');
185
+ db = opened;
186
+ }
187
+ catch (err) {
188
+ db = null;
189
+ salt = null;
190
+ unavailableMessage = new TelemetryUnavailableError(err).message;
191
+ noteTelemetryUnavailable(err);
192
+ }
193
+ this.db = db;
194
+ this.saltBuf = salt;
195
+ this.available = db !== null;
196
+ this.unavailableMessage = unavailableMessage;
197
+ if (this.db) {
198
+ this.initSchema();
199
+ this.applyTelemetryMigrations();
200
+ this.maybePurgeStaleRows();
201
+ }
202
+ }
203
+ /** True when `better-sqlite3` loaded and this store is backed by a real DB. */
204
+ isAvailable() {
205
+ return this.available;
206
+ }
207
+ /**
208
+ * Human-readable reason telemetry is unavailable, or `null` when
209
+ * {@link isAvailable} is true. Lets callers that are specifically about
210
+ * inspecting telemetry (`vault-guard data status`, `data export`) report
211
+ * "unavailable" explicitly rather than silently show all-zero results.
212
+ */
213
+ getUnavailableReason() {
214
+ return this.unavailableMessage;
215
+ }
216
+ /**
217
+ * Non-null accessor for the handful of private methods that only ever run
218
+ * from inside `if (this.db)` (constructor init) or after an
219
+ * `isAvailable`/`this.db` guard in a public method. Never reachable while
220
+ * degraded; exists so those method bodies don't each repeat the guard.
221
+ */
222
+ requireDb() {
223
+ if (!this.db) {
224
+ throw new Error('vault-guard telemetry: internal invariant violated (db unavailable)');
225
+ }
226
+ return this.db;
227
+ }
228
+ /** Same invariant as {@link requireDb}: only ever reached while available, where saltBuf is always set alongside db. */
229
+ requireSalt() {
230
+ if (!this.saltBuf) {
231
+ throw new Error('vault-guard telemetry: internal invariant violated (salt unavailable)');
232
+ }
233
+ return this.saltBuf;
168
234
  }
169
235
  /**
170
236
  * One-time migration (pragma `user_version` < 2): replace plaintext `cwd`
171
237
  * values with HMAC-SHA256 hex digests using the current salt file.
172
238
  */
173
239
  applyTelemetryMigrations() {
174
- const ver = Number(this.db.pragma('user_version', { simple: true }));
240
+ const db = this.requireDb();
241
+ const ver = Number(db.pragma('user_version', { simple: true }));
175
242
  if (ver >= 2)
176
243
  return;
177
- const salt = this.saltBuf;
178
- const uRows = this.db
244
+ const salt = this.requireSalt();
245
+ const uRows = db
179
246
  .prepare(`SELECT id, cwd FROM usage_events WHERE cwd IS NOT NULL AND cwd != ''`)
180
247
  .all();
181
- const uUpd = this.db.prepare(`UPDATE usage_events SET cwd = ? WHERE id = ?`);
248
+ const uUpd = db.prepare(`UPDATE usage_events SET cwd = ? WHERE id = ?`);
182
249
  for (const r of uRows) {
183
250
  if (isStoredCwdDigest(r.cwd))
184
251
  continue;
185
252
  uUpd.run(hashCwdForStore(r.cwd, salt), r.id);
186
253
  }
187
- const sRows = this.db
254
+ const sRows = db
188
255
  .prepare(`SELECT id, cwd FROM session_events WHERE cwd IS NOT NULL AND cwd != ''`)
189
256
  .all();
190
- const sUpd = this.db.prepare(`UPDATE session_events SET cwd = ? WHERE id = ?`);
257
+ const sUpd = db.prepare(`UPDATE session_events SET cwd = ? WHERE id = ?`);
191
258
  for (const r of sRows) {
192
259
  if (isStoredCwdDigest(r.cwd))
193
260
  continue;
194
261
  sUpd.run(hashCwdForStore(r.cwd, salt), r.id);
195
262
  }
196
- this.db.pragma('user_version = 2');
263
+ db.pragma('user_version = 2');
197
264
  }
198
265
  /**
199
266
  * Delete rows older than {@link getTelemetryRetentionDays}. Throttled to at
200
267
  * most once per hour per process to avoid hammering SQLite on hot paths.
268
+ * No-op when the store is unavailable.
201
269
  */
202
270
  maybePurgeStaleRows() {
271
+ if (!this.db)
272
+ return;
203
273
  const days = getTelemetryRetentionDays();
204
274
  if (days <= 0)
205
275
  return;
@@ -212,7 +282,8 @@ class TelemetryStore {
212
282
  this.db.prepare(`DELETE FROM session_events WHERE created_at < ?`).run(cutoff);
213
283
  }
214
284
  initSchema() {
215
- this.db.exec(`
285
+ const db = this.requireDb();
286
+ db.exec(`
216
287
  CREATE TABLE IF NOT EXISTS usage_events (
217
288
  id INTEGER PRIMARY KEY AUTOINCREMENT,
218
289
  created_at TEXT NOT NULL,
@@ -242,8 +313,9 @@ class TelemetryStore {
242
313
  CREATE INDEX IF NOT EXISTS idx_session_type ON session_events(event_type);
243
314
  `);
244
315
  }
316
+ /** No-op when the store is unavailable: there is no handle to close. */
245
317
  close() {
246
- this.db.close();
318
+ this.db?.close();
247
319
  }
248
320
  /**
249
321
  * Force a WAL checkpoint (TRUNCATE) and close the database.
@@ -256,9 +328,12 @@ class TelemetryStore {
256
328
  * with no recovery surprises.
257
329
  *
258
330
  * Best-effort: if the pragma fails (e.g. handle already closed by another
259
- * shutdown path) we still attempt to close the underlying handle.
331
+ * shutdown path) we still attempt to close the underlying handle. No-op
332
+ * when the store is unavailable.
260
333
  */
261
334
  closeAndCheckpoint() {
335
+ if (!this.db)
336
+ return;
262
337
  try {
263
338
  this.db.pragma('wal_checkpoint(TRUNCATE)');
264
339
  }
@@ -272,7 +347,10 @@ class TelemetryStore {
272
347
  // Best-effort: nothing useful we can do on shutdown if close throws.
273
348
  }
274
349
  }
350
+ /** No-op (records nothing) when the store is unavailable. */
275
351
  recordUsage(input) {
352
+ if (!this.db)
353
+ return;
276
354
  this.maybePurgeStaleRows();
277
355
  const created = (input.createdAt ?? new Date()).toISOString();
278
356
  let cost = input.estCostUsd;
@@ -289,9 +367,12 @@ class TelemetryStore {
289
367
  INSERT INTO usage_events (created_at, provider, model, cwd, input_tokens, output_tokens, est_cost_usd, source)
290
368
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
291
369
  `);
292
- stmt.run(created, input.provider ?? 'unknown', input.model ?? null, hashCwdForStore(input.cwd, this.saltBuf), input.inputTokens, input.outputTokens, cost, input.source ?? null);
370
+ stmt.run(created, input.provider ?? 'unknown', input.model ?? null, hashCwdForStore(input.cwd, this.requireSalt()), input.inputTokens, input.outputTokens, cost, input.source ?? null);
293
371
  }
372
+ /** No-op (records nothing) when the store is unavailable. */
294
373
  recordSession(input) {
374
+ if (!this.db)
375
+ return;
295
376
  this.maybePurgeStaleRows();
296
377
  const created = (input.createdAt ?? new Date()).toISOString();
297
378
  const extra = input.extra && Object.keys(input.extra).length > 0 ? JSON.stringify(input.extra) : null;
@@ -301,19 +382,32 @@ class TelemetryStore {
301
382
  lines_accepted, lines_suggested, lines_reverted, extra_json
302
383
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
303
384
  `);
304
- stmt.run(created, input.eventType, input.model ?? null, hashCwdForStore(input.cwd, this.saltBuf), input.language ?? null, input.linesAccepted ?? null, input.linesSuggested ?? null, input.linesReverted ?? null, extra);
385
+ stmt.run(created, input.eventType, input.model ?? null, hashCwdForStore(input.cwd, this.requireSalt()), input.language ?? null, input.linesAccepted ?? null, input.linesSuggested ?? null, input.linesReverted ?? null, extra);
305
386
  }
306
- /** Count session events that represent blocked secrets today (UTC date). */
387
+ /** Count session events that represent blocked secrets today (UTC date). Returns 0 when unavailable. */
307
388
  secretsBlockedToday(day = utcDayStart()) {
389
+ if (!this.db)
390
+ return 0;
308
391
  const row = this.db
309
392
  .prepare(`SELECT COUNT(*) AS c FROM session_events
310
393
  WHERE event_type = 'secret_blocked' AND substr(created_at, 1, 10) = ?`)
311
394
  .get(day);
312
395
  return row.c;
313
396
  }
397
+ /** Returns a zeroed payload (never throws) when the store is unavailable. */
314
398
  getStatuslinePayload(now = new Date()) {
315
399
  const day = utcDayStart(now);
316
400
  const windowStart = `${day}T00:00:00.000Z`;
401
+ if (!this.db) {
402
+ return {
403
+ secrets_today: 0,
404
+ tokens_today_input: 0,
405
+ tokens_today_output: 0,
406
+ est_cost_usd: 0,
407
+ model: null,
408
+ window_start_utc: windowStart,
409
+ };
410
+ }
317
411
  const usage = this.db
318
412
  .prepare(`SELECT
319
413
  COALESCE(SUM(input_tokens), 0) AS tin,
@@ -339,8 +433,16 @@ class TelemetryStore {
339
433
  /**
340
434
  * Heuristic model hint from the last 7 days of session + usage data.
341
435
  * Prefer models with more usage and lower revert_rate when session metrics exist.
436
+ * Returns an empty suggestion (never throws) when the store is unavailable.
342
437
  */
343
438
  suggestModel(opts = {}) {
439
+ if (!this.db) {
440
+ return {
441
+ suggested_model: null,
442
+ reason: 'Telemetry is unavailable (better-sqlite3 native bindings not installed); no suggestion.',
443
+ by_model: [],
444
+ };
445
+ }
344
446
  const since = new Date();
345
447
  since.setUTCDate(since.getUTCDate() - 7);
346
448
  const sinceIso = since.toISOString();
@@ -409,9 +511,12 @@ class TelemetryStore {
409
511
  * Read all rows from `usage_events` ordered by `id ASC`.
410
512
  *
411
513
  * Intended for `vault-guard data export`. Returns raw `cwd` strings — see
412
- * {@link DataStatusJson} for the privacy-respecting alternative.
514
+ * {@link DataStatusJson} for the privacy-respecting alternative. Returns an
515
+ * empty array (never throws) when the store is unavailable.
413
516
  */
414
517
  exportUsageEvents() {
518
+ if (!this.db)
519
+ return [];
415
520
  return this.db
416
521
  .prepare(`SELECT id, created_at, provider, model, cwd, input_tokens, output_tokens, est_cost_usd, source
417
522
  FROM usage_events ORDER BY id ASC`)
@@ -421,9 +526,12 @@ class TelemetryStore {
421
526
  * Read all rows from `session_events` ordered by `id ASC`.
422
527
  *
423
528
  * Intended for `vault-guard data export`. Returns raw `cwd` strings and
424
- * the `extra_json` payload as stored.
529
+ * the `extra_json` payload as stored. Returns an empty array (never
530
+ * throws) when the store is unavailable.
425
531
  */
426
532
  exportSessionEvents() {
533
+ if (!this.db)
534
+ return [];
427
535
  return this.db
428
536
  .prepare(`SELECT id, created_at, event_type, model, cwd, language,
429
537
  lines_accepted, lines_suggested, lines_reverted, extra_json
@@ -461,6 +569,25 @@ class TelemetryStore {
461
569
  }
462
570
  })
463
571
  .filter((x) => x !== null);
572
+ // File-level facts (db_exists / db_size_bytes / last_write_iso / sidecars)
573
+ // come from fs, not the DB handle, so they stay accurate even when the
574
+ // store is unavailable. Row-level facts default to zero/null below.
575
+ if (!this.db) {
576
+ return {
577
+ db_path: dbFilePath,
578
+ db_exists: dbExists,
579
+ db_size_bytes: dbSize,
580
+ last_write_iso: lastWriteIso,
581
+ sidecars,
582
+ usage_events: 0,
583
+ session_events: 0,
584
+ earliest_event_iso: null,
585
+ latest_event_iso: null,
586
+ distinct_cwd_count: 0,
587
+ distinct_model_count: 0,
588
+ last_model: null,
589
+ };
590
+ }
464
591
  // COUNT(*) on indexed tables is cheap; we don't need to bound it.
465
592
  const usageEvents = this.db.prepare('SELECT COUNT(*) AS n FROM usage_events').get().n;
466
593
  const sessionEvents = this.db.prepare('SELECT COUNT(*) AS n FROM session_events').get().n;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultcompass/vault-guard-telemetry",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "Local-only Anthropic token cost and session tracking via the Vault Guard proxy. No cloud.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,8 +22,10 @@
22
22
  },
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "better-sqlite3": "^13.0.2",
26
- "@vaultcompass/vault-guard-core": "1.4.1"
25
+ "@vaultcompass/vault-guard-core": "1.4.2"
26
+ },
27
+ "optionalDependencies": {
28
+ "better-sqlite3": "^13.0.2"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@types/better-sqlite3": "^7.6.12",