@wu529778790/open-im 1.11.2-beta.0 → 1.11.2-beta.2
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 +3 -45
- package/dist/constants.d.ts +0 -10
- package/dist/constants.js +0 -10
- package/dist/index.js +1 -1
- package/dist/logger.js +1 -37
- package/dist/shared/sentry.d.ts +8 -9
- package/dist/shared/sentry.js +56 -31
- package/package.json +1 -1
- package/dist/telemetry/telemetry-upload.d.ts +0 -21
- package/dist/telemetry/telemetry-upload.js +0 -233
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,
|
|
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
|
-
|
|
405
|
-
|
|
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
|
};
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
package/dist/logger.js
CHANGED
|
@@ -4,7 +4,6 @@ import { finished } from 'node:stream/promises';
|
|
|
4
4
|
import { sanitize } from './sanitize.js';
|
|
5
5
|
import { APP_HOME } from './constants.js';
|
|
6
6
|
import { sanitizeTelemetryData } from './telemetry/telemetry-sanitize.js';
|
|
7
|
-
import { enqueueTelemetryLine, getTelemetryUploadStats, initTelemetryUpload, shutdownTelemetryUpload, } from './telemetry/telemetry-upload.js';
|
|
8
7
|
const DEFAULT_LOG_DIR = join(APP_HOME, 'logs');
|
|
9
8
|
const MAX_LOG_FILES = 10;
|
|
10
9
|
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
|
|
@@ -14,9 +13,6 @@ let logStream;
|
|
|
14
13
|
let eventsStream;
|
|
15
14
|
let auditStream;
|
|
16
15
|
let telemetryEnabled = false;
|
|
17
|
-
let telemetryStatsTimer = null;
|
|
18
|
-
let lastTelemetryStatsSignature = '';
|
|
19
|
-
const TELEMETRY_STATS_INTERVAL_MS = 5 * 60_000;
|
|
20
16
|
function pad(n) {
|
|
21
17
|
return String(n).padStart(2, '0');
|
|
22
18
|
}
|
|
@@ -85,11 +81,6 @@ export function initLogger(dirOrOpts, level, telemetry) {
|
|
|
85
81
|
}
|
|
86
82
|
auditStream = createWriteStream(join(logDir, 'audit.log'), { flags: 'a' });
|
|
87
83
|
telemetryEnabled = !!tel?.enabled;
|
|
88
|
-
if (telemetryStatsTimer) {
|
|
89
|
-
clearInterval(telemetryStatsTimer);
|
|
90
|
-
telemetryStatsTimer = null;
|
|
91
|
-
}
|
|
92
|
-
lastTelemetryStatsSignature = '';
|
|
93
84
|
if (eventsStream) {
|
|
94
85
|
eventsStream.end();
|
|
95
86
|
eventsStream = undefined;
|
|
@@ -97,29 +88,8 @@ export function initLogger(dirOrOpts, level, telemetry) {
|
|
|
97
88
|
if (telemetryEnabled) {
|
|
98
89
|
rotateOldJsonl();
|
|
99
90
|
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
91
|
}
|
|
112
92
|
}
|
|
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
|
-
}
|
|
123
93
|
function write(level, tag, msg, ...args) {
|
|
124
94
|
if (LOG_LEVELS[level] < minLevel)
|
|
125
95
|
return;
|
|
@@ -170,15 +140,9 @@ export function emitStructuredEvent(tag, event, data, level = 'INFO', msg = '')
|
|
|
170
140
|
};
|
|
171
141
|
const line = `${JSON.stringify(payload)}\n`;
|
|
172
142
|
eventsStream?.write(line);
|
|
173
|
-
enqueueTelemetryLine(line);
|
|
174
143
|
}
|
|
175
144
|
export async function shutdownLoggerTelemetry() {
|
|
176
|
-
|
|
177
|
-
if (telemetryStatsTimer) {
|
|
178
|
-
clearInterval(telemetryStatsTimer);
|
|
179
|
-
telemetryStatsTimer = null;
|
|
180
|
-
}
|
|
181
|
-
await shutdownTelemetryUpload();
|
|
145
|
+
// Local event logging only — no upload to clean up
|
|
182
146
|
}
|
|
183
147
|
export async function closeLogger() {
|
|
184
148
|
if (eventsStream) {
|
package/dist/shared/sentry.d.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sentry 错误追踪集成
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* - 默认启用,收集错误用于改进产品质量
|
|
5
|
+
* - 用户可通过 OPEN_IM_TELEMETRY=false 或 config.json opt-out
|
|
6
|
+
* - 只收集错误类型和堆栈,不收集用户数据(PII)
|
|
6
7
|
*/
|
|
7
8
|
/**
|
|
8
|
-
* 初始化 Sentry
|
|
9
|
+
* 初始化 Sentry
|
|
10
|
+
* - 默认启用(收集错误改进产品)
|
|
11
|
+
* - 用户可通过 OPEN_IM_TELEMETRY=false 或 telemetry.enabled=false opt-out
|
|
9
12
|
*/
|
|
10
|
-
export declare function initSentry(): void;
|
|
13
|
+
export declare function initSentry(telemetryEnabled?: boolean): void;
|
|
11
14
|
/**
|
|
12
|
-
* 上报错误到 Sentry
|
|
15
|
+
* 上报错误到 Sentry(自动清理 PII)
|
|
13
16
|
*/
|
|
14
17
|
export declare function captureError(error: Error | unknown, context?: Record<string, unknown>): void;
|
|
15
|
-
/**
|
|
16
|
-
* 添加面包屑(错误发生前的上下文)
|
|
17
|
-
*/
|
|
18
|
-
export declare function addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
|
|
19
18
|
/**
|
|
20
19
|
* 刷新并关闭 Sentry(优雅关闭时调用)
|
|
21
20
|
*/
|
package/dist/shared/sentry.js
CHANGED
|
@@ -1,38 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sentry 错误追踪集成
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* - 默认启用,收集错误用于改进产品质量
|
|
5
|
+
* - 用户可通过 OPEN_IM_TELEMETRY=false 或 config.json opt-out
|
|
6
|
+
* - 只收集错误类型和堆栈,不收集用户数据(PII)
|
|
6
7
|
*/
|
|
7
8
|
import * as Sentry from '@sentry/node';
|
|
8
9
|
import { createLogger } from '../logger.js';
|
|
9
10
|
const log = createLogger('Sentry');
|
|
11
|
+
// 开发者的 Sentry DSN(所有 open-im 实例共享)
|
|
12
|
+
const DEFAULT_DSN = 'https://cc5ad094c1229b2a2ff23ab54b0fd807@o4508612762861568.ingest.us.sentry.io/4511583989727232';
|
|
10
13
|
let initialized = false;
|
|
11
14
|
/**
|
|
12
|
-
*
|
|
15
|
+
* 清理 PII(用户数据)
|
|
16
|
+
* 只保留错误信息,移除敏感内容
|
|
13
17
|
*/
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
function sanitizeContext(context) {
|
|
19
|
+
const clean = {};
|
|
20
|
+
for (const [key, value] of Object.entries(context)) {
|
|
21
|
+
// 跳过敏感字段
|
|
22
|
+
if (/token|secret|password|key|api_key|apikey|auth/i.test(key))
|
|
23
|
+
continue;
|
|
24
|
+
// 跳过用户 ID(只保留平台信息)
|
|
25
|
+
if (/userId|user_id|userKey|user_key/i.test(key)) {
|
|
26
|
+
clean[key] = '[redacted]';
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// 截断长字符串
|
|
30
|
+
if (typeof value === 'string' && value.length > 200) {
|
|
31
|
+
clean[key] = value.substring(0, 200) + '...';
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
clean[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return clean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 初始化 Sentry
|
|
41
|
+
* - 默认启用(收集错误改进产品)
|
|
42
|
+
* - 用户可通过 OPEN_IM_TELEMETRY=false 或 telemetry.enabled=false opt-out
|
|
43
|
+
*/
|
|
44
|
+
export function initSentry(telemetryEnabled = true) {
|
|
45
|
+
// 检查 opt-out
|
|
46
|
+
const envOptOut = process.env.OPEN_IM_TELEMETRY?.trim().toLowerCase();
|
|
47
|
+
if (envOptOut === 'false' || envOptOut === '0' || envOptOut === 'no' || !telemetryEnabled) {
|
|
48
|
+
log.debug('Sentry disabled (user opt-out)');
|
|
18
49
|
return;
|
|
19
50
|
}
|
|
51
|
+
// 优先使用用户自定义 DSN,否则用默认 DSN
|
|
52
|
+
const dsn = process.env.OPEN_IM_SENTRY_DSN || DEFAULT_DSN;
|
|
20
53
|
try {
|
|
21
54
|
Sentry.init({
|
|
22
55
|
dsn,
|
|
23
|
-
// 只捕获 error
|
|
56
|
+
// 只捕获 error 级别
|
|
24
57
|
beforeSend(event) {
|
|
25
58
|
if (event.level !== 'error' && event.level !== 'fatal')
|
|
26
59
|
return null;
|
|
60
|
+
// 清理 PII
|
|
61
|
+
if (event.extra) {
|
|
62
|
+
event.extra = sanitizeContext(event.extra);
|
|
63
|
+
}
|
|
64
|
+
if (event.contexts) {
|
|
65
|
+
for (const [key, value] of Object.entries(event.contexts)) {
|
|
66
|
+
if (typeof value === 'object' && value !== null) {
|
|
67
|
+
event.contexts[key] = sanitizeContext(value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
27
71
|
return event;
|
|
28
72
|
},
|
|
29
73
|
// 不开启 performance monitoring
|
|
30
74
|
tracesSampleRate: 0,
|
|
31
75
|
// 环境标识
|
|
32
76
|
environment: process.env.NODE_ENV ?? 'production',
|
|
33
|
-
//
|
|
77
|
+
// 附加面包屑
|
|
34
78
|
beforeBreadcrumb(breadcrumb) {
|
|
35
|
-
// 只保留关键 breadcrumb
|
|
36
79
|
if (breadcrumb.category === 'console' || breadcrumb.category === 'http') {
|
|
37
80
|
return breadcrumb;
|
|
38
81
|
}
|
|
@@ -40,14 +83,14 @@ export function initSentry() {
|
|
|
40
83
|
},
|
|
41
84
|
});
|
|
42
85
|
initialized = true;
|
|
43
|
-
log.info('Sentry initialized');
|
|
86
|
+
log.info('Sentry initialized (error tracking enabled)');
|
|
44
87
|
}
|
|
45
88
|
catch (err) {
|
|
46
89
|
log.warn('Sentry init failed:', err);
|
|
47
90
|
}
|
|
48
91
|
}
|
|
49
92
|
/**
|
|
50
|
-
* 上报错误到 Sentry
|
|
93
|
+
* 上报错误到 Sentry(自动清理 PII)
|
|
51
94
|
*/
|
|
52
95
|
export function captureError(error, context) {
|
|
53
96
|
if (!initialized)
|
|
@@ -55,7 +98,7 @@ export function captureError(error, context) {
|
|
|
55
98
|
try {
|
|
56
99
|
Sentry.withScope((scope) => {
|
|
57
100
|
if (context) {
|
|
58
|
-
scope.setExtras(context);
|
|
101
|
+
scope.setExtras(sanitizeContext(context));
|
|
59
102
|
}
|
|
60
103
|
if (error instanceof Error) {
|
|
61
104
|
scope.setTag('error.name', error.name);
|
|
@@ -68,24 +111,6 @@ export function captureError(error, context) {
|
|
|
68
111
|
// Sentry 本身不应影响业务
|
|
69
112
|
}
|
|
70
113
|
}
|
|
71
|
-
/**
|
|
72
|
-
* 添加面包屑(错误发生前的上下文)
|
|
73
|
-
*/
|
|
74
|
-
export function addBreadcrumb(category, message, data) {
|
|
75
|
-
if (!initialized)
|
|
76
|
-
return;
|
|
77
|
-
try {
|
|
78
|
-
Sentry.addBreadcrumb({
|
|
79
|
-
category,
|
|
80
|
-
message: message.substring(0, 200),
|
|
81
|
-
data,
|
|
82
|
-
level: 'info',
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
// ignore
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
114
|
/**
|
|
90
115
|
* 刷新并关闭 Sentry(优雅关闭时调用)
|
|
91
116
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.11.2-beta.
|
|
3
|
+
"version": "1.11.2-beta.2",
|
|
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,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
|
-
}
|