sandoichi 0.1.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Reduce repeated tool-output context in Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
package/src/hook-cli.mjs CHANGED
@@ -6,40 +6,51 @@ import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } fr
6
6
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
7
7
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
8
8
  import {
9
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
9
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordActiveDay, recordFailure,
10
10
  } from './telemetry.mjs';
11
-
12
- const PLUGIN_VERSION = '0.1';
11
+ import { PLUGIN_VERSION } from './version.mjs';
13
12
 
14
13
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
15
14
 
16
- /** Only counts (never content, paths, or IDs) — see docs/plans/2026-08-25-sando-telemetry-design.md.
17
- * `enforce` covers both `apply` and `dry-run`: both walk the real rewrite path, only
18
- * `observe` collects without deciding anything. */
15
+ /** Only counts (never content, paths, or IDs). */
19
16
  function recordHookTelemetry({ host, env, policy, optimization }) {
20
- const configPath = defaultTelemetryConfigPath(env);
21
- let config;
22
- try { config = readTelemetryConfig(configPath); } catch { return; }
23
- if (!config.enabled) return;
24
17
  try {
18
+ const configPath = defaultTelemetryConfigPath(env);
19
+ const config = readTelemetryConfig(configPath);
20
+ if (!config.enabled || isDoNotTrack(env)) return;
25
21
  const statePaths = defaultTelemetryStatePaths(env);
22
+ recordActiveDay({ statePaths, day: todayUtc(), pluginVersion: PLUGIN_VERSION, host });
26
23
  incrementCounter({
27
24
  statePaths,
28
25
  day: todayUtc(),
29
26
  event: 'hook_summary',
30
27
  host,
31
- mode: policy.mode === 'observe' ? 'observe' : 'enforce',
28
+ mode: policy.mode === 'apply' ? 'enforce' : policy.mode === 'dry-run' ? 'dry_run' : 'observe',
32
29
  deltas: {
33
30
  toolCalls: 1,
34
31
  redactions: optimization.stats.redactions,
35
32
  cappedOutputs: optimization.artifact ? 1 : 0,
36
33
  bytesSaved: Math.max(0, optimization.stats.inputBytes - optimization.stats.inlineBytes),
34
+ inputTokensSaved: Math.max(0, optimization.stats.estimatedInputTokens - optimization.stats.estimatedInlineTokens),
37
35
  },
38
36
  });
39
37
  closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
40
38
  } catch { /* telemetry is best-effort and must never affect hook output */ }
41
39
  }
42
40
 
41
+ function recordHookFailure({ host, env, failureStage }) {
42
+ try {
43
+ const configPath = defaultTelemetryConfigPath(env);
44
+ const config = readTelemetryConfig(configPath);
45
+ if (!config.enabled || isDoNotTrack(env)) return;
46
+ const statePaths = defaultTelemetryStatePaths(env);
47
+ const day = todayUtc();
48
+ recordActiveDay({ statePaths, day, pluginVersion: PLUGIN_VERSION, host });
49
+ recordFailure({ statePaths, day, event: 'hook_failure_summary', host, failureStage });
50
+ closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
51
+ } catch { /* telemetry is best-effort and must never affect hook output */ }
52
+ }
53
+
43
54
  function hookPolicy(env, host) {
44
55
  const policy = env.SANDO_POLICY
45
56
  ? JSON.parse(env.SANDO_POLICY)
@@ -79,18 +90,23 @@ export function runHookCli({ host, env = process.env } = {}) {
79
90
  try {
80
91
  policy = hookPolicy(env, host);
81
92
  } catch (error) {
93
+ recordHookFailure({ host, env, failureStage: 'policy' });
82
94
  process.stderr.write(`sando invalid policy: ${error instanceof Error ? error.message : 'invalid input'}\n`);
83
95
  process.exitCode = 2;
84
96
  return;
85
97
  }
98
+ let failureStage = 'input';
86
99
  try {
87
100
  const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
88
101
  const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
89
102
  if (eventName === 'PostToolUse') {
90
103
  const event = normalizeEvent(input);
104
+ failureStage = 'redaction';
91
105
  const redactionProfile = policy.redact ? loadProjectRedactionProfile(event.cwd).profile : undefined;
106
+ failureStage = 'optimization';
92
107
  const optimization = optimizeToolOutput({ toolName: event.toolName, toolInput: event.toolInput, output: event.output, cwd: event.cwd, policy, redactionProfile });
93
108
  let shaped;
109
+ failureStage = 'artifact';
94
110
  if (host === 'claude' && policy.mode === 'apply') {
95
111
  shaped = shapeForClaude({
96
112
  original: event.output,
@@ -101,6 +117,7 @@ export function runHookCli({ host, env = process.env } = {}) {
101
117
  policy,
102
118
  });
103
119
  }
120
+ failureStage = 'output';
104
121
  const receipt = createReceipt({ host, event, optimization, replacement: shaped });
105
122
  try {
106
123
  recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
@@ -120,6 +137,7 @@ export function runHookCli({ host, env = process.env } = {}) {
120
137
  }
121
138
  }
122
139
  } catch (error) {
140
+ recordHookFailure({ host, env, failureStage });
123
141
  if (error?.code === 'SANDO_REDACTION_CONFIG') {
124
142
  process.stderr.write(`sando invalid redaction config: ${error.message}\n`);
125
143
  process.exitCode = 2;
@@ -1,6 +1,7 @@
1
1
  import readline from 'node:readline';
2
2
 
3
3
  import { optimizeToolOutput } from './core.mjs';
4
+ import { PLUGIN_VERSION } from './version.mjs';
4
5
 
5
6
  const TOOL = {
6
7
  name: 'prepare_tool_output',
@@ -19,7 +20,7 @@ function dispatch(message) {
19
20
  if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(message?.id, -32600, 'Invalid Request');
20
21
  if (message.id === undefined) return null;
21
22
  if (message.method === 'initialize') return response(message.id, {
22
- protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: '0.1.0' },
23
+ protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: PLUGIN_VERSION },
23
24
  });
24
25
  if (message.method === 'ping') return response(message.id, {});
25
26
  if (message.method === 'tools/list') return response(message.id, { tools: [TOOL] });
@@ -8,7 +8,7 @@ import readline from 'node:readline/promises';
8
8
  import { pathToFileURL } from 'node:url';
9
9
 
10
10
  import {
11
- CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, readTelemetryConfig, TELEMETRY_DETAILS_URL,
11
+ CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
12
12
  } from './telemetry.mjs';
13
13
 
14
14
  const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
@@ -19,6 +19,7 @@ export async function runPostinstall({
19
19
  } = {}) {
20
20
  try {
21
21
  if (env.SANDO_SKIP_TELEMETRY_PROMPT) return;
22
+ if (isDoNotTrack(env)) return;
22
23
  if (!stdin.isTTY || !stdout.isTTY) return; // CI, --ignore-scripts consumers, piped installs, etc.
23
24
 
24
25
  const configPath = defaultTelemetryConfigPath(env);
package/src/proxy.mjs CHANGED
@@ -4,43 +4,56 @@ import { estimateTokens } from './core.mjs';
4
4
  import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
5
5
  import { recordProxyRequest } from './proxy-metrics.mjs';
6
6
  import {
7
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
7
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordFailure,
8
8
  } from './telemetry.mjs';
9
-
10
- const PLUGIN_VERSION = '0.1';
9
+ import { PLUGIN_VERSION } from './version.mjs';
11
10
 
12
11
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
13
12
 
14
- /** Only counts (never request/response content) — see docs/plans/2026-08-25-sando-telemetry-design.md.
15
- * `host` here is the provider request shape (`anthropic`/`openai`), which does not map cleanly to
16
- * Sando's `claude`/`codex` telemetry host enum; the proxy fronts both hosts equally, so it reports
17
- * under a fixed `claude` label — this is the one place the design doc's host/provider split is
18
- * approximate, flagged for the design doc rather than silently assumed. */
19
- function recordProxyTelemetry({ env, transformed, beforeText, afterText }) {
20
- const configPath = defaultTelemetryConfigPath(env);
21
- let config;
22
- try { config = readTelemetryConfig(configPath); } catch { return; }
23
- if (!config.enabled) return;
24
- const cacheWarm = transformed.stats.cacheRewriteRatio !== null;
13
+ function telemetryProvider(provider) {
14
+ if (provider === 'anthropic') return 'anthropic';
15
+ if (provider === 'openai-chat' || provider === 'openai-responses') return 'openai';
16
+ return 'unknown';
17
+ }
18
+
19
+ /** Only counts (never request/response content). */
20
+ function recordProxyTelemetry({ env, provider, transformed, beforeText, afterText }) {
25
21
  try {
22
+ const configPath = defaultTelemetryConfigPath(env);
23
+ const config = readTelemetryConfig(configPath);
24
+ if (!config.enabled || isDoNotTrack(env)) return;
26
25
  const statePaths = defaultTelemetryStatePaths(env);
27
26
  incrementCounter({
28
27
  statePaths,
29
28
  day: todayUtc(),
30
29
  event: 'proxy_summary',
31
- host: 'claude',
30
+ provider: telemetryProvider(provider),
31
+ mode: 'enforce',
32
32
  deltas: {
33
33
  rewritesApplied: transformed.changed ? 1 : 0,
34
34
  rewritesSkippedCache: transformed.stats.cacheProtectedSkips > 0 ? 1 : 0,
35
35
  inputTokensSaved: Math.max(0, estimateTokens(beforeText) - estimateTokens(afterText)),
36
- cacheHitYes: cacheWarm ? 1 : 0,
37
- cacheHitNo: cacheWarm ? 0 : 1,
38
36
  },
39
37
  });
40
38
  closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
41
39
  } catch { /* telemetry is best-effort and must never affect the proxied response */ }
42
40
  }
43
41
 
42
+ function recordProxyFailure({ env, provider, failureStage }) {
43
+ try {
44
+ const configPath = defaultTelemetryConfigPath(env);
45
+ const config = readTelemetryConfig(configPath);
46
+ if (!config.enabled || isDoNotTrack(env)) return;
47
+ const statePaths = defaultTelemetryStatePaths(env);
48
+ const day = todayUtc();
49
+ recordFailure({
50
+ statePaths, day, event: 'proxy_failure_summary',
51
+ provider: telemetryProvider(provider), failureStage,
52
+ });
53
+ closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
54
+ } catch { /* telemetry is best-effort and must never affect the proxied response */ }
55
+ }
56
+
44
57
  const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
45
58
  const HOP_BY_HOP_HEADERS = new Set([
46
59
  'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
@@ -176,53 +189,86 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
176
189
  let lastRequestAt = null;
177
190
 
178
191
  const server = http.createServer(async (request, outgoing) => {
192
+ let failureProvider = null;
179
193
  try {
180
194
  if (request.method === 'GET' && new URL(request.url, 'http://sando.invalid').pathname === '/health') {
181
195
  jsonResponse(outgoing, 200, { schema: 'sando-provider-proxy/v1', status: 'ok', lastStats });
182
196
  return;
183
197
  }
184
- const rawBody = await readBody(request, maxBodyBytes);
198
+ let rawBody;
199
+ try {
200
+ rawBody = await readBody(request, maxBodyBytes);
201
+ } catch (error) {
202
+ recordProxyFailure({ env, provider: null, failureStage: 'input' });
203
+ if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
204
+ else outgoing.destroy();
205
+ return;
206
+ }
185
207
  let body = rawBody;
186
208
  let recordProvider = null;
187
209
  let recordModel = null;
188
210
  let recordStats = null;
189
211
  if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
212
+ let parsed;
190
213
  try {
191
- const parsed = JSON.parse(rawBody.toString('utf8'));
214
+ parsed = JSON.parse(rawBody.toString('utf8'));
215
+ } catch {
216
+ recordProxyFailure({ env, provider: null, failureStage: 'input' });
217
+ }
218
+ if (parsed) {
192
219
  const provider = detectProviderBody(parsed, request.headers);
193
- if (provider) {
194
- const now = Date.now();
195
- const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
196
- lastRequestAt = now;
197
- const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
198
- if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
199
- recordProxyTelemetry({
200
- env, transformed,
201
- beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
202
- });
203
- lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
204
- recordProvider = provider;
205
- recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
206
- recordStats = transformed.stats;
207
- if (typeof semanticCompactor === 'function') {
208
- const candidates = listSemanticCandidates({ provider, body: transformed.body });
209
- const stats = createSemanticStats(candidates);
210
- lastStats.semantic = stats;
211
- setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
220
+ failureProvider = provider;
221
+ try {
222
+ if (provider) {
223
+ const now = Date.now();
224
+ const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
225
+ lastRequestAt = now;
226
+ const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
227
+ if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
228
+ recordProxyTelemetry({
229
+ env, provider, transformed,
230
+ beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
231
+ });
232
+ lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
233
+ recordProvider = provider;
234
+ recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
235
+ recordStats = transformed.stats;
236
+ if (typeof semanticCompactor === 'function') {
237
+ const candidates = listSemanticCandidates({ provider, body: transformed.body });
238
+ const stats = createSemanticStats(candidates);
239
+ lastStats.semantic = stats;
240
+ setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
241
+ }
212
242
  }
243
+ } catch {
244
+ recordProxyFailure({ env, provider, failureStage: 'optimization' });
245
+ body = rawBody;
213
246
  }
214
- } catch {
215
- body = rawBody;
216
247
  }
217
248
  }
218
- const response = await fetch(targetUrl(upstreamUrl, request.url), {
219
- method: request.method,
220
- headers: forwardedHeaders(request),
221
- body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
222
- redirect: 'manual',
223
- });
249
+ let response;
250
+ try {
251
+ response = await fetch(targetUrl(upstreamUrl, request.url), {
252
+ method: request.method,
253
+ headers: forwardedHeaders(request),
254
+ body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
255
+ redirect: 'manual',
256
+ });
257
+ } catch {
258
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'upstream' });
259
+ if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
260
+ else outgoing.destroy();
261
+ return;
262
+ }
224
263
  let responseText = '';
225
- await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
264
+ try {
265
+ await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
266
+ } catch {
267
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
268
+ if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
269
+ else outgoing.destroy();
270
+ return;
271
+ }
226
272
  if (metricsPath && recordProvider) {
227
273
  try {
228
274
  recordProxyRequest({
@@ -232,6 +278,7 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
232
278
  } catch { /* metrics are best-effort and must never affect the proxied response */ }
233
279
  }
234
280
  } catch (error) {
281
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
235
282
  if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
236
283
  else outgoing.destroy();
237
284
  }
@@ -1,15 +1,27 @@
1
1
  import path from 'node:path';
2
2
 
3
- import { defaultTelemetryConfigPath, readTelemetryConfig, TELEMETRY_DETAILS_URL } from './telemetry.mjs';
3
+ import {
4
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
5
+ } from './telemetry.mjs';
6
+ import { PLUGIN_VERSION } from './version.mjs';
4
7
 
5
8
  export function runSessionStart({
6
9
  env = process.env,
7
10
  stdout = process.stdout,
8
11
  rootEnv = 'PLUGIN_ROOT',
12
+ spawnImpl,
13
+ configPath = defaultTelemetryConfigPath(env),
14
+ statePaths = defaultTelemetryStatePaths(env),
9
15
  } = {}) {
10
16
  try {
11
- const config = readTelemetryConfig(defaultTelemetryConfigPath(env));
12
- if (config.prompted_consent_version > 0) {
17
+ const config = readTelemetryConfig(configPath);
18
+ if (config.enabled && !isDoNotTrack(env)) {
19
+ closeFinishedDays({
20
+ statePaths, configPath, day: new Date().toISOString().slice(0, 10),
21
+ pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
22
+ });
23
+ }
24
+ if (isDoNotTrack(env) || config.prompted_consent_version > 0) {
13
25
  stdout.write('{}\n');
14
26
  return;
15
27
  }
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
6
6
 
7
7
  import {
8
8
  defaultTelemetryConfigPath, defaultTelemetryStatePaths,
9
- disableTelemetry, enableTelemetry, flushQueue, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
9
+ disableTelemetry, enableTelemetry, flushQueue, isDoNotTrack, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
10
10
  } from './telemetry.mjs';
11
11
 
12
12
  const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
@@ -26,13 +26,26 @@ export async function runTelemetryCli({
26
26
  try {
27
27
  if (command === 'status') {
28
28
  const config = statusTelemetry(configPath);
29
+ if (isDoNotTrack(env)) {
30
+ stdout.write('telemetry: disabled by DO_NOT_TRACK\n');
31
+ return { ...config, enabled: false };
32
+ }
29
33
  stdout.write(`telemetry: ${config.enabled ? 'enabled' : 'disabled'}\n`);
30
34
  return config;
31
35
  }
32
36
  if (command === 'enable') {
37
+ if (isDoNotTrack(env)) {
38
+ stderr.write('sando telemetry: DO_NOT_TRACK is set; telemetry remains disabled\n');
39
+ return { ...statusTelemetry(configPath), enabled: false, exitCode: 1 };
40
+ }
33
41
  if (!interactive) {
34
42
  stderr.write('sando telemetry: enable requires an interactive session\n');
35
- return enableTelemetry({ configPath, interactive: false });
43
+ return { ...statusTelemetry(configPath), exitCode: 1 };
44
+ }
45
+ const current = statusTelemetry(configPath);
46
+ if (current.enabled) {
47
+ stdout.write('telemetry already enabled.\n');
48
+ return current;
36
49
  }
37
50
  const answer = await prompt(CONSENT_PROMPT);
38
51
  const result = enableTelemetry({
@@ -54,6 +67,10 @@ export async function runTelemetryCli({
54
67
  return preview;
55
68
  }
56
69
  if (command === 'flush') {
70
+ if (isDoNotTrack(env)) {
71
+ stdout.write('telemetry disabled by DO_NOT_TRACK; nothing to flush.\n');
72
+ return { sent: 0 };
73
+ }
57
74
  const config = statusTelemetry(configPath);
58
75
  if (!config.enabled) {
59
76
  stdout.write('telemetry is disabled; nothing to flush.\n');
@@ -72,5 +89,6 @@ export async function runTelemetryCli({
72
89
  }
73
90
 
74
91
  if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
75
- await runTelemetryCli();
92
+ const result = await runTelemetryCli();
93
+ if (result?.exitCode) process.exitCode = result.exitCode;
76
94
  }
@@ -5,19 +5,19 @@
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
- import { flushQueue, readTelemetryConfig } from './telemetry.mjs';
8
+ import { flushQueue, isDoNotTrack, readTelemetryConfig } from './telemetry.mjs';
9
9
 
10
10
  function option(argv, name) {
11
11
  const index = argv.indexOf(`--${name}`);
12
12
  return index === -1 ? undefined : argv[index + 1];
13
13
  }
14
14
 
15
- export async function runTelemetryFlushEntry({ argv = process.argv.slice(2) } = {}) {
15
+ export async function runTelemetryFlushEntry({ argv = process.argv.slice(2), env = process.env } = {}) {
16
16
  const queuePath = option(argv, 'queue');
17
17
  const configPath = option(argv, 'config');
18
18
  if (!queuePath || !configPath) throw new Error('telemetry-flush-entry requires --queue and --config');
19
19
  const config = readTelemetryConfig(configPath);
20
- if (!config.enabled) return { sent: 0 };
20
+ if (!config.enabled || isDoNotTrack(env)) return { sent: 0 };
21
21
  return flushQueue({ statePaths: { queue: queuePath }, endpoint: config.endpoint });
22
22
  }
23
23
 
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;