codelark 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/SECURITY.md +34 -0
- package/SKILL.md +67 -0
- package/agents/openai.yaml +4 -0
- package/dist/cli.mjs +8794 -0
- package/dist/daemon.mjs +47172 -0
- package/dist/ui-server.mjs +22165 -0
- package/package.json +73 -0
- package/schemas/config.v1.schema.json +259 -0
- package/schemas/data/audit.v1.schema.json +44 -0
- package/schemas/data/auto-tasks.v1.schema.json +94 -0
- package/schemas/data/channel-chats.v1.schema.json +159 -0
- package/schemas/data/channel-default-targets.v1.schema.json +43 -0
- package/schemas/data/messages.v1.schema.json +23 -0
- package/schemas/data/number-map.v1.schema.json +9 -0
- package/schemas/data/permissions.v1.schema.json +35 -0
- package/schemas/data/sessions.v1.schema.json +330 -0
- package/schemas/data/string-map.v1.schema.json +9 -0
- package/schemas/manifest.json +121 -0
- package/scripts/analyze-bridge-log.js +838 -0
- package/scripts/build-preflight.d.ts +21 -0
- package/scripts/build-preflight.js +70 -0
- package/scripts/build.js +53 -0
- package/scripts/check-npm-pack.js +46 -0
- package/scripts/daemon.ps1 +16 -0
- package/scripts/daemon.sh +206 -0
- package/scripts/doctor.ps1 +27 -0
- package/scripts/doctor.sh +185 -0
- package/scripts/hot-update-bridge.sh +298 -0
- package/scripts/install-codex-skills.sh +127 -0
- package/scripts/install-codex.sh +10 -0
- package/scripts/migrate-bindings-to-channel-chats.js +228 -0
- package/scripts/patch-codex-sdk-windows-hide.js +96 -0
- package/scripts/real-feishu-e2e.ts +5804 -0
- package/scripts/run-tests.js +83 -0
- package/scripts/setup-wizard-real-e2e.ts +195 -0
- package/scripts/supervisor-linux.sh +49 -0
- package/scripts/supervisor-macos.sh +167 -0
- package/scripts/supervisor-windows.ps1 +481 -0
- package/skills/codelark/SKILL.md +67 -0
- package/skills/codelark-auto/SKILL.md +80 -0
- package/skills/codelark-question/SKILL.md +54 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LOG_PATH = path.join(os.homedir(), '.codelark', 'logs', 'bridge.log');
|
|
8
|
+
const DEFAULT_OUT_DIR = path.join(process.cwd(), 'work', `bridge-log-analysis-${timestampForPath(new Date())}`);
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const options = {
|
|
12
|
+
logPath: DEFAULT_LOG_PATH,
|
|
13
|
+
outDir: DEFAULT_OUT_DIR,
|
|
14
|
+
sinceMs: null,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
if (arg === '--log') {
|
|
20
|
+
options.logPath = argv[++i] || options.logPath;
|
|
21
|
+
} else if (arg.startsWith('--log=')) {
|
|
22
|
+
options.logPath = arg.slice('--log='.length);
|
|
23
|
+
} else if (arg === '--out') {
|
|
24
|
+
options.outDir = argv[++i] || options.outDir;
|
|
25
|
+
} else if (arg.startsWith('--out=')) {
|
|
26
|
+
options.outDir = arg.slice('--out='.length);
|
|
27
|
+
} else if (arg === '--since') {
|
|
28
|
+
options.sinceMs = parseSince(argv[++i]);
|
|
29
|
+
} else if (arg.startsWith('--since=')) {
|
|
30
|
+
options.sinceMs = parseSince(arg.slice('--since='.length));
|
|
31
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
32
|
+
printHelp();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
} else {
|
|
35
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printHelp() {
|
|
43
|
+
console.log(`Usage: node scripts/analyze-bridge-log.js [--log PATH] [--out DIR] [--since DATE_OR_MS]
|
|
44
|
+
|
|
45
|
+
Reads bridge.log JSONL and writes:
|
|
46
|
+
- index.html
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
node scripts/analyze-bridge-log.js --out work/lane-flamegraph-latest
|
|
50
|
+
node scripts/analyze-bridge-log.js --since 2026-06-05T04:46:57Z
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSince(value) {
|
|
55
|
+
if (!value) return null;
|
|
56
|
+
if (/^\d+$/.test(value)) return Number(value);
|
|
57
|
+
const parsed = Date.parse(value);
|
|
58
|
+
if (!Number.isFinite(parsed)) {
|
|
59
|
+
throw new Error(`Invalid --since value: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function timestampForPath(date) {
|
|
65
|
+
return date.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readJsonl(logPath, sinceMs) {
|
|
69
|
+
const lines = fs.readFileSync(logPath, 'utf8').split(/\n/).filter(Boolean);
|
|
70
|
+
const entries = [];
|
|
71
|
+
let parseErrors = 0;
|
|
72
|
+
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
try {
|
|
75
|
+
const entry = JSON.parse(line);
|
|
76
|
+
const timeMs = Date.parse(entry.time || '');
|
|
77
|
+
if (!Number.isFinite(timeMs)) continue;
|
|
78
|
+
if (sinceMs !== null && timeMs < sinceMs) continue;
|
|
79
|
+
entries.push({ ...entry, __time_ms: timeMs });
|
|
80
|
+
} catch {
|
|
81
|
+
parseErrors += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { entries, parseErrors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildAdapterSpans(entries) {
|
|
89
|
+
const spans = new Map();
|
|
90
|
+
const events = entries.filter((entry) => String(entry.event || '').startsWith('adapter.message.'));
|
|
91
|
+
let fallbackSeq = 0;
|
|
92
|
+
|
|
93
|
+
for (const entry of events) {
|
|
94
|
+
fallbackSeq += 1;
|
|
95
|
+
const id = entry.span_id || entry.message_id || entry.message || `adapter-message:missing:${fallbackSeq}`;
|
|
96
|
+
const span = spans.get(id) || {
|
|
97
|
+
span_id: id,
|
|
98
|
+
events: [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
span.events.push(entry.event);
|
|
102
|
+
for (const key of [
|
|
103
|
+
'lane',
|
|
104
|
+
'lane_kind',
|
|
105
|
+
'channel',
|
|
106
|
+
'chat',
|
|
107
|
+
'category',
|
|
108
|
+
'job_kind',
|
|
109
|
+
'message_id',
|
|
110
|
+
'message',
|
|
111
|
+
'text',
|
|
112
|
+
'status',
|
|
113
|
+
'session_id',
|
|
114
|
+
'uses_session_lock',
|
|
115
|
+
'conversation_barrier',
|
|
116
|
+
'blocked_by_span_id',
|
|
117
|
+
'blocked_by_message_id',
|
|
118
|
+
'blocked_by_session_id',
|
|
119
|
+
'blocked_by_category',
|
|
120
|
+
'blocked_by_age_ms',
|
|
121
|
+
'message_timestamp_ms',
|
|
122
|
+
]) {
|
|
123
|
+
if (entry[key] !== undefined && entry[key] !== null) span[key] = entry[key];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (entry.event === 'adapter.message.scheduled') {
|
|
127
|
+
span.scheduled_at_ms = entry.scheduled_at_ms || entry.__time_ms;
|
|
128
|
+
span.message_age_ms_at_schedule = entry.message_age_ms;
|
|
129
|
+
} else if (entry.event === 'adapter.message.started') {
|
|
130
|
+
span.started_at_ms = entry.started_at_ms || entry.__time_ms;
|
|
131
|
+
span.lane_wait_ms = entry.lane_wait_ms;
|
|
132
|
+
span.session_lock_wait_ms = entry.session_lock_wait_ms;
|
|
133
|
+
} else if (entry.event === 'adapter.session_lock.acquired') {
|
|
134
|
+
span.session_lock_acquired_at_ms = entry.__time_ms;
|
|
135
|
+
span.session_lock_wait_ms = entry.session_lock_wait_ms;
|
|
136
|
+
} else if (entry.event === 'adapter.message.finished') {
|
|
137
|
+
span.finished_at_ms = entry.finished_at_ms || entry.ended_at_ms || entry.__time_ms;
|
|
138
|
+
span.ended_at_ms = entry.ended_at_ms || entry.__time_ms;
|
|
139
|
+
span.duration_ms = entry.duration_ms;
|
|
140
|
+
span.total_ms = entry.total_ms;
|
|
141
|
+
span.lane_wait_ms = entry.lane_wait_ms ?? span.lane_wait_ms;
|
|
142
|
+
span.session_lock_wait_ms = entry.session_lock_wait_ms ?? span.session_lock_wait_ms;
|
|
143
|
+
span.status = entry.status || span.status;
|
|
144
|
+
} else if (entry.event === 'adapter.message.error') {
|
|
145
|
+
span.error_at_ms = entry.__time_ms;
|
|
146
|
+
span.status = 'error';
|
|
147
|
+
span.error = entry.error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
spans.set(id, span);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const all = Array.from(spans.values());
|
|
154
|
+
return {
|
|
155
|
+
events,
|
|
156
|
+
spans: all,
|
|
157
|
+
completed: all.filter((span) => Number.isFinite(span.scheduled_at_ms) && Number.isFinite(span.finished_at_ms)),
|
|
158
|
+
pending: all.filter((span) => Number.isFinite(span.scheduled_at_ms) && !Number.isFinite(span.finished_at_ms)),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildFeishuRequests(entries) {
|
|
163
|
+
return entries
|
|
164
|
+
.filter((entry) => entry.event === 'perf.feishu.request' && Number.isFinite(entry.duration_ms))
|
|
165
|
+
.map((entry) => {
|
|
166
|
+
const endMs = entry.__time_ms;
|
|
167
|
+
const durationMs = Number(entry.duration_ms);
|
|
168
|
+
return {
|
|
169
|
+
time: entry.time,
|
|
170
|
+
end_ms: endMs,
|
|
171
|
+
start_ms: endMs - durationMs,
|
|
172
|
+
duration_ms: durationMs,
|
|
173
|
+
operation: entry.operation || entry.target || 'unknown',
|
|
174
|
+
target: entry.target || entry.operation || 'unknown',
|
|
175
|
+
scope: entry.scope || '',
|
|
176
|
+
status: entry.status || entry.phase || '',
|
|
177
|
+
phase: entry.phase || '',
|
|
178
|
+
chat: entry.chat || entry.chatId || '',
|
|
179
|
+
stream_key: entry.stream_key || entry.streamKey || '',
|
|
180
|
+
card_id: entry.card_id || entry.cardId || '',
|
|
181
|
+
message_id: entry.message_id || entry.messageId || '',
|
|
182
|
+
response_msg: entry.response_msg || '',
|
|
183
|
+
detail: entry.detail || '',
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function overlapMs(aStart, aEnd, bStart, bEnd) {
|
|
189
|
+
return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function summarizeFeishuRequests(requests, adapterSpans) {
|
|
193
|
+
const byOperation = new Map();
|
|
194
|
+
const byScope = new Map();
|
|
195
|
+
|
|
196
|
+
for (const request of requests) {
|
|
197
|
+
addDurationGroup(byOperation, request.operation, request);
|
|
198
|
+
if (request.scope) addDurationGroup(byScope, request.scope, request);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const operationRows = durationRows(byOperation);
|
|
202
|
+
const scopeRows = durationRows(byScope);
|
|
203
|
+
const slowRequests = [...requests].sort((a, b) => b.duration_ms - a.duration_ms).slice(0, 30);
|
|
204
|
+
const concurrency = summarizeRequestConcurrency(requests);
|
|
205
|
+
const adapterOverlap = adapterSpans
|
|
206
|
+
.filter((span) => Number.isFinite(span.started_at_ms) && Number.isFinite(span.finished_at_ms))
|
|
207
|
+
.map((span) => {
|
|
208
|
+
const globallyOverlappingRequests = requests
|
|
209
|
+
.map((request) => ({
|
|
210
|
+
...request,
|
|
211
|
+
overlap_ms: overlapMs(span.started_at_ms, span.finished_at_ms, request.start_ms, request.end_ms),
|
|
212
|
+
}))
|
|
213
|
+
.filter((request) => request.overlap_ms > 0)
|
|
214
|
+
.sort((a, b) => b.overlap_ms - a.overlap_ms);
|
|
215
|
+
const matchedRequests = globallyOverlappingRequests.filter((request) => requestMatchesSpan(request, span));
|
|
216
|
+
const feishuOverlapMs = sum(matchedRequests.map((request) => request.overlap_ms));
|
|
217
|
+
const globalOverlapMs = sum(globallyOverlappingRequests.map((request) => request.overlap_ms));
|
|
218
|
+
return {
|
|
219
|
+
span_id: span.span_id,
|
|
220
|
+
message_id: span.message_id || span.message,
|
|
221
|
+
lane: span.lane,
|
|
222
|
+
chat: span.chat,
|
|
223
|
+
category: span.category,
|
|
224
|
+
job_kind: span.job_kind,
|
|
225
|
+
text: span.text,
|
|
226
|
+
started_at: iso(span.started_at_ms),
|
|
227
|
+
finished_at: iso(span.finished_at_ms),
|
|
228
|
+
total_ms: span.finished_at_ms - span.scheduled_at_ms,
|
|
229
|
+
handler_ms: span.duration_ms || span.finished_at_ms - span.started_at_ms,
|
|
230
|
+
lane_wait_ms: span.lane_wait_ms || 0,
|
|
231
|
+
feishu_overlap_ms: feishuOverlapMs,
|
|
232
|
+
global_feishu_overlap_ms: globalOverlapMs,
|
|
233
|
+
global_feishu_request_count: globallyOverlappingRequests.length,
|
|
234
|
+
feishu_request_count: matchedRequests.length,
|
|
235
|
+
feishu_requests: matchedRequests.slice(0, 10).map((request) => ({
|
|
236
|
+
operation: request.operation,
|
|
237
|
+
status: request.status,
|
|
238
|
+
duration_ms: request.duration_ms,
|
|
239
|
+
overlap_ms: request.overlap_ms,
|
|
240
|
+
scope: request.scope,
|
|
241
|
+
chat: request.chat,
|
|
242
|
+
stream_key: request.stream_key,
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
})
|
|
246
|
+
.sort((a, b) => b.feishu_overlap_ms - a.feishu_overlap_ms)
|
|
247
|
+
.slice(0, 20);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
count: requests.length,
|
|
251
|
+
operationRows,
|
|
252
|
+
scopeRows,
|
|
253
|
+
slowRequests,
|
|
254
|
+
concurrency,
|
|
255
|
+
adapterOverlap,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function requestMatchesSpan(request, span) {
|
|
260
|
+
const spanChat = span.chat || chatFromLane(span.lane || '');
|
|
261
|
+
const spanMessageId = span.message_id || span.message || '';
|
|
262
|
+
if (spanChat && request.chat && request.chat === spanChat) return true;
|
|
263
|
+
if (spanChat && request.scope && String(request.scope).includes(spanChat)) return true;
|
|
264
|
+
if (spanChat && request.stream_key && String(request.stream_key).includes(spanChat)) return true;
|
|
265
|
+
if (spanMessageId && request.scope && String(request.scope).includes(spanMessageId)) return true;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function chatFromLane(lane) {
|
|
270
|
+
const parts = String(lane || '').split(':');
|
|
271
|
+
if (parts[0] !== 'chat' || parts.length < 3) return '';
|
|
272
|
+
return parts.slice(2).join(':');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function addDurationGroup(groups, key, request) {
|
|
276
|
+
const row = groups.get(key) || {
|
|
277
|
+
key,
|
|
278
|
+
count: 0,
|
|
279
|
+
durations: [],
|
|
280
|
+
statuses: new Map(),
|
|
281
|
+
total_ms: 0,
|
|
282
|
+
};
|
|
283
|
+
row.count += 1;
|
|
284
|
+
row.durations.push(request.duration_ms);
|
|
285
|
+
row.total_ms += request.duration_ms;
|
|
286
|
+
row.statuses.set(request.status, (row.statuses.get(request.status) || 0) + 1);
|
|
287
|
+
groups.set(key, row);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function durationRows(groups) {
|
|
291
|
+
return Array.from(groups.values())
|
|
292
|
+
.map((row) => ({
|
|
293
|
+
key: row.key,
|
|
294
|
+
count: row.count,
|
|
295
|
+
total_ms: row.total_ms,
|
|
296
|
+
avg_ms: Math.round(row.total_ms / Math.max(1, row.count)),
|
|
297
|
+
p50_ms: percentile(row.durations, 0.5),
|
|
298
|
+
p90_ms: percentile(row.durations, 0.9),
|
|
299
|
+
max_ms: Math.max(0, ...row.durations),
|
|
300
|
+
statuses: Object.fromEntries(row.statuses.entries()),
|
|
301
|
+
}))
|
|
302
|
+
.sort((a, b) => b.total_ms - a.total_ms);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function summarizeRequestConcurrency(requests) {
|
|
306
|
+
const points = [];
|
|
307
|
+
for (const request of requests) {
|
|
308
|
+
points.push({ time_ms: request.start_ms, delta: 1 });
|
|
309
|
+
points.push({ time_ms: request.end_ms, delta: -1 });
|
|
310
|
+
}
|
|
311
|
+
points.sort((a, b) => a.time_ms - b.time_ms || b.delta - a.delta);
|
|
312
|
+
|
|
313
|
+
let active = 0;
|
|
314
|
+
let maxActive = 0;
|
|
315
|
+
let activeWeightedMs = 0;
|
|
316
|
+
let previousMs = null;
|
|
317
|
+
const maxWindows = [];
|
|
318
|
+
|
|
319
|
+
for (const point of points) {
|
|
320
|
+
if (previousMs !== null && point.time_ms > previousMs) {
|
|
321
|
+
activeWeightedMs += active * (point.time_ms - previousMs);
|
|
322
|
+
}
|
|
323
|
+
active += point.delta;
|
|
324
|
+
if (active > maxActive) {
|
|
325
|
+
maxActive = active;
|
|
326
|
+
maxWindows.length = 0;
|
|
327
|
+
maxWindows.push(point.time_ms);
|
|
328
|
+
} else if (active === maxActive && point.delta > 0) {
|
|
329
|
+
maxWindows.push(point.time_ms);
|
|
330
|
+
}
|
|
331
|
+
previousMs = point.time_ms;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const minMs = requests.length ? Math.min(...requests.map((request) => request.start_ms)) : 0;
|
|
335
|
+
const maxMs = requests.length ? Math.max(...requests.map((request) => request.end_ms)) : 0;
|
|
336
|
+
return {
|
|
337
|
+
max_active: maxActive,
|
|
338
|
+
avg_active: maxMs > minMs ? activeWeightedMs / (maxMs - minMs) : 0,
|
|
339
|
+
max_active_at: maxWindows.slice(0, 5).map(iso),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function summarizeAdapterSpans(spans) {
|
|
344
|
+
const completed = spans.filter((span) => Number.isFinite(span.scheduled_at_ms) && Number.isFinite(span.finished_at_ms));
|
|
345
|
+
const byLane = new Map();
|
|
346
|
+
|
|
347
|
+
for (const span of completed) {
|
|
348
|
+
const lane = span.lane || 'unknown';
|
|
349
|
+
const row = byLane.get(lane) || {
|
|
350
|
+
lane,
|
|
351
|
+
lane_kind: span.lane_kind || 'unknown',
|
|
352
|
+
count: 0,
|
|
353
|
+
total_ms: 0,
|
|
354
|
+
wait_ms: 0,
|
|
355
|
+
handler_ms: 0,
|
|
356
|
+
max_ms: 0,
|
|
357
|
+
};
|
|
358
|
+
const totalMs = span.finished_at_ms - span.scheduled_at_ms;
|
|
359
|
+
const handlerMs = span.duration_ms || span.finished_at_ms - (span.started_at_ms || span.scheduled_at_ms);
|
|
360
|
+
row.count += 1;
|
|
361
|
+
row.total_ms += totalMs;
|
|
362
|
+
row.wait_ms += span.lane_wait_ms || 0;
|
|
363
|
+
row.handler_ms += handlerMs;
|
|
364
|
+
row.max_ms = Math.max(row.max_ms, totalMs);
|
|
365
|
+
byLane.set(lane, row);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const blockers = completed
|
|
369
|
+
.filter((span) => span.blocked_by_span_id || (span.lane_wait_ms || 0) > 0)
|
|
370
|
+
.map((span) => ({
|
|
371
|
+
span_id: span.span_id,
|
|
372
|
+
message_id: span.message_id || span.message,
|
|
373
|
+
lane: span.lane,
|
|
374
|
+
category: span.category,
|
|
375
|
+
text: span.text,
|
|
376
|
+
lane_wait_ms: span.lane_wait_ms || 0,
|
|
377
|
+
total_ms: span.finished_at_ms - span.scheduled_at_ms,
|
|
378
|
+
blocked_by_span_id: span.blocked_by_span_id,
|
|
379
|
+
blocked_by_message_id: span.blocked_by_message_id,
|
|
380
|
+
blocked_by_category: span.blocked_by_category,
|
|
381
|
+
blocked_by_age_ms: span.blocked_by_age_ms,
|
|
382
|
+
}))
|
|
383
|
+
.sort((a, b) => b.lane_wait_ms - a.lane_wait_ms);
|
|
384
|
+
|
|
385
|
+
const topSpans = completed
|
|
386
|
+
.map((span) => ({
|
|
387
|
+
span_id: span.span_id,
|
|
388
|
+
message_id: span.message_id || span.message,
|
|
389
|
+
lane: span.lane,
|
|
390
|
+
lane_kind: span.lane_kind,
|
|
391
|
+
category: span.category,
|
|
392
|
+
job_kind: span.job_kind,
|
|
393
|
+
text: span.text,
|
|
394
|
+
status: span.status,
|
|
395
|
+
scheduled_at: iso(span.scheduled_at_ms),
|
|
396
|
+
started_at: span.started_at_ms ? iso(span.started_at_ms) : null,
|
|
397
|
+
finished_at: iso(span.finished_at_ms),
|
|
398
|
+
lane_wait_ms: span.lane_wait_ms || 0,
|
|
399
|
+
session_lock_wait_ms: span.session_lock_wait_ms || 0,
|
|
400
|
+
handler_ms: span.duration_ms || Math.max(0, span.finished_at_ms - (span.started_at_ms || span.scheduled_at_ms)),
|
|
401
|
+
total_ms: span.finished_at_ms - span.scheduled_at_ms,
|
|
402
|
+
blocked_by_span_id: span.blocked_by_span_id,
|
|
403
|
+
blocked_by_message_id: span.blocked_by_message_id,
|
|
404
|
+
}))
|
|
405
|
+
.sort((a, b) => b.total_ms - a.total_ms);
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
completed,
|
|
409
|
+
lanes: Array.from(byLane.values()).sort((a, b) => b.total_ms - a.total_ms),
|
|
410
|
+
blockers,
|
|
411
|
+
topSpans,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function summarizePerf(entries) {
|
|
416
|
+
const groups = new Map();
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
if (!String(entry.event || '').startsWith('perf.')) continue;
|
|
419
|
+
const key = `${entry.event}${entry.operation || entry.target ? `:${entry.operation || entry.target}` : ''}`;
|
|
420
|
+
const row = groups.get(key) || {
|
|
421
|
+
key,
|
|
422
|
+
event: entry.event,
|
|
423
|
+
operation: entry.operation || entry.target || '',
|
|
424
|
+
count: 0,
|
|
425
|
+
durations: [],
|
|
426
|
+
errors: 0,
|
|
427
|
+
timeouts: 0,
|
|
428
|
+
};
|
|
429
|
+
row.count += 1;
|
|
430
|
+
if (Number.isFinite(entry.duration_ms)) row.durations.push(Number(entry.duration_ms));
|
|
431
|
+
if (entry.status === 'error' || entry.phase === 'error') row.errors += 1;
|
|
432
|
+
if (entry.status === 'timeout' || entry.phase === 'timeout') row.timeouts += 1;
|
|
433
|
+
groups.set(key, row);
|
|
434
|
+
}
|
|
435
|
+
return Array.from(groups.values())
|
|
436
|
+
.map((row) => ({
|
|
437
|
+
key: row.key,
|
|
438
|
+
event: row.event,
|
|
439
|
+
operation: row.operation,
|
|
440
|
+
count: row.count,
|
|
441
|
+
avg_ms: row.durations.length ? Math.round(sum(row.durations) / row.durations.length) : 0,
|
|
442
|
+
p90_ms: percentile(row.durations, 0.9),
|
|
443
|
+
max_ms: Math.max(0, ...row.durations),
|
|
444
|
+
total_ms: sum(row.durations),
|
|
445
|
+
errors: row.errors,
|
|
446
|
+
timeouts: row.timeouts,
|
|
447
|
+
}))
|
|
448
|
+
.sort((a, b) => b.total_ms - a.total_ms);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderSvg(adapterSummary, sourceLog, window) {
|
|
452
|
+
const spans = adapterSummary.completed;
|
|
453
|
+
const lanes = [...new Set(spans.map((span) => span.lane || 'unknown'))].sort(laneSort);
|
|
454
|
+
const minMs = window.start_ms;
|
|
455
|
+
const maxMs = window.end_ms;
|
|
456
|
+
const spanMs = Math.max(1, maxMs - minMs);
|
|
457
|
+
const width = 1440;
|
|
458
|
+
const left = 330;
|
|
459
|
+
const right = 40;
|
|
460
|
+
const top = 96;
|
|
461
|
+
const rowH = 48;
|
|
462
|
+
const laneGap = 10;
|
|
463
|
+
const bottom = 80;
|
|
464
|
+
const height = top + Math.max(1, lanes.length) * (rowH + laneGap) + bottom;
|
|
465
|
+
const x = (ms) => left + ((ms - minMs) / spanMs) * (width - left - right);
|
|
466
|
+
const colors = { chat: '#3b82f6', session: '#16a34a', control: '#dc2626', job: '#9333ea', unknown: '#64748b' };
|
|
467
|
+
|
|
468
|
+
let svg = '';
|
|
469
|
+
svg += `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n`;
|
|
470
|
+
svg += '<style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.small{font-size:12px}.label{font-size:13px;font-weight:600}.muted{fill:#64748b}.title{font-size:22px;font-weight:700}.axis{stroke:#cbd5e1;stroke-width:1}.laneBg{fill:#f8fafc}.wait{fill:#f59e0b}.handler{opacity:.9}.outline{stroke:#0f172a;stroke-width:.5;opacity:.25}</style>\n';
|
|
471
|
+
svg += '<rect width="100%" height="100%" fill="#ffffff"/>\n';
|
|
472
|
+
svg += '<text class="title" x="24" y="36">Adapter Lane Flame Graph</text>\n';
|
|
473
|
+
svg += `<text class="small muted" x="24" y="60">${esc(iso(minMs))} -> ${esc(iso(maxMs))} | ${spans.length} completed spans | ${lanes.length} lanes | source: ${esc(sourceLog)}</text>\n`;
|
|
474
|
+
|
|
475
|
+
const tickCount = 8;
|
|
476
|
+
for (let i = 0; i <= tickCount; i += 1) {
|
|
477
|
+
const ms = minMs + (spanMs * i / tickCount);
|
|
478
|
+
const tx = x(ms);
|
|
479
|
+
svg += `<line class="axis" x1="${tx.toFixed(1)}" y1="${top - 28}" x2="${tx.toFixed(1)}" y2="${height - bottom + 10}" opacity="${i === 0 || i === tickCount ? 0.65 : 0.28}"/>\n`;
|
|
480
|
+
svg += `<text class="small muted" text-anchor="middle" x="${tx.toFixed(1)}" y="${top - 36}">${esc(new Date(ms).toISOString().slice(11, 19))}</text>\n`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
lanes.forEach((lane, idx) => {
|
|
484
|
+
const y = top + idx * (rowH + laneGap);
|
|
485
|
+
const laneSpans = spans.filter((span) => span.lane === lane).sort((a, b) => a.scheduled_at_ms - b.scheduled_at_ms);
|
|
486
|
+
svg += `<rect class="laneBg" x="16" y="${y - 6}" width="${width - 32}" height="${rowH + 10}" rx="6"/>\n`;
|
|
487
|
+
svg += `<text class="label" x="24" y="${y + 15}">${esc(short(lane, 44))}</text>\n`;
|
|
488
|
+
svg += `<text class="small muted" x="24" y="${y + 33}">${laneSpans.length} span${laneSpans.length === 1 ? '' : 's'}</text>\n`;
|
|
489
|
+
|
|
490
|
+
for (const span of laneSpans) {
|
|
491
|
+
const sy = y + 9;
|
|
492
|
+
const scheduled = span.scheduled_at_ms;
|
|
493
|
+
const started = span.started_at_ms || scheduled;
|
|
494
|
+
const finished = span.finished_at_ms || started;
|
|
495
|
+
const sx = x(scheduled);
|
|
496
|
+
const startX = x(started);
|
|
497
|
+
const fx = x(finished);
|
|
498
|
+
const waitWidth = Math.max(1, startX - sx);
|
|
499
|
+
const runWidth = Math.max(2, fx - startX);
|
|
500
|
+
const fill = colors[span.lane_kind] || colors.unknown;
|
|
501
|
+
if (startX > sx + 1) {
|
|
502
|
+
svg += `<rect class="wait outline" x="${sx.toFixed(1)}" y="${sy}" width="${waitWidth.toFixed(1)}" height="20" rx="3"/>\n`;
|
|
503
|
+
}
|
|
504
|
+
svg += `<rect class="handler outline" x="${startX.toFixed(1)}" y="${sy}" width="${runWidth.toFixed(1)}" height="20" rx="3" fill="${fill}"/>\n`;
|
|
505
|
+
const label = short(`${span.category || ''} ${span.text || span.message_id || ''}`, 34);
|
|
506
|
+
if (runWidth > 70) {
|
|
507
|
+
svg += `<text class="small" x="${(startX + 5).toFixed(1)}" y="${sy + 14}" fill="#ffffff">${esc(label)}</text>\n`;
|
|
508
|
+
}
|
|
509
|
+
if (span.blocked_by_span_id) {
|
|
510
|
+
svg += `<circle cx="${sx.toFixed(1)}" cy="${sy + 10}" r="4" fill="#ef4444"><title>${esc(`blocked by ${span.blocked_by_span_id}`)}</title></circle>\n`;
|
|
511
|
+
}
|
|
512
|
+
svg += `<title>${esc(`span=${span.span_id}\nlane=${span.lane}\nwait=${fmtMs((span.started_at_ms || span.scheduled_at_ms) - span.scheduled_at_ms)} run=${fmtMs((span.finished_at_ms || span.started_at_ms || span.scheduled_at_ms) - (span.started_at_ms || span.scheduled_at_ms))}\nblocked_by=${span.blocked_by_span_id || ''}`)}</title>\n`;
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
svg += `<g transform="translate(24 ${height - 42})"><rect x="0" y="-12" width="16" height="10" fill="#f59e0b"/><text class="small muted" x="22" y="-3">lane wait</text><rect x="110" y="-12" width="16" height="10" fill="#3b82f6"/><text class="small muted" x="132" y="-3">chat</text><rect x="194" y="-12" width="16" height="10" fill="#16a34a"/><text class="small muted" x="216" y="-3">session</text><rect x="294" y="-12" width="16" height="10" fill="#dc2626"/><text class="small muted" x="316" y="-3">control</text><rect x="394" y="-12" width="16" height="10" fill="#9333ea"/><text class="small muted" x="416" y="-3">job</text><circle cx="502" cy="-7" r="4" fill="#ef4444"/><text class="small muted" x="514" y="-3">blocked-by link recorded</text></g>\n`;
|
|
517
|
+
svg += '</svg>\n';
|
|
518
|
+
return svg;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function writeHtml(outPath, summary, flamegraphSvg) {
|
|
522
|
+
const topOperation = summary.feishu.operationRows[0] || null;
|
|
523
|
+
const slowestRequest = summary.feishu.slowRequests[0] || null;
|
|
524
|
+
const topOverlap = summary.feishu.adapterOverlap[0] || null;
|
|
525
|
+
const html = `<!doctype html>
|
|
526
|
+
<html lang="en">
|
|
527
|
+
<head>
|
|
528
|
+
<meta charset="utf-8">
|
|
529
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
530
|
+
<title>CodeLark Bridge Log Analysis</title>
|
|
531
|
+
<style>
|
|
532
|
+
:root {
|
|
533
|
+
color-scheme: light;
|
|
534
|
+
--bg: #f6f7f9;
|
|
535
|
+
--panel: #ffffff;
|
|
536
|
+
--text: #151922;
|
|
537
|
+
--muted: #647084;
|
|
538
|
+
--line: #d8dee8;
|
|
539
|
+
--accent: #0f766e;
|
|
540
|
+
--warn: #b45309;
|
|
541
|
+
--bad: #b91c1c;
|
|
542
|
+
--good: #15803d;
|
|
543
|
+
}
|
|
544
|
+
* { box-sizing: border-box; }
|
|
545
|
+
body {
|
|
546
|
+
margin: 0;
|
|
547
|
+
background: var(--bg);
|
|
548
|
+
color: var(--text);
|
|
549
|
+
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
550
|
+
}
|
|
551
|
+
header {
|
|
552
|
+
padding: 24px 28px 16px;
|
|
553
|
+
border-bottom: 1px solid var(--line);
|
|
554
|
+
background: var(--panel);
|
|
555
|
+
position: sticky;
|
|
556
|
+
top: 0;
|
|
557
|
+
z-index: 2;
|
|
558
|
+
}
|
|
559
|
+
h1 { margin: 0 0 6px; font-size: 22px; line-height: 1.2; letter-spacing: 0; }
|
|
560
|
+
h2 { margin: 0 0 12px; font-size: 16px; letter-spacing: 0; }
|
|
561
|
+
main { padding: 20px 28px 36px; max-width: 1480px; margin: 0 auto; }
|
|
562
|
+
section {
|
|
563
|
+
background: var(--panel);
|
|
564
|
+
border: 1px solid var(--line);
|
|
565
|
+
border-radius: 8px;
|
|
566
|
+
margin: 0 0 18px;
|
|
567
|
+
padding: 16px;
|
|
568
|
+
overflow: hidden;
|
|
569
|
+
}
|
|
570
|
+
.muted { color: var(--muted); }
|
|
571
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-top: 16px; }
|
|
572
|
+
.metric { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fbfcfe; }
|
|
573
|
+
.metric .label { color: var(--muted); font-size: 12px; }
|
|
574
|
+
.metric .value { margin-top: 4px; font-size: 20px; font-weight: 700; }
|
|
575
|
+
.metric .detail { margin-top: 4px; color: var(--muted); font-size: 12px; word-break: break-word; }
|
|
576
|
+
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
|
|
577
|
+
table { width: 100%; border-collapse: collapse; min-width: 780px; }
|
|
578
|
+
th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
|
|
579
|
+
th { background: #eef2f7; color: #334155; font-size: 12px; position: sticky; top: 0; z-index: 1; }
|
|
580
|
+
.num, td.num, th.num { text-align: right; white-space: nowrap; }
|
|
581
|
+
tr:last-child td { border-bottom: 0; }
|
|
582
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
|
583
|
+
.scope { max-width: 560px; word-break: break-all; color: var(--muted); }
|
|
584
|
+
.status-timeout, .status-error { color: var(--bad); font-weight: 700; }
|
|
585
|
+
.status-success { color: var(--good); }
|
|
586
|
+
.flamegraph { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
587
|
+
.flamegraph svg { display: block; min-width: 1120px; }
|
|
588
|
+
.note { border-left: 4px solid var(--accent); padding: 10px 12px; background: #ecfdf5; margin: 12px 0 0; }
|
|
589
|
+
@media (max-width: 720px) {
|
|
590
|
+
header, main { padding-left: 14px; padding-right: 14px; }
|
|
591
|
+
section { padding: 12px; }
|
|
592
|
+
table { min-width: 680px; }
|
|
593
|
+
}
|
|
594
|
+
</style>
|
|
595
|
+
</head>
|
|
596
|
+
<body>
|
|
597
|
+
<header>
|
|
598
|
+
<h1>CodeLark Bridge Log Analysis</h1>
|
|
599
|
+
<div class="muted">${htmlEsc(summary.window.start)} -> ${htmlEsc(summary.window.end)} (${htmlEsc(fmtMs(summary.window.duration_ms))})</div>
|
|
600
|
+
<div class="muted">Source: <code>${htmlEsc(summary.source_log)}</code></div>
|
|
601
|
+
</header>
|
|
602
|
+
<main>
|
|
603
|
+
<section>
|
|
604
|
+
<h2>Summary</h2>
|
|
605
|
+
<div class="grid">
|
|
606
|
+
${metric('Feishu requests', String(summary.feishu.count), `max concurrent ${summary.feishu.concurrency.max_active}, avg ${summary.feishu.concurrency.avg_active.toFixed(2)}`)}
|
|
607
|
+
${metric('Adapter spans', String(summary.adapter.completed_span_count), `${summary.adapter.pending_span_count} pending, ${summary.adapter.event_count} events`)}
|
|
608
|
+
${metric('Top Feishu operation', topOperation ? topOperation.key : 'n/a', topOperation ? `${fmtMs(topOperation.total_ms)} total, avg ${fmtMs(topOperation.avg_ms)}` : '')}
|
|
609
|
+
${metric('Slowest request', slowestRequest ? slowestRequest.operation : 'n/a', slowestRequest ? `${fmtMs(slowestRequest.duration_ms)} ${slowestRequest.status || ''}` : '')}
|
|
610
|
+
${metric('Top matched overlap', topOverlap ? fmtMs(topOverlap.feishu_overlap_ms) : 'n/a', topOverlap ? `${topOverlap.text || topOverlap.message_id || ''}` : '')}
|
|
611
|
+
</div>
|
|
612
|
+
<div class="note">
|
|
613
|
+
Matched Feishu overlap only counts requests with the same chat/scope/message relationship as the adapter span.
|
|
614
|
+
Global overlap is shown separately so unrelated background API concurrency is visible without being treated as causality.
|
|
615
|
+
</div>
|
|
616
|
+
</section>
|
|
617
|
+
|
|
618
|
+
<section>
|
|
619
|
+
<h2>Adapter Lane Flame Graph</h2>
|
|
620
|
+
<div class="flamegraph">${flamegraphSvg}</div>
|
|
621
|
+
</section>
|
|
622
|
+
|
|
623
|
+
${sectionTable('Feishu Operations', ['Operation', 'Count', 'Total', 'Avg', 'P90', 'Max', 'Statuses'], summary.feishu.operationRows.slice(0, 20).map((row) => [
|
|
624
|
+
code(row.key),
|
|
625
|
+
num(row.count),
|
|
626
|
+
num(fmtMs(row.total_ms)),
|
|
627
|
+
num(fmtMs(row.avg_ms)),
|
|
628
|
+
num(fmtMs(row.p90_ms)),
|
|
629
|
+
num(fmtMs(row.max_ms)),
|
|
630
|
+
htmlEsc(JSON.stringify(row.statuses)),
|
|
631
|
+
]))}
|
|
632
|
+
|
|
633
|
+
${sectionTable('Slow Feishu Requests', ['End time', 'Duration', 'Status', 'Operation', 'Scope'], summary.feishu.slowRequests.slice(0, 24).map((request) => [
|
|
634
|
+
htmlEsc(request.time),
|
|
635
|
+
num(fmtMs(request.duration_ms)),
|
|
636
|
+
status(request.status),
|
|
637
|
+
code(request.operation),
|
|
638
|
+
`<div class="scope">${htmlEsc(short(request.scope || request.chat || request.stream_key, 140))}</div>`,
|
|
639
|
+
]))}
|
|
640
|
+
|
|
641
|
+
${sectionTable('Adapter Spans With Feishu Overlap', ['Matched Feishu', 'Global Feishu', 'Handler', 'Wait', 'Lane', 'Text', 'Top matched requests'], summary.feishu.adapterOverlap.slice(0, 16).map((span) => [
|
|
642
|
+
num(fmtMs(span.feishu_overlap_ms)),
|
|
643
|
+
num(fmtMs(span.global_feishu_overlap_ms)),
|
|
644
|
+
num(fmtMs(span.handler_ms)),
|
|
645
|
+
num(fmtMs(span.lane_wait_ms)),
|
|
646
|
+
code(short(span.lane, 64)),
|
|
647
|
+
htmlEsc(short(span.text || span.message_id || '', 120)),
|
|
648
|
+
span.feishu_requests.slice(0, 4).map((request) => `<div>${code(request.operation)} ${htmlEsc(fmtMs(request.duration_ms))} overlap ${htmlEsc(fmtMs(request.overlap_ms))}</div>`).join(''),
|
|
649
|
+
]))}
|
|
650
|
+
|
|
651
|
+
${sectionTable('Adapter Lanes', ['Lane', 'Spans', 'Total', 'Max', 'Wait'], summary.adapter.lanes.slice(0, 16).map((row) => [
|
|
652
|
+
code(row.lane),
|
|
653
|
+
num(row.count),
|
|
654
|
+
num(fmtMs(row.total_ms)),
|
|
655
|
+
num(fmtMs(row.max_ms)),
|
|
656
|
+
num(fmtMs(row.wait_ms)),
|
|
657
|
+
]))}
|
|
658
|
+
|
|
659
|
+
${sectionTable('Blocking Chains', ['Wait', 'Lane', 'Message', 'Blocked by', 'Category', 'Age'], summary.adapter.blockers.slice(0, 20).map((blocker) => [
|
|
660
|
+
num(fmtMs(blocker.lane_wait_ms)),
|
|
661
|
+
code(short(blocker.lane, 64)),
|
|
662
|
+
htmlEsc(short(blocker.text || blocker.message_id || '', 120)),
|
|
663
|
+
code(blocker.blocked_by_message_id || blocker.blocked_by_span_id || 'previous lane tail'),
|
|
664
|
+
htmlEsc(blocker.blocked_by_category || 'unknown'),
|
|
665
|
+
num(fmtMs(blocker.blocked_by_age_ms)),
|
|
666
|
+
]))}
|
|
667
|
+
|
|
668
|
+
${sectionTable('Overall Perf Events', ['Event / operation', 'Count', 'Total', 'Avg', 'P90', 'Max', 'Timeouts', 'Errors'], summary.perfHotspots.slice(0, 24).map((row) => [
|
|
669
|
+
code(row.key),
|
|
670
|
+
num(row.count),
|
|
671
|
+
num(fmtMs(row.total_ms)),
|
|
672
|
+
num(fmtMs(row.avg_ms)),
|
|
673
|
+
num(fmtMs(row.p90_ms)),
|
|
674
|
+
num(fmtMs(row.max_ms)),
|
|
675
|
+
num(row.timeouts),
|
|
676
|
+
num(row.errors),
|
|
677
|
+
]))}
|
|
678
|
+
</main>
|
|
679
|
+
<script type="application/json" id="analysis-data">${htmlEsc(JSON.stringify(summary))}</script>
|
|
680
|
+
</body>
|
|
681
|
+
</html>
|
|
682
|
+
`;
|
|
683
|
+
fs.writeFileSync(outPath, html);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function metric(label, value, detail) {
|
|
687
|
+
return `<div class="metric"><div class="label">${htmlEsc(label)}</div><div class="value">${htmlEsc(value)}</div><div class="detail">${htmlEsc(detail || '')}</div></div>`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function sectionTable(title, headers, rows) {
|
|
691
|
+
return `<section>
|
|
692
|
+
<h2>${htmlEsc(title)}</h2>
|
|
693
|
+
<div class="table-wrap">
|
|
694
|
+
<table>
|
|
695
|
+
<thead><tr>${headers.map((header) => `<th>${htmlEsc(header)}</th>`).join('')}</tr></thead>
|
|
696
|
+
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td${String(cell).startsWith('<span class="num"') ? ' class="num"' : ''}>${cell}</td>`).join('')}</tr>`).join('')}</tbody>
|
|
697
|
+
</table>
|
|
698
|
+
</div>
|
|
699
|
+
</section>`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function num(value) {
|
|
703
|
+
return `<span class="num">${htmlEsc(String(value))}</span>`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function code(value) {
|
|
707
|
+
return `<code>${htmlEsc(value)}</code>`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function status(value) {
|
|
711
|
+
const text = String(value || '');
|
|
712
|
+
const className = text === 'timeout' || text === 'error'
|
|
713
|
+
? 'status-timeout'
|
|
714
|
+
: text === 'success'
|
|
715
|
+
? 'status-success'
|
|
716
|
+
: '';
|
|
717
|
+
return `<span class="${className}">${htmlEsc(text)}</span>`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function htmlEsc(value) {
|
|
721
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
722
|
+
'&': '&',
|
|
723
|
+
'<': '<',
|
|
724
|
+
'>': '>',
|
|
725
|
+
'"': '"',
|
|
726
|
+
"'": ''',
|
|
727
|
+
})[ch]);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function laneSort(a, b) {
|
|
731
|
+
return laneSortKey(a).localeCompare(laneSortKey(b));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function laneSortKey(lane) {
|
|
735
|
+
if (lane.startsWith('control:')) return `0:${lane}`;
|
|
736
|
+
if (lane.startsWith('job:')) return `1:${lane}`;
|
|
737
|
+
if (lane.startsWith('session:')) return `2:${lane}`;
|
|
738
|
+
if (lane.startsWith('chat:')) return `3:${lane}`;
|
|
739
|
+
return `9:${lane}`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function percentile(values, q) {
|
|
743
|
+
if (!values.length) return 0;
|
|
744
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
745
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
|
|
746
|
+
return sorted[idx];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sum(values) {
|
|
750
|
+
return values.reduce((total, value) => total + value, 0);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function short(value, length = 72) {
|
|
754
|
+
const text = String(value || '');
|
|
755
|
+
return text.length > length ? `${text.slice(0, length - 3)}...` : text;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function esc(value) {
|
|
759
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
760
|
+
'&': '&',
|
|
761
|
+
'<': '<',
|
|
762
|
+
'>': '>',
|
|
763
|
+
'"': '"',
|
|
764
|
+
"'": ''',
|
|
765
|
+
})[ch]);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function fmtMs(ms) {
|
|
769
|
+
if (!Number.isFinite(ms)) return 'n/a';
|
|
770
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10000 ? 1 : 2)}s`;
|
|
771
|
+
return `${Math.round(ms)}ms`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function iso(ms) {
|
|
775
|
+
if (!Number.isFinite(ms)) return null;
|
|
776
|
+
return new Date(ms).toISOString().replace('T', ' ').replace('Z', ' UTC');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function main() {
|
|
780
|
+
const options = parseArgs(process.argv.slice(2));
|
|
781
|
+
const { entries, parseErrors } = readJsonl(options.logPath, options.sinceMs);
|
|
782
|
+
if (entries.length === 0) {
|
|
783
|
+
throw new Error(`No log entries found in ${options.logPath}`);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const adapter = buildAdapterSpans(entries);
|
|
787
|
+
const adapterSummary = summarizeAdapterSpans(adapter.spans);
|
|
788
|
+
const feishuRequests = buildFeishuRequests(entries);
|
|
789
|
+
const feishu = summarizeFeishuRequests(feishuRequests, adapterSummary.completed);
|
|
790
|
+
const minMs = Math.min(...entries.map((entry) => entry.__time_ms));
|
|
791
|
+
const maxMs = Math.max(...entries.map((entry) => entry.__time_ms));
|
|
792
|
+
const outDir = path.resolve(options.outDir);
|
|
793
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
794
|
+
|
|
795
|
+
const summary = {
|
|
796
|
+
generated_at: new Date().toISOString(),
|
|
797
|
+
source_log: options.logPath,
|
|
798
|
+
parse_errors: parseErrors,
|
|
799
|
+
window: {
|
|
800
|
+
start: iso(minMs),
|
|
801
|
+
end: iso(maxMs),
|
|
802
|
+
start_ms: minMs,
|
|
803
|
+
end_ms: maxMs,
|
|
804
|
+
duration_ms: maxMs - minMs,
|
|
805
|
+
},
|
|
806
|
+
adapter: {
|
|
807
|
+
event_count: adapter.events.length,
|
|
808
|
+
span_count: adapter.spans.length,
|
|
809
|
+
completed_span_count: adapter.completed.length,
|
|
810
|
+
pending_span_count: adapter.pending.length,
|
|
811
|
+
lanes: adapterSummary.lanes,
|
|
812
|
+
topSpans: adapterSummary.topSpans.slice(0, 30),
|
|
813
|
+
blockers: adapterSummary.blockers.slice(0, 30),
|
|
814
|
+
},
|
|
815
|
+
feishu,
|
|
816
|
+
perfHotspots: summarizePerf(entries).slice(0, 50),
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const htmlPath = path.join(outDir, 'index.html');
|
|
820
|
+
const flamegraphSvg = renderSvg(adapterSummary, options.logPath, summary.window);
|
|
821
|
+
writeHtml(htmlPath, summary, flamegraphSvg);
|
|
822
|
+
|
|
823
|
+
console.log(JSON.stringify({
|
|
824
|
+
outDir,
|
|
825
|
+
html: htmlPath,
|
|
826
|
+
adapterCompletedSpans: summary.adapter.completed_span_count,
|
|
827
|
+
feishuRequests: summary.feishu.count,
|
|
828
|
+
topFeishuOperation: summary.feishu.operationRows[0] || null,
|
|
829
|
+
slowestFeishuRequest: summary.feishu.slowRequests[0] || null,
|
|
830
|
+
}, null, 2));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
main();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
837
|
+
process.exitCode = 1;
|
|
838
|
+
}
|