sandoichi 0.1.5 → 0.2.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
@@ -13,34 +13,54 @@ const MAX_EVENT_BYTES = 2048;
13
13
  const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20'];
14
14
  const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', 'gte_64k'];
15
15
  const HOSTS = ['claude', 'codex'];
16
- const MODES = ['enforce', 'observe'];
17
- const YES_NO_UNKNOWN = ['yes', 'no', 'unknown'];
16
+ const PROVIDERS = ['anthropic', 'openai', 'unknown'];
17
+ const MODES = ['enforce', 'observe', 'dry_run'];
18
+ export const FAILURE_STAGES = [
19
+ 'policy', 'input', 'redaction', 'optimization', 'artifact', 'output', 'upstream', 'response',
20
+ ];
18
21
 
19
22
  const SHARED_FIELDS = {
20
23
  schema_version: (value) => value === SCHEMA_VERSION,
21
- event: (value) => value === 'hook_summary' || value === 'proxy_summary',
24
+ event: (value) => [
25
+ 'hook_summary', 'proxy_summary', 'active_day', 'hook_failure_summary', 'proxy_failure_summary',
26
+ ].includes(value),
22
27
  day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
23
- plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+$/.test(value) && value.length <= MAX_STRING_LENGTH,
24
- host: (value) => HOSTS.includes(value),
28
+ plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(value) && value.length <= MAX_STRING_LENGTH,
25
29
  };
26
30
 
27
31
  const HOOK_FIELDS = {
32
+ host: (value) => HOSTS.includes(value),
28
33
  mode: (value) => MODES.includes(value),
29
34
  tool_calls_bucket: (value) => COUNT_BUCKETS.includes(value),
30
- redactions_bucket: (value) => COUNT_BUCKETS.includes(value),
31
35
  capped_outputs_bucket: (value) => COUNT_BUCKETS.includes(value),
32
36
  bytes_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
37
+ input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
33
38
  };
34
39
 
35
40
  const PROXY_FIELDS = {
41
+ provider: (value) => PROVIDERS.includes(value),
42
+ mode: (value) => MODES.includes(value),
36
43
  rewrites_applied_bucket: (value) => COUNT_BUCKETS.includes(value),
37
44
  rewrites_skipped_cache_bucket: (value) => COUNT_BUCKETS.includes(value),
38
45
  input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
39
- prompt_cache_hit: (value) => YES_NO_UNKNOWN.includes(value),
46
+ };
47
+
48
+ const ACTIVE_DAY_FIELDS = { host: (value) => HOSTS.includes(value) };
49
+ const HOOK_FAILURE_FIELDS = {
50
+ host: (value) => HOSTS.includes(value),
51
+ failure_stage: (value) => FAILURE_STAGES.includes(value),
52
+ };
53
+ const PROXY_FAILURE_FIELDS = {
54
+ provider: (value) => PROVIDERS.includes(value),
55
+ failure_stage: (value) => FAILURE_STAGES.includes(value),
40
56
  };
41
57
 
42
58
  function fieldsForEvent(eventType) {
43
- return eventType === 'hook_summary' ? HOOK_FIELDS : PROXY_FIELDS;
59
+ if (eventType === 'hook_summary') return HOOK_FIELDS;
60
+ if (eventType === 'proxy_summary') return PROXY_FIELDS;
61
+ if (eventType === 'active_day') return ACTIVE_DAY_FIELDS;
62
+ if (eventType === 'hook_failure_summary') return HOOK_FAILURE_FIELDS;
63
+ return PROXY_FAILURE_FIELDS;
44
64
  }
45
65
 
46
66
  export function countBucket(count) {
@@ -92,6 +112,10 @@ export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/
92
112
  // session-handoff/docs/telemetry-canary-report.md.
93
113
  export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
94
114
 
115
+ export function isDoNotTrack(env = process.env) {
116
+ return env.DO_NOT_TRACK !== undefined && env.DO_NOT_TRACK !== '' && env.DO_NOT_TRACK !== '0';
117
+ }
118
+
95
119
  function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
96
120
 
97
121
  function emptyTelemetryConfig() {
@@ -140,11 +164,10 @@ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
140
164
  return readTelemetryConfig(configPath);
141
165
  }
142
166
 
143
- /** Only an explicit `yes` in an interactive session enables collection; anything else
144
- * (blank, `no`, EOF, or a non-interactive caller) writes the disabled prompt marker so
145
- * upgrades and reinstalls never re-prompt or silently opt a user in. */
167
+ /** Only an explicit `yes` in an interactive session enables collection. */
146
168
  export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
147
- if (!interactive || typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
169
+ if (!interactive) return { ...readTelemetryConfig(configPath), exitCode: 1 };
170
+ if (typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
148
171
  return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
149
172
  }
150
173
  return writeTelemetryConfig(configPath, {
@@ -157,14 +180,21 @@ export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), ans
157
180
  });
158
181
  }
159
182
 
160
- const QUEUE_MAX_ROWS = 256;
161
- const QUEUE_MAX_BYTES = 256 * 1024;
183
+ // 30 days of daily aggregates plus activity markers for both supported hosts,
184
+ // with headroom for concurrent event dimensions and temporary outages.
185
+ const QUEUE_MAX_ROWS = 4096;
186
+ const QUEUE_MAX_BYTES = 4 * 1024 * 1024;
187
+ const ACTIVE_DAY_RETENTION_DAYS = 30;
162
188
  const DEFAULT_BATCH_MAX = 32;
189
+ const LEASE_MS = 5 * 60 * 1000;
190
+ const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000];
191
+ const CHILD_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE'];
163
192
 
164
- function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {} }; }
193
+ function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {} }; }
165
194
  function readCounters(countersPath) {
166
195
  if (!fs.existsSync(countersPath)) return emptyCounters();
167
- return JSON.parse(fs.readFileSync(countersPath, 'utf8'));
196
+ const state = JSON.parse(fs.readFileSync(countersPath, 'utf8'));
197
+ return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {} };
168
198
  }
169
199
 
170
200
  function readQueueRows(queuePath) {
@@ -181,24 +211,29 @@ function writeQueueRows(queuePath, rows) {
181
211
  fs.chmodSync(queuePath, 0o600);
182
212
  }
183
213
 
184
- /** Enforces the bounded queue (256 rows / 256 KiB), dropping the oldest rows first —
214
+ /** Enforces the bounded queue (4096 rows / 4 MiB), dropping the oldest rows first —
185
215
  * a telemetry backlog must never grow without bound or block product behavior. */
186
216
  function appendQueueRows(queuePath, newRows) {
217
+ ensureDirectory(path.dirname(queuePath));
187
218
  withLock(`${queuePath}.lock`, () => {
188
- let rows = [...readQueueRows(queuePath), ...newRows];
219
+ let rows = readQueueRows(queuePath);
220
+ const keys = new Set(rows.map((row) => queueKey(row)));
221
+ for (const row of newRows) {
222
+ if (!keys.has(queueKey(row))) { rows.push(row); keys.add(queueKey(row)); }
223
+ }
189
224
  if (rows.length > QUEUE_MAX_ROWS) rows = rows.slice(rows.length - QUEUE_MAX_ROWS);
190
225
  while (rows.length > 0 && Buffer.byteLength(rows.map((row) => JSON.stringify(row)).join('\n')) > QUEUE_MAX_BYTES) rows.shift();
191
226
  writeQueueRows(queuePath, rows);
192
227
  });
193
228
  }
194
229
 
195
- function majorityCacheHit(entry) {
196
- const yes = entry.cacheHitYes ?? 0;
197
- const no = entry.cacheHitNo ?? 0;
198
- const unknown = entry.cacheHitUnknown ?? 0;
199
- if (yes > no && yes >= unknown) return 'yes';
200
- if (no > yes && no >= unknown) return 'no';
201
- return 'unknown';
230
+ function queueKey(row) {
231
+ return [row.event, row.day_utc, row.plugin_version, row.host ?? row.provider ?? '', row.mode ?? '', row.failureStage ?? row.failure_stage ?? ''].join('|');
232
+ }
233
+
234
+ function publicRow(row) {
235
+ const allowed = { ...SHARED_FIELDS, ...fieldsForEvent(row.event) };
236
+ return Object.fromEntries(Object.entries(row).filter(([key]) => Object.hasOwn(allowed, key)));
202
237
  }
203
238
 
204
239
  function bucketEntry(entry, pluginVersion) {
@@ -207,30 +242,47 @@ function bucketEntry(entry, pluginVersion) {
207
242
  schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
208
243
  host: entry.host, mode: entry.mode,
209
244
  tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
210
- redactions_bucket: countBucket(entry.redactions ?? 0),
211
245
  capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
212
246
  bytes_saved_bucket: byteBucket(entry.bytesSaved ?? 0),
247
+ input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
213
248
  };
214
249
  }
215
- return {
250
+ if (entry.event === 'proxy_summary') return {
216
251
  schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
217
- host: entry.host,
252
+ provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
218
253
  rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
219
254
  rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
220
255
  input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
221
- prompt_cache_hit: majorityCacheHit(entry),
256
+ };
257
+ if (entry.event === 'hook_failure_summary') return {
258
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
259
+ host: entry.host, failure_stage: entry.failureStage,
260
+ };
261
+ return {
262
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
263
+ provider: entry.provider, failure_stage: entry.failureStage,
222
264
  };
223
265
  }
224
266
 
225
267
  /** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
226
268
  * only ever leave the machine) once `closeDay` closes a finished UTC day. */
227
- export function incrementCounter({ statePaths, day, event, host, mode, deltas = {} }) {
228
- if (!['hook_summary', 'proxy_summary'].includes(event)) throw new Error('incrementCounter: invalid event');
229
- const key = `${day}|${event}|${host}|${mode ?? ''}`;
269
+ export function incrementCounter({ statePaths, day, event, host, provider, mode, failureStage, deltas = {} }) {
270
+ if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary'].includes(event)) {
271
+ throw new Error('incrementCounter: invalid event');
272
+ }
273
+ const isProxy = event.startsWith('proxy_');
274
+ const dimension = isProxy ? provider : host;
275
+ const key = event.includes('failure')
276
+ ? [day, event, dimension, failureStage ?? ''].join('|')
277
+ : [day, event, dimension, mode ?? ''].join('|');
230
278
  ensureDirectory(path.dirname(statePaths.counters));
231
279
  withLock(`${statePaths.counters}.lock`, () => {
232
280
  const state = readCounters(statePaths.counters);
233
- const existing = state.counters[key] ?? { day, event, host, mode: mode ?? null };
281
+ const existing = state.counters[key] ?? {
282
+ day, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
283
+ ...(event.endsWith('_summary') && !event.includes('failure') ? { mode: mode ?? null } : {}),
284
+ ...(event.includes('failure') ? { failureStage } : {}),
285
+ };
234
286
  for (const [field, value] of Object.entries(deltas)) {
235
287
  if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
236
288
  existing[field] = (existing[field] ?? 0) + value;
@@ -240,6 +292,37 @@ export function incrementCounter({ statePaths, day, event, host, mode, deltas =
240
292
  });
241
293
  }
242
294
 
295
+ export function recordFailure({ statePaths, day, event, host, provider, failureStage }) {
296
+ incrementCounter({
297
+ statePaths, day, event, host, provider, failureStage, deltas: { count: 1 },
298
+ });
299
+ }
300
+
301
+ /** Queues a single non-aggregate activity marker for this UTC day and host. */
302
+ export function recordActiveDay({ statePaths, day, pluginVersion, host }) {
303
+ const marker = {
304
+ schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
305
+ };
306
+ const validatedMarker = validateEvent(marker);
307
+ const activeDayKey = `${day}|${host}`;
308
+ ensureDirectory(path.dirname(statePaths.counters));
309
+ withLock(`${statePaths.counters}.lock`, () => {
310
+ const state = readCounters(statePaths.counters);
311
+ const activeDays = state.active_days;
312
+ const cutoff = Date.parse(`${day}T00:00:00Z`) - (ACTIVE_DAY_RETENTION_DAYS - 1) * 86_400_000;
313
+ for (const [key, recordedDay] of Object.entries(activeDays)) {
314
+ if (Date.parse(`${recordedDay}T00:00:00Z`) < cutoff) delete activeDays[key];
315
+ }
316
+ if (Object.hasOwn(activeDays, activeDayKey)) {
317
+ atomicWrite(statePaths.counters, state);
318
+ return;
319
+ }
320
+ appendQueueRows(statePaths.queue, [validatedMarker]);
321
+ activeDays[activeDayKey] = day;
322
+ atomicWrite(statePaths.counters, state);
323
+ });
324
+ }
325
+
243
326
  /** Closes a finished UTC day: buckets its raw counters into daily_aggregate rows,
244
327
  * appends them to the upload queue, and clears them from the raw counter file so a
245
328
  * day is never counted twice. */
@@ -252,11 +335,11 @@ export function closeDay({ statePaths, day, pluginVersion }) {
252
335
  if (entry.day !== day) { remaining[key] = entry; continue; }
253
336
  closedRows.push(validateEvent(bucketEntry(entry, pluginVersion)));
254
337
  }
338
+ if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
255
339
  state.counters = remaining;
256
340
  ensureDirectory(path.dirname(statePaths.counters));
257
341
  atomicWrite(statePaths.counters, state);
258
342
  });
259
- if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
260
343
  return closedRows;
261
344
  }
262
345
 
@@ -264,14 +347,16 @@ function launchDetachedFlush({ statePaths, configPath, spawnImpl }) {
264
347
  try {
265
348
  const entryPath = fileURLToPath(new URL('./telemetry-flush-entry.mjs', import.meta.url));
266
349
  const child = spawnImpl(process.execPath, [entryPath, '--queue', statePaths.queue, '--config', configPath], {
267
- detached: true, env: {}, stdio: 'ignore', windowsHide: true,
350
+ detached: true,
351
+ env: Object.fromEntries(CHILD_ENV_KEYS.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]])),
352
+ stdio: 'ignore', windowsHide: true,
268
353
  });
269
354
  child.unref();
270
355
  } catch { /* telemetry must never affect the caller */ }
271
356
  }
272
357
 
273
- /** Closes every raw counter day before `day` and starts one detached uploader.
274
- * The child receives only local state paths and an empty environment. */
358
+ /** Closes every raw counter day before `day` and starts one detached uploader
359
+ * whenever any queue row remains, including a queue from a previous session. */
275
360
  export function closeFinishedDays({
276
361
  statePaths, configPath = defaultTelemetryConfigPath(), day, pluginVersion,
277
362
  spawnImpl = spawn,
@@ -287,18 +372,44 @@ export function closeFinishedDays({
287
372
  for (const closedDay of [...days].sort()) {
288
373
  closedRows.push(...closeDay({ statePaths, day: closedDay, pluginVersion }));
289
374
  }
290
- if (closedRows.length) launchDetachedFlush({ statePaths, configPath, spawnImpl });
375
+ if (fs.existsSync(statePaths.queue) && readQueueRows(statePaths.queue).length) {
376
+ launchDetachedFlush({ statePaths, configPath, spawnImpl });
377
+ }
291
378
  return closedRows;
292
379
  }
293
380
 
294
- export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX } = {}) {
295
- return readQueueRows(statePaths.queue).slice(0, max);
381
+ function claimBatch({ statePaths, max, now = Date.now, leaseMs = LEASE_MS }) {
382
+ let claimed = [];
383
+ withLock(`${statePaths.queue}.lock`, () => {
384
+ const rows = readQueueRows(statePaths.queue);
385
+ const timestamp = now();
386
+ const available = rows.filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= timestamp));
387
+ if (!available.length) return;
388
+ if (available.some((row) => row._leaseUntil > timestamp)) return;
389
+ const leaseId = `${process.pid}-${timestamp}-${Math.random().toString(36).slice(2)}`;
390
+ const selected = available.slice(0, max);
391
+ const selectedKeys = new Set(selected.map(queueKey));
392
+ for (const row of rows) {
393
+ if (selectedKeys.has(queueKey(row))) { row._leaseId = leaseId; row._leaseUntil = timestamp + leaseMs; }
394
+ }
395
+ writeQueueRows(statePaths.queue, rows);
396
+ claimed = selected.map((row) => ({ ...row, _leaseId: leaseId, _leaseUntil: timestamp + leaseMs }));
397
+ });
398
+ return claimed;
399
+ }
400
+
401
+ export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX, lease = true, now = Date.now } = {}) {
402
+ if (lease) return claimBatch({ statePaths, max, now });
403
+ return readQueueRows(statePaths.queue).filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= now())).slice(0, max).map(publicRow);
296
404
  }
297
405
 
298
- export function ackBatch({ statePaths, count }) {
406
+ export function ackBatch({ statePaths, count, leaseId } = {}) {
299
407
  withLock(`${statePaths.queue}.lock`, () => {
300
408
  const rows = readQueueRows(statePaths.queue);
301
- writeQueueRows(statePaths.queue, rows.slice(count));
409
+ if (!leaseId) { writeQueueRows(statePaths.queue, rows.slice(count)); return; }
410
+ const leased = rows.filter((row) => row._leaseId === leaseId).slice(0, count);
411
+ const keys = new Set(leased.map(queueKey));
412
+ writeQueueRows(statePaths.queue, rows.filter((row) => !keys.has(queueKey(row))));
302
413
  });
303
414
  }
304
415
 
@@ -309,7 +420,7 @@ export function toOtlpLogs(rows) {
309
420
  scopeLogs: [{
310
421
  logRecords: rows.map((row) => ({
311
422
  body: { stringValue: 'sando.daily_aggregate' },
312
- attributes: Object.entries(row).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
423
+ attributes: Object.entries(publicRow(row)).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
313
424
  })),
314
425
  }],
315
426
  }],
@@ -317,30 +428,78 @@ export function toOtlpLogs(rows) {
317
428
  }
318
429
 
319
430
  export function previewNextUpload({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX } = {}) {
320
- const rows = loadBatch({ statePaths, max });
431
+ const rows = loadBatch({ statePaths, max, lease: false });
321
432
  return { url: endpoint, headers: { 'content-type': 'application/json' }, body: toOtlpLogs(rows) };
322
433
  }
323
434
 
324
- /** Uploads at most one batch. Every failure mode (timeout, network error, non-2xx) is
325
- * swallowed and reported as `sent: 0` telemetry must never throw into, or change the
326
- * outcome of, the hook or proxy call that triggered a day close. */
327
- export async function flushQueue({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000 } = {}) {
328
- const rows = loadBatch({ statePaths, max });
329
- if (rows.length === 0) return { sent: 0 };
330
- const controller = new AbortController();
331
- const timer = setTimeout(() => controller.abort(), timeoutMs);
332
- try {
333
- const response = await fetch(endpoint, {
334
- method: 'POST', headers: { 'content-type': 'application/json' },
335
- body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
336
- });
337
- if (!response.ok) return { sent: 0 };
338
- ackBatch({ statePaths, count: rows.length });
339
- return { sent: rows.length };
340
- } catch {
341
- return { sent: 0 };
342
- } finally {
343
- clearTimeout(timer);
435
+ /** Drains eligible batches. Every failure mode is swallowed and reported without
436
+ * changing the outcome of the hook or proxy call that triggered the flush. */
437
+ function retryAfterMs(response, now) {
438
+ const value = response.headers?.get?.('retry-after');
439
+ if (!value) return 0;
440
+ const seconds = Number(value);
441
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
442
+ const timestamp = Date.parse(value);
443
+ return Number.isNaN(timestamp) ? 0 : Math.max(0, timestamp - now);
444
+ }
445
+
446
+ function updateClaimedRows({ statePaths, leaseId, update }) {
447
+ withLock(`${statePaths.queue}.lock`, () => {
448
+ const rows = readQueueRows(statePaths.queue);
449
+ for (const row of rows) if (row._leaseId === leaseId) update(row);
450
+ writeQueueRows(statePaths.queue, rows);
451
+ });
452
+ }
453
+
454
+ function isRetryableStatus(status) { return [429, 502, 503, 504].includes(status); }
455
+
456
+ export async function flushQueue({
457
+ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000,
458
+ fetchImpl = fetch, now = Date.now, random = Math.random, sleep = async () => {},
459
+ } = {}) {
460
+ const result = { sent: 0, rejectedLogRecords: 0 };
461
+ while (true) {
462
+ const rows = claimBatch({ statePaths, max, now });
463
+ if (rows.length === 0) return result;
464
+ const leaseId = rows[0]._leaseId;
465
+ const controller = new AbortController();
466
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
467
+ try {
468
+ const response = await fetchImpl(endpoint, {
469
+ method: 'POST', headers: { 'content-type': 'application/json' },
470
+ body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
471
+ });
472
+ if (response.ok) {
473
+ let body = {};
474
+ try { body = await response.json(); } catch { /* empty 2xx body */ }
475
+ result.rejectedLogRecords += Number(body?.partialSuccess?.rejectedLogRecords || 0);
476
+ ackBatch({ statePaths, count: rows.length, leaseId });
477
+ result.sent += rows.length;
478
+ } else if (isRetryableStatus(response.status)) {
479
+ updateClaimedRows({ statePaths, leaseId, update: (row) => {
480
+ const attempt = (row._attemptCount ?? 0) + 1;
481
+ const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
482
+ row._attemptCount = attempt;
483
+ row._nextAttemptAt = now() + Math.max(base * random(), retryAfterMs(response, now()));
484
+ delete row._leaseId; delete row._leaseUntil;
485
+ }});
486
+ return result;
487
+ } else {
488
+ ackBatch({ statePaths, count: rows.length, leaseId });
489
+ }
490
+ } catch {
491
+ updateClaimedRows({ statePaths, leaseId, update: (row) => {
492
+ const attempt = (row._attemptCount ?? 0) + 1;
493
+ const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
494
+ row._attemptCount = attempt;
495
+ row._nextAttemptAt = now() + base * random();
496
+ delete row._leaseId; delete row._leaseUntil;
497
+ }});
498
+ return result;
499
+ } finally {
500
+ clearTimeout(timer);
501
+ }
502
+ await sleep(0);
344
503
  }
345
504
  }
346
505
 
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const VERSION_PATTERN = /^\d+\.\d+(?:\.\d+)?$/;
5
+ const STANDALONE_VERSION = '0.2.0';
6
+ const METADATA_FILES = ['package.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json'];
7
+
8
+ function findMetadataFile() {
9
+ let directory = path.resolve(import.meta.dirname, '..');
10
+ while (true) {
11
+ for (const relativePath of METADATA_FILES) {
12
+ const file = path.join(directory, relativePath);
13
+ if (fs.existsSync(file)) return file;
14
+ }
15
+ const parent = path.dirname(directory);
16
+ if (parent === directory) break;
17
+ directory = parent;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ const metadataPath = findMetadataFile();
23
+ const metadata = metadataPath ? JSON.parse(fs.readFileSync(metadataPath, 'utf8')) : { version: STANDALONE_VERSION };
24
+ if (typeof metadata.version !== 'string' || !VERSION_PATTERN.test(metadata.version)) {
25
+ throw new Error(`Invalid Sando version in ${metadataPath}`);
26
+ }
27
+
28
+ // Evaluated once per process: no repeated filesystem reads in telemetry paths.
29
+ export const PLUGIN_VERSION = metadata.version;