sandoichi 0.1.6 → 0.3.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 +1 -1
- package/src/hook-cli.mjs +29 -11
- package/src/mcp-server.mjs +2 -1
- package/src/postinstall.mjs +4 -3
- package/src/proxy.mjs +94 -47
- package/src/session-start.mjs +25 -5
- package/src/telemetry-cli.mjs +27 -6
- package/src/telemetry-flush-entry.mjs +3 -3
- package/src/telemetry.mjs +272 -72
- package/src/user-prompt-submit.mjs +52 -0
- package/src/version.mjs +29 -0
package/package.json
CHANGED
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)
|
|
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 === '
|
|
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;
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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:
|
|
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] });
|
package/src/postinstall.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
|
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
12
12
|
} from './telemetry.mjs';
|
|
13
13
|
|
|
14
|
-
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
|
|
14
|
+
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
|
|
15
15
|
|
|
16
16
|
export async function runPostinstall({
|
|
17
17
|
env = process.env, stdin = process.stdin, stdout = process.stdout,
|
|
@@ -19,11 +19,12 @@ 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);
|
|
25
26
|
const current = readTelemetryConfig(configPath);
|
|
26
|
-
if (current.
|
|
27
|
+
if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
|
|
27
28
|
|
|
28
29
|
const rl = readlineFactory();
|
|
29
30
|
let answer;
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/session-start.mjs
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, markTelemetryAsked,
|
|
5
|
+
readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
6
|
+
} from './telemetry.mjs';
|
|
7
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
4
8
|
|
|
5
9
|
export function runSessionStart({
|
|
6
10
|
env = process.env,
|
|
7
11
|
stdout = process.stdout,
|
|
8
12
|
rootEnv = 'PLUGIN_ROOT',
|
|
13
|
+
spawnImpl,
|
|
14
|
+
configPath,
|
|
15
|
+
statePaths,
|
|
9
16
|
} = {}) {
|
|
10
17
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
if (isDoNotTrack(env)) {
|
|
19
|
+
stdout.write('{}\n');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
|
|
23
|
+
const telemetryStatePaths = statePaths ?? defaultTelemetryStatePaths(env);
|
|
24
|
+
const config = readTelemetryConfig(telemetryConfigPath);
|
|
25
|
+
if (config.enabled) {
|
|
26
|
+
closeFinishedDays({
|
|
27
|
+
statePaths: telemetryStatePaths, configPath: telemetryConfigPath, day: new Date().toISOString().slice(0, 10),
|
|
28
|
+
pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (config.consent_state !== 'unasked' || !markTelemetryAsked(telemetryConfigPath)) {
|
|
13
32
|
stdout.write('{}\n');
|
|
14
33
|
return;
|
|
15
34
|
}
|
|
16
35
|
const pluginRoot = env[rootEnv] || path.resolve(import.meta.dirname, '..');
|
|
17
36
|
const cli = path.join(pluginRoot, 'lib', 'telemetry-cli.mjs');
|
|
18
37
|
stdout.write(`${JSON.stringify({
|
|
38
|
+
systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
|
|
39
|
+
+ 'Reply with exactly `sando telemetry yes` or `sando telemetry no`, '
|
|
40
|
+
+ `or run \`node "${cli}" enable\`. Details: ${TELEMETRY_DETAILS_URL}`,
|
|
19
41
|
hookSpecificOutput: {
|
|
20
42
|
hookEventName: 'SessionStart',
|
|
21
|
-
systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
|
|
22
|
-
+ `Run \`node "${cli}" enable\` to turn it on. Details: ${TELEMETRY_DETAILS_URL}`,
|
|
23
43
|
},
|
|
24
44
|
})}\n`);
|
|
25
45
|
} catch {
|
package/src/telemetry-cli.mjs
CHANGED
|
@@ -6,11 +6,11 @@ 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';
|
|
13
|
-
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
|
|
13
|
+
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
|
|
14
14
|
|
|
15
15
|
async function defaultPrompt(message) {
|
|
16
16
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -26,18 +26,34 @@ 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
|
|
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;
|
|
49
|
+
}
|
|
50
|
+
if (current.consent_state === 'declined') {
|
|
51
|
+
stdout.write('telemetry not enabled.\n');
|
|
52
|
+
return current;
|
|
36
53
|
}
|
|
37
54
|
const answer = await prompt(CONSENT_PROMPT);
|
|
38
55
|
const result = enableTelemetry({
|
|
39
|
-
configPath, interactive: true,
|
|
40
|
-
answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
|
|
56
|
+
configPath, interactive: true, answer,
|
|
41
57
|
});
|
|
42
58
|
stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
|
|
43
59
|
return result;
|
|
@@ -54,6 +70,10 @@ export async function runTelemetryCli({
|
|
|
54
70
|
return preview;
|
|
55
71
|
}
|
|
56
72
|
if (command === 'flush') {
|
|
73
|
+
if (isDoNotTrack(env)) {
|
|
74
|
+
stdout.write('telemetry disabled by DO_NOT_TRACK; nothing to flush.\n');
|
|
75
|
+
return { sent: 0 };
|
|
76
|
+
}
|
|
57
77
|
const config = statusTelemetry(configPath);
|
|
58
78
|
if (!config.enabled) {
|
|
59
79
|
stdout.write('telemetry is disabled; nothing to flush.\n');
|
|
@@ -72,5 +92,6 @@ export async function runTelemetryCli({
|
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
75
|
-
await runTelemetryCli();
|
|
95
|
+
const result = await runTelemetryCli();
|
|
96
|
+
if (result?.exitCode) process.exitCode = result.exitCode;
|
|
76
97
|
}
|
|
@@ -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
|
|
17
|
-
const
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -85,6 +105,7 @@ export function serializeEvent(payload) {
|
|
|
85
105
|
export const TELEMETRY_CONFIG_VERSION = 1;
|
|
86
106
|
export const CONSENT_VERSION = 1;
|
|
87
107
|
export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
|
|
108
|
+
export const CONSENT_STATES = ['unasked', 'asked', 'enabled', 'declined'];
|
|
88
109
|
// Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
|
|
89
110
|
// reachable from any of the owner's machines (see
|
|
90
111
|
// session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
|
|
@@ -92,15 +113,22 @@ export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/
|
|
|
92
113
|
// session-handoff/docs/telemetry-canary-report.md.
|
|
93
114
|
export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
|
|
94
115
|
|
|
116
|
+
export function isDoNotTrack(env = process.env) {
|
|
117
|
+
return env.DO_NOT_TRACK !== undefined && env.DO_NOT_TRACK !== '' && env.DO_NOT_TRACK !== '0';
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
96
121
|
|
|
97
122
|
function emptyTelemetryConfig() {
|
|
98
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0, consent_state: 'unasked',
|
|
125
|
+
};
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
function validateTelemetryConfig(value) {
|
|
102
129
|
if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
|
|
103
|
-
|| !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
|
|
130
|
+
|| !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
|
|
131
|
+
|| !CONSENT_STATES.includes(value.consent_state)) {
|
|
104
132
|
throw new Error('telemetry config is invalid');
|
|
105
133
|
}
|
|
106
134
|
if (value.enabled) {
|
|
@@ -126,7 +154,14 @@ export function defaultTelemetryStatePaths(env = process.env) {
|
|
|
126
154
|
|
|
127
155
|
export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
|
|
128
156
|
if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
|
|
129
|
-
|
|
157
|
+
const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
158
|
+
// Legacy files cannot distinguish an explicit no from blank input or the old
|
|
159
|
+
// y-as-no bug. Keep that ambiguous decision as asked, not as a decline.
|
|
160
|
+
const migrated = Object.hasOwn(value, 'consent_state') ? value : {
|
|
161
|
+
...value,
|
|
162
|
+
consent_state: value.enabled ? 'enabled' : value.prompted_consent_version > 0 ? 'asked' : 'unasked',
|
|
163
|
+
};
|
|
164
|
+
return validateTelemetryConfig(migrated);
|
|
130
165
|
}
|
|
131
166
|
|
|
132
167
|
function writeTelemetryConfig(configPath, config) {
|
|
@@ -140,31 +175,66 @@ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
|
|
|
140
175
|
return readTelemetryConfig(configPath);
|
|
141
176
|
}
|
|
142
177
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
178
|
+
export function normalizeConsentAnswer(answer) {
|
|
179
|
+
if (typeof answer !== 'string') return undefined;
|
|
180
|
+
const normalized = answer.trim().toLowerCase();
|
|
181
|
+
if (normalized === 'y' || normalized === 'yes') return 'yes';
|
|
182
|
+
if (normalized === 'n' || normalized === 'no') return 'no';
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function markTelemetryAsked(configPath = defaultTelemetryConfigPath()) {
|
|
187
|
+
ensureDirectory(path.dirname(configPath));
|
|
188
|
+
return withLock(`${configPath}.lock`, () => {
|
|
189
|
+
const current = readTelemetryConfig(configPath);
|
|
190
|
+
if (current.consent_state !== 'unasked') return false;
|
|
191
|
+
const next = {
|
|
192
|
+
...current, enabled: false, prompted_consent_version: CONSENT_VERSION, consent_state: 'asked',
|
|
193
|
+
};
|
|
194
|
+
validateTelemetryConfig(next);
|
|
195
|
+
atomicWrite(configPath, next);
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Only an explicit yes enables collection; explicit no declines, other input stays asked/off. */
|
|
146
201
|
export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
|
|
147
|
-
if (!interactive
|
|
148
|
-
|
|
202
|
+
if (!interactive) return { ...readTelemetryConfig(configPath), exitCode: 1 };
|
|
203
|
+
const normalized = normalizeConsentAnswer(answer);
|
|
204
|
+
if (normalized === 'yes') {
|
|
205
|
+
return writeTelemetryConfig(configPath, {
|
|
206
|
+
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
207
|
+
enabled: true,
|
|
208
|
+
prompted_consent_version: CONSENT_VERSION,
|
|
209
|
+
consent_state: 'enabled',
|
|
210
|
+
consent_version: CONSENT_VERSION,
|
|
211
|
+
consented_at: now().toISOString(),
|
|
212
|
+
endpoint: TELEMETRY_ENDPOINT,
|
|
213
|
+
});
|
|
149
214
|
}
|
|
150
215
|
return writeTelemetryConfig(configPath, {
|
|
151
216
|
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
152
|
-
enabled:
|
|
217
|
+
enabled: false,
|
|
153
218
|
prompted_consent_version: CONSENT_VERSION,
|
|
154
|
-
|
|
155
|
-
consented_at: now().toISOString(),
|
|
156
|
-
endpoint: TELEMETRY_ENDPOINT,
|
|
219
|
+
consent_state: normalized === 'no' ? 'declined' : 'asked',
|
|
157
220
|
});
|
|
158
221
|
}
|
|
159
222
|
|
|
160
|
-
|
|
161
|
-
|
|
223
|
+
// 30 days of daily aggregates plus activity markers for both supported hosts,
|
|
224
|
+
// with headroom for concurrent event dimensions and temporary outages.
|
|
225
|
+
const QUEUE_MAX_ROWS = 4096;
|
|
226
|
+
const QUEUE_MAX_BYTES = 4 * 1024 * 1024;
|
|
227
|
+
const ACTIVE_DAY_RETENTION_DAYS = 30;
|
|
162
228
|
const DEFAULT_BATCH_MAX = 32;
|
|
229
|
+
const LEASE_MS = 5 * 60 * 1000;
|
|
230
|
+
const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000];
|
|
231
|
+
const CHILD_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE'];
|
|
163
232
|
|
|
164
|
-
function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {} }; }
|
|
233
|
+
function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {} }; }
|
|
165
234
|
function readCounters(countersPath) {
|
|
166
235
|
if (!fs.existsSync(countersPath)) return emptyCounters();
|
|
167
|
-
|
|
236
|
+
const state = JSON.parse(fs.readFileSync(countersPath, 'utf8'));
|
|
237
|
+
return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {} };
|
|
168
238
|
}
|
|
169
239
|
|
|
170
240
|
function readQueueRows(queuePath) {
|
|
@@ -181,24 +251,29 @@ function writeQueueRows(queuePath, rows) {
|
|
|
181
251
|
fs.chmodSync(queuePath, 0o600);
|
|
182
252
|
}
|
|
183
253
|
|
|
184
|
-
/** Enforces the bounded queue (
|
|
254
|
+
/** Enforces the bounded queue (4096 rows / 4 MiB), dropping the oldest rows first —
|
|
185
255
|
* a telemetry backlog must never grow without bound or block product behavior. */
|
|
186
256
|
function appendQueueRows(queuePath, newRows) {
|
|
257
|
+
ensureDirectory(path.dirname(queuePath));
|
|
187
258
|
withLock(`${queuePath}.lock`, () => {
|
|
188
|
-
let rows =
|
|
259
|
+
let rows = readQueueRows(queuePath);
|
|
260
|
+
const keys = new Set(rows.map((row) => queueKey(row)));
|
|
261
|
+
for (const row of newRows) {
|
|
262
|
+
if (!keys.has(queueKey(row))) { rows.push(row); keys.add(queueKey(row)); }
|
|
263
|
+
}
|
|
189
264
|
if (rows.length > QUEUE_MAX_ROWS) rows = rows.slice(rows.length - QUEUE_MAX_ROWS);
|
|
190
265
|
while (rows.length > 0 && Buffer.byteLength(rows.map((row) => JSON.stringify(row)).join('\n')) > QUEUE_MAX_BYTES) rows.shift();
|
|
191
266
|
writeQueueRows(queuePath, rows);
|
|
192
267
|
});
|
|
193
268
|
}
|
|
194
269
|
|
|
195
|
-
function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
270
|
+
function queueKey(row) {
|
|
271
|
+
return [row.event, row.day_utc, row.plugin_version, row.host ?? row.provider ?? '', row.mode ?? '', row.failureStage ?? row.failure_stage ?? ''].join('|');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function publicRow(row) {
|
|
275
|
+
const allowed = { ...SHARED_FIELDS, ...fieldsForEvent(row.event) };
|
|
276
|
+
return Object.fromEntries(Object.entries(row).filter(([key]) => Object.hasOwn(allowed, key)));
|
|
202
277
|
}
|
|
203
278
|
|
|
204
279
|
function bucketEntry(entry, pluginVersion) {
|
|
@@ -207,30 +282,47 @@ function bucketEntry(entry, pluginVersion) {
|
|
|
207
282
|
schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
208
283
|
host: entry.host, mode: entry.mode,
|
|
209
284
|
tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
|
|
210
|
-
redactions_bucket: countBucket(entry.redactions ?? 0),
|
|
211
285
|
capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
|
|
212
286
|
bytes_saved_bucket: byteBucket(entry.bytesSaved ?? 0),
|
|
287
|
+
input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
|
|
213
288
|
};
|
|
214
289
|
}
|
|
215
|
-
return {
|
|
290
|
+
if (entry.event === 'proxy_summary') return {
|
|
216
291
|
schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
217
|
-
|
|
292
|
+
provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
|
|
218
293
|
rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
|
|
219
294
|
rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
|
|
220
295
|
input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
|
|
221
|
-
|
|
296
|
+
};
|
|
297
|
+
if (entry.event === 'hook_failure_summary') return {
|
|
298
|
+
schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
299
|
+
host: entry.host, failure_stage: entry.failureStage,
|
|
300
|
+
};
|
|
301
|
+
return {
|
|
302
|
+
schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
303
|
+
provider: entry.provider, failure_stage: entry.failureStage,
|
|
222
304
|
};
|
|
223
305
|
}
|
|
224
306
|
|
|
225
307
|
/** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
|
|
226
308
|
* 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))
|
|
229
|
-
|
|
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)) {
|
|
311
|
+
throw new Error('incrementCounter: invalid event');
|
|
312
|
+
}
|
|
313
|
+
const isProxy = event.startsWith('proxy_');
|
|
314
|
+
const dimension = isProxy ? provider : host;
|
|
315
|
+
const key = event.includes('failure')
|
|
316
|
+
? [day, event, dimension, failureStage ?? ''].join('|')
|
|
317
|
+
: [day, event, dimension, mode ?? ''].join('|');
|
|
230
318
|
ensureDirectory(path.dirname(statePaths.counters));
|
|
231
319
|
withLock(`${statePaths.counters}.lock`, () => {
|
|
232
320
|
const state = readCounters(statePaths.counters);
|
|
233
|
-
const existing = state.counters[key] ?? {
|
|
321
|
+
const existing = state.counters[key] ?? {
|
|
322
|
+
day, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
|
|
323
|
+
...(event.endsWith('_summary') && !event.includes('failure') ? { mode: mode ?? null } : {}),
|
|
324
|
+
...(event.includes('failure') ? { failureStage } : {}),
|
|
325
|
+
};
|
|
234
326
|
for (const [field, value] of Object.entries(deltas)) {
|
|
235
327
|
if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
|
|
236
328
|
existing[field] = (existing[field] ?? 0) + value;
|
|
@@ -240,6 +332,37 @@ export function incrementCounter({ statePaths, day, event, host, mode, deltas =
|
|
|
240
332
|
});
|
|
241
333
|
}
|
|
242
334
|
|
|
335
|
+
export function recordFailure({ statePaths, day, event, host, provider, failureStage }) {
|
|
336
|
+
incrementCounter({
|
|
337
|
+
statePaths, day, event, host, provider, failureStage, deltas: { count: 1 },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Queues a single non-aggregate activity marker for this UTC day and host. */
|
|
342
|
+
export function recordActiveDay({ statePaths, day, pluginVersion, host }) {
|
|
343
|
+
const marker = {
|
|
344
|
+
schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
|
|
345
|
+
};
|
|
346
|
+
const validatedMarker = validateEvent(marker);
|
|
347
|
+
const activeDayKey = `${day}|${host}`;
|
|
348
|
+
ensureDirectory(path.dirname(statePaths.counters));
|
|
349
|
+
withLock(`${statePaths.counters}.lock`, () => {
|
|
350
|
+
const state = readCounters(statePaths.counters);
|
|
351
|
+
const activeDays = state.active_days;
|
|
352
|
+
const cutoff = Date.parse(`${day}T00:00:00Z`) - (ACTIVE_DAY_RETENTION_DAYS - 1) * 86_400_000;
|
|
353
|
+
for (const [key, recordedDay] of Object.entries(activeDays)) {
|
|
354
|
+
if (Date.parse(`${recordedDay}T00:00:00Z`) < cutoff) delete activeDays[key];
|
|
355
|
+
}
|
|
356
|
+
if (Object.hasOwn(activeDays, activeDayKey)) {
|
|
357
|
+
atomicWrite(statePaths.counters, state);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
appendQueueRows(statePaths.queue, [validatedMarker]);
|
|
361
|
+
activeDays[activeDayKey] = day;
|
|
362
|
+
atomicWrite(statePaths.counters, state);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
243
366
|
/** Closes a finished UTC day: buckets its raw counters into daily_aggregate rows,
|
|
244
367
|
* appends them to the upload queue, and clears them from the raw counter file so a
|
|
245
368
|
* day is never counted twice. */
|
|
@@ -252,11 +375,11 @@ export function closeDay({ statePaths, day, pluginVersion }) {
|
|
|
252
375
|
if (entry.day !== day) { remaining[key] = entry; continue; }
|
|
253
376
|
closedRows.push(validateEvent(bucketEntry(entry, pluginVersion)));
|
|
254
377
|
}
|
|
378
|
+
if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
|
|
255
379
|
state.counters = remaining;
|
|
256
380
|
ensureDirectory(path.dirname(statePaths.counters));
|
|
257
381
|
atomicWrite(statePaths.counters, state);
|
|
258
382
|
});
|
|
259
|
-
if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
|
|
260
383
|
return closedRows;
|
|
261
384
|
}
|
|
262
385
|
|
|
@@ -264,14 +387,16 @@ function launchDetachedFlush({ statePaths, configPath, spawnImpl }) {
|
|
|
264
387
|
try {
|
|
265
388
|
const entryPath = fileURLToPath(new URL('./telemetry-flush-entry.mjs', import.meta.url));
|
|
266
389
|
const child = spawnImpl(process.execPath, [entryPath, '--queue', statePaths.queue, '--config', configPath], {
|
|
267
|
-
detached: true,
|
|
390
|
+
detached: true,
|
|
391
|
+
env: Object.fromEntries(CHILD_ENV_KEYS.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]])),
|
|
392
|
+
stdio: 'ignore', windowsHide: true,
|
|
268
393
|
});
|
|
269
394
|
child.unref();
|
|
270
395
|
} catch { /* telemetry must never affect the caller */ }
|
|
271
396
|
}
|
|
272
397
|
|
|
273
|
-
/** Closes every raw counter day before `day` and starts one detached uploader
|
|
274
|
-
*
|
|
398
|
+
/** Closes every raw counter day before `day` and starts one detached uploader
|
|
399
|
+
* whenever any queue row remains, including a queue from a previous session. */
|
|
275
400
|
export function closeFinishedDays({
|
|
276
401
|
statePaths, configPath = defaultTelemetryConfigPath(), day, pluginVersion,
|
|
277
402
|
spawnImpl = spawn,
|
|
@@ -287,18 +412,44 @@ export function closeFinishedDays({
|
|
|
287
412
|
for (const closedDay of [...days].sort()) {
|
|
288
413
|
closedRows.push(...closeDay({ statePaths, day: closedDay, pluginVersion }));
|
|
289
414
|
}
|
|
290
|
-
if (
|
|
415
|
+
if (fs.existsSync(statePaths.queue) && readQueueRows(statePaths.queue).length) {
|
|
416
|
+
launchDetachedFlush({ statePaths, configPath, spawnImpl });
|
|
417
|
+
}
|
|
291
418
|
return closedRows;
|
|
292
419
|
}
|
|
293
420
|
|
|
294
|
-
|
|
295
|
-
|
|
421
|
+
function claimBatch({ statePaths, max, now = Date.now, leaseMs = LEASE_MS }) {
|
|
422
|
+
let claimed = [];
|
|
423
|
+
withLock(`${statePaths.queue}.lock`, () => {
|
|
424
|
+
const rows = readQueueRows(statePaths.queue);
|
|
425
|
+
const timestamp = now();
|
|
426
|
+
const available = rows.filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= timestamp));
|
|
427
|
+
if (!available.length) return;
|
|
428
|
+
if (available.some((row) => row._leaseUntil > timestamp)) return;
|
|
429
|
+
const leaseId = `${process.pid}-${timestamp}-${Math.random().toString(36).slice(2)}`;
|
|
430
|
+
const selected = available.slice(0, max);
|
|
431
|
+
const selectedKeys = new Set(selected.map(queueKey));
|
|
432
|
+
for (const row of rows) {
|
|
433
|
+
if (selectedKeys.has(queueKey(row))) { row._leaseId = leaseId; row._leaseUntil = timestamp + leaseMs; }
|
|
434
|
+
}
|
|
435
|
+
writeQueueRows(statePaths.queue, rows);
|
|
436
|
+
claimed = selected.map((row) => ({ ...row, _leaseId: leaseId, _leaseUntil: timestamp + leaseMs }));
|
|
437
|
+
});
|
|
438
|
+
return claimed;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX, lease = true, now = Date.now } = {}) {
|
|
442
|
+
if (lease) return claimBatch({ statePaths, max, now });
|
|
443
|
+
return readQueueRows(statePaths.queue).filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= now())).slice(0, max).map(publicRow);
|
|
296
444
|
}
|
|
297
445
|
|
|
298
|
-
export function ackBatch({ statePaths, count }) {
|
|
446
|
+
export function ackBatch({ statePaths, count, leaseId } = {}) {
|
|
299
447
|
withLock(`${statePaths.queue}.lock`, () => {
|
|
300
448
|
const rows = readQueueRows(statePaths.queue);
|
|
301
|
-
writeQueueRows(statePaths.queue, rows.slice(count));
|
|
449
|
+
if (!leaseId) { writeQueueRows(statePaths.queue, rows.slice(count)); return; }
|
|
450
|
+
const leased = rows.filter((row) => row._leaseId === leaseId).slice(0, count);
|
|
451
|
+
const keys = new Set(leased.map(queueKey));
|
|
452
|
+
writeQueueRows(statePaths.queue, rows.filter((row) => !keys.has(queueKey(row))));
|
|
302
453
|
});
|
|
303
454
|
}
|
|
304
455
|
|
|
@@ -309,7 +460,7 @@ export function toOtlpLogs(rows) {
|
|
|
309
460
|
scopeLogs: [{
|
|
310
461
|
logRecords: rows.map((row) => ({
|
|
311
462
|
body: { stringValue: 'sando.daily_aggregate' },
|
|
312
|
-
attributes: Object.entries(row).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
|
|
463
|
+
attributes: Object.entries(publicRow(row)).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
|
|
313
464
|
})),
|
|
314
465
|
}],
|
|
315
466
|
}],
|
|
@@ -317,30 +468,78 @@ export function toOtlpLogs(rows) {
|
|
|
317
468
|
}
|
|
318
469
|
|
|
319
470
|
export function previewNextUpload({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX } = {}) {
|
|
320
|
-
const rows = loadBatch({ statePaths, max });
|
|
471
|
+
const rows = loadBatch({ statePaths, max, lease: false });
|
|
321
472
|
return { url: endpoint, headers: { 'content-type': 'application/json' }, body: toOtlpLogs(rows) };
|
|
322
473
|
}
|
|
323
474
|
|
|
324
|
-
/**
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
475
|
+
/** Drains eligible batches. Every failure mode is swallowed and reported without
|
|
476
|
+
* changing the outcome of the hook or proxy call that triggered the flush. */
|
|
477
|
+
function retryAfterMs(response, now) {
|
|
478
|
+
const value = response.headers?.get?.('retry-after');
|
|
479
|
+
if (!value) return 0;
|
|
480
|
+
const seconds = Number(value);
|
|
481
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
482
|
+
const timestamp = Date.parse(value);
|
|
483
|
+
return Number.isNaN(timestamp) ? 0 : Math.max(0, timestamp - now);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function updateClaimedRows({ statePaths, leaseId, update }) {
|
|
487
|
+
withLock(`${statePaths.queue}.lock`, () => {
|
|
488
|
+
const rows = readQueueRows(statePaths.queue);
|
|
489
|
+
for (const row of rows) if (row._leaseId === leaseId) update(row);
|
|
490
|
+
writeQueueRows(statePaths.queue, rows);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isRetryableStatus(status) { return [429, 502, 503, 504].includes(status); }
|
|
495
|
+
|
|
496
|
+
export async function flushQueue({
|
|
497
|
+
statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000,
|
|
498
|
+
fetchImpl = fetch, now = Date.now, random = Math.random, sleep = async () => {},
|
|
499
|
+
} = {}) {
|
|
500
|
+
const result = { sent: 0, rejectedLogRecords: 0 };
|
|
501
|
+
while (true) {
|
|
502
|
+
const rows = claimBatch({ statePaths, max, now });
|
|
503
|
+
if (rows.length === 0) return result;
|
|
504
|
+
const leaseId = rows[0]._leaseId;
|
|
505
|
+
const controller = new AbortController();
|
|
506
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
507
|
+
try {
|
|
508
|
+
const response = await fetchImpl(endpoint, {
|
|
509
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
510
|
+
body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
|
|
511
|
+
});
|
|
512
|
+
if (response.ok) {
|
|
513
|
+
let body = {};
|
|
514
|
+
try { body = await response.json(); } catch { /* empty 2xx body */ }
|
|
515
|
+
result.rejectedLogRecords += Number(body?.partialSuccess?.rejectedLogRecords || 0);
|
|
516
|
+
ackBatch({ statePaths, count: rows.length, leaseId });
|
|
517
|
+
result.sent += rows.length;
|
|
518
|
+
} else if (isRetryableStatus(response.status)) {
|
|
519
|
+
updateClaimedRows({ statePaths, leaseId, update: (row) => {
|
|
520
|
+
const attempt = (row._attemptCount ?? 0) + 1;
|
|
521
|
+
const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
|
|
522
|
+
row._attemptCount = attempt;
|
|
523
|
+
row._nextAttemptAt = now() + Math.max(base * random(), retryAfterMs(response, now()));
|
|
524
|
+
delete row._leaseId; delete row._leaseUntil;
|
|
525
|
+
}});
|
|
526
|
+
return result;
|
|
527
|
+
} else {
|
|
528
|
+
ackBatch({ statePaths, count: rows.length, leaseId });
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
updateClaimedRows({ statePaths, leaseId, update: (row) => {
|
|
532
|
+
const attempt = (row._attemptCount ?? 0) + 1;
|
|
533
|
+
const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
|
|
534
|
+
row._attemptCount = attempt;
|
|
535
|
+
row._nextAttemptAt = now() + base * random();
|
|
536
|
+
delete row._leaseId; delete row._leaseUntil;
|
|
537
|
+
}});
|
|
538
|
+
return result;
|
|
539
|
+
} finally {
|
|
540
|
+
clearTimeout(timer);
|
|
541
|
+
}
|
|
542
|
+
await sleep(0);
|
|
344
543
|
}
|
|
345
544
|
}
|
|
346
545
|
|
|
@@ -350,6 +549,7 @@ export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), pu
|
|
|
350
549
|
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
351
550
|
enabled: false,
|
|
352
551
|
prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
|
|
552
|
+
consent_state: 'declined',
|
|
353
553
|
});
|
|
354
554
|
if (purge) {
|
|
355
555
|
for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig,
|
|
9
|
+
} from './telemetry.mjs';
|
|
10
|
+
|
|
11
|
+
const CONSENT_COMMANDS = new Map([
|
|
12
|
+
['sando telemetry yes', 'yes'],
|
|
13
|
+
['sando telemetry no', 'no'],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function pass(stdout) { stdout.write('{}\n'); }
|
|
17
|
+
|
|
18
|
+
export function runUserPromptSubmit({
|
|
19
|
+
env = process.env, input, stdout = process.stdout, configPath,
|
|
20
|
+
} = {}) {
|
|
21
|
+
try {
|
|
22
|
+
if (isDoNotTrack(env)) {
|
|
23
|
+
pass(stdout);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
|
|
27
|
+
const rawInput = input === undefined ? fs.readFileSync(0, 'utf8') : input;
|
|
28
|
+
const prompt = JSON.parse(rawInput || '{}').prompt;
|
|
29
|
+
// Normalizza solo gli spazi ai bordi: non introduce ambiguita' (la stringa
|
|
30
|
+
// resta esatta) ed evita di perdere risposte genuine incollate con spazi.
|
|
31
|
+
const answer = typeof prompt === 'string' ? CONSENT_COMMANDS.get(prompt.trim()) : undefined;
|
|
32
|
+
if (!answer) {
|
|
33
|
+
pass(stdout);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const current = readTelemetryConfig(telemetryConfigPath);
|
|
37
|
+
if (current.consent_state === 'declined' && answer === 'yes') {
|
|
38
|
+
pass(stdout);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const result = enableTelemetry({ configPath: telemetryConfigPath, interactive: true, answer });
|
|
42
|
+
stdout.write(`${JSON.stringify({
|
|
43
|
+
systemMessage: result.enabled ? 'Sando telemetry enabled.' : 'Sando telemetry disabled.',
|
|
44
|
+
})}\n`);
|
|
45
|
+
} catch {
|
|
46
|
+
pass(stdout);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
51
|
+
runUserPromptSubmit();
|
|
52
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -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.3.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;
|