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 +1 -1
- package/src/hook-cli.mjs +29 -11
- package/src/mcp-server.mjs +2 -1
- package/src/postinstall.mjs +2 -1
- package/src/proxy.mjs +94 -47
- package/src/session-start.mjs +15 -3
- package/src/telemetry-cli.mjs +21 -3
- package/src/telemetry-flush-entry.mjs +3 -3
- package/src/telemetry.mjs +223 -64
- 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,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
|
-
|
|
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,15 +1,27 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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(
|
|
12
|
-
if (config.
|
|
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
|
}
|
package/src/telemetry-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
229
|
-
|
|
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] ?? {
|
|
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,
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
/**
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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.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;
|