sandoichi 0.4.1 → 0.5.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/telemetry.mjs CHANGED
@@ -5,16 +5,44 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { atomicWrite, ensureDirectory, withLock } from './provider-usage.mjs';
8
+ import { PLUGIN_VERSION } from './version.mjs';
8
9
 
9
- const SCHEMA_VERSION = 1;
10
+ export const SCHEMA_VERSION = 2;
11
+ const LEGACY_SCHEMA_VERSION = 1;
12
+ const SUPPORTED_QUEUE_SCHEMA_VERSIONS = new Set([LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]);
10
13
  const MAX_STRING_LENGTH = 32;
11
14
  const MAX_EVENT_BYTES = 2048;
12
15
 
13
- const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20'];
14
- const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', 'gte_64k'];
16
+ const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', '21_to_100', 'gt_100'];
17
+ const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', '64_to_256k', '256k_to_1m', 'gte_1m'];
18
+ const LOCAL_ONLY_EVENTS = new Set(['f1_footprint', 'f4_gateway']);
15
19
  const HOSTS = ['claude', 'codex'];
16
20
  const PROVIDERS = ['anthropic', 'openai', 'unknown'];
17
21
  const MODES = ['enforce', 'observe', 'dry_run'];
22
+ const F4_HOSTS = ['claude', 'codex', 'unknown'];
23
+ const F4_OPERATIONS = ['catalog', 'call'];
24
+ const F4_OUTCOMES = ['success', 'rejected', 'timeout', 'cancelled', 'error'];
25
+ const F4_LATENCY_BUCKETS = ['lt_10ms', '10_to_100ms', '100_to_1000ms', 'gte_1000ms'];
26
+ const F4_RESULT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20', 'unknown'];
27
+ const F1_HOSTS = ['claude', 'codex'];
28
+ const F1_STATUSES = ['complete', 'partial', 'unavailable'];
29
+ const F1_RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', 'gt_10pct', 'unavailable'];
30
+ const F1_SIZE_BUCKETS = [...BYTE_BUCKETS, 'unavailable'];
31
+ const F1_INPUT_BUCKETS = [...COUNT_BUCKETS, 'unavailable'];
32
+ // Reduction without coverage reads as better than it is: a day that bounds heavily on the 3% of
33
+ // commands it recognises looks identical to one that bounds everything. These are the reasons the
34
+ // shell classifier already emits, so they are a closed set and carry nothing free-form.
35
+ // Counts alone cannot answer "how much did it reach": 1,276 routed and 43,945 bypassed both land
36
+ // in `gt_100`, and so would the reverse. The ratio is the field that carries the answer.
37
+ export const COVERAGE_RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', '10_to_50pct', '50_to_90pct', 'gt_90pct'];
38
+
39
+ export const COVERAGE_REASONS = [
40
+ 'ambiguous-shell', 'compound-feeds-pipeline', 'compound-has-redirect', 'compound-segment-ambiguous',
41
+ 'grep-shape', 'head-shape', 'invalid-input', 'read-shape', 'routing-disabled', 'sed-shape',
42
+ 'tail-unbounded-from-end', 'unsafe-cwd', 'unsafe-grep-pattern', 'unsafe-grep-target',
43
+ 'unsafe-read-target', 'unsupported-shell', 'unsupported-tool', 'other',
44
+ ];
45
+
18
46
  export const FAILURE_STAGES = [
19
47
  'policy', 'input', 'redaction', 'optimization', 'artifact', 'output', 'upstream', 'response',
20
48
  ];
@@ -23,6 +51,7 @@ const SHARED_FIELDS = {
23
51
  schema_version: (value) => value === SCHEMA_VERSION,
24
52
  event: (value) => [
25
53
  'hook_summary', 'proxy_summary', 'active_day', 'hook_failure_summary', 'proxy_failure_summary',
54
+ 'f1_footprint', 'f4_gateway', 'coverage_summary',
26
55
  ].includes(value),
27
56
  day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
28
57
  plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(value) && value.length <= MAX_STRING_LENGTH,
@@ -54,12 +83,37 @@ const PROXY_FAILURE_FIELDS = {
54
83
  provider: (value) => PROVIDERS.includes(value),
55
84
  failure_stage: (value) => FAILURE_STAGES.includes(value),
56
85
  };
86
+ const COVERAGE_FIELDS = {
87
+ host: (value) => HOSTS.includes(value),
88
+ routed_bucket: (value) => COUNT_BUCKETS.includes(value),
89
+ bypassed_bucket: (value) => COUNT_BUCKETS.includes(value),
90
+ coverage_ratio_bucket: (value) => COVERAGE_RATIO_BUCKETS.includes(value),
91
+ top_bypass_reason: (value) => COVERAGE_REASONS.includes(value),
92
+ };
93
+
94
+ const F4_FIELDS = {
95
+ f4_host: (value) => F4_HOSTS.includes(value),
96
+ f4_operation: (value) => F4_OPERATIONS.includes(value),
97
+ f4_outcome: (value) => F4_OUTCOMES.includes(value),
98
+ f4_latency_bucket: (value) => F4_LATENCY_BUCKETS.includes(value),
99
+ f4_result_bucket: (value) => F4_RESULT_BUCKETS.includes(value),
100
+ };
101
+ const F1_FIELDS = {
102
+ f1_host: (value) => F1_HOSTS.includes(value),
103
+ f1_status: (value) => F1_STATUSES.includes(value),
104
+ f1_unknown_ratio_bucket: (value) => F1_RATIO_BUCKETS.includes(value),
105
+ f1_body_size_bucket: (value) => F1_SIZE_BUCKETS.includes(value),
106
+ f1_input_tokens_bucket: (value) => F1_INPUT_BUCKETS.includes(value),
107
+ };
57
108
 
58
109
  function fieldsForEvent(eventType) {
110
+ if (eventType === 'f1_footprint') return F1_FIELDS;
59
111
  if (eventType === 'hook_summary') return HOOK_FIELDS;
60
112
  if (eventType === 'proxy_summary') return PROXY_FIELDS;
61
113
  if (eventType === 'active_day') return ACTIVE_DAY_FIELDS;
62
114
  if (eventType === 'hook_failure_summary') return HOOK_FAILURE_FIELDS;
115
+ if (eventType === 'f4_gateway') return F4_FIELDS;
116
+ if (eventType === 'coverage_summary') return COVERAGE_FIELDS;
63
117
  return PROXY_FAILURE_FIELDS;
64
118
  }
65
119
 
@@ -69,7 +123,8 @@ export function countBucket(count) {
69
123
  if (count === 1) return 'one';
70
124
  if (count <= 5) return '2_to_5';
71
125
  if (count <= 20) return '6_to_20';
72
- return 'gt_20';
126
+ if (count <= 100) return '21_to_100';
127
+ return 'gt_100';
73
128
  }
74
129
 
75
130
  export function byteBucket(bytes) {
@@ -77,7 +132,9 @@ export function byteBucket(bytes) {
77
132
  if (bytes < 4096) return 'lt_4k';
78
133
  if (bytes < 16384) return '4_to_16k';
79
134
  if (bytes < 65536) return '16_to_64k';
80
- return 'gte_64k';
135
+ if (bytes < 262144) return '64_to_256k';
136
+ if (bytes < 1048576) return '256k_to_1m';
137
+ return 'gte_1m';
81
138
  }
82
139
 
83
140
  export function validateEvent(payload) {
@@ -106,11 +163,6 @@ export const TELEMETRY_CONFIG_VERSION = 1;
106
163
  export const CONSENT_VERSION = 1;
107
164
  export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
108
165
  export const CONSENT_STATES = ['unasked', 'asked', 'enabled', 'declined'];
109
- // Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
110
- // reachable from any of the owner's machines (see
111
- // session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
112
- // Release/broader publication is still gated on the open items in
113
- // session-handoff/docs/telemetry-canary-report.md.
114
166
  export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
115
167
 
116
168
  export function isDoNotTrack(env = process.env) {
@@ -230,16 +282,26 @@ const LEASE_MS = 5 * 60 * 1000;
230
282
  const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000];
231
283
  const CHILD_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE'];
232
284
 
233
- function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {} }; }
285
+ function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {}, coverage_days: {} }; }
234
286
  function readCounters(countersPath) {
235
287
  if (!fs.existsSync(countersPath)) return emptyCounters();
236
288
  const state = JSON.parse(fs.readFileSync(countersPath, 'utf8'));
237
- return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {} };
289
+ return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {}, coverage_days: state.coverage_days ?? {} };
238
290
  }
239
291
 
240
292
  function readQueueRows(queuePath) {
241
293
  if (!fs.existsSync(queuePath)) return [];
242
- return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
294
+ return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => {
295
+ const row = JSON.parse(line);
296
+ assertQueueRow(row);
297
+ return row;
298
+ });
299
+ }
300
+
301
+ function assertQueueRow(row) {
302
+ if (!record(row) || !SUPPORTED_QUEUE_SCHEMA_VERSIONS.has(row.schema_version)) {
303
+ throw new Error('telemetry queue event schema is unsupported');
304
+ }
243
305
  }
244
306
 
245
307
  function writeQueueRows(queuePath, rows) {
@@ -253,7 +315,11 @@ function writeQueueRows(queuePath, rows) {
253
315
 
254
316
  /** Enforces the bounded queue (4096 rows / 4 MiB), dropping the oldest rows first —
255
317
  * a telemetry backlog must never grow without bound or block product behavior. */
256
- function appendQueueRows(queuePath, newRows) {
318
+ export function appendQueueRows(queuePath, newRows) {
319
+ for (const row of newRows) {
320
+ assertQueueRow(row);
321
+ if (LOCAL_ONLY_EVENTS.has(row.event)) throw new Error(`${row.event}: local-only event must never reach the upload queue`);
322
+ }
257
323
  ensureDirectory(path.dirname(queuePath));
258
324
  withLock(`${queuePath}.lock`, () => {
259
325
  let rows = readQueueRows(queuePath);
@@ -277,9 +343,10 @@ function publicRow(row) {
277
343
  }
278
344
 
279
345
  function bucketEntry(entry, pluginVersion) {
346
+ const recordedVersion = entry.pluginVersion ?? pluginVersion;
280
347
  if (entry.event === 'hook_summary') {
281
348
  return {
282
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
349
+ schema_version: SCHEMA_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: recordedVersion,
283
350
  host: entry.host, mode: entry.mode,
284
351
  tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
285
352
  capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
@@ -288,41 +355,70 @@ function bucketEntry(entry, pluginVersion) {
288
355
  };
289
356
  }
290
357
  if (entry.event === 'proxy_summary') return {
291
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
358
+ schema_version: SCHEMA_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: recordedVersion,
292
359
  provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
293
360
  rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
294
361
  rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
295
362
  input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
296
363
  };
364
+ if (entry.event === 'coverage_summary') {
365
+ const reasons = Object.entries(entry)
366
+ .filter(([field, count]) => field.startsWith(REASON_PREFIX) && Number.isInteger(count) && count > 0)
367
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
368
+ const leading = reasons[0]?.[0].slice(REASON_PREFIX.length);
369
+ const routed = entry.routed ?? 0;
370
+ const bypassed = entry.bypassed ?? 0;
371
+ return {
372
+ schema_version: SCHEMA_VERSION, event: 'coverage_summary', day_utc: entry.day, plugin_version: recordedVersion,
373
+ host: entry.host,
374
+ routed_bucket: countBucket(routed),
375
+ bypassed_bucket: countBucket(bypassed),
376
+ coverage_ratio_bucket: coverageRatioBucket(routed, routed + bypassed),
377
+ // A reason this build does not know travels as `other`, never as free text.
378
+ top_bypass_reason: COVERAGE_REASONS.includes(leading) ? leading : 'other',
379
+ };
380
+ }
297
381
  if (entry.event === 'hook_failure_summary') return {
298
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
382
+ schema_version: SCHEMA_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: recordedVersion,
299
383
  host: entry.host, failure_stage: entry.failureStage,
300
384
  };
301
385
  return {
302
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
386
+ schema_version: SCHEMA_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: recordedVersion,
303
387
  provider: entry.provider, failure_stage: entry.failureStage,
304
388
  };
305
389
  }
306
390
 
307
391
  /** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
308
392
  * only ever leave the machine) once `closeDay` closes a finished UTC day. */
309
- export function incrementCounter({ statePaths, day, event, host, provider, mode, failureStage, deltas = {} }) {
310
- if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary'].includes(event)) {
393
+ export function incrementCounter({ statePaths, day, pluginVersion = PLUGIN_VERSION, event, host, provider, mode, failureStage, deltas = {} }) {
394
+ if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary', 'coverage_summary'].includes(event)) {
311
395
  throw new Error('incrementCounter: invalid event');
312
396
  }
313
397
  const isProxy = event.startsWith('proxy_');
314
398
  const dimension = isProxy ? provider : host;
315
- const key = event.includes('failure')
316
- ? [day, event, dimension, failureStage ?? ''].join('|')
317
- : [day, event, dimension, mode ?? ''].join('|');
399
+ if (!SHARED_FIELDS.plugin_version(pluginVersion)) throw new Error('incrementCounter: invalid plugin version');
400
+ const suffix = event.includes('failure') ? failureStage ?? '' : mode ?? '';
401
+ const key = [day, pluginVersion, event, dimension, suffix].join('|');
402
+ const legacyKey = [day, event, dimension, suffix].join('|');
318
403
  ensureDirectory(path.dirname(statePaths.counters));
319
404
  withLock(`${statePaths.counters}.lock`, () => {
320
405
  const state = readCounters(statePaths.counters);
321
- const existing = state.counters[key] ?? {
322
- day, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
406
+ const legacy = state.counters[legacyKey];
407
+ let existing = state.counters[key];
408
+ if (legacy && !legacy.pluginVersion) {
409
+ if (existing) {
410
+ for (const [field, value] of Object.entries(legacy)) {
411
+ if (Number.isInteger(value) && value >= 0) existing[field] = (existing[field] ?? 0) + value;
412
+ }
413
+ } else existing = legacy;
414
+ delete state.counters[legacyKey];
415
+ }
416
+ existing ??= {
417
+ day, pluginVersion, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
323
418
  ...(event.endsWith('_summary') && !event.includes('failure') ? { mode: mode ?? null } : {}),
324
419
  ...(event.includes('failure') ? { failureStage } : {}),
325
420
  };
421
+ existing.pluginVersion = pluginVersion;
326
422
  for (const [field, value] of Object.entries(deltas)) {
327
423
  if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
328
424
  existing[field] = (existing[field] ?? 0) + value;
@@ -332,23 +428,52 @@ export function incrementCounter({ statePaths, day, event, host, provider, mode,
332
428
  });
333
429
  }
334
430
 
335
- export function recordFailure({ statePaths, day, event, host, provider, failureStage }) {
431
+ export function recordFailure({ statePaths, day, pluginVersion = PLUGIN_VERSION, event, host, provider, failureStage }) {
336
432
  incrementCounter({
337
- statePaths, day, event, host, provider, failureStage, deltas: { count: 1 },
433
+ statePaths, day, pluginVersion, event, host, provider, failureStage, deltas: { count: 1 },
338
434
  });
339
435
  }
340
436
 
341
437
  /** Queues a single non-aggregate activity marker for this UTC day and host. */
342
- export function recordActiveDay({ statePaths, day, pluginVersion, host }) {
438
+ export const REASON_PREFIX = 'reason:';
439
+
440
+ export function coverageRatioBucket(routed, total) {
441
+ if (!Number.isInteger(routed) || !Number.isInteger(total) || routed < 0 || total < routed) {
442
+ throw new Error('coverageRatioBucket: invalid counts');
443
+ }
444
+ if (total === 0 || routed === 0) return 'zero';
445
+ const ratio = routed / total;
446
+ if (ratio < 0.01) return 'lt_1pct';
447
+ if (ratio < 0.1) return '1_to_10pct';
448
+ if (ratio < 0.5) return '10_to_50pct';
449
+ if (ratio < 0.9) return '50_to_90pct';
450
+ return 'gt_90pct';
451
+ }
452
+
453
+ /** One call per classified shell command. Counters close into a single row per day, next to the
454
+ * reduction they qualify: a large saving on a small share of commands should not read the same as
455
+ * a large saving on all of them. */
456
+ export function recordCoverage({ statePaths, day, pluginVersion = PLUGIN_VERSION, host, routed, reason }) {
457
+ const deltas = routed ? { routed: 1 } : { bypassed: 1 };
458
+ if (!routed) {
459
+ const label = COVERAGE_REASONS.includes(reason) ? reason : 'other';
460
+ deltas[`${REASON_PREFIX}${label}`] = 1;
461
+ }
462
+ incrementCounter({ statePaths, day, pluginVersion, event: 'coverage_summary', host, deltas });
463
+ }
464
+
465
+ export function recordActiveDay({ statePaths, day, pluginVersion = PLUGIN_VERSION, host }) {
343
466
  const marker = {
344
467
  schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
345
468
  };
346
469
  const validatedMarker = validateEvent(marker);
347
- const activeDayKey = `${day}|${host}`;
470
+ const activeDayKey = `${day}|${pluginVersion}|${host}`;
471
+ const legacyActiveDayKey = `${day}|${host}`;
348
472
  ensureDirectory(path.dirname(statePaths.counters));
349
473
  withLock(`${statePaths.counters}.lock`, () => {
350
474
  const state = readCounters(statePaths.counters);
351
475
  const activeDays = state.active_days;
476
+ if (Object.hasOwn(activeDays, legacyActiveDayKey)) delete activeDays[legacyActiveDayKey];
352
477
  const cutoff = Date.parse(`${day}T00:00:00Z`) - (ACTIVE_DAY_RETENTION_DAYS - 1) * 86_400_000;
353
478
  for (const [key, recordedDay] of Object.entries(activeDays)) {
354
479
  if (Date.parse(`${recordedDay}T00:00:00Z`) < cutoff) delete activeDays[key];
@@ -459,6 +584,7 @@ export function toOtlpLogs(rows) {
459
584
  resource: { attributes: [{ key: 'service.name', value: { stringValue: 'sando' } }] },
460
585
  scopeLogs: [{
461
586
  logRecords: rows.map((row) => ({
587
+ ...(typeof row._timeUnixNano === 'string' ? { timeUnixNano: row._timeUnixNano } : {}),
462
588
  body: { stringValue: 'sando.daily_aggregate' },
463
589
  attributes: Object.entries(publicRow(row)).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
464
590
  })),