sandoichi 0.1.0 → 0.1.1
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 +3 -2
- package/src/hook-cli.mjs +30 -0
- package/src/postinstall.mjs +42 -0
- package/src/provider-usage.mjs +3 -3
- package/src/proxy.mjs +38 -1
- package/src/telemetry-cli.mjs +76 -0
- package/src/telemetry-flush-entry.mjs +26 -0
- package/src/telemetry.mjs +324 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sandoichi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Cuts what Claude Code and Codex charge you to re-read their own output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "yuzushi",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"src"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test tests/*.test.mjs"
|
|
25
|
+
"test": "node --test tests/*.test.mjs",
|
|
26
|
+
"postinstall": "node src/postinstall.mjs"
|
|
26
27
|
}
|
|
27
28
|
}
|
package/src/hook-cli.mjs
CHANGED
|
@@ -4,6 +4,35 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
|
|
6
6
|
import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
|
|
7
|
+
import {
|
|
8
|
+
defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
|
|
9
|
+
} from './telemetry.mjs';
|
|
10
|
+
|
|
11
|
+
function todayUtc() { return new Date().toISOString().slice(0, 10); }
|
|
12
|
+
|
|
13
|
+
/** Only counts (never content, paths, or IDs) — see docs/plans/2026-08-25-sando-telemetry-design.md.
|
|
14
|
+
* `enforce` covers both `apply` and `dry-run`: both walk the real rewrite path, only
|
|
15
|
+
* `observe` collects without deciding anything. */
|
|
16
|
+
function recordHookTelemetry({ host, env, policy, optimization }) {
|
|
17
|
+
let config;
|
|
18
|
+
try { config = readTelemetryConfig(defaultTelemetryConfigPath(env)); } catch { return; }
|
|
19
|
+
if (!config.enabled) return;
|
|
20
|
+
try {
|
|
21
|
+
incrementCounter({
|
|
22
|
+
statePaths: defaultTelemetryStatePaths(env),
|
|
23
|
+
day: todayUtc(),
|
|
24
|
+
event: 'hook_summary',
|
|
25
|
+
host,
|
|
26
|
+
mode: policy.mode === 'observe' ? 'observe' : 'enforce',
|
|
27
|
+
deltas: {
|
|
28
|
+
toolCalls: 1,
|
|
29
|
+
redactions: optimization.stats.redactions,
|
|
30
|
+
cappedOutputs: optimization.artifact ? 1 : 0,
|
|
31
|
+
bytesSaved: Math.max(0, optimization.stats.inputBytes - optimization.stats.inlineBytes),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
} catch { /* telemetry is best-effort and must never affect hook output */ }
|
|
35
|
+
}
|
|
7
36
|
|
|
8
37
|
function hookPolicy(env, host) {
|
|
9
38
|
const policy = env.SANDO_POLICY
|
|
@@ -69,6 +98,7 @@ export function runHookCli({ host, env = process.env } = {}) {
|
|
|
69
98
|
try {
|
|
70
99
|
recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
|
|
71
100
|
} catch {}
|
|
101
|
+
recordHookTelemetry({ host, env, policy, optimization });
|
|
72
102
|
if (host === 'codex' && policy.mode === 'apply' && env.SANDO_CODEX_FALLBACK === 'feedback') {
|
|
73
103
|
process.stdout.write(`${JSON.stringify(buildCodexFallback({ optimization, cwd: event.cwd }))}\n`);
|
|
74
104
|
return;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npm postinstall: one-time, local-only consent prompt. No network calls here —
|
|
3
|
+
// upload happens later, only if enabled, from the hook/proxy paths. Must never
|
|
4
|
+
// fail or hang an `npm install`: every path resolves, every error is swallowed.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline/promises';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, readTelemetryConfig } from './telemetry.mjs';
|
|
11
|
+
|
|
12
|
+
const CONSENT_PROMPT = 'Enable anonymous telemetry? Full details at: TELEMETRY.md [y/N] ';
|
|
13
|
+
|
|
14
|
+
export async function runPostinstall({
|
|
15
|
+
env = process.env, stdin = process.stdin, stdout = process.stdout,
|
|
16
|
+
readlineFactory = () => readline.createInterface({ input: stdin, output: stdout }),
|
|
17
|
+
} = {}) {
|
|
18
|
+
try {
|
|
19
|
+
if (env.SANDO_SKIP_TELEMETRY_PROMPT) return;
|
|
20
|
+
if (!stdin.isTTY || !stdout.isTTY) return; // CI, --ignore-scripts consumers, piped installs, etc.
|
|
21
|
+
|
|
22
|
+
const configPath = defaultTelemetryConfigPath(env);
|
|
23
|
+
const current = readTelemetryConfig(configPath);
|
|
24
|
+
if (current.prompted_consent_version >= CONSENT_VERSION) return; // never re-ask on reinstall/upgrade
|
|
25
|
+
|
|
26
|
+
const rl = readlineFactory();
|
|
27
|
+
let answer;
|
|
28
|
+
try {
|
|
29
|
+
answer = await rl.question(CONSENT_PROMPT);
|
|
30
|
+
} finally {
|
|
31
|
+
rl.close();
|
|
32
|
+
}
|
|
33
|
+
const result = enableTelemetry({ configPath, interactive: true, answer });
|
|
34
|
+
stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
|
|
35
|
+
} catch {
|
|
36
|
+
// A postinstall script must never fail `npm install`.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
41
|
+
runPostinstall().finally(() => process.exit(0));
|
|
42
|
+
}
|
package/src/provider-usage.mjs
CHANGED
|
@@ -148,7 +148,7 @@ function validateState(value) {
|
|
|
148
148
|
return value;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
function ensureDirectory(directory) {
|
|
151
|
+
export function ensureDirectory(directory) {
|
|
152
152
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
153
153
|
const stat = fs.lstatSync(directory);
|
|
154
154
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('provider usage directory is unsafe');
|
|
@@ -156,7 +156,7 @@ function ensureDirectory(directory) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
function waitForLock() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS); }
|
|
159
|
-
function withLock(lockPath, operation) {
|
|
159
|
+
export function withLock(lockPath, operation) {
|
|
160
160
|
let handle;
|
|
161
161
|
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
162
162
|
try { handle = fs.openSync(lockPath, 'wx', 0o600); break; }
|
|
@@ -171,7 +171,7 @@ function withLock(lockPath, operation) {
|
|
|
171
171
|
try { return operation(); } finally { fs.closeSync(handle); fs.rmSync(lockPath, { force: true }); }
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
function atomicWrite(filePath, value) {
|
|
174
|
+
export function atomicWrite(filePath, value) {
|
|
175
175
|
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
176
176
|
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
177
177
|
try { fs.renameSync(temporary, filePath); } catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
|
package/src/proxy.mjs
CHANGED
|
@@ -1,7 +1,40 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
|
|
3
|
+
import { estimateTokens } from './core.mjs';
|
|
3
4
|
import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
|
|
4
5
|
import { recordProxyRequest } from './proxy-metrics.mjs';
|
|
6
|
+
import {
|
|
7
|
+
defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
|
|
8
|
+
} from './telemetry.mjs';
|
|
9
|
+
|
|
10
|
+
function todayUtc() { return new Date().toISOString().slice(0, 10); }
|
|
11
|
+
|
|
12
|
+
/** Only counts (never request/response content) — see docs/plans/2026-08-25-sando-telemetry-design.md.
|
|
13
|
+
* `host` here is the provider request shape (`anthropic`/`openai`), which does not map cleanly to
|
|
14
|
+
* Sando's `claude`/`codex` telemetry host enum; the proxy fronts both hosts equally, so it reports
|
|
15
|
+
* under a fixed `claude` label — this is the one place the design doc's host/provider split is
|
|
16
|
+
* approximate, flagged for the design doc rather than silently assumed. */
|
|
17
|
+
function recordProxyTelemetry({ env, transformed, beforeText, afterText }) {
|
|
18
|
+
let config;
|
|
19
|
+
try { config = readTelemetryConfig(defaultTelemetryConfigPath(env)); } catch { return; }
|
|
20
|
+
if (!config.enabled) return;
|
|
21
|
+
const cacheWarm = transformed.stats.cacheRewriteRatio !== null;
|
|
22
|
+
try {
|
|
23
|
+
incrementCounter({
|
|
24
|
+
statePaths: defaultTelemetryStatePaths(env),
|
|
25
|
+
day: todayUtc(),
|
|
26
|
+
event: 'proxy_summary',
|
|
27
|
+
host: 'claude',
|
|
28
|
+
deltas: {
|
|
29
|
+
rewritesApplied: transformed.changed ? 1 : 0,
|
|
30
|
+
rewritesSkippedCache: transformed.stats.cacheProtectedSkips > 0 ? 1 : 0,
|
|
31
|
+
inputTokensSaved: Math.max(0, estimateTokens(beforeText) - estimateTokens(afterText)),
|
|
32
|
+
cacheHitYes: cacheWarm ? 1 : 0,
|
|
33
|
+
cacheHitNo: cacheWarm ? 0 : 1,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
} catch { /* telemetry is best-effort and must never affect the proxied response */ }
|
|
37
|
+
}
|
|
5
38
|
|
|
6
39
|
const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
7
40
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
@@ -130,7 +163,7 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
|
|
|
130
163
|
}
|
|
131
164
|
}
|
|
132
165
|
|
|
133
|
-
export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath } = {}) {
|
|
166
|
+
export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath, env = process.env } = {}) {
|
|
134
167
|
const upstreamUrl = assertUpstream(upstream);
|
|
135
168
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('port is invalid');
|
|
136
169
|
if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024) throw new TypeError('maxBodyBytes is invalid');
|
|
@@ -158,6 +191,10 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
|
|
|
158
191
|
lastRequestAt = now;
|
|
159
192
|
const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
|
|
160
193
|
if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
|
|
194
|
+
recordProxyTelemetry({
|
|
195
|
+
env, transformed,
|
|
196
|
+
beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
|
|
197
|
+
});
|
|
161
198
|
lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
|
|
162
199
|
recordProvider = provider;
|
|
163
200
|
recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline/promises';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
defaultTelemetryConfigPath, defaultTelemetryStatePaths,
|
|
9
|
+
disableTelemetry, enableTelemetry, flushQueue, previewNextUpload, statusTelemetry,
|
|
10
|
+
} from './telemetry.mjs';
|
|
11
|
+
|
|
12
|
+
const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
|
|
13
|
+
const CONSENT_PROMPT = 'Enable anonymous telemetry? Full details at: TELEMETRY.md [y/N] ';
|
|
14
|
+
|
|
15
|
+
async function defaultPrompt(message) {
|
|
16
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
try { return await rl.question(message); } finally { rl.close(); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runTelemetryCli({
|
|
21
|
+
argv = process.argv.slice(2), env = process.env, stdout = process.stdout, stderr = process.stderr,
|
|
22
|
+
configPath = defaultTelemetryConfigPath(env), statePaths = defaultTelemetryStatePaths(env),
|
|
23
|
+
interactive = Boolean(process.stdin.isTTY), prompt = defaultPrompt,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const [command, ...rest] = argv;
|
|
26
|
+
try {
|
|
27
|
+
if (command === 'status') {
|
|
28
|
+
const config = statusTelemetry(configPath);
|
|
29
|
+
stdout.write(`telemetry: ${config.enabled ? 'enabled' : 'disabled'}\n`);
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
if (command === 'enable') {
|
|
33
|
+
if (!interactive) {
|
|
34
|
+
stderr.write('sando telemetry: enable requires an interactive session\n');
|
|
35
|
+
return enableTelemetry({ configPath, interactive: false });
|
|
36
|
+
}
|
|
37
|
+
const answer = await prompt(CONSENT_PROMPT);
|
|
38
|
+
const result = enableTelemetry({
|
|
39
|
+
configPath, interactive: true,
|
|
40
|
+
answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
|
|
41
|
+
});
|
|
42
|
+
stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
if (command === 'disable') {
|
|
46
|
+
const result = disableTelemetry({ configPath, statePaths, purge: rest.includes('--purge') });
|
|
47
|
+
stdout.write('telemetry disabled.\n');
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
if (command === 'preview') {
|
|
51
|
+
const config = statusTelemetry(configPath);
|
|
52
|
+
const preview = previewNextUpload({ statePaths, endpoint: config.endpoint });
|
|
53
|
+
stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
|
|
54
|
+
return preview;
|
|
55
|
+
}
|
|
56
|
+
if (command === 'flush') {
|
|
57
|
+
const config = statusTelemetry(configPath);
|
|
58
|
+
if (!config.enabled) {
|
|
59
|
+
stdout.write('telemetry is disabled; nothing to flush.\n');
|
|
60
|
+
return { sent: 0 };
|
|
61
|
+
}
|
|
62
|
+
const result = await flushQueue({ statePaths, endpoint: config.endpoint });
|
|
63
|
+
stdout.write(`flushed ${result.sent} row(s).\n`);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
stdout.write(USAGE);
|
|
67
|
+
return null;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
stderr.write(`sando telemetry: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
75
|
+
await runTelemetryCli();
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached flush entrypoint: invoked with --queue and --config, nothing else.
|
|
3
|
+
// Never inherits provider credential env vars or contacts anything but the configured endpoint.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { flushQueue, readTelemetryConfig } from './telemetry.mjs';
|
|
9
|
+
|
|
10
|
+
function option(argv, name) {
|
|
11
|
+
const index = argv.indexOf(`--${name}`);
|
|
12
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runTelemetryFlushEntry({ argv = process.argv.slice(2) } = {}) {
|
|
16
|
+
const queuePath = option(argv, 'queue');
|
|
17
|
+
const configPath = option(argv, 'config');
|
|
18
|
+
if (!queuePath || !configPath) throw new Error('telemetry-flush-entry requires --queue and --config');
|
|
19
|
+
const config = readTelemetryConfig(configPath);
|
|
20
|
+
if (!config.enabled) return { sent: 0 };
|
|
21
|
+
return flushQueue({ statePaths: { queue: queuePath }, endpoint: config.endpoint });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
25
|
+
await runTelemetryFlushEntry();
|
|
26
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { atomicWrite, ensureDirectory, withLock } from './provider-usage.mjs';
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
const MAX_STRING_LENGTH = 32;
|
|
9
|
+
const MAX_EVENT_BYTES = 2048;
|
|
10
|
+
|
|
11
|
+
const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20'];
|
|
12
|
+
const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', 'gte_64k'];
|
|
13
|
+
const HOSTS = ['claude', 'codex'];
|
|
14
|
+
const MODES = ['enforce', 'observe'];
|
|
15
|
+
const YES_NO_UNKNOWN = ['yes', 'no', 'unknown'];
|
|
16
|
+
|
|
17
|
+
const SHARED_FIELDS = {
|
|
18
|
+
schema_version: (value) => value === SCHEMA_VERSION,
|
|
19
|
+
event: (value) => value === 'hook_summary' || value === 'proxy_summary',
|
|
20
|
+
day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
|
|
21
|
+
plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+$/.test(value) && value.length <= MAX_STRING_LENGTH,
|
|
22
|
+
host: (value) => HOSTS.includes(value),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const HOOK_FIELDS = {
|
|
26
|
+
mode: (value) => MODES.includes(value),
|
|
27
|
+
tool_calls_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
28
|
+
redactions_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
29
|
+
capped_outputs_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
30
|
+
bytes_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PROXY_FIELDS = {
|
|
34
|
+
rewrites_applied_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
35
|
+
rewrites_skipped_cache_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
36
|
+
input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
|
|
37
|
+
prompt_cache_hit: (value) => YES_NO_UNKNOWN.includes(value),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function fieldsForEvent(eventType) {
|
|
41
|
+
return eventType === 'hook_summary' ? HOOK_FIELDS : PROXY_FIELDS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function countBucket(count) {
|
|
45
|
+
if (!Number.isInteger(count) || count < 0) throw new Error('countBucket: invalid count');
|
|
46
|
+
if (count === 0) return 'zero';
|
|
47
|
+
if (count === 1) return 'one';
|
|
48
|
+
if (count <= 5) return '2_to_5';
|
|
49
|
+
if (count <= 20) return '6_to_20';
|
|
50
|
+
return 'gt_20';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function byteBucket(bytes) {
|
|
54
|
+
if (!Number.isInteger(bytes) || bytes < 0) throw new Error('byteBucket: invalid byte count');
|
|
55
|
+
if (bytes < 4096) return 'lt_4k';
|
|
56
|
+
if (bytes < 16384) return '4_to_16k';
|
|
57
|
+
if (bytes < 65536) return '16_to_64k';
|
|
58
|
+
return 'gte_64k';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function validateEvent(payload) {
|
|
62
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('event must be an object');
|
|
63
|
+
if (!SHARED_FIELDS.event(payload.event)) throw new Error('event: unknown event type');
|
|
64
|
+
const allowed = { ...SHARED_FIELDS, ...fieldsForEvent(payload.event) };
|
|
65
|
+
for (const key of Object.keys(payload)) {
|
|
66
|
+
if (!Object.hasOwn(allowed, key)) throw new Error(`unknown field: ${key}`);
|
|
67
|
+
}
|
|
68
|
+
for (const [key, check] of Object.entries(allowed)) {
|
|
69
|
+
if (!Object.hasOwn(payload, key)) throw new Error(`missing field: ${key}`);
|
|
70
|
+
if (typeof payload[key] === 'string' && payload[key].length > MAX_STRING_LENGTH) throw new Error(`${key}: string too long`);
|
|
71
|
+
if (!check(payload[key])) throw new Error(`${key}: invalid value`);
|
|
72
|
+
}
|
|
73
|
+
return payload;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function serializeEvent(payload) {
|
|
77
|
+
validateEvent(payload);
|
|
78
|
+
const serialized = JSON.stringify(payload);
|
|
79
|
+
if (Buffer.byteLength(serialized) > MAX_EVENT_BYTES) throw new Error('event exceeds serialized size limit');
|
|
80
|
+
return serialized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const TELEMETRY_CONFIG_VERSION = 1;
|
|
84
|
+
export const CONSENT_VERSION = 1;
|
|
85
|
+
// Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
|
|
86
|
+
// reachable from any of the owner's machines (see
|
|
87
|
+
// session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
|
|
88
|
+
// Release/broader publication is still gated on the open items in
|
|
89
|
+
// session-handoff/docs/telemetry-canary-report.md.
|
|
90
|
+
export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
|
|
91
|
+
|
|
92
|
+
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
93
|
+
|
|
94
|
+
function emptyTelemetryConfig() {
|
|
95
|
+
return { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateTelemetryConfig(value) {
|
|
99
|
+
if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
|
|
100
|
+
|| !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0) {
|
|
101
|
+
throw new Error('telemetry config is invalid');
|
|
102
|
+
}
|
|
103
|
+
if (value.enabled) {
|
|
104
|
+
if (!Number.isInteger(value.consent_version) || value.consent_version < 1
|
|
105
|
+
|| typeof value.consented_at !== 'string' || Number.isNaN(Date.parse(value.consented_at))
|
|
106
|
+
|| typeof value.endpoint !== 'string' || !value.endpoint) throw new Error('telemetry config is invalid');
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function defaultTelemetryConfigPath(env = process.env) {
|
|
112
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
113
|
+
if (!path.isAbsolute(configHome)) throw new Error('config directory must be absolute');
|
|
114
|
+
return path.join(configHome, 'sando', 'telemetry.json');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function defaultTelemetryStatePaths(env = process.env) {
|
|
118
|
+
const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
119
|
+
if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
|
|
120
|
+
const directory = path.join(stateHome, 'sando');
|
|
121
|
+
return { counters: path.join(directory, 'telemetry-counters.json'), queue: path.join(directory, 'telemetry-queue.jsonl') };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
|
|
125
|
+
if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
|
|
126
|
+
return validateTelemetryConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeTelemetryConfig(configPath, config) {
|
|
130
|
+
validateTelemetryConfig(config);
|
|
131
|
+
ensureDirectory(path.dirname(configPath));
|
|
132
|
+
withLock(`${configPath}.lock`, () => atomicWrite(configPath, config));
|
|
133
|
+
return config;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
|
|
137
|
+
return readTelemetryConfig(configPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Only an explicit `yes` in an interactive session enables collection; anything else
|
|
141
|
+
* (blank, `no`, EOF, or a non-interactive caller) writes the disabled prompt marker so
|
|
142
|
+
* upgrades and reinstalls never re-prompt or silently opt a user in. */
|
|
143
|
+
export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
|
|
144
|
+
if (!interactive || typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
|
|
145
|
+
return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
|
|
146
|
+
}
|
|
147
|
+
return writeTelemetryConfig(configPath, {
|
|
148
|
+
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
149
|
+
enabled: true,
|
|
150
|
+
prompted_consent_version: CONSENT_VERSION,
|
|
151
|
+
consent_version: CONSENT_VERSION,
|
|
152
|
+
consented_at: now().toISOString(),
|
|
153
|
+
endpoint: TELEMETRY_ENDPOINT,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const QUEUE_MAX_ROWS = 256;
|
|
158
|
+
const QUEUE_MAX_BYTES = 256 * 1024;
|
|
159
|
+
const DEFAULT_BATCH_MAX = 32;
|
|
160
|
+
|
|
161
|
+
function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {} }; }
|
|
162
|
+
function readCounters(countersPath) {
|
|
163
|
+
if (!fs.existsSync(countersPath)) return emptyCounters();
|
|
164
|
+
return JSON.parse(fs.readFileSync(countersPath, 'utf8'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readQueueRows(queuePath) {
|
|
168
|
+
if (!fs.existsSync(queuePath)) return [];
|
|
169
|
+
return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeQueueRows(queuePath, rows) {
|
|
173
|
+
ensureDirectory(path.dirname(queuePath));
|
|
174
|
+
const temporary = path.join(path.dirname(queuePath), `.${path.basename(queuePath)}.${process.pid}.tmp`);
|
|
175
|
+
const content = rows.length ? `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` : '';
|
|
176
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
177
|
+
try { fs.renameSync(temporary, queuePath); } catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
|
|
178
|
+
fs.chmodSync(queuePath, 0o600);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Enforces the bounded queue (256 rows / 256 KiB), dropping the oldest rows first —
|
|
182
|
+
* a telemetry backlog must never grow without bound or block product behavior. */
|
|
183
|
+
function appendQueueRows(queuePath, newRows) {
|
|
184
|
+
withLock(`${queuePath}.lock`, () => {
|
|
185
|
+
let rows = [...readQueueRows(queuePath), ...newRows];
|
|
186
|
+
if (rows.length > QUEUE_MAX_ROWS) rows = rows.slice(rows.length - QUEUE_MAX_ROWS);
|
|
187
|
+
while (rows.length > 0 && Buffer.byteLength(rows.map((row) => JSON.stringify(row)).join('\n')) > QUEUE_MAX_BYTES) rows.shift();
|
|
188
|
+
writeQueueRows(queuePath, rows);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function majorityCacheHit(entry) {
|
|
193
|
+
const yes = entry.cacheHitYes ?? 0;
|
|
194
|
+
const no = entry.cacheHitNo ?? 0;
|
|
195
|
+
const unknown = entry.cacheHitUnknown ?? 0;
|
|
196
|
+
if (yes > no && yes >= unknown) return 'yes';
|
|
197
|
+
if (no > yes && no >= unknown) return 'no';
|
|
198
|
+
return 'unknown';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bucketEntry(entry, pluginVersion) {
|
|
202
|
+
if (entry.event === 'hook_summary') {
|
|
203
|
+
return {
|
|
204
|
+
schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
205
|
+
host: entry.host, mode: entry.mode,
|
|
206
|
+
tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
|
|
207
|
+
redactions_bucket: countBucket(entry.redactions ?? 0),
|
|
208
|
+
capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
|
|
209
|
+
bytes_saved_bucket: byteBucket(entry.bytesSaved ?? 0),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
|
|
214
|
+
host: entry.host,
|
|
215
|
+
rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
|
|
216
|
+
rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
|
|
217
|
+
input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
|
|
218
|
+
prompt_cache_hit: majorityCacheHit(entry),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
|
|
223
|
+
* only ever leave the machine) once `closeDay` closes a finished UTC day. */
|
|
224
|
+
export function incrementCounter({ statePaths, day, event, host, mode, deltas = {} }) {
|
|
225
|
+
if (!['hook_summary', 'proxy_summary'].includes(event)) throw new Error('incrementCounter: invalid event');
|
|
226
|
+
const key = `${day}|${event}|${host}|${mode ?? ''}`;
|
|
227
|
+
ensureDirectory(path.dirname(statePaths.counters));
|
|
228
|
+
withLock(`${statePaths.counters}.lock`, () => {
|
|
229
|
+
const state = readCounters(statePaths.counters);
|
|
230
|
+
const existing = state.counters[key] ?? { day, event, host, mode: mode ?? null };
|
|
231
|
+
for (const [field, value] of Object.entries(deltas)) {
|
|
232
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
|
|
233
|
+
existing[field] = (existing[field] ?? 0) + value;
|
|
234
|
+
}
|
|
235
|
+
state.counters[key] = existing;
|
|
236
|
+
atomicWrite(statePaths.counters, state);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Closes a finished UTC day: buckets its raw counters into daily_aggregate rows,
|
|
241
|
+
* appends them to the upload queue, and clears them from the raw counter file so a
|
|
242
|
+
* day is never counted twice. */
|
|
243
|
+
export function closeDay({ statePaths, day, pluginVersion }) {
|
|
244
|
+
const closedRows = [];
|
|
245
|
+
withLock(`${statePaths.counters}.lock`, () => {
|
|
246
|
+
const state = readCounters(statePaths.counters);
|
|
247
|
+
const remaining = {};
|
|
248
|
+
for (const [key, entry] of Object.entries(state.counters)) {
|
|
249
|
+
if (entry.day !== day) { remaining[key] = entry; continue; }
|
|
250
|
+
closedRows.push(validateEvent(bucketEntry(entry, pluginVersion)));
|
|
251
|
+
}
|
|
252
|
+
state.counters = remaining;
|
|
253
|
+
ensureDirectory(path.dirname(statePaths.counters));
|
|
254
|
+
atomicWrite(statePaths.counters, state);
|
|
255
|
+
});
|
|
256
|
+
if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
|
|
257
|
+
return closedRows;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX } = {}) {
|
|
261
|
+
return readQueueRows(statePaths.queue).slice(0, max);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function ackBatch({ statePaths, count }) {
|
|
265
|
+
withLock(`${statePaths.queue}.lock`, () => {
|
|
266
|
+
const rows = readQueueRows(statePaths.queue);
|
|
267
|
+
writeQueueRows(statePaths.queue, rows.slice(count));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function toOtlpLogs(rows) {
|
|
272
|
+
return {
|
|
273
|
+
resourceLogs: [{
|
|
274
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: 'sando' } }] },
|
|
275
|
+
scopeLogs: [{
|
|
276
|
+
logRecords: rows.map((row) => ({
|
|
277
|
+
body: { stringValue: 'sando.daily_aggregate' },
|
|
278
|
+
attributes: Object.entries(row).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
|
|
279
|
+
})),
|
|
280
|
+
}],
|
|
281
|
+
}],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function previewNextUpload({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX } = {}) {
|
|
286
|
+
const rows = loadBatch({ statePaths, max });
|
|
287
|
+
return { url: endpoint, headers: { 'content-type': 'application/json' }, body: toOtlpLogs(rows) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Uploads at most one batch. Every failure mode (timeout, network error, non-2xx) is
|
|
291
|
+
* swallowed and reported as `sent: 0` — telemetry must never throw into, or change the
|
|
292
|
+
* outcome of, the hook or proxy call that triggered a day close. */
|
|
293
|
+
export async function flushQueue({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000 } = {}) {
|
|
294
|
+
const rows = loadBatch({ statePaths, max });
|
|
295
|
+
if (rows.length === 0) return { sent: 0 };
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
298
|
+
try {
|
|
299
|
+
const response = await fetch(endpoint, {
|
|
300
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
301
|
+
body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
|
|
302
|
+
});
|
|
303
|
+
if (!response.ok) return { sent: 0 };
|
|
304
|
+
ackBatch({ statePaths, count: rows.length });
|
|
305
|
+
return { sent: rows.length };
|
|
306
|
+
} catch {
|
|
307
|
+
return { sent: 0 };
|
|
308
|
+
} finally {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), purge = false, statePaths = defaultTelemetryStatePaths() } = {}) {
|
|
314
|
+
const previous = readTelemetryConfig(configPath);
|
|
315
|
+
const result = writeTelemetryConfig(configPath, {
|
|
316
|
+
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
317
|
+
enabled: false,
|
|
318
|
+
prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
|
|
319
|
+
});
|
|
320
|
+
if (purge) {
|
|
321
|
+
for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
}
|