sandoichi 0.4.1 → 0.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/src/telemetry.mjs CHANGED
@@ -5,16 +5,30 @@ 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'];
18
32
  export const FAILURE_STAGES = [
19
33
  'policy', 'input', 'redaction', 'optimization', 'artifact', 'output', 'upstream', 'response',
20
34
  ];
@@ -23,6 +37,7 @@ const SHARED_FIELDS = {
23
37
  schema_version: (value) => value === SCHEMA_VERSION,
24
38
  event: (value) => [
25
39
  'hook_summary', 'proxy_summary', 'active_day', 'hook_failure_summary', 'proxy_failure_summary',
40
+ 'f1_footprint', 'f4_gateway',
26
41
  ].includes(value),
27
42
  day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
28
43
  plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(value) && value.length <= MAX_STRING_LENGTH,
@@ -54,12 +69,28 @@ const PROXY_FAILURE_FIELDS = {
54
69
  provider: (value) => PROVIDERS.includes(value),
55
70
  failure_stage: (value) => FAILURE_STAGES.includes(value),
56
71
  };
72
+ const F4_FIELDS = {
73
+ f4_host: (value) => F4_HOSTS.includes(value),
74
+ f4_operation: (value) => F4_OPERATIONS.includes(value),
75
+ f4_outcome: (value) => F4_OUTCOMES.includes(value),
76
+ f4_latency_bucket: (value) => F4_LATENCY_BUCKETS.includes(value),
77
+ f4_result_bucket: (value) => F4_RESULT_BUCKETS.includes(value),
78
+ };
79
+ const F1_FIELDS = {
80
+ f1_host: (value) => F1_HOSTS.includes(value),
81
+ f1_status: (value) => F1_STATUSES.includes(value),
82
+ f1_unknown_ratio_bucket: (value) => F1_RATIO_BUCKETS.includes(value),
83
+ f1_body_size_bucket: (value) => F1_SIZE_BUCKETS.includes(value),
84
+ f1_input_tokens_bucket: (value) => F1_INPUT_BUCKETS.includes(value),
85
+ };
57
86
 
58
87
  function fieldsForEvent(eventType) {
88
+ if (eventType === 'f1_footprint') return F1_FIELDS;
59
89
  if (eventType === 'hook_summary') return HOOK_FIELDS;
60
90
  if (eventType === 'proxy_summary') return PROXY_FIELDS;
61
91
  if (eventType === 'active_day') return ACTIVE_DAY_FIELDS;
62
92
  if (eventType === 'hook_failure_summary') return HOOK_FAILURE_FIELDS;
93
+ if (eventType === 'f4_gateway') return F4_FIELDS;
63
94
  return PROXY_FAILURE_FIELDS;
64
95
  }
65
96
 
@@ -69,7 +100,8 @@ export function countBucket(count) {
69
100
  if (count === 1) return 'one';
70
101
  if (count <= 5) return '2_to_5';
71
102
  if (count <= 20) return '6_to_20';
72
- return 'gt_20';
103
+ if (count <= 100) return '21_to_100';
104
+ return 'gt_100';
73
105
  }
74
106
 
75
107
  export function byteBucket(bytes) {
@@ -77,7 +109,9 @@ export function byteBucket(bytes) {
77
109
  if (bytes < 4096) return 'lt_4k';
78
110
  if (bytes < 16384) return '4_to_16k';
79
111
  if (bytes < 65536) return '16_to_64k';
80
- return 'gte_64k';
112
+ if (bytes < 262144) return '64_to_256k';
113
+ if (bytes < 1048576) return '256k_to_1m';
114
+ return 'gte_1m';
81
115
  }
82
116
 
83
117
  export function validateEvent(payload) {
@@ -106,11 +140,6 @@ export const TELEMETRY_CONFIG_VERSION = 1;
106
140
  export const CONSENT_VERSION = 1;
107
141
  export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
108
142
  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
143
  export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
115
144
 
116
145
  export function isDoNotTrack(env = process.env) {
@@ -239,7 +268,17 @@ function readCounters(countersPath) {
239
268
 
240
269
  function readQueueRows(queuePath) {
241
270
  if (!fs.existsSync(queuePath)) return [];
242
- return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
271
+ return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => {
272
+ const row = JSON.parse(line);
273
+ assertQueueRow(row);
274
+ return row;
275
+ });
276
+ }
277
+
278
+ function assertQueueRow(row) {
279
+ if (!record(row) || !SUPPORTED_QUEUE_SCHEMA_VERSIONS.has(row.schema_version)) {
280
+ throw new Error('telemetry queue event schema is unsupported');
281
+ }
243
282
  }
244
283
 
245
284
  function writeQueueRows(queuePath, rows) {
@@ -253,7 +292,11 @@ function writeQueueRows(queuePath, rows) {
253
292
 
254
293
  /** Enforces the bounded queue (4096 rows / 4 MiB), dropping the oldest rows first —
255
294
  * a telemetry backlog must never grow without bound or block product behavior. */
256
- function appendQueueRows(queuePath, newRows) {
295
+ export function appendQueueRows(queuePath, newRows) {
296
+ for (const row of newRows) {
297
+ assertQueueRow(row);
298
+ if (LOCAL_ONLY_EVENTS.has(row.event)) throw new Error(`${row.event}: local-only event must never reach the upload queue`);
299
+ }
257
300
  ensureDirectory(path.dirname(queuePath));
258
301
  withLock(`${queuePath}.lock`, () => {
259
302
  let rows = readQueueRows(queuePath);
@@ -279,7 +322,7 @@ function publicRow(row) {
279
322
  function bucketEntry(entry, pluginVersion) {
280
323
  if (entry.event === 'hook_summary') {
281
324
  return {
282
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
325
+ schema_version: SCHEMA_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: PLUGIN_VERSION,
283
326
  host: entry.host, mode: entry.mode,
284
327
  tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
285
328
  capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
@@ -288,18 +331,18 @@ function bucketEntry(entry, pluginVersion) {
288
331
  };
289
332
  }
290
333
  if (entry.event === 'proxy_summary') return {
291
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
334
+ schema_version: SCHEMA_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: PLUGIN_VERSION,
292
335
  provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
293
336
  rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
294
337
  rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
295
338
  input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
296
339
  };
297
340
  if (entry.event === 'hook_failure_summary') return {
298
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
341
+ schema_version: SCHEMA_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: PLUGIN_VERSION,
299
342
  host: entry.host, failure_stage: entry.failureStage,
300
343
  };
301
344
  return {
302
- schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
345
+ schema_version: SCHEMA_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: PLUGIN_VERSION,
303
346
  provider: entry.provider, failure_stage: entry.failureStage,
304
347
  };
305
348
  }
@@ -341,7 +384,7 @@ export function recordFailure({ statePaths, day, event, host, provider, failureS
341
384
  /** Queues a single non-aggregate activity marker for this UTC day and host. */
342
385
  export function recordActiveDay({ statePaths, day, pluginVersion, host }) {
343
386
  const marker = {
344
- schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
387
+ schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: PLUGIN_VERSION, host,
345
388
  };
346
389
  const validatedMarker = validateEvent(marker);
347
390
  const activeDayKey = `${day}|${host}`;
@@ -459,6 +502,7 @@ export function toOtlpLogs(rows) {
459
502
  resource: { attributes: [{ key: 'service.name', value: { stringValue: 'sando' } }] },
460
503
  scopeLogs: [{
461
504
  logRecords: rows.map((row) => ({
505
+ ...(typeof row._timeUnixNano === 'string' ? { timeUnixNano: row._timeUnixNano } : {}),
462
506
  body: { stringValue: 'sando.daily_aggregate' },
463
507
  attributes: Object.entries(publicRow(row)).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
464
508
  })),