@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 +13 -17
- package/dist/store.d.ts +49 -27
- package/dist/store.js +174 -47
- package/package.json +5 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
* @
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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.
|
|
178
|
-
const uRows =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
26
|
-
|
|
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",
|