@ycodium-ai/plugin-telemetry 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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/dist/index.js +336 -0
  3. package/package.json +44 -0
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @ycodium-ai/plugin-telemetry
2
+
3
+ Sends anonymous counts of which Ycodium features get used. No code, no
4
+ prompts, no file names.
5
+
6
+ ## What it does
7
+
8
+ The host forwards usage events into the `record` command. This plugin batches
9
+ them and posts to PostHog. The endpoint and project key come from the entry
10
+ config, which the host fills from `YCODIUM_POSTHOG_KEY` and
11
+ `YCODIUM_POSTHOG_HOST`.
12
+
13
+ Identity is a SHA-256 of a Codex account id, else a Claude user id, else a
14
+ one-time anonymous id stored in the plugin data directory. If none of those
15
+ can be read, nothing is sent.
16
+
17
+ `YCODIUM_TELEMETRY_ENABLED=false` disables the bundle entry before the plugin
18
+ loads. Turning the row off in Settings → Plugins does the same thing.
19
+
20
+ ## Install
21
+
22
+ ```
23
+ ycodium plugin add @ycodium-ai/plugin-telemetry
24
+ ```
25
+
26
+ The plugin asks to read the identity files the host injects at assembly
27
+ (`config:telemetryIdentityPaths`). Those paths are listed on the plugin row
28
+ under Declared permissions.
29
+
30
+ Ycodium ships it enabled as `core.telemetry`. Disabling it stops collection.
31
+ Uninstalling it writes a removal to the home patch so a restart does not bring
32
+ it back.
33
+
34
+ ## Developing
35
+
36
+ Built from the Ycodium repository. The Node entry is bundled, but
37
+ `@ycodium-ai/plugin-api` stays external and is provided by the Ycodium host at
38
+ load time.
39
+
40
+ ## License
41
+
42
+ UNLICENSED.
package/dist/index.js ADDED
@@ -0,0 +1,336 @@
1
+ // src/index.ts
2
+ import * as NodeProcess from "node:process";
3
+ import { definePlugin } from "@ycodium-ai/plugin-api";
4
+
5
+ // src/identity.ts
6
+ import * as NodeCrypto from "node:crypto";
7
+ import * as NodeFS from "node:fs";
8
+ import * as NodePath from "node:path";
9
+ var ANONYMOUS_ID_FILENAME = "anonymous-id";
10
+ var sha256Hex = (value) => NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex");
11
+ var readJsonString = async (fs, path, pick) => {
12
+ if (path === null) return null;
13
+ let text;
14
+ try {
15
+ text = await fs.readFile(path);
16
+ } catch {
17
+ return null;
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(text);
22
+ } catch {
23
+ return null;
24
+ }
25
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
26
+ const picked = pick(parsed);
27
+ return typeof picked === "string" && picked.trim().length > 0 ? picked : null;
28
+ };
29
+ var codexAccountId = (fs, path) => readJsonString(fs, path, (root) => {
30
+ const tokens = root["tokens"];
31
+ return typeof tokens === "object" && tokens !== null ? tokens["account_id"] : void 0;
32
+ });
33
+ var claudeUserId = (fs, path) => readJsonString(fs, path, (root) => root["userID"]);
34
+ var anonymousId = async (fs, dataDir, legacyPath) => {
35
+ const ownPath = NodePath.join(dataDir, ANONYMOUS_ID_FILENAME);
36
+ try {
37
+ const existing = NodeFS.readFileSync(ownPath, "utf8").trim();
38
+ if (existing.length > 0) return existing;
39
+ } catch {
40
+ }
41
+ let value = null;
42
+ if (legacyPath !== null) {
43
+ try {
44
+ const legacy = (await fs.readFile(legacyPath)).trim();
45
+ if (legacy.length > 0) value = legacy;
46
+ } catch {
47
+ }
48
+ }
49
+ value ??= NodeCrypto.randomUUID();
50
+ try {
51
+ NodeFS.mkdirSync(dataDir, { recursive: true });
52
+ NodeFS.writeFileSync(ownPath, value, "utf8");
53
+ } catch {
54
+ }
55
+ return value;
56
+ };
57
+ var resolveTelemetryIdentity = async (fs, paths, dataDir) => {
58
+ const codex = await codexAccountId(fs, paths.codexAuthPath);
59
+ if (codex !== null) return { source: "codex", distinctId: sha256Hex(codex) };
60
+ const claude = await claudeUserId(fs, paths.claudeConfigPath);
61
+ if (claude !== null) return { source: "claude", distinctId: sha256Hex(claude) };
62
+ if (dataDir.trim().length === 0) return null;
63
+ const anonymous = await anonymousId(fs, dataDir, paths.legacyAnonymousIdPath);
64
+ return { source: "anonymous", distinctId: sha256Hex(anonymous) };
65
+ };
66
+
67
+ // src/sender.ts
68
+ var DEFAULT_BATCH_SIZE = 20;
69
+ var DEFAULT_MAX_BUFFERED_EVENTS = 1e3;
70
+ var FLUSH_INTERVAL_MILLIS = 1e3;
71
+ var MAX_FLUSH_INTERVAL_MILLIS = 3e5;
72
+ var flushIntervalMillis = (consecutiveFailures) => consecutiveFailures <= 0 ? FLUSH_INTERVAL_MILLIS : Math.min(FLUSH_INTERVAL_MILLIS * 2 ** consecutiveFailures, MAX_FLUSH_INTERVAL_MILLIS);
73
+ var shouldReportFlushFailure = (consecutiveFailures) => (consecutiveFailures & consecutiveFailures - 1) === 0;
74
+ var TelemetrySender = class {
75
+ #queue = [];
76
+ #consecutiveFailures = 0;
77
+ #timer = null;
78
+ #stopped = false;
79
+ #options;
80
+ #batchSize;
81
+ #maxBuffered;
82
+ constructor(options) {
83
+ this.#options = options;
84
+ this.#batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
85
+ this.#maxBuffered = options.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS;
86
+ }
87
+ /** 队列当前长度。测试用。 */
88
+ get pending() {
89
+ return this.#queue.length;
90
+ }
91
+ start() {
92
+ this.#schedule();
93
+ }
94
+ /** 队列满了丢最老的:新事件比旧事件有用,而无界队列是宿主的性能红线。 */
95
+ record(event) {
96
+ if (this.#stopped) return;
97
+ this.#queue.push(event);
98
+ if (this.#queue.length > this.#maxBuffered) {
99
+ const dropped = this.#queue.length - this.#maxBuffered;
100
+ this.#queue.splice(0, dropped);
101
+ this.#options.log.warn("telemetry buffer full; dropped oldest events", {
102
+ dropped,
103
+ event: event.event
104
+ });
105
+ }
106
+ }
107
+ async stop() {
108
+ this.#stopped = true;
109
+ if (this.#timer !== null) {
110
+ clearTimeout(this.#timer);
111
+ this.#timer = null;
112
+ }
113
+ await this.flush();
114
+ }
115
+ /** 一直发到队列空或者某一批失败为止。 */
116
+ async flush() {
117
+ while (this.#queue.length > 0) {
118
+ const batch = this.#queue.slice(0, this.#batchSize);
119
+ try {
120
+ await this.#send(batch);
121
+ } catch (cause) {
122
+ this.#onFailure(cause);
123
+ return;
124
+ }
125
+ this.#queue.splice(0, batch.length);
126
+ if (this.#consecutiveFailures > 0) {
127
+ this.#options.log.info("telemetry flush recovered", {
128
+ afterConsecutiveFailures: this.#consecutiveFailures
129
+ });
130
+ this.#consecutiveFailures = 0;
131
+ }
132
+ }
133
+ }
134
+ #onFailure(cause) {
135
+ this.#consecutiveFailures += 1;
136
+ if (!shouldReportFlushFailure(this.#consecutiveFailures)) return;
137
+ const message = cause instanceof Error ? cause.message : String(cause);
138
+ if (this.#consecutiveFailures === 1) {
139
+ this.#options.log.error("failed to flush telemetry", { cause: message });
140
+ return;
141
+ }
142
+ this.#options.log.warn("telemetry is still failing to flush", {
143
+ consecutiveFailures: this.#consecutiveFailures,
144
+ nextAttemptInMillis: flushIntervalMillis(this.#consecutiveFailures)
145
+ });
146
+ }
147
+ async #send(batch) {
148
+ const response = await this.#options.fetch(`${this.#options.endpoint}/batch/`, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify({
152
+ api_key: this.#options.apiKey,
153
+ batch: batch.map((entry) => ({
154
+ event: entry.event,
155
+ distinct_id: this.#options.distinctId,
156
+ properties: {
157
+ ...entry.properties,
158
+ ...this.#options.baseProperties,
159
+ // 不给这个 distinct_id 建 person profile:我们只数事件,不画像。
160
+ $process_person_profile: false
161
+ },
162
+ timestamp: entry.capturedAt
163
+ }))
164
+ })
165
+ });
166
+ if (!response.ok) {
167
+ throw new Error(`telemetry endpoint returned ${response.status}`);
168
+ }
169
+ }
170
+ /** 每轮重算间隔:失败越久等得越长,恢复后立刻回到一秒一次。 */
171
+ #schedule() {
172
+ if (this.#stopped) return;
173
+ this.#timer = setTimeout(() => {
174
+ void this.flush().finally(() => this.#schedule());
175
+ }, flushIntervalMillis(this.#consecutiveFailures));
176
+ this.#timer.unref?.();
177
+ }
178
+ };
179
+
180
+ // src/index.ts
181
+ var RECORD_PAYLOAD_SCHEMA = {
182
+ type: "object",
183
+ properties: {
184
+ event: { type: "string" },
185
+ properties: { type: "object" }
186
+ },
187
+ required: ["event"],
188
+ additionalProperties: false
189
+ };
190
+ var EMPTY_PAYLOAD_SCHEMA = {
191
+ type: "object",
192
+ properties: {},
193
+ additionalProperties: false
194
+ };
195
+ var stringField = (value, key) => {
196
+ if (typeof value !== "object" || value === null) return null;
197
+ const picked = value[key];
198
+ return typeof picked === "string" && picked.trim().length > 0 ? picked : null;
199
+ };
200
+ var objectField = (value, key) => {
201
+ if (typeof value !== "object" || value === null) return void 0;
202
+ const picked = value[key];
203
+ return typeof picked === "object" && picked !== null && !Array.isArray(picked) ? picked : void 0;
204
+ };
205
+ var identityPathsOf = (config) => ({
206
+ codexAuthPath: config.codexAuthPath ?? null,
207
+ claudeConfigPath: config.claudeConfigPath ?? null,
208
+ legacyAnonymousIdPath: config.legacyAnonymousIdPath ?? null
209
+ });
210
+ var baseProperties = (config) => {
211
+ const wsl = NodeProcess.env["WSL_DISTRO_NAME"]?.trim();
212
+ return {
213
+ platform: NodeProcess.platform,
214
+ arch: NodeProcess.arch,
215
+ ...wsl !== void 0 && wsl.length > 0 ? { wsl } : {},
216
+ ...config.appVersion !== void 0 ? { ycodiumCodeVersion: config.appVersion } : {},
217
+ ...config.clientType !== void 0 ? { clientType: config.clientType } : {}
218
+ };
219
+ };
220
+ var TelemetryRuntime = class {
221
+ #sender = null;
222
+ #pending = [];
223
+ #ready = false;
224
+ // 在飞的身份解析。stop 必须等它落完:那条路会往 dataDir 写文件,不等就会在宿主
225
+ // 已经开始删目录之后再把目录建回来。
226
+ #starting = null;
227
+ #ctx;
228
+ constructor(ctx) {
229
+ this.#ctx = ctx;
230
+ }
231
+ start() {
232
+ this.#starting = this.#initialize().catch((cause) => {
233
+ this.#ctx.log.error("telemetry could not start", {
234
+ cause: cause instanceof Error ? cause.message : String(cause)
235
+ });
236
+ this.#pending = [];
237
+ this.#ready = true;
238
+ });
239
+ }
240
+ async #initialize() {
241
+ const config = this.#ctx.config;
242
+ const apiKey = config.posthogKey?.trim();
243
+ const endpoint = config.posthogHost?.trim();
244
+ if (apiKey === void 0 || apiKey.length === 0 || endpoint === void 0) {
245
+ this.#ctx.log.warn("telemetry has no endpoint configured; not sending anything");
246
+ this.#pending = [];
247
+ this.#ready = true;
248
+ return;
249
+ }
250
+ const identity = await resolveTelemetryIdentity(
251
+ this.#ctx.fs,
252
+ identityPathsOf(config),
253
+ this.#ctx.paths.dataDir
254
+ );
255
+ if (identity === null) {
256
+ this.#ctx.log.warn("telemetry has no stable identifier; not sending anything");
257
+ this.#pending = [];
258
+ this.#ready = true;
259
+ return;
260
+ }
261
+ const sender = new TelemetrySender({
262
+ endpoint,
263
+ apiKey,
264
+ distinctId: identity.distinctId,
265
+ baseProperties: baseProperties(config),
266
+ // ctx.http 已按这台机器的全局默认代理解析过。裸 fetch 绕开代理,配了代理的机器
267
+ // 上会静默失败——搬迁前宿主用的就是裸 fetch,那些机器一条都没发出去。
268
+ fetch: this.#ctx.http,
269
+ log: this.#ctx.log
270
+ });
271
+ for (const event of this.#pending) sender.record(event);
272
+ this.#pending = [];
273
+ this.#sender = sender;
274
+ this.#ready = true;
275
+ sender.start();
276
+ this.#ctx.log.info("telemetry started", { identitySource: identity.source });
277
+ }
278
+ record(event, properties) {
279
+ const entry = {
280
+ event,
281
+ properties,
282
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
283
+ };
284
+ if (this.#sender !== null) {
285
+ this.#sender.record(entry);
286
+ return;
287
+ }
288
+ if (!this.#ready) this.#pending.push(entry);
289
+ }
290
+ flush() {
291
+ return this.#sender?.flush() ?? Promise.resolve();
292
+ }
293
+ async stop() {
294
+ await this.#starting;
295
+ this.#starting = null;
296
+ await this.#sender?.stop();
297
+ this.#sender = null;
298
+ this.#pending = [];
299
+ }
300
+ };
301
+ var index_default = definePlugin({
302
+ id: "telemetry",
303
+ apiVersion: 2,
304
+ activate: (ctx) => {
305
+ const runtime = new TelemetryRuntime(ctx);
306
+ runtime.start();
307
+ ctx.command({
308
+ name: "record",
309
+ description: "Records one anonymous usage event for batched delivery.",
310
+ // 宿主的薄壳按事件逐条转发,不是人点出来的动作。
311
+ scope: "read",
312
+ payloadSchema: RECORD_PAYLOAD_SCHEMA,
313
+ handle: (payload) => {
314
+ const event = stringField(payload, "event");
315
+ if (event === null) throw new Error("record requires a non-empty 'event'.");
316
+ runtime.record(event, objectField(payload, "properties"));
317
+ return Promise.resolve({ recorded: true });
318
+ }
319
+ });
320
+ ctx.command({
321
+ name: "flush",
322
+ description: "Sends everything currently queued, without waiting for the next interval.",
323
+ scope: "read",
324
+ payloadSchema: EMPTY_PAYLOAD_SCHEMA,
325
+ handle: async () => {
326
+ await runtime.flush();
327
+ return { flushed: true };
328
+ }
329
+ });
330
+ ctx.onDispose(() => runtime.stop());
331
+ }
332
+ });
333
+ export {
334
+ TelemetryRuntime,
335
+ index_default as default
336
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@ycodium-ai/plugin-telemetry",
3
+ "version": "0.2.0",
4
+ "description": "Sends anonymous counts of which features get used. No code, no prompts.",
5
+ "keywords": [
6
+ "telemetry",
7
+ "ycodium",
8
+ "ycodium-plugin"
9
+ ],
10
+ "license": "UNLICENSED",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "devDependencies": {
19
+ "@effect/vitest": "4.0.0-beta.103",
20
+ "@types/node": "24.12.4",
21
+ "esbuild": "0.28.1",
22
+ "vite-plus": "0.2.2",
23
+ "@ycodium-ai/plugin-api": "0.1.1"
24
+ },
25
+ "ycodium": {
26
+ "plugin": "./dist/index.js",
27
+ "displayName": "Anonymous usage statistics",
28
+ "description": "Sends anonymous counts of which features get used. No code, no prompts.",
29
+ "icon": "chartColumn",
30
+ "permissions": [
31
+ {
32
+ "kind": "fs.read",
33
+ "paths": [
34
+ "config:telemetryIdentityPaths"
35
+ ]
36
+ }
37
+ ]
38
+ },
39
+ "scripts": {
40
+ "build": "node build.mjs",
41
+ "typecheck": "tsgo --noEmit",
42
+ "test": "vp test run"
43
+ }
44
+ }