dsh-my-observability 0.1.2 → 0.1.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/CHANGELOG.md +8 -0
- package/README.md +3 -2
- package/lib/audit-view.js +256 -0
- package/lib/client.js +718 -34
- package/lib/client.src.js +2 -0
- package/lib/parts/i18n.js +19 -0
- package/lib/parts/replay-ext.js +375 -0
- package/lib/parts/replay.js +51 -57
- package/lib/parts/styles.js +17 -0
- package/lib/store-persist.js +174 -0
- package/lib/store.js +147 -102
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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.3] - 2026-09-03
|
|
6
|
+
|
|
7
|
+
### 变更
|
|
8
|
+
|
|
9
|
+
- fix(observability): 审计持久化改增量追加,根治写放大磁盘风暴
|
|
10
|
+
- feat(observability): #89 审计日志搜索/过滤 + JSON/CSV 导出 (#120)
|
|
11
|
+
- docs: #106 安装命令统一加 --trust-lockfile (#113)
|
|
12
|
+
|
|
5
13
|
## [0.1.2] - 2026-09-01
|
|
6
14
|
|
|
7
15
|
### 变更
|
package/README.md
CHANGED
|
@@ -23,8 +23,9 @@ Server 端只读观察 DSH 生命周期事件并记录审计日志:
|
|
|
23
23
|
| `tools/execute` | `tool_result` | 工具名 + 成功/失败 + 耗时 |
|
|
24
24
|
|
|
25
25
|
- **会话隔离**:事件按会话分桶,切换会话互不串扰;
|
|
26
|
-
- **重启恢复**:持久化到 `$DSH_HOME/observability/audit.
|
|
26
|
+
- **重启恢复**:持久化到 `$DSH_HOME/observability/audit.jsonl`(**增量追加** + 防抖批量 flush + 周期 compact 原子快照),重启后完整恢复;升级前旧格式 `audit.json` 自动迁移;
|
|
27
27
|
- **防膨胀**:每会话最多 2000 条(FIFO 淘汰)、全局 20000 条(轮转淘汰);
|
|
28
|
+
- **零写放大**:落盘只写新增事件(≈事件本体字节),不会因事件流持续而反复全量重写审计文件;
|
|
28
29
|
- **只读观察**:waterfall 事件一律透传 `next()`,绝不改变工具/模型流程。
|
|
29
30
|
|
|
30
31
|
### 2. 轨迹回放面板(时间轴)
|
|
@@ -63,7 +64,7 @@ Server 端只读观察 DSH 生命周期事件并记录审计日志:
|
|
|
63
64
|
|
|
64
65
|
## 安装
|
|
65
66
|
|
|
66
|
-
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-observability`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。
|
|
67
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-observability --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
70
|
# 1) 克隆本仓库(任意目录)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-observability — audit log view helpers (pure functions).
|
|
3
|
+
*
|
|
4
|
+
* 轨迹回放面板的搜索 / 组合过滤 / 导出(JSON/CSV)/ 统计的纯逻辑,无副作用、
|
|
5
|
+
* 不依赖 React 与 cordis(可被 vitest 直接导入单测),仅供 client 端在已加载
|
|
6
|
+
* 的审计事件数据上做视图变换。DSH ModuleLoader 不支持相对路径 require,client
|
|
7
|
+
* 侧经 scripts/build.mjs 把本文件(剥离 `export` 前缀)作为片段拼接进
|
|
8
|
+
* lib/client.js 的 factory 作用域,因此本文件约定:
|
|
9
|
+
* - 只用 `export function`(单行形式),不用 export 块 / export default;
|
|
10
|
+
* - 顶层没有 import / 副作用;
|
|
11
|
+
* - 不读取 strings —— 涉及界面文案的默认值集中在此,client 如需 i18n 覆盖
|
|
12
|
+
* 通过参数传入。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 事件类型 → 中文标签(CSV 默认;client 可传 labels 覆盖)。非导出常量。 */
|
|
16
|
+
const DEFAULT_CSV_LABELS = Object.freeze({
|
|
17
|
+
time: '时间',
|
|
18
|
+
type: '类型',
|
|
19
|
+
tool: '工具',
|
|
20
|
+
result: '结果',
|
|
21
|
+
typeMap: Object.freeze({
|
|
22
|
+
agent_status: 'agent 状态',
|
|
23
|
+
llm_stream: '模型流',
|
|
24
|
+
tool_call: '工具调用',
|
|
25
|
+
tool_result: '工具结果',
|
|
26
|
+
}),
|
|
27
|
+
ok: '成功',
|
|
28
|
+
fail: '失败',
|
|
29
|
+
error: '错误',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const MAX_STATS_TOP = 50
|
|
33
|
+
|
|
34
|
+
/** 两位补零。 */
|
|
35
|
+
function pad2(n) {
|
|
36
|
+
return String(n).padStart(2, '0')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 毫秒时间戳 → `YYYY-MM-DD HH:MM:SS`(本地时区);非法输入返回空串。 */
|
|
40
|
+
export function formatTime(time) {
|
|
41
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) return ''
|
|
42
|
+
const d = new Date(time)
|
|
43
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
44
|
+
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
45
|
+
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
|
46
|
+
return `${date} ${clock}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** agent 状态事件的搜索片段。 */
|
|
50
|
+
function agentStatusParts(data) {
|
|
51
|
+
return [data.status, data.agentType].filter((part) => typeof part === 'string')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 模型流事件的搜索片段(含错误消息)。 */
|
|
55
|
+
function llmParts(data) {
|
|
56
|
+
const parts = [data.phase]
|
|
57
|
+
if (typeof data.message === 'string' && data.message !== '') parts.push(data.message)
|
|
58
|
+
return parts
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 工具调用事件的搜索片段(工具名 + 参数键 + 参数摘要)。 */
|
|
62
|
+
function toolCallParts(data) {
|
|
63
|
+
const parts = []
|
|
64
|
+
if (typeof data.name === 'string') parts.push(data.name)
|
|
65
|
+
if (Array.isArray(data.args?.keys)) parts.push(...data.args.keys)
|
|
66
|
+
if (typeof data.args?.summary === 'string' && data.args.summary !== '') parts.push(data.args.summary)
|
|
67
|
+
return parts
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 工具结果事件的搜索片段(工具名 + 成败)。 */
|
|
71
|
+
function toolResultParts(data) {
|
|
72
|
+
const parts = []
|
|
73
|
+
if (typeof data.name === 'string') parts.push(data.name)
|
|
74
|
+
parts.push(data.ok === false ? '失败' : '成功')
|
|
75
|
+
return parts
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 事件类型 → 搜索片段收集函数(查表消分支)。 */
|
|
79
|
+
const PARTS_COLLECTORS = {
|
|
80
|
+
agent_status: agentStatusParts,
|
|
81
|
+
llm_stream: llmParts,
|
|
82
|
+
tool_call: toolCallParts,
|
|
83
|
+
tool_result: toolResultParts,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 提取事件可用于关键词匹配的文本(工具名/参数摘要/错误信息/状态/阶段等)。 */
|
|
87
|
+
export function searchableText(event) {
|
|
88
|
+
const data = event && event.data ? event.data : {}
|
|
89
|
+
const parts = [event?.type, event?.sessionId]
|
|
90
|
+
const collector = PARTS_COLLECTORS[event?.type]
|
|
91
|
+
if (collector !== undefined) parts.push(...collector(data))
|
|
92
|
+
return parts
|
|
93
|
+
.filter((part) => typeof part === 'string')
|
|
94
|
+
.join(' ')
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 事件是否命中关键词(不区分大小写;空关键词视为命中全部)。 */
|
|
99
|
+
export function matchesKeyword(event, keyword) {
|
|
100
|
+
const kw = String(keyword ?? '')
|
|
101
|
+
.trim()
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
if (kw === '') return true
|
|
104
|
+
return searchableText(event).includes(kw)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 返回 `true` 表示事件具备失败语义(工具失败 / 模型流出错)。 */
|
|
108
|
+
function isFailEvent(event) {
|
|
109
|
+
if (event?.type === 'tool_result') return event.data?.ok === false
|
|
110
|
+
if (event?.type === 'llm_stream') return event.data?.phase === 'error'
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 归一化过滤条件(时间转为闭区间数值;空值透传)。 */
|
|
115
|
+
function normalizeCriteria(criteria) {
|
|
116
|
+
const start =
|
|
117
|
+
typeof criteria.timeStart === 'number' && Number.isFinite(criteria.timeStart) ? criteria.timeStart : undefined
|
|
118
|
+
const end = typeof criteria.timeEnd === 'number' && Number.isFinite(criteria.timeEnd) ? criteria.timeEnd : undefined
|
|
119
|
+
return { type: criteria.type ?? '', keyword: criteria.keyword ?? '', result: criteria.result ?? '', start, end }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 类型过滤('tool' 表示 tool_call + tool_result)。 */
|
|
123
|
+
function passType(type, filterType) {
|
|
124
|
+
if (filterType === '') return true
|
|
125
|
+
if (filterType === 'tool') return type === 'tool_call' || type === 'tool_result'
|
|
126
|
+
return type === filterType
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 时间范围闭区间。 */
|
|
130
|
+
function passTime(time, start, end) {
|
|
131
|
+
if (start !== undefined && time < start) return false
|
|
132
|
+
if (end !== undefined && time > end) return false
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 成功/失败过滤:只作用于有成败语义的事件,其余事件透传。 */
|
|
137
|
+
function passResult(event, result) {
|
|
138
|
+
if (result === '') return true
|
|
139
|
+
if (result === 'success') return !isFailEvent(event)
|
|
140
|
+
if (result === 'fail') return isFailEvent(event)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 组合过滤:类型(tool 表示 tool_call+tool_result)+ 时间范围 + 成功/失败 + 关键词。
|
|
145
|
+
* criteria: { type, timeStart, timeEnd, result, keyword } */
|
|
146
|
+
export function applyAuditFilter(events, criteria = {}) {
|
|
147
|
+
const ctx = normalizeCriteria(criteria)
|
|
148
|
+
return (events ?? []).filter(
|
|
149
|
+
(event) =>
|
|
150
|
+
passType(event.type, ctx.type) &&
|
|
151
|
+
passTime(event.time, ctx.start, ctx.end) &&
|
|
152
|
+
passResult(event, ctx.result) &&
|
|
153
|
+
matchesKeyword(event, ctx.keyword),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** CSV 单元格转义:含逗号/引号/换行时用双引号包裹并转义内嵌引号。 */
|
|
158
|
+
export function csvCell(value) {
|
|
159
|
+
const s = value === null || value === undefined ? '' : String(value)
|
|
160
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 事件的工具名(仅 tool_call/tool_result;否则空)。 */
|
|
164
|
+
export function toolNameOf(event) {
|
|
165
|
+
if (event?.type === 'tool_call' || event?.type === 'tool_result') return String(event.data?.name ?? '')
|
|
166
|
+
return ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 事件的结果摘要(成功/失败/错误;其余空)。 */
|
|
170
|
+
export function resultTextOf(event, labels = DEFAULT_CSV_LABELS) {
|
|
171
|
+
if (event?.type === 'tool_result') return event.data?.ok === false ? labels.fail : labels.ok
|
|
172
|
+
if (event?.type === 'llm_stream' && event.data?.phase === 'error') return labels.error
|
|
173
|
+
return ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 生成 CSV 摘要(表头:时间/类型/工具/结果)。labels 可覆盖默认中文。
|
|
177
|
+
* 返回不含换行结尾符的 CSV 文本。 */
|
|
178
|
+
export function auditToCsv(events, labels = DEFAULT_CSV_LABELS) {
|
|
179
|
+
const typeMap = labels.typeMap ?? {}
|
|
180
|
+
const header = [labels.time, labels.type, labels.tool, labels.result]
|
|
181
|
+
const lines = [header.map(csvCell).join(',')]
|
|
182
|
+
for (const event of events ?? []) {
|
|
183
|
+
const typeLabel = typeMap[event.type] ?? String(event.type)
|
|
184
|
+
const row = [formatTime(event.time), typeLabel, toolNameOf(event), resultTextOf(event, labels)]
|
|
185
|
+
lines.push(row.map(csvCell).join(','))
|
|
186
|
+
}
|
|
187
|
+
return lines.join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 生成 JSON 完整数据(缩进默认 2)。 */
|
|
191
|
+
export function auditToJson(events, space = 2) {
|
|
192
|
+
return JSON.stringify(events ?? [], null, space)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 事件是否为工具类(tool_call / tool_result)。 */
|
|
196
|
+
function isToolEvent(event) {
|
|
197
|
+
return event?.type === 'tool_call' || event?.type === 'tool_result'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 把单条工具事件计入聚合(调用次数 / 失败次数)。 */
|
|
201
|
+
function bumpTool(byTool, event, name) {
|
|
202
|
+
const entry = byTool.get(name) ?? { tool: name, calls: 0, fails: 0 }
|
|
203
|
+
if (event.type === 'tool_call') entry.calls += 1
|
|
204
|
+
if (event.type === 'tool_result' && event.data?.ok === false) entry.fails += 1
|
|
205
|
+
byTool.set(name, entry)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 按工具名聚合调用次数与失败次数。 */
|
|
209
|
+
function aggregateToolStats(events) {
|
|
210
|
+
const byTool = new Map()
|
|
211
|
+
for (const event of events ?? []) {
|
|
212
|
+
if (!isToolEvent(event)) continue
|
|
213
|
+
const name = String(event.data?.name ?? '')
|
|
214
|
+
if (name === '') continue
|
|
215
|
+
bumpTool(byTool, event, name)
|
|
216
|
+
}
|
|
217
|
+
return byTool
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 聚合结果 → 排序 + 失败率列表。 */
|
|
221
|
+
function rankTools(byTool) {
|
|
222
|
+
return [...byTool.values()]
|
|
223
|
+
.map((entry) => ({ ...entry, failRate: entry.calls > 0 ? entry.fails / entry.calls : 0 }))
|
|
224
|
+
.sort((a, b) => b.calls - a.calls || b.fails - a.fails || a.tool.localeCompare(b.tool))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 工具调用统计:每个工具调用次数 + 失败率(topN 截断,默认 5)。
|
|
228
|
+
* 返回 [{ tool, calls, fails, failRate }] 按调用次数降序。 */
|
|
229
|
+
export function computeToolStats(events, topN = 5) {
|
|
230
|
+
const n = typeof topN === 'number' && topN > 0 ? Math.min(topN, MAX_STATS_TOP) : 5
|
|
231
|
+
return rankTools(aggregateToolStats(events)).slice(0, n)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 把 text 按 keyword 切成 [ { text, hit } ] 分段(用于命中关键词高亮)。
|
|
235
|
+
* 空关键词返回整段未命中。 */
|
|
236
|
+
export function highlightSegments(text, keyword) {
|
|
237
|
+
const raw = String(text ?? '')
|
|
238
|
+
const kw = String(keyword ?? '')
|
|
239
|
+
.trim()
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
if (kw === '') return [{ text: raw, hit: false }]
|
|
242
|
+
const lower = raw.toLowerCase()
|
|
243
|
+
const out = []
|
|
244
|
+
let cursor = 0
|
|
245
|
+
for (;;) {
|
|
246
|
+
const idx = lower.indexOf(kw, cursor)
|
|
247
|
+
if (idx === -1) {
|
|
248
|
+
if (cursor < raw.length) out.push({ text: raw.slice(cursor), hit: false })
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
if (idx > cursor) out.push({ text: raw.slice(cursor, idx), hit: false })
|
|
252
|
+
out.push({ text: raw.slice(idx, idx + kw.length), hit: true })
|
|
253
|
+
cursor = idx + kw.length
|
|
254
|
+
}
|
|
255
|
+
return out.length === 0 ? [{ text: raw, hit: false }] : out
|
|
256
|
+
}
|