@cleocode/core 2026.4.48 → 2026.4.50
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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +730 -362
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +4 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1061 -683
- package/dist/internal.js.map +4 -4
- package/dist/nexus/index.d.ts +1 -1
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/registry.d.ts +25 -0
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/store/nexus-schema.d.ts +64 -0
- package/dist/store/nexus-schema.d.ts.map +1 -1
- package/dist/store/nexus-validation-schemas.d.ts +128 -0
- package/dist/store/nexus-validation-schemas.d.ts.map +1 -1
- package/dist/telemetry/index.d.ts +107 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/schema.d.ts +228 -0
- package/dist/telemetry/schema.d.ts.map +1 -0
- package/dist/telemetry/sqlite.d.ts +33 -0
- package/dist/telemetry/sqlite.d.ts.map +1 -0
- package/migrations/drizzle-telemetry/20260415000001_t624-initial/migration.sql +23 -0
- package/migrations/drizzle-telemetry/20260415000001_t624-initial/snapshot.json +35 -0
- package/package.json +8 -8
- package/src/__tests__/telemetry.test.ts +221 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +20 -1
- package/src/nexus/index.ts +2 -0
- package/src/nexus/registry.ts +103 -1
- package/src/store/nexus-schema.ts +9 -0
- package/src/telemetry/index.ts +341 -0
- package/src/telemetry/schema.ts +68 -0
- package/src/telemetry/sqlite.ts +140 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry module — opt-in command telemetry for CLEO self-improvement.
|
|
3
|
+
*
|
|
4
|
+
* Entry point for all telemetry operations:
|
|
5
|
+
* - Recording events (fire-and-forget)
|
|
6
|
+
* - Querying patterns for `cleo diagnostics analyze`
|
|
7
|
+
* - Managing the anonymous ID and opt-in state
|
|
8
|
+
* - Emitting BRAIN observations from high-signal patterns
|
|
9
|
+
*
|
|
10
|
+
* Telemetry is DISABLED by default. Enable with `cleo diagnostics enable`.
|
|
11
|
+
*
|
|
12
|
+
* @task T624
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { and, count, desc, gt, sql } from 'drizzle-orm';
|
|
19
|
+
import { getCleoHome } from '../paths.js';
|
|
20
|
+
import { telemetryEvents } from './schema.js';
|
|
21
|
+
import { getTelemetryDb } from './sqlite.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** A single telemetry event to record. */
|
|
28
|
+
export interface TelemetryEvent {
|
|
29
|
+
/** Canonical domain (e.g. "tasks", "session"). */
|
|
30
|
+
domain: string;
|
|
31
|
+
/** CQRS gateway. */
|
|
32
|
+
gateway: 'query' | 'mutate';
|
|
33
|
+
/** Operation name (e.g. "show", "add"). */
|
|
34
|
+
operation: string;
|
|
35
|
+
/** Wall-clock duration in milliseconds. */
|
|
36
|
+
durationMs: number;
|
|
37
|
+
/** LAFS exit code (0 = success). */
|
|
38
|
+
exitCode: number;
|
|
39
|
+
/** Machine-readable error code (null on success). */
|
|
40
|
+
errorCode?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Aggregated stats for a single command. */
|
|
44
|
+
export interface CommandStats {
|
|
45
|
+
/** Composed "{domain}.{operation}" command name. */
|
|
46
|
+
command: string;
|
|
47
|
+
/** Total invocation count. */
|
|
48
|
+
count: number;
|
|
49
|
+
/** Count of invocations that returned exitCode != 0. */
|
|
50
|
+
failureCount: number;
|
|
51
|
+
/** Failure rate as a fraction (0..1). */
|
|
52
|
+
failureRate: number;
|
|
53
|
+
/** Mean duration in milliseconds. */
|
|
54
|
+
avgDurationMs: number;
|
|
55
|
+
/** Max duration in milliseconds. */
|
|
56
|
+
maxDurationMs: number;
|
|
57
|
+
/** Most frequent error code, or null if no failures. */
|
|
58
|
+
topErrorCode: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Aggregated diagnostics summary. */
|
|
62
|
+
export interface DiagnosticsReport {
|
|
63
|
+
/** Period analyzed (ISO-8601 start and end). */
|
|
64
|
+
period: { from: string; to: string };
|
|
65
|
+
/** Total events in period. */
|
|
66
|
+
totalEvents: number;
|
|
67
|
+
/** Top 10 commands by failure rate (min 5 invocations). */
|
|
68
|
+
topFailing: CommandStats[];
|
|
69
|
+
/** Top 10 slowest commands by average duration. */
|
|
70
|
+
topSlow: CommandStats[];
|
|
71
|
+
/** Commands invoked exactly once (potential dead ends). */
|
|
72
|
+
rareCommands: string[];
|
|
73
|
+
/** High-signal observations suitable for BRAIN storage. */
|
|
74
|
+
observations: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Global telemetry config stored in ~/.local/share/cleo/telemetry-config.json */
|
|
78
|
+
export interface TelemetryConfig {
|
|
79
|
+
/** Whether telemetry collection is enabled. */
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
/** Anonymous UUID stable across invocations. Generated on first enable. */
|
|
82
|
+
anonymousId: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Config I/O
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
const TELEMETRY_CONFIG_FILENAME = 'telemetry-config.json';
|
|
90
|
+
|
|
91
|
+
/** Return the path to the telemetry config JSON file. */
|
|
92
|
+
export function getTelemetryConfigPath(): string {
|
|
93
|
+
return join(getCleoHome(), TELEMETRY_CONFIG_FILENAME);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Load telemetry config from disk. Returns default (disabled) config if absent. */
|
|
97
|
+
export function loadTelemetryConfig(): TelemetryConfig {
|
|
98
|
+
const path = getTelemetryConfigPath();
|
|
99
|
+
if (!existsSync(path)) {
|
|
100
|
+
return { enabled: false, anonymousId: '' };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as TelemetryConfig;
|
|
104
|
+
} catch {
|
|
105
|
+
return { enabled: false, anonymousId: '' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Persist telemetry config to disk. */
|
|
110
|
+
export function saveTelemetryConfig(config: TelemetryConfig): void {
|
|
111
|
+
const path = getTelemetryConfigPath();
|
|
112
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
113
|
+
writeFileSync(path, JSON.stringify(config, null, 2), 'utf-8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Return true if telemetry collection is currently enabled. */
|
|
117
|
+
export function isTelemetryEnabled(): boolean {
|
|
118
|
+
return loadTelemetryConfig().enabled;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enable telemetry and generate a stable anonymous ID.
|
|
123
|
+
* Idempotent — calling again preserves the existing anonymousId.
|
|
124
|
+
*/
|
|
125
|
+
export function enableTelemetry(): TelemetryConfig {
|
|
126
|
+
const existing = loadTelemetryConfig();
|
|
127
|
+
const config: TelemetryConfig = {
|
|
128
|
+
enabled: true,
|
|
129
|
+
anonymousId: existing.anonymousId || randomUUID(),
|
|
130
|
+
};
|
|
131
|
+
saveTelemetryConfig(config);
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Disable telemetry collection.
|
|
137
|
+
* Existing data in telemetry.db is not deleted.
|
|
138
|
+
*/
|
|
139
|
+
export function disableTelemetry(): TelemetryConfig {
|
|
140
|
+
const existing = loadTelemetryConfig();
|
|
141
|
+
const config: TelemetryConfig = { ...existing, enabled: false };
|
|
142
|
+
saveTelemetryConfig(config);
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Event recording
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record one telemetry event.
|
|
152
|
+
* Fire-and-forget — errors are swallowed; never blocks the calling command.
|
|
153
|
+
* No-op when telemetry is disabled.
|
|
154
|
+
*/
|
|
155
|
+
export async function recordTelemetryEvent(event: TelemetryEvent): Promise<void> {
|
|
156
|
+
const config = loadTelemetryConfig();
|
|
157
|
+
if (!config.enabled || !config.anonymousId) return;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const db = await getTelemetryDb();
|
|
161
|
+
const command = `${event.domain}.${event.operation}`;
|
|
162
|
+
await db
|
|
163
|
+
.insert(telemetryEvents)
|
|
164
|
+
.values({
|
|
165
|
+
id: randomUUID(),
|
|
166
|
+
anonymousId: config.anonymousId,
|
|
167
|
+
domain: event.domain,
|
|
168
|
+
gateway: event.gateway,
|
|
169
|
+
operation: event.operation,
|
|
170
|
+
command,
|
|
171
|
+
exitCode: event.exitCode,
|
|
172
|
+
durationMs: event.durationMs,
|
|
173
|
+
errorCode: event.errorCode ?? null,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
})
|
|
176
|
+
.run();
|
|
177
|
+
} catch {
|
|
178
|
+
// Non-fatal: telemetry must never crash the calling command.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Analysis
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** ISO-8601 timestamp N days ago. */
|
|
187
|
+
function daysAgo(n: number): string {
|
|
188
|
+
const d = new Date();
|
|
189
|
+
d.setDate(d.getDate() - n);
|
|
190
|
+
return d.toISOString();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build a diagnostics report over the last `days` days (default 30).
|
|
195
|
+
* Requires telemetry to be enabled; returns null if disabled or no data.
|
|
196
|
+
*/
|
|
197
|
+
export async function buildDiagnosticsReport(days = 30): Promise<DiagnosticsReport | null> {
|
|
198
|
+
const config = loadTelemetryConfig();
|
|
199
|
+
if (!config.enabled) return null;
|
|
200
|
+
|
|
201
|
+
const db = await getTelemetryDb();
|
|
202
|
+
const from = daysAgo(days);
|
|
203
|
+
const to = new Date().toISOString();
|
|
204
|
+
|
|
205
|
+
// Total events in window
|
|
206
|
+
const [totalRow] = await db
|
|
207
|
+
.select({ n: count(telemetryEvents.id) })
|
|
208
|
+
.from(telemetryEvents)
|
|
209
|
+
.where(gt(telemetryEvents.timestamp, from))
|
|
210
|
+
.all();
|
|
211
|
+
const totalEvents = totalRow?.n ?? 0;
|
|
212
|
+
|
|
213
|
+
if (totalEvents === 0)
|
|
214
|
+
return {
|
|
215
|
+
period: { from, to },
|
|
216
|
+
totalEvents: 0,
|
|
217
|
+
topFailing: [],
|
|
218
|
+
topSlow: [],
|
|
219
|
+
rareCommands: [],
|
|
220
|
+
observations: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Per-command aggregates
|
|
224
|
+
const rows = await db
|
|
225
|
+
.select({
|
|
226
|
+
command: telemetryEvents.command,
|
|
227
|
+
total: count(telemetryEvents.id),
|
|
228
|
+
failures: sql<number>`SUM(CASE WHEN ${telemetryEvents.exitCode} != 0 THEN 1 ELSE 0 END)`,
|
|
229
|
+
avgMs: sql<number>`AVG(${telemetryEvents.durationMs})`,
|
|
230
|
+
maxMs: sql<number>`MAX(${telemetryEvents.durationMs})`,
|
|
231
|
+
})
|
|
232
|
+
.from(telemetryEvents)
|
|
233
|
+
.where(gt(telemetryEvents.timestamp, from))
|
|
234
|
+
.groupBy(telemetryEvents.command)
|
|
235
|
+
.all();
|
|
236
|
+
|
|
237
|
+
// Get top error code per command
|
|
238
|
+
const errorCodeRows = await db
|
|
239
|
+
.select({
|
|
240
|
+
command: telemetryEvents.command,
|
|
241
|
+
errorCode: telemetryEvents.errorCode,
|
|
242
|
+
n: count(telemetryEvents.id),
|
|
243
|
+
})
|
|
244
|
+
.from(telemetryEvents)
|
|
245
|
+
.where(and(gt(telemetryEvents.timestamp, from), sql`${telemetryEvents.errorCode} IS NOT NULL`))
|
|
246
|
+
.groupBy(telemetryEvents.command, telemetryEvents.errorCode)
|
|
247
|
+
.orderBy(desc(count(telemetryEvents.id)))
|
|
248
|
+
.all();
|
|
249
|
+
|
|
250
|
+
// Build a map: command → top error code
|
|
251
|
+
const topErrorMap = new Map<string, string>();
|
|
252
|
+
for (const r of errorCodeRows) {
|
|
253
|
+
if (!topErrorMap.has(r.command)) {
|
|
254
|
+
topErrorMap.set(r.command, r.errorCode ?? '');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Build CommandStats
|
|
259
|
+
const stats: CommandStats[] = rows.map((r) => {
|
|
260
|
+
const failures = Number(r.failures) || 0;
|
|
261
|
+
const total = Number(r.total) || 0;
|
|
262
|
+
return {
|
|
263
|
+
command: r.command,
|
|
264
|
+
count: total,
|
|
265
|
+
failureCount: failures,
|
|
266
|
+
failureRate: total > 0 ? failures / total : 0,
|
|
267
|
+
avgDurationMs: Math.round(Number(r.avgMs) || 0),
|
|
268
|
+
maxDurationMs: Math.round(Number(r.maxMs) || 0),
|
|
269
|
+
topErrorCode: topErrorMap.get(r.command) ?? null,
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Top failing (min 5 invocations, sorted by failure rate desc)
|
|
274
|
+
const topFailing = stats
|
|
275
|
+
.filter((s) => s.count >= 5 && s.failureRate > 0)
|
|
276
|
+
.sort((a, b) => b.failureRate - a.failureRate)
|
|
277
|
+
.slice(0, 10);
|
|
278
|
+
|
|
279
|
+
// Top slowest (sorted by avgDurationMs desc)
|
|
280
|
+
const topSlow = [...stats].sort((a, b) => b.avgDurationMs - a.avgDurationMs).slice(0, 10);
|
|
281
|
+
|
|
282
|
+
// Rare commands (invoked only once in the window)
|
|
283
|
+
const rareCommands = stats.filter((s) => s.count === 1).map((s) => s.command);
|
|
284
|
+
|
|
285
|
+
// Generate high-signal observations for BRAIN
|
|
286
|
+
const observations: string[] = [];
|
|
287
|
+
for (const s of topFailing.slice(0, 5)) {
|
|
288
|
+
const pct = Math.round(s.failureRate * 100);
|
|
289
|
+
const errPart = s.topErrorCode ? ` (most common error: ${s.topErrorCode})` : '';
|
|
290
|
+
observations.push(
|
|
291
|
+
`Command '${s.command}' fails ${pct}% of the time across ${s.count} invocations${errPart}. Investigate root cause.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
// Flag commands 2x slower than median
|
|
295
|
+
const sortedAvg = stats.map((s) => s.avgDurationMs).sort((a, b) => a - b);
|
|
296
|
+
const median = sortedAvg[Math.floor(sortedAvg.length / 2)] ?? 0;
|
|
297
|
+
for (const s of topSlow.slice(0, 5)) {
|
|
298
|
+
if (median > 0 && s.avgDurationMs > median * 2) {
|
|
299
|
+
observations.push(
|
|
300
|
+
`Command '${s.command}' averages ${s.avgDurationMs}ms — ${Math.round(s.avgDurationMs / median)}x slower than the ${median}ms median. Profile for performance improvement.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { period: { from, to }, totalEvents, topFailing, topSlow, rareCommands, observations };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Export all telemetry events as a JSON array.
|
|
310
|
+
* Returns empty array when telemetry is disabled or DB is empty.
|
|
311
|
+
*/
|
|
312
|
+
export async function exportTelemetryEvents(days?: number): Promise<TelemetryEvent[]> {
|
|
313
|
+
const config = loadTelemetryConfig();
|
|
314
|
+
if (!config.enabled) return [];
|
|
315
|
+
|
|
316
|
+
const db = await getTelemetryDb();
|
|
317
|
+
const rows = days
|
|
318
|
+
? await db
|
|
319
|
+
.select()
|
|
320
|
+
.from(telemetryEvents)
|
|
321
|
+
.where(gt(telemetryEvents.timestamp, daysAgo(days)))
|
|
322
|
+
.orderBy(desc(telemetryEvents.timestamp))
|
|
323
|
+
.all()
|
|
324
|
+
: await db.select().from(telemetryEvents).orderBy(desc(telemetryEvents.timestamp)).all();
|
|
325
|
+
|
|
326
|
+
return rows.map((r) => ({
|
|
327
|
+
domain: r.domain,
|
|
328
|
+
gateway: r.gateway as 'query' | 'mutate',
|
|
329
|
+
operation: r.operation,
|
|
330
|
+
durationMs: r.durationMs,
|
|
331
|
+
exitCode: r.exitCode,
|
|
332
|
+
errorCode: r.errorCode,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Re-exports
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
export * from './schema.js';
|
|
341
|
+
export { getTelemetryDb, getTelemetryDbPath } from './sqlite.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle ORM schema for CLEO telemetry.db (SQLite via node:sqlite + sqlite-proxy).
|
|
3
|
+
*
|
|
4
|
+
* Tables: telemetry_events, telemetry_schema_meta
|
|
5
|
+
*
|
|
6
|
+
* Stores anonymous, opt-in command telemetry for self-improvement analysis.
|
|
7
|
+
* Tracks which commands run, how fast they are, and whether they succeed.
|
|
8
|
+
* Telemetry is DISABLED by default; users must run `cleo diagnostics enable`.
|
|
9
|
+
*
|
|
10
|
+
* @task T624
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sql } from 'drizzle-orm';
|
|
14
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
15
|
+
|
|
16
|
+
// === TELEMETRY_EVENTS TABLE ===
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One row per command invocation captured by the telemetry middleware.
|
|
20
|
+
*
|
|
21
|
+
* Fields are intentionally minimal — no params, no output, no user data.
|
|
22
|
+
* Only the shape of what was invoked and its outcome are stored.
|
|
23
|
+
*/
|
|
24
|
+
export const telemetryEvents = sqliteTable(
|
|
25
|
+
'telemetry_events',
|
|
26
|
+
{
|
|
27
|
+
/** UUID primary key. */
|
|
28
|
+
id: text('id').primaryKey(),
|
|
29
|
+
/** Anonymous install identifier (UUIDv4, generated once on first enable). */
|
|
30
|
+
anonymousId: text('anonymous_id').notNull(),
|
|
31
|
+
/** Canonical domain (e.g. "tasks", "session", "memory", "admin"). */
|
|
32
|
+
domain: text('domain').notNull(),
|
|
33
|
+
/** CQRS gateway ("query" or "mutate"). */
|
|
34
|
+
gateway: text('gateway').notNull(),
|
|
35
|
+
/** Operation name (e.g. "show", "add", "complete"). */
|
|
36
|
+
operation: text('operation').notNull(),
|
|
37
|
+
/** Composed command string "{domain}.{operation}" for easy grouping. */
|
|
38
|
+
command: text('command').notNull(),
|
|
39
|
+
/** LAFS exit code (0 = success, non-zero = failure). */
|
|
40
|
+
exitCode: integer('exit_code').notNull().default(0),
|
|
41
|
+
/** Wall-clock duration in milliseconds. */
|
|
42
|
+
durationMs: integer('duration_ms').notNull(),
|
|
43
|
+
/** Machine-readable error code when exit_code != 0. NULL on success. */
|
|
44
|
+
errorCode: text('error_code'),
|
|
45
|
+
/** ISO-8601 timestamp of the invocation. */
|
|
46
|
+
timestamp: text('timestamp').notNull().default(sql`(datetime('now'))`),
|
|
47
|
+
},
|
|
48
|
+
(table) => [
|
|
49
|
+
index('idx_telemetry_command').on(table.command),
|
|
50
|
+
index('idx_telemetry_domain').on(table.domain),
|
|
51
|
+
index('idx_telemetry_exit_code').on(table.exitCode),
|
|
52
|
+
index('idx_telemetry_timestamp').on(table.timestamp),
|
|
53
|
+
index('idx_telemetry_duration').on(table.durationMs),
|
|
54
|
+
],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// === TELEMETRY_SCHEMA_META TABLE ===
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Key-value store for schema version tracking.
|
|
61
|
+
* Single row with key='schema_version' on first migration.
|
|
62
|
+
*/
|
|
63
|
+
export const telemetrySchemaMeta = sqliteTable('telemetry_schema_meta', {
|
|
64
|
+
/** Config key. */
|
|
65
|
+
key: text('key').primaryKey(),
|
|
66
|
+
/** Config value. */
|
|
67
|
+
value: text('value').notNull(),
|
|
68
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite store for telemetry.db via drizzle-orm/node-sqlite + node:sqlite.
|
|
3
|
+
*
|
|
4
|
+
* Stores opt-in command telemetry in ~/.local/share/cleo/telemetry.db.
|
|
5
|
+
* Follows the same singleton + WAL + migration pattern as brain-sqlite.ts.
|
|
6
|
+
* Telemetry is disabled by default — check isTelemetryEnabled() before writing.
|
|
7
|
+
*
|
|
8
|
+
* @task T624
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
|
|
16
|
+
import { drizzle } from 'drizzle-orm/node-sqlite';
|
|
17
|
+
import { getCleoHome } from '../paths.js';
|
|
18
|
+
import { ensureColumns, migrateWithRetry, reconcileJournal } from '../store/migration-manager.js';
|
|
19
|
+
import { openNativeDatabase } from '../store/sqlite.js';
|
|
20
|
+
import * as telemetrySchema from './schema.js';
|
|
21
|
+
|
|
22
|
+
/** Database file name in the global CLEO home directory. */
|
|
23
|
+
const DB_FILENAME = 'telemetry.db';
|
|
24
|
+
|
|
25
|
+
/** Schema version. Single source of truth. */
|
|
26
|
+
export const TELEMETRY_SCHEMA_VERSION = '1.0.0';
|
|
27
|
+
|
|
28
|
+
/** Singleton state. */
|
|
29
|
+
let _db: NodeSQLiteDatabase<typeof telemetrySchema> | null = null;
|
|
30
|
+
let _nativeDb: DatabaseSync | null = null;
|
|
31
|
+
let _dbPath: string | null = null;
|
|
32
|
+
let _initPromise: Promise<NodeSQLiteDatabase<typeof telemetrySchema>> | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the absolute path to telemetry.db in the global CLEO home directory.
|
|
36
|
+
* Linux: ~/.local/share/cleo/telemetry.db
|
|
37
|
+
*/
|
|
38
|
+
export function getTelemetryDbPath(): string {
|
|
39
|
+
return join(getCleoHome(), DB_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the drizzle-telemetry migrations folder.
|
|
44
|
+
* Handles both src/ (dev via tsx) and dist/ (bundled) layouts.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveTelemetryMigrationsFolder(): string {
|
|
47
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
48
|
+
const __dir = dirname(__filename);
|
|
49
|
+
// src/telemetry/ → ../../migrations/drizzle-telemetry
|
|
50
|
+
// dist/ → ../migrations/drizzle-telemetry
|
|
51
|
+
const isBundled = __dir.endsWith('/dist') || __dir.endsWith('\\dist');
|
|
52
|
+
const pkgRoot = isBundled ? join(__dir, '..') : join(__dir, '..', '..');
|
|
53
|
+
return join(pkgRoot, 'migrations', 'drizzle-telemetry');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run drizzle migrations to create/update telemetry.db tables.
|
|
58
|
+
*/
|
|
59
|
+
function runTelemetryMigrations(
|
|
60
|
+
nativeDb: DatabaseSync,
|
|
61
|
+
db: NodeSQLiteDatabase<typeof telemetrySchema>,
|
|
62
|
+
): void {
|
|
63
|
+
const migrationsFolder = resolveTelemetryMigrationsFolder();
|
|
64
|
+
|
|
65
|
+
reconcileJournal(nativeDb, migrationsFolder, 'telemetry_events', 'telemetry');
|
|
66
|
+
migrateWithRetry(db, migrationsFolder, nativeDb, 'telemetry_events', 'telemetry');
|
|
67
|
+
|
|
68
|
+
// Safety net: ensure core columns exist even if migration was skipped.
|
|
69
|
+
ensureColumns(
|
|
70
|
+
nativeDb,
|
|
71
|
+
'telemetry_events',
|
|
72
|
+
[
|
|
73
|
+
{ name: 'anonymous_id', ddl: "text NOT NULL DEFAULT ''" },
|
|
74
|
+
{ name: 'domain', ddl: "text NOT NULL DEFAULT ''" },
|
|
75
|
+
{ name: 'gateway', ddl: "text NOT NULL DEFAULT 'query'" },
|
|
76
|
+
{ name: 'operation', ddl: "text NOT NULL DEFAULT ''" },
|
|
77
|
+
{ name: 'command', ddl: "text NOT NULL DEFAULT ''" },
|
|
78
|
+
{ name: 'exit_code', ddl: 'integer NOT NULL DEFAULT 0' },
|
|
79
|
+
{ name: 'duration_ms', ddl: 'integer NOT NULL DEFAULT 0' },
|
|
80
|
+
{ name: 'error_code', ddl: 'text' },
|
|
81
|
+
],
|
|
82
|
+
'telemetry',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reset the singleton (used in tests).
|
|
88
|
+
*/
|
|
89
|
+
export function resetTelemetryDbState(): void {
|
|
90
|
+
try {
|
|
91
|
+
_nativeDb?.close();
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore
|
|
94
|
+
}
|
|
95
|
+
_db = null;
|
|
96
|
+
_nativeDb = null;
|
|
97
|
+
_dbPath = null;
|
|
98
|
+
_initPromise = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Initialize telemetry.db (lazy singleton).
|
|
103
|
+
* Creates the file and runs migrations on first call.
|
|
104
|
+
*/
|
|
105
|
+
export async function getTelemetryDb(): Promise<NodeSQLiteDatabase<typeof telemetrySchema>> {
|
|
106
|
+
const requestedPath = getTelemetryDbPath();
|
|
107
|
+
|
|
108
|
+
if (_db && _dbPath !== requestedPath) {
|
|
109
|
+
resetTelemetryDbState();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (_db) return _db;
|
|
113
|
+
if (_initPromise) return _initPromise;
|
|
114
|
+
|
|
115
|
+
_initPromise = (async () => {
|
|
116
|
+
const dbPath = requestedPath;
|
|
117
|
+
_dbPath = dbPath;
|
|
118
|
+
|
|
119
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
120
|
+
|
|
121
|
+
const nativeDb = openNativeDatabase(dbPath);
|
|
122
|
+
_nativeDb = nativeDb;
|
|
123
|
+
|
|
124
|
+
const db = drizzle({ client: nativeDb, schema: telemetrySchema });
|
|
125
|
+
|
|
126
|
+
runTelemetryMigrations(nativeDb, db);
|
|
127
|
+
|
|
128
|
+
// Seed schema version (idempotent)
|
|
129
|
+
nativeDb
|
|
130
|
+
.prepare(
|
|
131
|
+
`INSERT OR IGNORE INTO telemetry_schema_meta (key, value) VALUES ('schemaVersion', '${TELEMETRY_SCHEMA_VERSION}')`,
|
|
132
|
+
)
|
|
133
|
+
.run();
|
|
134
|
+
|
|
135
|
+
_db = db;
|
|
136
|
+
return db;
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
return _initPromise;
|
|
140
|
+
}
|