dsh-my-observability 0.1.3 → 0.1.5
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/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/assets/replay-panel.png +0 -0
- package/lib/client.js +107 -0
- package/lib/client.src.js +1 -0
- package/lib/index.js +26 -4
- package/lib/parts/i18n.js +6 -0
- package/lib/parts/replay-ext.js +21 -0
- package/lib/parts/replay.js +1 -0
- package/lib/parts/resource.js +80 -0
- package/lib/parts/styles.js +10 -0
- package/lib/resource-monitor.js +118 -0
- package/lib/resource-rules.js +102 -0
- package/lib/routes.js +9 -5
- package/lib/store-persist.js +5 -3
- package/lib/store.js +25 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录 dsh-my-observability 的所有版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [0.1.5] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- feat(observability): #127 资源看门狗自动降级——写入速率/文件大小连续 ≥3 次采样超限自动暂停落盘(事件仍入内存,FIFO 有界不丢)+ 日志告警;连续 ≥3 次正常自动恢复(全量快照补齐降级窗口);API 暴露 degraded 标记(#136)
|
|
10
|
+
- 发版前功能级验证清单(issue #67):verification/dsh-my-observability-0.1.5.md
|
|
11
|
+
|
|
12
|
+
## [0.1.4] - 2026-09-03
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- feat(observability): #R17 资源监控看板——写放大/资源超限提前发现
|
|
17
|
+
|
|
5
18
|
## [0.1.3] - 2026-09-03
|
|
6
19
|
|
|
7
20
|
### 变更
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Server 端只读观察 DSH 生命周期事件并记录审计日志:
|
|
|
26
26
|
- **重启恢复**:持久化到 `$DSH_HOME/observability/audit.jsonl`(**增量追加** + 防抖批量 flush + 周期 compact 原子快照),重启后完整恢复;升级前旧格式 `audit.json` 自动迁移;
|
|
27
27
|
- **防膨胀**:每会话最多 2000 条(FIFO 淘汰)、全局 20000 条(轮转淘汰);
|
|
28
28
|
- **零写放大**:落盘只写新增事件(≈事件本体字节),不会因事件流持续而反复全量重写审计文件;
|
|
29
|
+
- **资源监控 + 自动降级(资源看门狗)**:面板「资源」区块展示本进程 CPU/内存 + 审计文件大小与写入速率(15s 采样),写放大/超限自动告警(告警列表展示在「资源」区块,阈值见 `resource-budget-review`);写入速率/文件大小连续超限自动**暂停落盘**(事件仍入内存,有界不丢),回落自动恢复(全量快照补齐降级窗口),全程日志告警 + API `degraded` 标记(issue #127)。
|
|
29
30
|
- **只读观察**:waterfall 事件一律透传 `next()`,绝不改变工具/模型流程。
|
|
30
31
|
|
|
31
32
|
### 2. 轨迹回放面板(时间轴)
|
package/assets/replay-panel.png
CHANGED
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -42,6 +42,12 @@ function isZh() {
|
|
|
42
42
|
|
|
43
43
|
const strings = {
|
|
44
44
|
replayTitle: () => (isZh() ? '轨迹回放' : 'Trajectory'),
|
|
45
|
+
resourceTitle: () => (isZh() ? '资源监控' : 'Resources'),
|
|
46
|
+
resourceLoading: () => (isZh() ? '资源采样中…' : 'Sampling…'),
|
|
47
|
+
resourceFile: () => (isZh() ? '审计文件' : 'Audit file'),
|
|
48
|
+
resourceRate: () => (isZh() ? '写入速率' : 'Write rate'),
|
|
49
|
+
resourceCpu: () => (isZh() ? 'CPU' : 'CPU'),
|
|
50
|
+
resourceMem: () => (isZh() ? '内存' : 'Memory'),
|
|
45
51
|
gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
|
|
46
52
|
allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
|
|
47
53
|
filterAll: () => (isZh() ? '全部' : 'All'),
|
|
@@ -986,6 +992,7 @@ function ReplayPanel(props) {
|
|
|
986
992
|
return createElement(
|
|
987
993
|
'div',
|
|
988
994
|
{ className: 'dsh-my-observability-panel' },
|
|
995
|
+
createElement(ResourcePanel, { resource: s.resource }),
|
|
989
996
|
createElement(ReplayToolbar, {
|
|
990
997
|
sessions: s.sessions,
|
|
991
998
|
selected: s.selected,
|
|
@@ -1026,6 +1033,78 @@ function ReplayPanel(props) {
|
|
|
1026
1033
|
)
|
|
1027
1034
|
}
|
|
1028
1035
|
|
|
1036
|
+
// ── 资源监控区块(写放大/资源超限预警,见 lib/resource-monitor.js)──────
|
|
1037
|
+
// 依赖 replay.js 先拼接(apiJson)与 i18n.js(strings)。纯函数声明文本。
|
|
1038
|
+
|
|
1039
|
+
const RESOURCE_POLL_MS = 15000
|
|
1040
|
+
|
|
1041
|
+
function fmtResourceBytes(bytes) {
|
|
1042
|
+
if (!Number.isFinite(bytes)) return '-'
|
|
1043
|
+
return `${(bytes / 1048576).toFixed(1)} MB`
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** 资源采样状态:可见时每 15s 轮询 /observability/api/resources。 */
|
|
1047
|
+
function useResourceState(visible) {
|
|
1048
|
+
const [resource, setResource] = useState(null)
|
|
1049
|
+
useEffect(() => {
|
|
1050
|
+
if (!visible) return undefined
|
|
1051
|
+
let alive = true
|
|
1052
|
+
const tick = () => {
|
|
1053
|
+
if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
|
|
1054
|
+
}
|
|
1055
|
+
tick()
|
|
1056
|
+
const timer = setInterval(tick, RESOURCE_POLL_MS)
|
|
1057
|
+
return () => {
|
|
1058
|
+
alive = false
|
|
1059
|
+
clearInterval(timer)
|
|
1060
|
+
}
|
|
1061
|
+
}, [visible])
|
|
1062
|
+
return resource
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function ResourceMetric({ label, value }) {
|
|
1066
|
+
return createElement(
|
|
1067
|
+
'div',
|
|
1068
|
+
{ className: 'dsh-my-observability-resource-metric' },
|
|
1069
|
+
createElement('span', { className: 'dsh-my-observability-resource-label' }, label),
|
|
1070
|
+
createElement('span', { className: 'dsh-my-observability-resource-value' }, value),
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** 资源面板:四指标 + 告警列表(write-rate/file-size level=error 红色,cpu/memory warn 黄色)。 */
|
|
1075
|
+
function ResourcePanel({ resource }) {
|
|
1076
|
+
if (resource === null || resource === undefined) {
|
|
1077
|
+
return createElement('div', { className: 'dsh-my-observability-resource' }, strings.resourceLoading())
|
|
1078
|
+
}
|
|
1079
|
+
const alerts = Array.isArray(resource.alerts) ? resource.alerts : []
|
|
1080
|
+
return createElement(
|
|
1081
|
+
'div',
|
|
1082
|
+
{ className: 'dsh-my-observability-resource' },
|
|
1083
|
+
createElement('div', { className: 'dsh-my-observability-resource-head' }, strings.resourceTitle()),
|
|
1084
|
+
createElement(
|
|
1085
|
+
'div',
|
|
1086
|
+
{ className: 'dsh-my-observability-resource-grid' },
|
|
1087
|
+
createElement(ResourceMetric, { label: strings.resourceFile(), value: fmtResourceBytes(resource.fileBytes) }),
|
|
1088
|
+
createElement(ResourceMetric, { label: strings.resourceRate(), value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h` }),
|
|
1089
|
+
createElement(ResourceMetric, { label: strings.resourceCpu(), value: `${Math.round(resource.cpuPercent ?? 0)}%` }),
|
|
1090
|
+
createElement(ResourceMetric, { label: strings.resourceMem(), value: fmtResourceBytes(resource.memoryBytes) }),
|
|
1091
|
+
),
|
|
1092
|
+
alerts.length > 0
|
|
1093
|
+
? createElement(
|
|
1094
|
+
'div',
|
|
1095
|
+
{ className: 'dsh-my-observability-resource-alerts' },
|
|
1096
|
+
alerts.map((alert) =>
|
|
1097
|
+
createElement(
|
|
1098
|
+
'div',
|
|
1099
|
+
{ className: `dsh-my-observability-resource-alert dsh-my-observability-resource-alert-${alert.level}` },
|
|
1100
|
+
alert.message,
|
|
1101
|
+
),
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
: null,
|
|
1105
|
+
)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1029
1108
|
// ── 审计视图扩展:搜索 / 组合过滤 / 导出 / 统计 / 高亮(replay.js 拆出)────
|
|
1030
1109
|
// 依赖 replay.js(REPLAY_POLL_MS 等常量与 loadReplayData/EventRow)与
|
|
1031
1110
|
// audit-view.js 纯函数(applyAuditFilter 等)。始终以 function 声明提升。
|
|
@@ -1300,6 +1379,22 @@ function useReplayDataState(props) {
|
|
|
1300
1379
|
const [loading, setLoading] = useState(true)
|
|
1301
1380
|
const [error, setError] = useState('')
|
|
1302
1381
|
const [reloadTick, setReloadTick] = useState(0)
|
|
1382
|
+
const [resource, setResource] = useState(null)
|
|
1383
|
+
|
|
1384
|
+
// 资源采样轮询(写放大/资源超限预警;可见时 15s 一次,隐藏暂停)
|
|
1385
|
+
useEffect(() => {
|
|
1386
|
+
if (!visible) return undefined
|
|
1387
|
+
let alive = true
|
|
1388
|
+
const tick = () => {
|
|
1389
|
+
if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
|
|
1390
|
+
}
|
|
1391
|
+
tick()
|
|
1392
|
+
const timer = setInterval(tick, RESOURCE_POLL_MS)
|
|
1393
|
+
return () => {
|
|
1394
|
+
alive = false
|
|
1395
|
+
clearInterval(timer)
|
|
1396
|
+
}
|
|
1397
|
+
}, [visible])
|
|
1303
1398
|
|
|
1304
1399
|
useEffect(() => {
|
|
1305
1400
|
if (!visible) return undefined
|
|
@@ -1324,6 +1419,7 @@ function useReplayDataState(props) {
|
|
|
1324
1419
|
|
|
1325
1420
|
return {
|
|
1326
1421
|
currentSession,
|
|
1422
|
+
resource,
|
|
1327
1423
|
sessions,
|
|
1328
1424
|
selected,
|
|
1329
1425
|
events,
|
|
@@ -1372,6 +1468,7 @@ function useReplayState(props) {
|
|
|
1372
1468
|
const onExport = (format) => void runExport(format, scope, filtered, criteria, data.setError)
|
|
1373
1469
|
|
|
1374
1470
|
return {
|
|
1471
|
+
resource: data.resource,
|
|
1375
1472
|
sessions: data.sessions,
|
|
1376
1473
|
selected: data.selected,
|
|
1377
1474
|
events: data.events,
|
|
@@ -1826,6 +1923,16 @@ const STYLES = `
|
|
|
1826
1923
|
.dsh-my-observability-review-ok{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-state-success-primary)}
|
|
1827
1924
|
.dsh-my-observability-ai{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.5;
|
|
1828
1925
|
border:1px dashed var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px}
|
|
1926
|
+
.dsh-my-observability-resource{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;margin:0 0 8px}
|
|
1927
|
+
.dsh-my-observability-resource-head{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-primary);margin-bottom:6px}
|
|
1928
|
+
.dsh-my-observability-resource-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 10px}
|
|
1929
|
+
.dsh-my-observability-resource-metric{display:flex;justify-content:space-between;gap:8px;font:var(--dsw-font-xxs-12)}
|
|
1930
|
+
.dsh-my-observability-resource-label{color:var(--dsw-alias-label-secondary)}
|
|
1931
|
+
.dsh-my-observability-resource-value{color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-mono-xxs)}
|
|
1932
|
+
.dsh-my-observability-resource-alerts{margin-top:6px;display:flex;flex-direction:column;gap:4px}
|
|
1933
|
+
.dsh-my-observability-resource-alert{font:var(--dsw-font-xxxs-11);border-radius:4px;padding:2px 6px}
|
|
1934
|
+
.dsh-my-observability-resource-alert-error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
|
|
1935
|
+
.dsh-my-observability-resource-alert-warn{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}
|
|
1829
1936
|
`
|
|
1830
1937
|
|
|
1831
1938
|
function injectStyles() {
|
package/lib/client.src.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { createStore } from './store.js'
|
|
23
23
|
import { attachAuditListeners } from './audit.js'
|
|
24
24
|
import { registerObservabilityRoutes } from './routes.js'
|
|
25
|
+
import { createResourceMonitor } from './resource-monitor.js'
|
|
25
26
|
|
|
26
27
|
export const name = 'dsh-my-observability'
|
|
27
28
|
|
|
@@ -40,9 +41,30 @@ export function apply(ctx, config) {
|
|
|
40
41
|
// ── 事件监听(只读观察;waterfall 一律透传 next())──────────────────
|
|
41
42
|
attachAuditListeners(ctx, store.record)
|
|
42
43
|
|
|
43
|
-
// ──
|
|
44
|
-
|
|
44
|
+
// ── 资源监控 + 降级看门狗(15s 采样 CPU/内存/审计写入速率;写放大/文件
|
|
45
|
+
// 超限连续触发时自动降级停落盘,资源回归后自动恢复——issue #127)────
|
|
46
|
+
const monitor = createResourceMonitor(ctx, {
|
|
47
|
+
intervalMs: config?.resourceIntervalMs,
|
|
48
|
+
limits: config?.resourceLimits,
|
|
49
|
+
onDegrade: () => {
|
|
50
|
+
store.setPersistEnabled(false)
|
|
51
|
+
ctx.logger.warn(
|
|
52
|
+
'[dsh-my-observability] 资源看门狗:审计写入速率/文件大小连续超限,已暂停落盘(事件仍在内存,恢复后全量快照补齐)',
|
|
53
|
+
)
|
|
54
|
+
},
|
|
55
|
+
onRecover: () => {
|
|
56
|
+
store.setPersistEnabled(true)
|
|
57
|
+
ctx.logger.warn('[dsh-my-observability] 资源看门狗:写入速率回归正常,已恢复审计落盘')
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
monitor.start()
|
|
45
61
|
|
|
46
|
-
// ──
|
|
47
|
-
ctx
|
|
62
|
+
// ── 路由(查询 / git 工具 / diff 审查 / 资源)───────────────────────
|
|
63
|
+
registerObservabilityRoutes(ctx, store, monitor, options)
|
|
64
|
+
|
|
65
|
+
// ── 卸载冲刷:清防抖定时器 + 立即落盘 + 停采样 ──────────────────────
|
|
66
|
+
ctx.effect(() => {
|
|
67
|
+
monitor.stop()
|
|
68
|
+
return store.dispose
|
|
69
|
+
}, 'dsh-my-observability: persistence teardown')
|
|
48
70
|
}
|
package/lib/parts/i18n.js
CHANGED
|
@@ -10,6 +10,12 @@ function isZh() {
|
|
|
10
10
|
|
|
11
11
|
const strings = {
|
|
12
12
|
replayTitle: () => (isZh() ? '轨迹回放' : 'Trajectory'),
|
|
13
|
+
resourceTitle: () => (isZh() ? '资源监控' : 'Resources'),
|
|
14
|
+
resourceLoading: () => (isZh() ? '资源采样中…' : 'Sampling…'),
|
|
15
|
+
resourceFile: () => (isZh() ? '审计文件' : 'Audit file'),
|
|
16
|
+
resourceRate: () => (isZh() ? '写入速率' : 'Write rate'),
|
|
17
|
+
resourceCpu: () => (isZh() ? 'CPU' : 'CPU'),
|
|
18
|
+
resourceMem: () => (isZh() ? '内存' : 'Memory'),
|
|
13
19
|
gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
|
|
14
20
|
allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
|
|
15
21
|
filterAll: () => (isZh() ? '全部' : 'All'),
|
package/lib/parts/replay-ext.js
CHANGED
|
@@ -272,6 +272,25 @@ function useReplayDataState(props) {
|
|
|
272
272
|
const [loading, setLoading] = useState(true)
|
|
273
273
|
const [error, setError] = useState('')
|
|
274
274
|
const [reloadTick, setReloadTick] = useState(0)
|
|
275
|
+
const [resource, setResource] = useState(null)
|
|
276
|
+
|
|
277
|
+
// 资源采样轮询(写放大/资源超限预警;可见时 15s 一次,隐藏暂停)
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!visible) return undefined
|
|
280
|
+
let alive = true
|
|
281
|
+
const tick = () => {
|
|
282
|
+
if (alive)
|
|
283
|
+
apiJson('/observability/api/resources')
|
|
284
|
+
.then(setResource)
|
|
285
|
+
.catch(() => {})
|
|
286
|
+
}
|
|
287
|
+
tick()
|
|
288
|
+
const timer = setInterval(tick, RESOURCE_POLL_MS)
|
|
289
|
+
return () => {
|
|
290
|
+
alive = false
|
|
291
|
+
clearInterval(timer)
|
|
292
|
+
}
|
|
293
|
+
}, [visible])
|
|
275
294
|
|
|
276
295
|
useEffect(() => {
|
|
277
296
|
if (!visible) return undefined
|
|
@@ -296,6 +315,7 @@ function useReplayDataState(props) {
|
|
|
296
315
|
|
|
297
316
|
return {
|
|
298
317
|
currentSession,
|
|
318
|
+
resource,
|
|
299
319
|
sessions,
|
|
300
320
|
selected,
|
|
301
321
|
events,
|
|
@@ -344,6 +364,7 @@ function useReplayState(props) {
|
|
|
344
364
|
const onExport = (format) => void runExport(format, scope, filtered, criteria, data.setError)
|
|
345
365
|
|
|
346
366
|
return {
|
|
367
|
+
resource: data.resource,
|
|
347
368
|
sessions: data.sessions,
|
|
348
369
|
selected: data.selected,
|
|
349
370
|
events: data.events,
|
package/lib/parts/replay.js
CHANGED
|
@@ -279,6 +279,7 @@ function ReplayPanel(props) {
|
|
|
279
279
|
return createElement(
|
|
280
280
|
'div',
|
|
281
281
|
{ className: 'dsh-my-observability-panel' },
|
|
282
|
+
createElement(ResourcePanel, { resource: s.resource }),
|
|
282
283
|
createElement(ReplayToolbar, {
|
|
283
284
|
sessions: s.sessions,
|
|
284
285
|
selected: s.selected,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ── 资源监控区块(写放大/资源超限预警,见 lib/resource-monitor.js)──────
|
|
2
|
+
// 依赖 replay.js 先拼接(apiJson)与 i18n.js(strings)。纯函数声明文本。
|
|
3
|
+
|
|
4
|
+
const RESOURCE_POLL_MS = 15000
|
|
5
|
+
|
|
6
|
+
function fmtResourceBytes(bytes) {
|
|
7
|
+
if (!Number.isFinite(bytes)) return '-'
|
|
8
|
+
return `${(bytes / 1048576).toFixed(1)} MB`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 资源采样状态:可见时每 15s 轮询 /observability/api/resources。 */
|
|
12
|
+
function useResourceState(visible) {
|
|
13
|
+
const [resource, setResource] = useState(null)
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!visible) return undefined
|
|
16
|
+
let alive = true
|
|
17
|
+
const tick = () => {
|
|
18
|
+
if (alive)
|
|
19
|
+
apiJson('/observability/api/resources')
|
|
20
|
+
.then(setResource)
|
|
21
|
+
.catch(() => {})
|
|
22
|
+
}
|
|
23
|
+
tick()
|
|
24
|
+
const timer = setInterval(tick, RESOURCE_POLL_MS)
|
|
25
|
+
return () => {
|
|
26
|
+
alive = false
|
|
27
|
+
clearInterval(timer)
|
|
28
|
+
}
|
|
29
|
+
}, [visible])
|
|
30
|
+
return resource
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ResourceMetric({ label, value }) {
|
|
34
|
+
return createElement(
|
|
35
|
+
'div',
|
|
36
|
+
{ className: 'dsh-my-observability-resource-metric' },
|
|
37
|
+
createElement('span', { className: 'dsh-my-observability-resource-label' }, label),
|
|
38
|
+
createElement('span', { className: 'dsh-my-observability-resource-value' }, value),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 资源面板:四指标 + 告警列表(write-rate/file-size level=error 红色,cpu/memory warn 黄色)。 */
|
|
43
|
+
function ResourcePanel({ resource }) {
|
|
44
|
+
if (resource === null || resource === undefined) {
|
|
45
|
+
return createElement('div', { className: 'dsh-my-observability-resource' }, strings.resourceLoading())
|
|
46
|
+
}
|
|
47
|
+
const alerts = Array.isArray(resource.alerts) ? resource.alerts : []
|
|
48
|
+
return createElement(
|
|
49
|
+
'div',
|
|
50
|
+
{ className: 'dsh-my-observability-resource' },
|
|
51
|
+
createElement('div', { className: 'dsh-my-observability-resource-head' }, strings.resourceTitle()),
|
|
52
|
+
createElement(
|
|
53
|
+
'div',
|
|
54
|
+
{ className: 'dsh-my-observability-resource-grid' },
|
|
55
|
+
createElement(ResourceMetric, { label: strings.resourceFile(), value: fmtResourceBytes(resource.fileBytes) }),
|
|
56
|
+
createElement(ResourceMetric, {
|
|
57
|
+
label: strings.resourceRate(),
|
|
58
|
+
value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h`,
|
|
59
|
+
}),
|
|
60
|
+
createElement(ResourceMetric, {
|
|
61
|
+
label: strings.resourceCpu(),
|
|
62
|
+
value: `${Math.round(resource.cpuPercent ?? 0)}%`,
|
|
63
|
+
}),
|
|
64
|
+
createElement(ResourceMetric, { label: strings.resourceMem(), value: fmtResourceBytes(resource.memoryBytes) }),
|
|
65
|
+
),
|
|
66
|
+
alerts.length > 0
|
|
67
|
+
? createElement(
|
|
68
|
+
'div',
|
|
69
|
+
{ className: 'dsh-my-observability-resource-alerts' },
|
|
70
|
+
alerts.map((alert) =>
|
|
71
|
+
createElement(
|
|
72
|
+
'div',
|
|
73
|
+
{ className: `dsh-my-observability-resource-alert dsh-my-observability-resource-alert-${alert.level}` },
|
|
74
|
+
alert.message,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
: null,
|
|
79
|
+
)
|
|
80
|
+
}
|
package/lib/parts/styles.js
CHANGED
|
@@ -123,6 +123,16 @@ const STYLES = `
|
|
|
123
123
|
.dsh-my-observability-review-ok{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-state-success-primary)}
|
|
124
124
|
.dsh-my-observability-ai{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.5;
|
|
125
125
|
border:1px dashed var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px}
|
|
126
|
+
.dsh-my-observability-resource{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;margin:0 0 8px}
|
|
127
|
+
.dsh-my-observability-resource-head{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-primary);margin-bottom:6px}
|
|
128
|
+
.dsh-my-observability-resource-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 10px}
|
|
129
|
+
.dsh-my-observability-resource-metric{display:flex;justify-content:space-between;gap:8px;font:var(--dsw-font-xxs-12)}
|
|
130
|
+
.dsh-my-observability-resource-label{color:var(--dsw-alias-label-secondary)}
|
|
131
|
+
.dsh-my-observability-resource-value{color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-mono-xxs)}
|
|
132
|
+
.dsh-my-observability-resource-alerts{margin-top:6px;display:flex;flex-direction:column;gap:4px}
|
|
133
|
+
.dsh-my-observability-resource-alert{font:var(--dsw-font-xxxs-11);border-radius:4px;padding:2px 6px}
|
|
134
|
+
.dsh-my-observability-resource-alert-error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
|
|
135
|
+
.dsh-my-observability-resource-alert-warn{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}
|
|
126
136
|
`
|
|
127
137
|
|
|
128
138
|
function injectStyles() {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-observability — 资源采样监控 + 降级看门狗。
|
|
3
|
+
*
|
|
4
|
+
* 每 intervalMs(默认 15s)采样本进程 CPU/内存与审计文件大小/写入速率,
|
|
5
|
+
* 保留最近 MAX_HISTORY 个样本(ring buffer),并做阈值评估(resource-rules)。
|
|
6
|
+
* 用途:让「写放大/资源超限」在运行期当小时可见(9/2 事故复盘结论:
|
|
7
|
+
* 15 小时 300GB 写入零监控是事故未被及时发现的主因)。
|
|
8
|
+
*
|
|
9
|
+
* 降级看门狗(issue #127 资源占用防护):关键阈值(写放大/文件字节)连续
|
|
10
|
+
* DEGRADE_CONFIRM_COUNT 次超限 → 触发 onDegrade 回调(宿主降级:停落盘等);
|
|
11
|
+
* 连续 RECOVER_CONFIRM_COUNT 次正常 → 触发 onRecover 回调(宿主恢复 + 全量快照)。
|
|
12
|
+
* 判定为纯函数(resource-rules.shouldEnterDegrade/shouldExitDegrade),可单测。
|
|
13
|
+
*
|
|
14
|
+
* 采样自身开销:15s 一次 process.cpuUsage/memoryUsage + fs.stat(<0.01% CPU、
|
|
15
|
+
* 零分配大对象),远低于「监控不能放大被监控对象」的护栏(resource-budget-review)。
|
|
16
|
+
*/
|
|
17
|
+
import { statSync } from 'node:fs'
|
|
18
|
+
import { evaluateResourceAlerts, shouldEnterDegrade, shouldExitDegrade, DEFAULT_LIMITS } from './resource-rules.js'
|
|
19
|
+
import { jsonlFile } from './store-persist.js'
|
|
20
|
+
|
|
21
|
+
const DEFAULT_INTERVAL_MS = 15000
|
|
22
|
+
const MAX_HISTORY = 60
|
|
23
|
+
|
|
24
|
+
/** 创建资源监控器:{ sample, start, stop }。options: intervalMs / limits / onDegrade / onRecover。 */
|
|
25
|
+
export function createResourceMonitor(ctx, options = {}) {
|
|
26
|
+
const intervalMs =
|
|
27
|
+
Number.isFinite(options.intervalMs) && options.intervalMs > 0 ? options.intervalMs : DEFAULT_INTERVAL_MS
|
|
28
|
+
const limits = { ...DEFAULT_LIMITS, ...(options.limits ?? {}) }
|
|
29
|
+
const onDegrade = typeof options.onDegrade === 'function' ? options.onDegrade : null
|
|
30
|
+
const onRecover = typeof options.onRecover === 'function' ? options.onRecover : null
|
|
31
|
+
const state = {
|
|
32
|
+
timer: null,
|
|
33
|
+
file: jsonlFile(),
|
|
34
|
+
lastSample: null,
|
|
35
|
+
lastCpu: process.cpuUsage(),
|
|
36
|
+
history: [],
|
|
37
|
+
degraded: false,
|
|
38
|
+
}
|
|
39
|
+
const monitor = {
|
|
40
|
+
sample: () => sample(state, limits, onDegrade, onRecover),
|
|
41
|
+
start: () => startMonitor(state, intervalMs, monitor),
|
|
42
|
+
stop: () => stopMonitor(state),
|
|
43
|
+
isDegraded: () => state.degraded,
|
|
44
|
+
}
|
|
45
|
+
return monitor
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 采样一次:CPU 使用率(窗口内 user+sys)/RSS/审计文件字节/写入速率 + 告警 + 降级判定。 */
|
|
49
|
+
function sample(state, limits, onDegrade, onRecover) {
|
|
50
|
+
const now = Date.now()
|
|
51
|
+
const cpu = process.cpuUsage()
|
|
52
|
+
const cpuDelta = cpu.user - state.lastCpu.user + (cpu.system - state.lastCpu.system) // µs
|
|
53
|
+
state.lastCpu = cpu
|
|
54
|
+
const mem = process.memoryUsage()
|
|
55
|
+
const memoryBytes = mem.rss
|
|
56
|
+
let fileBytes = 0
|
|
57
|
+
try {
|
|
58
|
+
fileBytes = statSync(state.file).size
|
|
59
|
+
} catch {
|
|
60
|
+
// 审计文件尚未创建:字节为 0
|
|
61
|
+
}
|
|
62
|
+
const prev = state.lastSample
|
|
63
|
+
if (prev !== null) {
|
|
64
|
+
const deltaMs = Math.max(now - prev.time, 1)
|
|
65
|
+
// CPU 单核折算:cpuDelta(µs) / deltaMs(ms) / 1000 → 百分比(×100)
|
|
66
|
+
const cpuPercent = (cpuDelta / 1000 / deltaMs) * 100
|
|
67
|
+
const byteDelta = fileBytes - prev.fileBytes
|
|
68
|
+
const writeRateBytesPerHour = byteDelta > 0 ? (byteDelta / deltaMs) * 3600 * 1000 : 0
|
|
69
|
+
const sample = { time: now, cpuPercent, memoryBytes, fileBytes, writeRateBytesPerHour }
|
|
70
|
+
state.history.push(sample)
|
|
71
|
+
if (state.history.length > MAX_HISTORY) state.history.splice(0, state.history.length - MAX_HISTORY)
|
|
72
|
+
state.lastSample = sample
|
|
73
|
+
updateDegradeState(state, limits, onDegrade, onRecover)
|
|
74
|
+
return {
|
|
75
|
+
...sample,
|
|
76
|
+
history: [...state.history],
|
|
77
|
+
alerts: evaluateResourceAlerts(sample, limits),
|
|
78
|
+
degraded: state.degraded,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
state.lastSample = { time: now, fileBytes, memoryBytes, cpuPercent: 0, writeRateBytesPerHour: 0 }
|
|
82
|
+
return { ...state.lastSample, history: [...state.history], alerts: [], degraded: state.degraded }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 降级状态机:未降级且连续超限 → 进入降级(回调);已降级且连续正常 → 退出降级(回调)。
|
|
87
|
+
* 判定纯函数见 resource-rules.js;本函数只持有状态并触发宿主回调。
|
|
88
|
+
*/
|
|
89
|
+
function updateDegradeState(state, limits, onDegrade, onRecover) {
|
|
90
|
+
if (!state.degraded) {
|
|
91
|
+
if (shouldEnterDegrade(state.history, limits)) {
|
|
92
|
+
state.degraded = true
|
|
93
|
+
onDegrade?.()
|
|
94
|
+
}
|
|
95
|
+
} else if (shouldExitDegrade(state.history, limits)) {
|
|
96
|
+
state.degraded = false
|
|
97
|
+
onRecover?.()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 启动周期采样(幂等)。 */
|
|
102
|
+
function startMonitor(state, intervalMs, monitor) {
|
|
103
|
+
if (state.timer === null) {
|
|
104
|
+
state.timer = setInterval(() => {
|
|
105
|
+
void monitor.sample()
|
|
106
|
+
}, intervalMs)
|
|
107
|
+
if (state.timer.unref) state.timer.unref()
|
|
108
|
+
}
|
|
109
|
+
return state.timer
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 停止采样(幂等)。 */
|
|
113
|
+
function stopMonitor(state) {
|
|
114
|
+
if (state.timer !== null) {
|
|
115
|
+
clearInterval(state.timer)
|
|
116
|
+
state.timer = null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-observability — 资源阈值判定(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 阈值口径与 skill `resource-budget-review` 一致(设计/开发/运行三遍校验
|
|
5
|
+
* 的运行期部分):写放大/CPU/内存超限在采样数据上提前暴露,告警可查询。
|
|
6
|
+
* 纯函数便于单测与配置覆盖(config.resourceLimits 浅合并)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 默认阈值(DSH 插件场景,来源 resource-budget-review 五维表)。 */
|
|
10
|
+
export const DEFAULT_LIMITS = {
|
|
11
|
+
/** 审计文件写入速率上限:50 MB/小时。 */
|
|
12
|
+
writeRateBytesPerHour: 50 * 1024 * 1024,
|
|
13
|
+
/** 审计文件大小上限:50 MB(循环 compact 后应远小于此)。 */
|
|
14
|
+
fileBytes: 50 * 1024 * 1024,
|
|
15
|
+
/** 本进程 CPU 均值上限:10%(单核折算,采样窗口内 user+sys / 时长)。 */
|
|
16
|
+
cpuPercent: 10,
|
|
17
|
+
/** 本进程 RSS 上限:500 MB。 */
|
|
18
|
+
memoryBytes: 500 * 1024 * 1024,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 采样数据 → 告警列表([{ rule, level, message, value, limit }])。 */
|
|
22
|
+
export function evaluateResourceAlerts(sample, limits = DEFAULT_LIMITS) {
|
|
23
|
+
const alerts = []
|
|
24
|
+
if (typeof sample.writeRateBytesPerHour === 'number' && sample.writeRateBytesPerHour > limits.writeRateBytesPerHour) {
|
|
25
|
+
alerts.push({
|
|
26
|
+
rule: 'write-rate',
|
|
27
|
+
level: 'error',
|
|
28
|
+
message: `审计写入速率 ${fmtMB(sample.writeRateBytesPerHour)}/h 超过上限 ${fmtMB(limits.writeRateBytesPerHour)}/h(写放大风险)`,
|
|
29
|
+
value: sample.writeRateBytesPerHour,
|
|
30
|
+
limit: limits.writeRateBytesPerHour,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
if (typeof sample.fileBytes === 'number' && sample.fileBytes > limits.fileBytes) {
|
|
34
|
+
alerts.push({
|
|
35
|
+
rule: 'file-size',
|
|
36
|
+
level: 'error',
|
|
37
|
+
message: `审计文件 ${fmtMB(sample.fileBytes)} 超过上限 ${fmtMB(limits.fileBytes)}(请检查 compact/轮转)`,
|
|
38
|
+
value: sample.fileBytes,
|
|
39
|
+
limit: limits.fileBytes,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
if (typeof sample.cpuPercent === 'number' && sample.cpuPercent > limits.cpuPercent) {
|
|
43
|
+
alerts.push({
|
|
44
|
+
rule: 'cpu',
|
|
45
|
+
level: 'warn',
|
|
46
|
+
message: `本进程 CPU ${Math.round(sample.cpuPercent)}% 超过上限 ${limits.cpuPercent}%`,
|
|
47
|
+
value: sample.cpuPercent,
|
|
48
|
+
limit: limits.cpuPercent,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
if (typeof sample.memoryBytes === 'number' && sample.memoryBytes > limits.memoryBytes) {
|
|
52
|
+
alerts.push({
|
|
53
|
+
rule: 'memory',
|
|
54
|
+
level: 'warn',
|
|
55
|
+
message: `本进程内存 ${fmtMB(sample.memoryBytes)} 超过上限 ${fmtMB(limits.memoryBytes)}`,
|
|
56
|
+
value: sample.memoryBytes,
|
|
57
|
+
limit: limits.memoryBytes,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
return alerts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 连续多少次采样超限(关键阈值)判定进入降级(模块内默认值)。 */
|
|
64
|
+
const DEGRADE_CONFIRM_COUNT = 3
|
|
65
|
+
|
|
66
|
+
/** 连续多少次采样正常判定退出降级(模块内默认值)。 */
|
|
67
|
+
const RECOVER_CONFIRM_COUNT = 3
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 关键阈值判定:磁盘写放大风险主导(write-rate / file-size 任一超限即记超限)。
|
|
71
|
+
* CPU/内存超限告警但不触发落盘降级(避免误伤正常大请求峰值)。
|
|
72
|
+
*/
|
|
73
|
+
function isCriticalOverLimit(sample, limits = DEFAULT_LIMITS) {
|
|
74
|
+
return (
|
|
75
|
+
(sample.writeRateBytesPerHour ?? 0) > limits.writeRateBytesPerHour || (sample.fileBytes ?? 0) > limits.fileBytes
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 降级判定(纯函数):最近 confirmCount 个样本**全部**关键阈值超限 → 进入降级。
|
|
81
|
+
* 历史样本不足 confirmCount 时不降级(冷启动保护)。
|
|
82
|
+
* 返回布尔(shouldEnterDegrade)。
|
|
83
|
+
*/
|
|
84
|
+
export function shouldEnterDegrade(history, limits = DEFAULT_LIMITS, confirmCount = DEGRADE_CONFIRM_COUNT) {
|
|
85
|
+
const recent = history.slice(-confirmCount)
|
|
86
|
+
if (recent.length < confirmCount) return false
|
|
87
|
+
return recent.every((sample) => isCriticalOverLimit(sample, limits))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 恢复判定(纯函数):最近 confirmCount 个样本**全部**关键阈值正常 → 退出降级。
|
|
92
|
+
* 历史样本不足 confirmCount 时不恢复(避免刚降级立即恢复抖动)。
|
|
93
|
+
*/
|
|
94
|
+
export function shouldExitDegrade(history, limits = DEFAULT_LIMITS, confirmCount = RECOVER_CONFIRM_COUNT) {
|
|
95
|
+
const recent = history.slice(-confirmCount)
|
|
96
|
+
if (recent.length < confirmCount) return false
|
|
97
|
+
return recent.every((sample) => !isCriticalOverLimit(sample, limits))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fmtMB(bytes) {
|
|
101
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
102
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -17,7 +17,7 @@ import { reviewRules } from './review.js'
|
|
|
17
17
|
import { runAiReview } from './ai.js'
|
|
18
18
|
|
|
19
19
|
/** 注册 /observability/api 路由(effect 持有 disposer)。 */
|
|
20
|
-
export function registerObservabilityRoutes(ctx, store, options) {
|
|
20
|
+
export function registerObservabilityRoutes(ctx, store, monitor, options) {
|
|
21
21
|
const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined
|
|
22
22
|
const trustedHosts =
|
|
23
23
|
webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
|
|
@@ -30,14 +30,14 @@ export function registerObservabilityRoutes(ctx, store, options) {
|
|
|
30
30
|
ctx.webServer.register({
|
|
31
31
|
kind: 'prefix',
|
|
32
32
|
path: '/observability/api',
|
|
33
|
-
handler: apiHandler(ctx, fence, store, options),
|
|
33
|
+
handler: apiHandler(ctx, fence, store, monitor, options),
|
|
34
34
|
}),
|
|
35
35
|
'dsh-my-observability: /observability/api routes',
|
|
36
36
|
)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** 统一 handler:fence → 方法分派 → 404/错误兜底。 */
|
|
40
|
-
function apiHandler(ctx, fence, store, options) {
|
|
40
|
+
function apiHandler(ctx, fence, store, monitor, options) {
|
|
41
41
|
return async (request, response) => {
|
|
42
42
|
if (!fence(request)) {
|
|
43
43
|
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
@@ -47,7 +47,7 @@ function apiHandler(ctx, fence, store, options) {
|
|
|
47
47
|
const pathname = url.pathname
|
|
48
48
|
const method = pathname.startsWith('/observability/api/') ? pathname.slice('/observability/api/'.length) : undefined
|
|
49
49
|
try {
|
|
50
|
-
const handled = await dispatchMethod(method, request, response, url, ctx, store, options)
|
|
50
|
+
const handled = await dispatchMethod(method, request, response, url, ctx, store, monitor, options)
|
|
51
51
|
if (!handled) {
|
|
52
52
|
writeJson(response, 404, {
|
|
53
53
|
ok: false,
|
|
@@ -66,11 +66,15 @@ function isMethod(method, request, name, verb) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** 按 method 分派到具体 handler;未识别返回 false(调用方回 404)。 */
|
|
69
|
-
async function dispatchMethod(method, request, response, url, ctx, store, options) {
|
|
69
|
+
async function dispatchMethod(method, request, response, url, ctx, store, monitor, options) {
|
|
70
70
|
if (isMethod(method, request, 'sessions', 'GET')) {
|
|
71
71
|
writeJson(response, 200, { ok: true, value: store.sessions() })
|
|
72
72
|
return true
|
|
73
73
|
}
|
|
74
|
+
if (isMethod(method, request, 'resources', 'GET')) {
|
|
75
|
+
writeJson(response, 200, { ok: true, value: monitor.sample() })
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
74
78
|
if (isMethod(method, request, 'events', 'GET')) {
|
|
75
79
|
writeJson(response, 200, {
|
|
76
80
|
ok: true,
|
package/lib/store-persist.js
CHANGED
|
@@ -24,7 +24,7 @@ export const MAX_TOTAL_EVENTS = 20000
|
|
|
24
24
|
export const COMPACT_LINES = 5000
|
|
25
25
|
|
|
26
26
|
/** 数据根目录:$DSH_HOME(fallback 家目录)。 */
|
|
27
|
-
|
|
27
|
+
function dataHome() {
|
|
28
28
|
const home = process.env.DSH_HOME
|
|
29
29
|
return typeof home === 'string' && home !== '' ? home : homedir()
|
|
30
30
|
}
|
|
@@ -40,7 +40,7 @@ export function legacyFile() {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** 事件结构校验(时间/会话/类型为合理形态)。 */
|
|
43
|
-
|
|
43
|
+
function isValidEvent(event) {
|
|
44
44
|
return (
|
|
45
45
|
event !== null &&
|
|
46
46
|
typeof event === 'object' &&
|
|
@@ -65,7 +65,9 @@ export function normalizeLoaded(bySession) {
|
|
|
65
65
|
let total = 0
|
|
66
66
|
for (const bucket of Object.values(sessionBuckets)) total += bucket.events.length
|
|
67
67
|
if (total > MAX_TOTAL_EVENTS) {
|
|
68
|
-
const ordered = Object.keys(sessionBuckets).sort(
|
|
68
|
+
const ordered = Object.keys(sessionBuckets).sort(
|
|
69
|
+
(a, b) => firstTimeOf(sessionBuckets[a]) - firstTimeOf(sessionBuckets[b]),
|
|
70
|
+
)
|
|
69
71
|
for (const sessionId of ordered) {
|
|
70
72
|
if (total <= MAX_TOTAL_EVENTS) break
|
|
71
73
|
total -= sessionBuckets[sessionId].events.length
|
package/lib/store.js
CHANGED
|
@@ -53,12 +53,14 @@ export function createStore(ctx) {
|
|
|
53
53
|
compactTimer: null,
|
|
54
54
|
compacting: false,
|
|
55
55
|
migrated: false,
|
|
56
|
+
persistEnabled: true,
|
|
56
57
|
dirtyChain: Promise.resolve(),
|
|
57
58
|
}
|
|
58
59
|
store.record = (event) => record(handle, event)
|
|
59
60
|
store.events = (sessionId, type, limit) => eventsOf(handle, sessionId, type, limit)
|
|
60
61
|
store.sessions = () => sessionsOf(handle)
|
|
61
62
|
store.count = () => countOf(handle)
|
|
63
|
+
store.setPersistEnabled = (enabled) => setPersistEnabled(handle, enabled)
|
|
62
64
|
store.dispose = () => dispose(handle)
|
|
63
65
|
void loadPersisted(handle.file, handle.legacy).then((result) => onLoaded(handle, result))
|
|
64
66
|
return store
|
|
@@ -76,14 +78,34 @@ function record(handle, event) {
|
|
|
76
78
|
return item
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
/** 事件入内存桶 + 排队待落盘行(回放与运行时共用,保证回放也落盘)。
|
|
81
|
+
/** 事件入内存桶 + 排队待落盘行(回放与运行时共用,保证回放也落盘)。
|
|
82
|
+
* 降级(persistEnabled=false)时事件照常入内存桶(有 FIFO 上限保护),
|
|
83
|
+
* 但不排队落盘行、不触发 flush——写盘完全停止,恢复时全量快照补齐。 */
|
|
80
84
|
function enqueueRecord(handle, item) {
|
|
81
85
|
appendEvent(handle, item)
|
|
86
|
+
if (!handle.persistEnabled) return
|
|
82
87
|
handle.lineQueue.push(JSON.stringify(item))
|
|
83
88
|
handle.queuedLines += 1
|
|
84
89
|
if (handle.queuedLines >= COMPACT_LINES) scheduleCompact(handle)
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 落盘开关(资源看门狗降级用):
|
|
94
|
+
* - false(降级):停止落盘——清空待写行(内存 state 仍在,不丢),停 flush。
|
|
95
|
+
* - true(恢复):立即 compact 全量快照(内存=真相,补齐降级窗口事件),恢复增量。
|
|
96
|
+
*/
|
|
97
|
+
function setPersistEnabled(handle, enabled) {
|
|
98
|
+
if (handle.persistEnabled === enabled) return
|
|
99
|
+
handle.persistEnabled = enabled
|
|
100
|
+
if (handle.flushTimer !== null) {
|
|
101
|
+
clearTimeout(handle.flushTimer)
|
|
102
|
+
handle.flushTimer = null
|
|
103
|
+
}
|
|
104
|
+
handle.lineQueue = []
|
|
105
|
+
handle.queuedLines = 0
|
|
106
|
+
if (enabled) compactNow(handle)
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
/** 全部会话事件:合并各会话并按时间正序(sessionId='*')。 */
|
|
88
110
|
function eventsAllOf(handle, type, limit) {
|
|
89
111
|
let all = []
|
|
@@ -209,12 +231,10 @@ function scheduleFlush(handle) {
|
|
|
209
231
|
}
|
|
210
232
|
|
|
211
233
|
function flushNow(handle) {
|
|
212
|
-
if (handle.lineQueue.length === 0) return
|
|
234
|
+
if (handle.lineQueue.length === 0 || !handle.persistEnabled) return
|
|
213
235
|
const text = `${handle.lineQueue.join('\n')}\n`
|
|
214
236
|
handle.lineQueue = []
|
|
215
|
-
handle.dirtyChain = handle.dirtyChain.then(() =>
|
|
216
|
-
appendLines(handle.file, text, handle.ctx.logger, PREFIX),
|
|
217
|
-
)
|
|
237
|
+
handle.dirtyChain = handle.dirtyChain.then(() => appendLines(handle.file, text, handle.ctx.logger, PREFIX))
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
/** 防抖紧凑调度(合并短时间多次触发)。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-my-observability",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "DSH 可观测性 + Git 工程工具插件:会话轨迹回放(时间轴)、事件审计(agent 行为记录)、结构化 Git 提交(Conventional Commits)、增量 diff 审查(提交前审查)。DSH web plugin: trajectory replay timeline, event audit log, structured git commits, pre-commit incremental diff review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|