@wu529778790/open-im 1.11.2-beta.1 → 1.11.2-beta.3

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/dist/config.js CHANGED
@@ -8,7 +8,7 @@ import { accessSync, constants } from 'node:fs';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { join, isAbsolute } from 'node:path';
10
10
  import { createLogger } from './logger.js';
11
- import { APP_HOME, DEFAULT_TELEMETRY_INGEST_URL, DEFAULT_TELEMETRY_INGEST_TOKEN, } from './constants.js';
11
+ import { APP_HOME, } from './constants.js';
12
12
  const log = createLogger('config');
13
13
  /** 分渠道 AI 工具:未配置时使用 AI_COMMAND / 旧版根级 aiCommand / 默认 claude */
14
14
  function resolveFilePlatformAi(file, platform) {
@@ -401,48 +401,8 @@ export function loadConfig() {
401
401
  else {
402
402
  telemetryEnabled = true;
403
403
  }
404
- let telemetryUrl;
405
- const telemetryUrlRaw = process.env.OPEN_IM_TELEMETRY_URL ?? file.telemetry?.url;
406
- if (telemetryUrlRaw && typeof telemetryUrlRaw === 'string' && telemetryUrlRaw.trim()) {
407
- try {
408
- const u = new URL(telemetryUrlRaw.trim());
409
- if (u.protocol !== 'https:') {
410
- log.warn('OPEN_IM_TELEMETRY_URL / telemetry.url 必须为 https,已忽略上传地址');
411
- }
412
- else {
413
- telemetryUrl = u.href;
414
- }
415
- }
416
- catch {
417
- log.warn('无效的 OPEN_IM_TELEMETRY_URL / telemetry.url,已忽略上传地址');
418
- }
419
- }
420
- if (!telemetryUrl && telemetryEnabled && DEFAULT_TELEMETRY_INGEST_URL.trim()) {
421
- try {
422
- const u = new URL(DEFAULT_TELEMETRY_INGEST_URL.trim());
423
- if (u.protocol === 'https:') {
424
- telemetryUrl = u.href;
425
- }
426
- }
427
- catch {
428
- /* ignore */
429
- }
430
- }
431
- let telemetryToken;
432
- if (process.env.OPEN_IM_TELEMETRY_TOKEN !== undefined) {
433
- const t = process.env.OPEN_IM_TELEMETRY_TOKEN.trim();
434
- telemetryToken = t.length > 0 ? t : undefined;
435
- }
436
- else if (file.telemetry?.token !== undefined) {
437
- const t = String(file.telemetry.token).trim();
438
- telemetryToken = t.length > 0 ? t : undefined;
439
- }
440
- else {
441
- telemetryToken = DEFAULT_TELEMETRY_INGEST_TOKEN.trim() || undefined;
442
- }
443
- if (telemetryEnabled && !telemetryUrl) {
444
- log.warn('遥测已开启但未配置有效的 HTTPS 采集 URL:仅写入本地 events-*.jsonl;可设置 OPEN_IM_TELEMETRY_URL 或在 constants 中配置 DEFAULT_TELEMETRY_INGEST_URL。');
445
- }
404
+ // Telemetry: local event logging only (no cloud upload)
405
+ // Users can disable via OPEN_IM_TELEMETRY=false or telemetry.enabled=false
446
406
  const platforms = {
447
407
  telegram: telegramEnabled
448
408
  ? {
@@ -576,8 +536,6 @@ export function loadConfig() {
576
536
  logLevel,
577
537
  telemetry: {
578
538
  enabled: telemetryEnabled,
579
- url: telemetryUrl,
580
- token: telemetryToken,
581
539
  },
582
540
  platforms,
583
541
  };
@@ -1,14 +1,4 @@
1
1
  export declare const APP_HOME: string;
2
- /**
3
- * 未设置 `OPEN_IM_TELEMETRY_URL` / `telemetry.url` 时使用的默认采集端(HTTPS)。
4
- * 工具发行方可写死自有域名;留空字符串则仅在上游显式配置 URL 时上传。
5
- */
6
- export declare const DEFAULT_TELEMETRY_INGEST_URL = "https://open-im.shenzjd.com/v1/ingest";
7
- /**
8
- * 未设置 `OPEN_IM_TELEMETRY_TOKEN` / `telemetry.token` 时使用的默认 Bearer。
9
- * 须与 Cloudflare Worker 的 `wrangler secret put TELEMETRY_INGEST_TOKEN` 值完全一致。
10
- */
11
- export declare const DEFAULT_TELEMETRY_INGEST_TOKEN = "610457d55274f20f2d031d38cdfd86c8498016e75160f60cdbce0dab93a78240";
12
2
  /** 优雅关闭 HTTP 端口(stop 命令通过此端口触发 shutdown) */
13
3
  export declare const SHUTDOWN_PORT = 39281;
14
4
  /** 本地 Web 配置 API 固定端口(完整 UI 由 web/dist 随包提供,见 getPublicWebDashboardUrl) */
package/dist/constants.js CHANGED
@@ -1,16 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  export const APP_HOME = join(homedir(), ".open-im");
4
- /**
5
- * 未设置 `OPEN_IM_TELEMETRY_URL` / `telemetry.url` 时使用的默认采集端(HTTPS)。
6
- * 工具发行方可写死自有域名;留空字符串则仅在上游显式配置 URL 时上传。
7
- */
8
- export const DEFAULT_TELEMETRY_INGEST_URL = "https://open-im.shenzjd.com/v1/ingest";
9
- /**
10
- * 未设置 `OPEN_IM_TELEMETRY_TOKEN` / `telemetry.token` 时使用的默认 Bearer。
11
- * 须与 Cloudflare Worker 的 `wrangler secret put TELEMETRY_INGEST_TOKEN` 值完全一致。
12
- */
13
- export const DEFAULT_TELEMETRY_INGEST_TOKEN = "610457d55274f20f2d031d38cdfd86c8498016e75160f60cdbce0dab93a78240";
14
4
  /** 优雅关闭 HTTP 端口(stop 命令通过此端口触发 shutdown) */
15
5
  export const SHUTDOWN_PORT = 39281;
16
6
  /** 本地 Web 配置 API 固定端口(完整 UI 由 web/dist 随包提供,见 getPublicWebDashboardUrl) */
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@ import { initAdapters, cleanupAdapters } from "./adapters/registry.js";
32
32
  import { SessionManager } from "./session/session-manager.js";
33
33
  import { loadActiveChats, getActiveChatId, flushActiveChats, } from "./shared/active-chats.js";
34
34
  import { destroyAllLiveChildren } from "./shared/process-kill.js";
35
- import { initLogger, createLogger, closeLogger, emitStructuredEvent, shutdownLoggerTelemetry, } from "./logger.js";
35
+ import { initLogger, createLogger, closeLogger, } from "./logger.js";
36
36
  import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
37
37
  import { createRequire } from "node:module";
38
38
  import { escapePathForMarkdown, getAIToolDisplayName } from "./shared/utils.js";
@@ -267,10 +267,6 @@ export async function main() {
267
267
  }
268
268
  log.info("Service is running. Press Ctrl+C to stop.");
269
269
  log.info(`Successfully initialized platforms: ${successfulPlatforms.join(", ")}`);
270
- emitStructuredEvent("Main", "service.platform.init", {
271
- platforms: successfulPlatforms,
272
- version: APP_VERSION,
273
- });
274
270
  // Send notification only to successfully initialized platforms
275
271
  for (const platform of successfulPlatforms) {
276
272
  const startupMsg = buildStartupMessage(platform, APP_VERSION, resolvePlatformAiCommand(config, platform), startupCwd, sessionManager);
@@ -328,7 +324,6 @@ export async function main() {
328
324
  sessionManager.destroy();
329
325
  cleanupAdapters();
330
326
  flushActiveChats();
331
- await shutdownLoggerTelemetry();
332
327
  await flushSentry();
333
328
  await closeLogger();
334
329
  process.exit(0);
@@ -381,8 +376,7 @@ export async function main() {
381
376
  }
382
377
  }
383
378
  }
384
- void shutdownLoggerTelemetry()
385
- .then(() => closeLogger())
379
+ void closeLogger()
386
380
  .finally(() => process.exit(1));
387
381
  });
388
382
  }
@@ -391,8 +385,7 @@ const isEntry = process.argv[1]?.replace(/\\/g, "/").endsWith("/index.js") ||
391
385
  if (isEntry) {
392
386
  main().catch((err) => {
393
387
  log.error("Fatal error:", err);
394
- void shutdownLoggerTelemetry()
395
- .then(() => closeLogger())
388
+ void closeLogger()
396
389
  .finally(() => process.exit(1));
397
390
  });
398
391
  }
package/dist/logger.d.ts CHANGED
@@ -21,14 +21,11 @@ export declare function createLogger(tag: string): {
21
21
  warn: (msg: string, ...args: unknown[]) => void;
22
22
  error: (msg: string, ...args: unknown[]) => void;
23
23
  debug: (msg: string, ...args: unknown[]) => void;
24
- infoEvent: (event: string, data?: Record<string, unknown>, msg?: string) => void;
25
24
  };
26
25
  /**
27
26
  * Audit log — records user interactions for debugging and compliance.
28
27
  * Always enabled, writes to audit.log.
29
28
  */
30
29
  export declare function auditLog(platform: string, userId: string, action: string, detail?: Record<string, unknown>): void;
31
- export declare function emitStructuredEvent(tag: string, event: string, data?: Record<string, unknown>, level?: LogLevel, msg?: string): void;
32
- export declare function shutdownLoggerTelemetry(): Promise<void>;
33
30
  export declare function closeLogger(): Promise<void>;
34
31
  export {};
package/dist/logger.js CHANGED
@@ -3,20 +3,13 @@ import { join } from 'node:path';
3
3
  import { finished } from 'node:stream/promises';
4
4
  import { sanitize } from './sanitize.js';
5
5
  import { APP_HOME } from './constants.js';
6
- import { sanitizeTelemetryData } from './telemetry/telemetry-sanitize.js';
7
- import { enqueueTelemetryLine, getTelemetryUploadStats, initTelemetryUpload, shutdownTelemetryUpload, } from './telemetry/telemetry-upload.js';
8
6
  const DEFAULT_LOG_DIR = join(APP_HOME, 'logs');
9
7
  const MAX_LOG_FILES = 10;
10
8
  const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
11
9
  let logDir = DEFAULT_LOG_DIR;
12
10
  let minLevel = LOG_LEVELS.DEBUG;
13
11
  let logStream;
14
- let eventsStream;
15
12
  let auditStream;
16
- let telemetryEnabled = false;
17
- let telemetryStatsTimer = null;
18
- let lastTelemetryStatsSignature = '';
19
- const TELEMETRY_STATS_INTERVAL_MS = 5 * 60_000;
20
13
  function pad(n) {
21
14
  return String(n).padStart(2, '0');
22
15
  }
@@ -84,41 +77,6 @@ export function initLogger(dirOrOpts, level, telemetry) {
84
77
  auditStream = undefined;
85
78
  }
86
79
  auditStream = createWriteStream(join(logDir, 'audit.log'), { flags: 'a' });
87
- telemetryEnabled = !!tel?.enabled;
88
- if (telemetryStatsTimer) {
89
- clearInterval(telemetryStatsTimer);
90
- telemetryStatsTimer = null;
91
- }
92
- lastTelemetryStatsSignature = '';
93
- if (eventsStream) {
94
- eventsStream.end();
95
- eventsStream = undefined;
96
- }
97
- if (telemetryEnabled) {
98
- rotateOldJsonl();
99
- eventsStream = createWriteStream(join(logDir, getEventsFileName()), { flags: 'a' });
100
- initTelemetryUpload({
101
- enabled: true,
102
- url: tel?.url,
103
- token: tel?.token,
104
- });
105
- telemetryStatsTimer = setInterval(() => {
106
- emitTelemetryUploadStats(false);
107
- }, TELEMETRY_STATS_INTERVAL_MS);
108
- }
109
- else {
110
- initTelemetryUpload({ enabled: false });
111
- }
112
- }
113
- function emitTelemetryUploadStats(force) {
114
- if (!telemetryEnabled)
115
- return;
116
- const stats = getTelemetryUploadStats();
117
- const signature = JSON.stringify(stats);
118
- if (!force && signature === lastTelemetryStatsSignature)
119
- return;
120
- lastTelemetryStatsSignature = signature;
121
- emitStructuredEvent('Telemetry', 'telemetry.upload.stats', stats);
122
80
  }
123
81
  function write(level, tag, msg, ...args) {
124
82
  if (LOG_LEVELS[level] < minLevel)
@@ -139,7 +97,6 @@ export function createLogger(tag) {
139
97
  warn: (msg, ...args) => write('WARN', tag, msg, ...args),
140
98
  error: (msg, ...args) => write('ERROR', tag, msg, ...args),
141
99
  debug: (msg, ...args) => write('DEBUG', tag, msg, ...args),
142
- infoEvent: (event, data, msg) => emitStructuredEvent(tag, event, data, 'INFO', msg),
143
100
  };
144
101
  }
145
102
  /**
@@ -156,37 +113,13 @@ export function auditLog(platform, userId, action, detail) {
156
113
  };
157
114
  auditStream?.write(JSON.stringify(entry) + '\n');
158
115
  }
159
- export function emitStructuredEvent(tag, event, data, level = 'INFO', msg = '') {
160
- if (!telemetryEnabled)
161
- return;
162
- const payload = {
163
- v: 1,
164
- ts: new Date().toISOString(),
165
- level,
166
- tag,
167
- event,
168
- msg,
169
- data: sanitizeTelemetryData(data),
170
- };
171
- const line = `${JSON.stringify(payload)}\n`;
172
- eventsStream?.write(line);
173
- enqueueTelemetryLine(line);
174
- }
175
- export async function shutdownLoggerTelemetry() {
176
- emitTelemetryUploadStats(true);
177
- if (telemetryStatsTimer) {
178
- clearInterval(telemetryStatsTimer);
179
- telemetryStatsTimer = null;
180
- }
181
- await shutdownTelemetryUpload();
182
- }
183
116
  export async function closeLogger() {
184
- if (eventsStream) {
185
- const es = eventsStream;
186
- eventsStream = undefined;
187
- es.end();
117
+ if (auditStream) {
118
+ const as = auditStream;
119
+ auditStream = undefined;
120
+ as.end();
188
121
  try {
189
- await finished(es);
122
+ await finished(as);
190
123
  }
191
124
  catch {
192
125
  /* ignore */
@@ -4,9 +4,7 @@
4
4
  import { resolvePlatformAiCommand } from '../config.js';
5
5
  import { captureError } from './sentry.js';
6
6
  import { formatToolStats, formatToolCallNotification, getContextWarning, getAIToolDisplayName, toReplyPlainText, } from './utils.js';
7
- import { createLogger, emitStructuredEvent } from '../logger.js';
8
- import { hashUserId } from '../telemetry/hash-user.js';
9
- import { sanitize } from '../sanitize.js';
7
+ import { createLogger } from '../logger.js';
10
8
  const log = createLogger('AITask');
11
9
  function isUsageLimitError(error) {
12
10
  return /usage limit/i.test(error) || /try again at\s+\d{1,2}:\d{2}\s*(AM|PM)/i.test(error);
@@ -154,12 +152,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
154
152
  const aiCommand = resolvePlatformAiCommand(config, ctx.platform);
155
153
  const startRun = () => {
156
154
  log.info(`[AITask] Starting: userId=${ctx.userId}, initialSessionId=${currentSessionId ?? 'new'}, prompt="${prompt.slice(0, 50)}..."`);
157
- emitStructuredEvent('AITask', 'ai.task.start', {
158
- platform: ctx.platform,
159
- taskKey: hashUserId(ctx.taskKey),
160
- userKey: hashUserId(ctx.userId),
161
- toolId: aiCommand,
162
- });
163
155
  activeHandle = toolAdapter.run(prompt, currentSessionId, ctx.workDir, {
164
156
  onSessionId: (id) => {
165
157
  log.info(`[AITask] SessionId callback: old=${currentSessionId ?? 'none'}, new=${id}, aiCommand=${aiCommand}, userId=${ctx.userId}`);
@@ -218,17 +210,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
218
210
  log.info(`[AITask] onComplete fired: settled=${settled}, success=${result.success}, platform=${ctx.platform}, taskKey=${ctx.taskKey}`);
219
211
  if (settled)
220
212
  return;
221
- emitStructuredEvent('AITask', 'ai.task.complete', {
222
- platform: ctx.platform,
223
- taskKey: hashUserId(ctx.taskKey),
224
- userKey: hashUserId(ctx.userId),
225
- toolId: aiCommand,
226
- durationMs: result.durationMs,
227
- success: result.success,
228
- numTurns: result.numTurns,
229
- model: result.model,
230
- toolStats: result.toolStats,
231
- });
232
213
  settled = true;
233
214
  if (pendingUpdate) {
234
215
  clearTimeout(pendingUpdate);
@@ -282,15 +263,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
282
263
  }
283
264
  settled = true;
284
265
  log.error(`Task error for user ${ctx.userId}: ${error}`);
285
- emitStructuredEvent('AITask', 'ai.task.error', {
286
- platform: ctx.platform,
287
- taskKey: hashUserId(ctx.taskKey),
288
- userKey: hashUserId(ctx.userId),
289
- toolId: aiCommand,
290
- durationMs: Date.now() - taskState.startedAt,
291
- errorSnippet: sanitize(String(error).slice(0, 400)),
292
- errorType: classifyErrorType(String(error)),
293
- });
294
266
  if (isUsageLimitError(error)) {
295
267
  // Usage limit errors: keep session for all tools (user can retry later)
296
268
  log.warn(`Keeping ${aiCommand} session for user ${ctx.userId} after usage limit error`);
@@ -330,15 +302,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
330
302
  handle: {
331
303
  abort: () => {
332
304
  if (!settled) {
333
- emitStructuredEvent('AITask', 'ai.task.error', {
334
- platform: ctx.platform,
335
- taskKey: hashUserId(ctx.taskKey),
336
- userKey: hashUserId(ctx.userId),
337
- toolId: aiCommand,
338
- durationMs: Date.now() - taskState.startedAt,
339
- errorSnippet: 'aborted',
340
- errorType: 'aborted',
341
- });
342
305
  // 用户取消(/new、/resume、队列超时、stale 清理):把「思考中…」占位卡片编辑为终态,
343
306
  // 避免卡片卡在转圈。停按钮路径会先 settle() 再 abort,settled=true 时此处跳过,不会双发。
344
307
  void platformAdapter.sendError('⏹️ 已取消').catch(() => {
@@ -354,9 +317,9 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
354
317
  settle,
355
318
  startedAt: Date.now(),
356
319
  toolId: aiCommand,
357
- taskKey: hashUserId(ctx.taskKey),
320
+ taskKey: ctx.taskKey,
358
321
  platform: ctx.platform,
359
- userKey: hashUserId(ctx.userId),
322
+ userKey: ctx.userId,
360
323
  };
361
324
  try {
362
325
  startRun();
@@ -371,15 +334,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
371
334
  userId: ctx.userId,
372
335
  aiCommand,
373
336
  });
374
- emitStructuredEvent('AITask', 'ai.task.error', {
375
- platform: ctx.platform,
376
- taskKey: hashUserId(ctx.taskKey),
377
- userKey: hashUserId(ctx.userId),
378
- toolId: aiCommand,
379
- durationMs: 0,
380
- errorSnippet: sanitize(String(err).slice(0, 400)),
381
- errorType: classifyErrorType(String(err)),
382
- });
383
337
  platformAdapter
384
338
  .sendError(`内部错误:${err instanceof Error ? err.message : String(err)}`)
385
339
  .catch(() => {
@@ -4,7 +4,7 @@
4
4
  * Tasks older than 30 minutes are aborted and removed from the running-tasks
5
5
  * map so they never accumulate indefinitely.
6
6
  */
7
- import { createLogger, emitStructuredEvent } from '../logger.js';
7
+ import { createLogger } from '../logger.js';
8
8
  const log = createLogger('TaskCleanup');
9
9
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
10
10
  const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
@@ -46,17 +46,7 @@ export function startTaskCleanup(runningTasks) {
46
46
  export function emitInterruptedTerminals(runningTasks) {
47
47
  if (runningTasks.size === 0)
48
48
  return;
49
- const now = Date.now();
50
49
  for (const state of runningTasks.values()) {
51
- emitStructuredEvent('AITask', 'ai.task.error', {
52
- platform: state.platform,
53
- taskKey: state.taskKey,
54
- userKey: state.userKey,
55
- toolId: state.toolId,
56
- durationMs: now - state.startedAt,
57
- errorSnippet: 'interrupted',
58
- errorType: 'interrupted',
59
- });
60
50
  // 标记已结算,使随后 shutdown 的 abort() 跳过重复的 aborted 事件
61
51
  state.settle();
62
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.11.2-beta.1",
3
+ "version": "1.11.2-beta.3",
4
4
  "description": "Your AI coding assistant, in every chat app. Multi-platform IM bridge for Claude Code, Codex, and CodeBuddy.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,2 +0,0 @@
1
- /** 展示用哈希:同一 userId 稳定、不可逆还原原文。 */
2
- export declare function hashUserId(userId: string): string;
@@ -1,5 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- /** 展示用哈希:同一 userId 稳定、不可逆还原原文。 */
3
- export function hashUserId(userId) {
4
- return createHash('sha256').update(`open-im\0${userId}`, 'utf8').digest('hex').slice(0, 24);
5
- }
@@ -1,2 +0,0 @@
1
- /** 结构化遥测 data 字段:截断长串、走 sanitize。 */
2
- export declare function sanitizeTelemetryData(data: Record<string, unknown> | undefined): Record<string, unknown>;
@@ -1,30 +0,0 @@
1
- import { sanitize } from '../sanitize.js';
2
- const MAX_STRING = 512;
3
- function sanitizeValue(v) {
4
- if (v === null || typeof v === 'boolean' || typeof v === 'number')
5
- return v;
6
- if (typeof v === 'string') {
7
- const s = sanitize(v.length > MAX_STRING ? `${v.slice(0, MAX_STRING)}…` : v);
8
- return s;
9
- }
10
- if (Array.isArray(v))
11
- return v.slice(0, 32).map((x) => sanitizeValue(x));
12
- if (typeof v === 'object') {
13
- const o = v;
14
- const out = {};
15
- let i = 0;
16
- for (const [k, val] of Object.entries(o)) {
17
- if (i++ >= 32)
18
- break;
19
- out[k] = sanitizeValue(val);
20
- }
21
- return out;
22
- }
23
- return undefined;
24
- }
25
- /** 结构化遥测 data 字段:截断长串、走 sanitize。 */
26
- export function sanitizeTelemetryData(data) {
27
- if (!data)
28
- return {};
29
- return sanitizeValue(data);
30
- }
@@ -1,21 +0,0 @@
1
- interface TelemetryUploadStats {
2
- postedBatches: number;
3
- postedLines: number;
4
- retryableFailures: number;
5
- dropped4xxBatches: number;
6
- dropped4xxLines: number;
7
- networkFailures: number;
8
- }
9
- export declare function initTelemetryUpload(opts: {
10
- enabled: boolean;
11
- url?: string;
12
- token?: string;
13
- }): void;
14
- /**
15
- * 单行 NDJSON(已含 \\n)。
16
- * 满 BATCH_MAX_LINES 立即上传;否则自「当前积压周期」起至少间隔 MIN_PARTIAL_FLUSH_INTERVAL_MS 再上传。
17
- */
18
- export declare function enqueueTelemetryLine(line: string): void;
19
- export declare function shutdownTelemetryUpload(): Promise<void>;
20
- export declare function getTelemetryUploadStats(): Readonly<TelemetryUploadStats>;
21
- export {};
@@ -1,233 +0,0 @@
1
- /**
2
- * 遥测 NDJSON 上传:
3
- * - 单次 POST 最多 BATCH_MAX_LINES 条(控制 body 大小);
4
- * - 事件稀疏时按 MIN_PARTIAL_FLUSH_INTERVAL_MS 合并上报,降低时间维度上的请求频率;
5
- * - 积压达到 BATCH_MAX_LINES 时仍立即上传(突发流量)。
6
- */
7
- const BATCH_MAX_LINES = 100;
8
- /** 稀疏流量:队列未满批时,最早在「首条入队」后经过该间隔才上传(避免短间隔反复 POST) */
9
- const MIN_PARTIAL_FLUSH_INTERVAL_MS = 60_000;
10
- const MAX_QUEUE = 8000;
11
- const MAX_BACKOFF_MS = 120_000;
12
- const INITIAL_BACKOFF_MS = 1000;
13
- let queue = [];
14
- let idleTimer = null;
15
- let backoffTimer = null;
16
- let backoffMs = INITIAL_BACKOFF_MS;
17
- let uploadEnabled = false;
18
- let endpoint;
19
- let bearer;
20
- let flushing = false;
21
- const stats = {
22
- postedBatches: 0,
23
- postedLines: 0,
24
- retryableFailures: 0,
25
- dropped4xxBatches: 0,
26
- dropped4xxLines: 0,
27
- networkFailures: 0,
28
- };
29
- function resetStats() {
30
- stats.postedBatches = 0;
31
- stats.postedLines = 0;
32
- stats.retryableFailures = 0;
33
- stats.dropped4xxBatches = 0;
34
- stats.dropped4xxLines = 0;
35
- stats.networkFailures = 0;
36
- }
37
- function clearIdleTimer() {
38
- if (idleTimer) {
39
- clearTimeout(idleTimer);
40
- idleTimer = null;
41
- }
42
- }
43
- function clearBackoffTimer() {
44
- if (backoffTimer) {
45
- clearTimeout(backoffTimer);
46
- backoffTimer = null;
47
- }
48
- }
49
- function schedulePartialFlush() {
50
- if (!uploadEnabled || !endpoint || idleTimer || flushing || backoffTimer)
51
- return;
52
- idleTimer = setTimeout(() => {
53
- idleTimer = null;
54
- void flushPipeline().catch(() => {
55
- /* 静默;退避重试由 flushPipeline/backoff 处理 */
56
- });
57
- }, MIN_PARTIAL_FLUSH_INTERVAL_MS);
58
- }
59
- async function postBatch(lines) {
60
- if (!endpoint || lines.length === 0)
61
- return true;
62
- const body = lines.join('');
63
- const headers = {
64
- 'content-type': 'application/x-ndjson',
65
- accept: 'application/json',
66
- };
67
- if (bearer)
68
- headers.authorization = `Bearer ${bearer}`;
69
- try {
70
- const res = await fetch(endpoint, { method: 'POST', headers, body });
71
- try {
72
- await res.text();
73
- }
74
- catch {
75
- /* ignore body read errors */
76
- }
77
- if (res.ok) {
78
- stats.postedBatches += 1;
79
- stats.postedLines += lines.length;
80
- return true;
81
- }
82
- // 4xx(除 408/429)通常是请求本身不可恢复(鉴权/格式错误),不应无限重试。
83
- if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
84
- stats.dropped4xxBatches += 1;
85
- stats.dropped4xxLines += lines.length;
86
- return true;
87
- }
88
- stats.retryableFailures += 1;
89
- return false;
90
- }
91
- catch {
92
- stats.networkFailures += 1;
93
- return false;
94
- }
95
- }
96
- async function flushPipeline() {
97
- if (!uploadEnabled || !endpoint || flushing)
98
- return;
99
- if (queue.length === 0)
100
- return;
101
- flushing = true;
102
- try {
103
- while (uploadEnabled && endpoint && queue.length > 0) {
104
- const batch = queue.splice(0, BATCH_MAX_LINES);
105
- try {
106
- const ok = await postBatch(batch);
107
- if (ok) {
108
- backoffMs = INITIAL_BACKOFF_MS;
109
- clearBackoffTimer();
110
- }
111
- else {
112
- queue.unshift(...batch);
113
- await backoffThenRetry();
114
- break;
115
- }
116
- }
117
- catch {
118
- queue.unshift(...batch);
119
- await backoffThenRetry();
120
- break;
121
- }
122
- }
123
- }
124
- catch {
125
- /* 静默:上传失败不得向外抛,避免 unhandledRejection */
126
- }
127
- finally {
128
- flushing = false;
129
- }
130
- }
131
- function backoffThenRetry() {
132
- return new Promise((resolve) => {
133
- clearBackoffTimer();
134
- backoffTimer = setTimeout(() => {
135
- backoffTimer = null;
136
- backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs * 2);
137
- void flushPipeline().catch(() => { }).finally(resolve);
138
- }, backoffMs);
139
- });
140
- }
141
- export function initTelemetryUpload(opts) {
142
- clearIdleTimer();
143
- clearBackoffTimer();
144
- uploadEnabled = opts.enabled && !!opts.url;
145
- endpoint = opts.url;
146
- bearer = opts.token;
147
- backoffMs = INITIAL_BACKOFF_MS;
148
- resetStats();
149
- if (!uploadEnabled) {
150
- queue = [];
151
- }
152
- }
153
- /**
154
- * 单行 NDJSON(已含 \\n)。
155
- * 满 BATCH_MAX_LINES 立即上传;否则自「当前积压周期」起至少间隔 MIN_PARTIAL_FLUSH_INTERVAL_MS 再上传。
156
- */
157
- export function enqueueTelemetryLine(line) {
158
- if (!uploadEnabled || !endpoint)
159
- return;
160
- if (queue.length >= MAX_QUEUE) {
161
- queue.splice(0, Math.floor(MAX_QUEUE / 4));
162
- }
163
- const wasEmpty = queue.length === 0;
164
- queue.push(line);
165
- if (queue.length >= BATCH_MAX_LINES) {
166
- clearIdleTimer();
167
- void flushPipeline().catch(() => {
168
- /* 静默 */
169
- });
170
- return;
171
- }
172
- if (wasEmpty && !idleTimer && !flushing && !backoffTimer) {
173
- schedulePartialFlush();
174
- }
175
- }
176
- export async function shutdownTelemetryUpload() {
177
- clearIdleTimer();
178
- clearBackoffTimer();
179
- if (!uploadEnabled || !endpoint) {
180
- queue = [];
181
- uploadEnabled = false;
182
- endpoint = undefined;
183
- bearer = undefined;
184
- return;
185
- }
186
- const ep = endpoint;
187
- const br = bearer;
188
- const pending = queue;
189
- queue = [];
190
- uploadEnabled = false;
191
- endpoint = undefined;
192
- bearer = undefined;
193
- const lines = pending;
194
- while (lines.length > 0) {
195
- const batch = lines.splice(0, BATCH_MAX_LINES);
196
- try {
197
- const body = batch.join('');
198
- const headers = {
199
- 'content-type': 'application/x-ndjson',
200
- accept: 'application/json',
201
- };
202
- if (br)
203
- headers.authorization = `Bearer ${br}`;
204
- const res = await fetch(ep, { method: 'POST', headers, body });
205
- try {
206
- if (typeof res.text === 'function') {
207
- await res.text();
208
- }
209
- }
210
- catch {
211
- /* ignore body read errors */
212
- }
213
- if (res.ok) {
214
- stats.postedBatches += 1;
215
- stats.postedLines += batch.length;
216
- }
217
- else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
218
- stats.dropped4xxBatches += 1;
219
- stats.dropped4xxLines += batch.length;
220
- }
221
- else {
222
- stats.retryableFailures += 1;
223
- }
224
- }
225
- catch {
226
- stats.networkFailures += 1;
227
- /* best effort,静默 */
228
- }
229
- }
230
- }
231
- export function getTelemetryUploadStats() {
232
- return { ...stats };
233
- }