@aflueza/log-analysis 0.0.0-stage → 0.2.1-rc.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.
@@ -0,0 +1,1093 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const LEVELS = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
3
+ const MAX_SAMPLE_LENGTH = 128_000;
4
+ const timestamp = /(\d{4}-\d{2}-\d{2}[T ][0-9:.+\-]+|\d{2}:\d{2}:\d{2}(?:\.\d+)?)/;
5
+ const level = /^\s*\*?\s*(?:\d{4}-\d{2}-\d{2}[T ][0-9:.+\-]+\s+)?\*?-?(ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL)\b/i;
6
+ const logbackStart = /^\s*\*?\s*\d{4}-\d{2}-\d{2}[T ][0-9:.+\-]+\s+\*?-?(ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL)\b/i;
7
+ const bracketLogbackStart = /^\s*\[(ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL)\]\s*\[\d{4}-\d{2}-\d{2}[T ][0-9:.+\-]+\b/i;
8
+ const bracketLogHeader = /^\s*\[(ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL)\]\s*\[([^\]]+)\]/i;
9
+ const logHeader = /^\s*\*?\s*(\d{4}-\d{2}-\d{2}[T ][0-9:.+\-]+)\s+\*?-?(ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL)\b(?:\s+\d+\s+---\s+\[([^\]]+)\]\s+(.+?)\s*:)?/i;
10
+ const duration = /(?:duration|elapsed|cost|耗时)[=: ]+(\d+(?:\.\d+)?)\s*(ms|s)?/i;
11
+ const service = /(?:service|svc|application)[=: ]+([\w.-]+)/i;
12
+ const status = /\bstatus(?:Code)?[=: ]+(\d{3})\b/i;
13
+ const fieldsToCheck = [
14
+ 'timestamp',
15
+ 'level',
16
+ 'threadName',
17
+ 'loggerName',
18
+ 'sourceLine',
19
+ 'traceId',
20
+ 'spanId',
21
+ 'exceptionType',
22
+ 'service',
23
+ 'message',
24
+ 'mdc',
25
+ ];
26
+ export class LogAnalyzer {
27
+ currentSessionId = randomUUID();
28
+ entries = [];
29
+ files = [];
30
+ cancelled = false;
31
+ activeQuery = {};
32
+ listeners = new Set();
33
+ samples = new Map();
34
+ sampleRecords = new Map();
35
+ selectedFormat = 'unknown';
36
+ activeRule = null;
37
+ activeRuleId = null;
38
+ suggestedRule;
39
+ validatedRuleVersion;
40
+ sampleVersion = 1;
41
+ sampleFileId = '';
42
+ sampleSource = 'default';
43
+ sampleLevel;
44
+ chunkFiles = new Map();
45
+ sampleChunks = new Map();
46
+ get sessionId() {
47
+ return this.currentSessionId;
48
+ }
49
+ setQuery(query) {
50
+ if (query.keyword !== undefined) {
51
+ try {
52
+ new RegExp(query.keyword, 'i');
53
+ }
54
+ catch {
55
+ throw new Error('关键词正则无效');
56
+ }
57
+ }
58
+ this.activeQuery = { ...query };
59
+ }
60
+ start(files, samples = [], query = {}) {
61
+ if (!files.length)
62
+ throw new Error('至少选择一个日志文件');
63
+ if (query.keyword !== undefined) {
64
+ try {
65
+ new RegExp(query.keyword, 'i');
66
+ }
67
+ catch {
68
+ throw new Error('关键词正则无效');
69
+ }
70
+ }
71
+ this.currentSessionId = randomUUID();
72
+ this.entries = [];
73
+ this.cancelled = false;
74
+ this.setQuery(query);
75
+ this.activeRule = null;
76
+ this.activeRuleId = null;
77
+ this.suggestedRule = undefined;
78
+ this.validatedRuleVersion = undefined;
79
+ this.samples = new Map();
80
+ this.sampleRecords.clear();
81
+ for (const sample of samples) {
82
+ this.samples.set(sample.fileId, limitSampleToCompleteRecords(sample.content));
83
+ }
84
+ this.files = files.map((input) => ({
85
+ id: input.id,
86
+ name: input.name,
87
+ size: input.size,
88
+ status: 'pending',
89
+ count: 0,
90
+ errors: 0,
91
+ }));
92
+ this.sampleVersion = 1;
93
+ this.sampleFileId = files[0]?.id ?? '';
94
+ this.sampleSource = 'default';
95
+ this.sampleLevel = undefined;
96
+ this.selectedFormat = 'unknown';
97
+ const sample = this.samples.has(this.sampleFileId)
98
+ ? this.sampleContext(this.sampleFileId, this.sampleSource, this.sampleLevel)
99
+ : {
100
+ fileId: this.sampleFileId,
101
+ fileName: files[0]?.name ?? '',
102
+ content: '',
103
+ sampleVersion: this.sampleVersion,
104
+ source: this.sampleSource,
105
+ records: [],
106
+ };
107
+ this.sampleRecords.set(this.sampleFileId, sample.records);
108
+ this.emit();
109
+ return { summary: this.summary(), sample };
110
+ }
111
+ openSampleChunk(fileId, level) {
112
+ const file = this.files.find((item) => item.id === fileId);
113
+ if (!file)
114
+ throw new Error('样例文件不存在');
115
+ this.sampleChunks.set(fileId, {
116
+ fileId,
117
+ offset: 0,
118
+ line: 0,
119
+ pending: '',
120
+ pendingLine: 1,
121
+ level,
122
+ records: [],
123
+ });
124
+ return { fileId, offset: 0, found: false, needMore: true };
125
+ }
126
+ appendSampleChunk(fileId, offset, content, eof = false, byteLength) {
127
+ const state = this.sampleChunks.get(fileId);
128
+ if (!state)
129
+ throw new Error('样例分块会话不存在');
130
+ if (offset !== state.offset)
131
+ throw new Error(`样例分块偏移无效,期望 ${state.offset},收到 ${offset}`);
132
+ const parts = (state.pending + content.replace(/\r\n?/g, '\n')).split('\n');
133
+ state.pending = eof ? '' : (parts.pop() ?? '');
134
+ const commit = (raw, line) => {
135
+ if (!raw)
136
+ return;
137
+ const parsed = parseRecord(raw, this.files.find((item) => item.id === fileId)?.name ?? '', line, fileId, null);
138
+ const wanted = state.level === undefined || parsed.level === state.level;
139
+ if (wanted && !state.found)
140
+ state.found = raw.slice(0, MAX_SAMPLE_LENGTH);
141
+ if (state.level === undefined && state.records.join('\n').length < MAX_SAMPLE_LENGTH)
142
+ state.records.push(raw);
143
+ };
144
+ for (const rawLine of parts) {
145
+ state.line += 1;
146
+ if (isLogRecordStart(rawLine)) {
147
+ if (state.pending)
148
+ commit(state.pending, state.pendingLine);
149
+ state.pending = rawLine;
150
+ state.pendingLine = state.line;
151
+ }
152
+ else if (state.pending)
153
+ state.pending += `\n${rawLine}`;
154
+ }
155
+ if (eof && state.pending) {
156
+ commit(state.pending, state.pendingLine);
157
+ state.pending = '';
158
+ }
159
+ state.offset += byteLength ?? Buffer.byteLength(content, 'utf8');
160
+ if (eof && !state.found && state.level === undefined && state.records.length)
161
+ state.found = state.records.join('\n');
162
+ const record = state.found;
163
+ const done = Boolean(record) || eof;
164
+ if (done)
165
+ this.sampleChunks.delete(fileId);
166
+ return {
167
+ fileId,
168
+ offset: state.offset,
169
+ found: Boolean(record),
170
+ needMore: !done,
171
+ ...(record ? { record, level: state.level } : {}),
172
+ };
173
+ }
174
+ finishSampleChunk(fileId) {
175
+ const state = this.sampleChunks.get(fileId);
176
+ if (!state)
177
+ throw new Error('样例分块会话不存在');
178
+ return this.appendSampleChunk(fileId, state.offset, '', true);
179
+ }
180
+ cancelSampleChunk(fileId) {
181
+ this.sampleChunks.delete(fileId);
182
+ }
183
+ setSample(fileId, content, source, levelValue) {
184
+ const file = this.files.find((item) => item.id === fileId);
185
+ if (!file)
186
+ throw new Error('样例文件不存在');
187
+ if (!content.trim())
188
+ throw new Error('样例内容不能为空');
189
+ const bounded = limitSampleToCompleteRecords(content);
190
+ const candidateRecords = splitRecords(bounded, null);
191
+ const candidateEntries = candidateRecords.map((record) => parseRecord(record.raw, file.name, record.line, file.id, null));
192
+ const matchingIndex = source === 'level' && levelValue
193
+ ? candidateEntries.findIndex((entry) => entry.level === levelValue)
194
+ : 0;
195
+ const selectedRecord = matchingIndex >= 0 ? candidateRecords[matchingIndex] : undefined;
196
+ const selectedContent = source === 'level' ? (selectedRecord?.raw ?? '') : bounded;
197
+ this.sampleFileId = fileId;
198
+ this.samples.set(fileId, selectedContent);
199
+ this.sampleVersion += 1;
200
+ this.sampleSource = source;
201
+ this.sampleLevel = levelValue;
202
+ this.suggestedRule = undefined;
203
+ this.validatedRuleVersion = undefined;
204
+ this.activeRule = null;
205
+ this.activeRuleId = null;
206
+ const sample = selectedContent
207
+ ? this.sampleContext(fileId, source, levelValue)
208
+ : {
209
+ fileId,
210
+ fileName: file.name,
211
+ content: '',
212
+ sampleVersion: this.sampleVersion,
213
+ source,
214
+ level: levelValue,
215
+ records: [],
216
+ };
217
+ this.sampleRecords.set(fileId, sample.records);
218
+ this.emit();
219
+ return sample;
220
+ }
221
+ previewWithRule(rule, sampleContent, fileId = this.sampleFileId, fileName) {
222
+ const file = this.files.find((item) => item.id === fileId) ??
223
+ (fileName
224
+ ? {
225
+ id: fileId,
226
+ name: fileName,
227
+ size: Buffer.byteLength(sampleContent),
228
+ status: 'done',
229
+ count: 0,
230
+ errors: 0,
231
+ }
232
+ : undefined);
233
+ if (!file)
234
+ throw new Error('没有可用于格式识别的日志样例');
235
+ const records = splitRecords(sampleContent, rule);
236
+ const parsed = records.map((record) => parseRecord(record.raw, file.name, record.line, file.id, rule));
237
+ const first = parsed[0] ?? null;
238
+ const unknownFields = first
239
+ ? fieldsToCheck.filter((field) => !hasField(first, field))
240
+ : [...fieldsToCheck];
241
+ const multilineRecords = parsed.filter((entry) => entry.raw.includes('\n')).length;
242
+ const totalLines = sampleContent.split(/\r?\n/).filter(Boolean).length;
243
+ const matchedLines = parsed.reduce((sum, entry) => {
244
+ const firstLine = entry.raw.split(/\r?\n/, 1)[0] ?? '';
245
+ return (sum + (matchesPattern(rule.recordStart, firstLine) ? entry.raw.split(/\r?\n/).length : 0));
246
+ }, 0);
247
+ const fieldCoverage = parsed.length
248
+ ? parsed.reduce((sum, entry) => sum + fieldsToCheck.filter((field) => hasField(entry, field)).length, 0) /
249
+ (parsed.length * fieldsToCheck.length)
250
+ : 0;
251
+ return {
252
+ sessionId: this.currentSessionId,
253
+ sampleVersion: this.sampleVersion,
254
+ fileId: file.id,
255
+ fileName: file.name,
256
+ sampleSource: this.sampleSource,
257
+ sampleLevel: this.sampleLevel,
258
+ sampleRaw: sampleContent,
259
+ format: rule.format,
260
+ confidence: parsed.length && unknownFields.length < 4 ? 'high' : 'medium',
261
+ firstLine: first?.raw.split(/\r?\n/, 1)[0] ?? '',
262
+ firstRecord: first,
263
+ records: parsed,
264
+ sampleRecords: sampleRecordsFrom(records, parsed),
265
+ unknownFields,
266
+ matchedRecords: parsed.length,
267
+ multilineRecords,
268
+ unmatchedLines: Math.max(0, totalLines - matchedLines),
269
+ fieldCoverage,
270
+ rule,
271
+ validationErrors: [],
272
+ executionErrors: [],
273
+ };
274
+ }
275
+ previewParserRule(rule, fileId = this.sampleFileId) {
276
+ const sample = this.samples.get(fileId);
277
+ if (!sample)
278
+ throw new Error('分析会话中没有可供解析的日志样例');
279
+ return this.previewWithRule(rule, sample, fileId);
280
+ }
281
+ previewRuleOnSample(ruleValue, sampleContent, fileName) {
282
+ if (!sampleContent.trim())
283
+ throw new Error('样例内容不能为空');
284
+ const rule = validateParserRule(ruleValue);
285
+ const fileId = `rule-validation:${Date.now()}`;
286
+ return this.previewWithRule(rule, sampleContent, fileId, fileName || 'sample.log');
287
+ }
288
+ sampleContext(fileId, source, levelValue) {
289
+ const file = this.files.find((item) => item.id === fileId);
290
+ if (!file)
291
+ throw new Error('样例文件不存在');
292
+ const content = this.samples.get(fileId);
293
+ if (!content)
294
+ throw new Error('样例内容不存在');
295
+ const records = splitRecords(content, null);
296
+ const parsed = records.map((record) => parseRecord(record.raw, file.name, record.line, file.id, null));
297
+ return {
298
+ fileId,
299
+ fileName: file.name,
300
+ content,
301
+ sampleVersion: this.sampleVersion,
302
+ source,
303
+ level: levelValue,
304
+ records: sampleRecordsFrom(records, parsed),
305
+ };
306
+ }
307
+ async suggestParser(provider, model, stream, fileId = this.sampleFileId, sampleVersion = this.sampleVersion, signal, logModelOutput, externalSample) {
308
+ if (!externalSample && sampleVersion !== this.sampleVersion)
309
+ throw new Error('样例已变化,请重新识别');
310
+ const sample = externalSample?.content ?? this.samples.get(fileId);
311
+ if (!sample)
312
+ throw new Error('分析会话中没有可供智能解析的日志样例');
313
+ const prompt = [
314
+ '你是日志格式解析器设计助手。请仅返回一个 JSON 对象,不要 Markdown、代码围栏、解释、数组或已解析日志数据。',
315
+ '根据日志样例输出版本化声明式规则,不得输出可执行代码。字段正则的首个捕获组必须是字段值;可选字段可省略。',
316
+ '所有 pattern 必须是可直接传给 JavaScript new RegExp(pattern, "m") 的正则正文,不要附加斜杠包围符或内联模式标记(如 (?m));多行模式由程序设置。JSON 字符串中反斜杠只按 JSON 规则转义一次,例如正则 \\s 在 JSON 中写作 \\\\s。',
317
+ '结构:{"version":1,"format":"名称","recordStart":"^正则","fields":{"timestamp":{"pattern":"正则"},"level":{"pattern":"正则"},"threadName":{"pattern":"正则"},"loggerName":{"pattern":"正则"},"sourceLine":{"pattern":"正则"},"traceId":{"pattern":"正则"},"spanId":{"pattern":"正则"},"exceptionType":{"pattern":"正则"},"service":{"pattern":"正则"},"message":{"pattern":"正则"},"mdc":{"pattern":"正则"}},"continuation":"until-next-record"}',
318
+ '只允许字段 timestamp、level、threadName、loggerName、sourceLine、traceId、spanId、exceptionType、service、message、mdc。recordStart 必须从行首开始,并使用足以区分一条完整日志记录的非空特征;禁止使用 ^、^\\s* 或仅匹配任意日期前缀的宽泛规则。为兼容安全校验,recordStart 不得使用前瞻/后顾、反向引用或带嵌套重复量词的分组;优先使用明确的时间戳与级别/分隔符、普通非捕获组和字符类。JSON 日志匹配对象记录的起始结构。每个 pattern 的首个捕获组必须是字段值;message 必须存在。',
319
+ '日志记录可能跨越多行。recordStart 只匹配每条记录的首行,下一条 recordStart 之前的所有行属于当前记录。',
320
+ '常见 Logback 首行包括“2026-10-15 17:00:40.117 ERROR [SYSTEM] logger - message”和“2026-10-13 00:56:26.507 ERROR 8428 --- [thread] logger : message”。这两种格式都必须把时间戳与级别作为 recordStart 的边界;[SYSTEM]、线程、logger 和分隔符属于首行上下文,不要要求 message 必须在同一行结束。',
321
+ '如果 message 中包含 SQL、括号、方括号或换行,message pattern 只需从首行消息开始捕获到行尾;不要把 SQL 正文、异常堆栈或括号内容写入 recordStart。',
322
+ '日志样例(可能包含敏感信息,请只推断格式,不要复述样例内容):',
323
+ truncateSampleLines(sample, 1_000).slice(0, 12_000),
324
+ ].join('\n\n');
325
+ let output = '';
326
+ for await (const chunk of stream(provider, model, prompt)) {
327
+ if (signal?.aborted)
328
+ throw new Error('日志格式识别已取消');
329
+ if (typeof chunk === 'object' &&
330
+ chunk !== null &&
331
+ chunk.type === 'text-delta') {
332
+ const text = chunk.text;
333
+ if (typeof text === 'string')
334
+ output += text;
335
+ }
336
+ }
337
+ if (signal?.aborted)
338
+ throw new Error('日志格式识别已取消');
339
+ logModelOutput?.('raw', output);
340
+ const candidate = parseModelRuleJson(output);
341
+ logModelOutput?.('formatted-json', JSON.stringify(candidate, null, 2));
342
+ const rule = validateParserRule(candidate);
343
+ const preview = this.previewWithRule(rule, sample, fileId, externalSample?.fileName);
344
+ const parsed = preview.records;
345
+ const firstRecord = preview.firstRecord;
346
+ const known = parsed.filter((entry) => entry.level !== 'UNKNOWN' || entry.timestamp !== undefined).length;
347
+ const lineCount = sample.split(/\r?\n/).filter(Boolean).length;
348
+ const coveredLines = parsed.reduce((sum, entry) => {
349
+ const firstLine = entry.raw.split(/\r?\n/, 1)[0] ?? '';
350
+ return (sum + (matchesPattern(rule.recordStart, firstLine) ? entry.raw.split(/\r?\n/).length : 0));
351
+ }, 0);
352
+ const fieldCoverage = parsed.length
353
+ ? parsed.reduce((sum, entry) => sum + fieldsToCheck.filter((field) => hasField(entry, field)).length, 0) /
354
+ (parsed.length * fieldsToCheck.length)
355
+ : 0;
356
+ const validationErrors = parsed.length === 0
357
+ ? ['样例中没有找到记录']
358
+ : known === 0
359
+ ? ['规则无法识别样例中的时间或日志级别']
360
+ : [];
361
+ const executionErrors = firstRecord === null ? ['规则执行后没有可展示的首条记录'] : [];
362
+ const result = {
363
+ ...preview,
364
+ format: rule.format,
365
+ confidence: validationErrors.length ? 'low' : known / parsed.length > 0.8 ? 'high' : 'medium',
366
+ firstRecord,
367
+ records: parsed,
368
+ unknownFields: firstRecord
369
+ ? fieldsToCheck.filter((field) => !hasField(firstRecord, field))
370
+ : [...fieldsToCheck],
371
+ multilineRecords: parsed.filter((entry) => entry.raw.includes('\n')).length,
372
+ unmatchedLines: Math.max(0, lineCount - coveredLines),
373
+ fieldCoverage,
374
+ rule,
375
+ validationErrors,
376
+ executionErrors,
377
+ };
378
+ if (!externalSample && validationErrors.length === 0 && executionErrors.length === 0) {
379
+ this.suggestedRule = rule;
380
+ this.validatedRuleVersion = sampleVersion;
381
+ this.sampleRecords.set(fileId, result.sampleRecords);
382
+ }
383
+ else if (!externalSample) {
384
+ this.suggestedRule = undefined;
385
+ this.validatedRuleVersion = undefined;
386
+ }
387
+ return result;
388
+ }
389
+ async analyzeFile(fileId, content, ruleId, rule) {
390
+ const file = this.files.find((item) => item.id === fileId);
391
+ if (!file || file.status !== 'pending')
392
+ throw new Error(`未知或已开始解析的文件: ${fileId}`);
393
+ if (this.cancelled)
394
+ throw new Error('分析已取消');
395
+ if (!ruleId)
396
+ throw new Error('请先选择日志解析规则');
397
+ this.activeRuleId = ruleId;
398
+ this.activeRule = rule;
399
+ this.selectedFormat = rule.format;
400
+ file.status = 'processing';
401
+ this.emit();
402
+ try {
403
+ for (const record of splitRecords(content, this.activeRule))
404
+ this.addRecord(file, record.raw, record.line);
405
+ if (this.cancelled) {
406
+ file.status = 'failed';
407
+ file.error = '已取消';
408
+ }
409
+ else {
410
+ file.status = 'done';
411
+ }
412
+ }
413
+ catch (error) {
414
+ file.status = 'failed';
415
+ file.error = error instanceof Error ? error.message : String(error);
416
+ }
417
+ this.emit();
418
+ return this.summary();
419
+ }
420
+ openFileChunk(fileId, ruleId, rule) {
421
+ const file = this.files.find((item) => item.id === fileId);
422
+ if (!file)
423
+ throw new Error('未知日志文件');
424
+ if (!ruleId)
425
+ throw new Error('请先选择日志解析规则');
426
+ this.activeRuleId = ruleId;
427
+ this.activeRule = rule;
428
+ this.selectedFormat = rule.format;
429
+ file.status = 'processing';
430
+ this.chunkFiles.set(fileId, {
431
+ file,
432
+ offset: 0,
433
+ line: 0,
434
+ pending: '',
435
+ pendingLine: 1,
436
+ records: 0,
437
+ });
438
+ this.emit();
439
+ return { fileId, offset: 0, processedLines: 0, processedRecords: 0, status: 'processing' };
440
+ }
441
+ appendFileChunk(fileId, offset, content, eof = false, byteLength) {
442
+ const state = this.chunkFiles.get(fileId);
443
+ if (!state)
444
+ throw new Error('文件分块会话不存在');
445
+ if (offset !== state.offset)
446
+ throw new Error(`文件分块偏移无效,期望 ${state.offset},收到 ${offset}`);
447
+ const normalized = state.pending + content.replace(/\r\n?/g, '\n');
448
+ const parts = normalized.split('\n');
449
+ state.pending = eof ? '' : (parts.pop() ?? '');
450
+ for (const rawLine of parts) {
451
+ state.line += 1;
452
+ const start = this.activeRule
453
+ ? matchesPattern(this.activeRule.recordStart, rawLine)
454
+ : isLogRecordStart(rawLine);
455
+ if (start) {
456
+ if (state.pending) {
457
+ this.addRecord(state.file, state.pending, state.pendingLine);
458
+ state.records += 1;
459
+ }
460
+ state.pending = rawLine;
461
+ state.pendingLine = state.line;
462
+ }
463
+ else if (state.pending) {
464
+ state.pending += `\n${rawLine}`;
465
+ }
466
+ }
467
+ if (eof && state.pending) {
468
+ this.addRecord(state.file, state.pending, state.pendingLine);
469
+ state.records += 1;
470
+ state.pending = '';
471
+ }
472
+ state.offset += byteLength ?? Buffer.byteLength(content, 'utf8');
473
+ if (eof)
474
+ state.file.status = 'done';
475
+ this.emit();
476
+ return {
477
+ fileId,
478
+ offset: state.offset,
479
+ processedLines: state.line,
480
+ processedRecords: state.records,
481
+ status: eof ? 'done' : 'processing',
482
+ };
483
+ }
484
+ finishFileChunk(fileId) {
485
+ const state = this.chunkFiles.get(fileId);
486
+ if (!state)
487
+ throw new Error('文件分块会话不存在');
488
+ return this.appendFileChunk(fileId, state.offset, '', true, 0);
489
+ }
490
+ cancelFileChunk(fileId) {
491
+ const state = this.chunkFiles.get(fileId);
492
+ if (state) {
493
+ state.file.status = 'failed';
494
+ state.file.error = '已取消';
495
+ this.chunkFiles.delete(fileId);
496
+ }
497
+ this.emit();
498
+ return this.summary();
499
+ }
500
+ failFile(fileId, message) {
501
+ const file = this.files.find((item) => item.id === fileId);
502
+ if (file && file.status === 'pending') {
503
+ file.status = 'failed';
504
+ file.error = message;
505
+ this.emit();
506
+ }
507
+ return this.summary();
508
+ }
509
+ removeFile(fileId) {
510
+ this.chunkFiles.delete(fileId);
511
+ this.sampleChunks.delete(fileId);
512
+ this.files = this.files.filter((file) => file.id !== fileId);
513
+ this.entries = this.entries.filter((entry) => !entry.id.startsWith(`${fileId}:`));
514
+ this.samples.delete(fileId);
515
+ this.emit();
516
+ return this.summary();
517
+ }
518
+ cancel() {
519
+ this.cancelled = true;
520
+ for (const file of this.files)
521
+ if (file.status === 'pending' || file.status === 'processing') {
522
+ file.status = 'failed';
523
+ file.error = '已取消';
524
+ }
525
+ this.emit();
526
+ }
527
+ clear() {
528
+ this.entries = [];
529
+ this.files = [];
530
+ this.samples.clear();
531
+ this.sampleRecords.clear();
532
+ this.chunkFiles.clear();
533
+ this.sampleChunks.clear();
534
+ this.cancelled = false;
535
+ this.activeQuery = {};
536
+ this.activeRule = null;
537
+ this.activeRuleId = null;
538
+ this.suggestedRule = undefined;
539
+ this.validatedRuleVersion = undefined;
540
+ this.selectedFormat = 'unknown';
541
+ this.emit();
542
+ }
543
+ subscribe(listener) {
544
+ this.listeners.add(listener);
545
+ return () => this.listeners.delete(listener);
546
+ }
547
+ summary(recentRule) {
548
+ const levels = {};
549
+ for (const entry of this.entries)
550
+ levels[entry.level] = (levels[entry.level] ?? 0) + 1;
551
+ const times = this.entries
552
+ .map((entry) => entry.timestamp)
553
+ .filter(Boolean)
554
+ .sort();
555
+ const groups = new Map();
556
+ for (const entry of this.entries) {
557
+ const bucket = entry.timestamp?.slice(0, 16) ?? '未知时间';
558
+ const group = groups.get(bucket) ?? { total: 0, errors: 0, levels: {}, sources: {} };
559
+ group.total++;
560
+ if (entry.level === 'ERROR')
561
+ group.errors++;
562
+ group.levels[entry.level] = (group.levels[entry.level] ?? 0) + 1;
563
+ group.sources[entry.file || '未知来源'] = (group.sources[entry.file || '未知来源'] ?? 0) + 1;
564
+ groups.set(bucket, group);
565
+ }
566
+ const errorGroups = new Map();
567
+ for (const entry of this.entries.filter((item) => item.level === 'ERROR' || item.level === 'WARN')) {
568
+ const first = entry.message.split(/\r?\n/, 1)[0]?.trim() ||
569
+ entry.raw.split(/\r?\n/, 1)[0]?.trim() ||
570
+ '未知消息';
571
+ const message = normalizeMessageForGrouping(first);
572
+ const exceptionType = exceptionName(entry);
573
+ const key = `${entry.level}\0${exceptionType}\0${message}`;
574
+ const fullStack = entry.raw.split(/\r?\n/).slice(1).filter(Boolean).join('\n');
575
+ const group = errorGroups.get(key) ?? {
576
+ level: entry.level,
577
+ message,
578
+ count: 0,
579
+ exceptionType,
580
+ stack: fullStack.slice(0, 1200),
581
+ entryIds: [],
582
+ sample: entry.raw,
583
+ timeBuckets: {},
584
+ contextText: '',
585
+ };
586
+ group.count++;
587
+ group.first ??= entry.timestamp;
588
+ group.last = entry.timestamp;
589
+ if (group.entryIds.length < 100)
590
+ group.entryIds.push(entry.id);
591
+ if (entry.raw.length > group.sample.length)
592
+ group.sample = entry.raw;
593
+ const bucket = entry.timestamp?.slice(0, 16);
594
+ if (bucket)
595
+ group.timeBuckets[bucket] = (group.timeBuckets[bucket] ?? 0) + 1;
596
+ errorGroups.set(key, group);
597
+ }
598
+ for (const group of errorGroups.values()) {
599
+ const distribution = Object.entries(group.timeBuckets)
600
+ .slice(0, 60)
601
+ .map(([bucket, count]) => `${bucket} 出现 ${count} 次`)
602
+ .join('\n');
603
+ group.contextText = [
604
+ '【日志问题分析请求】',
605
+ '',
606
+ '## 问题',
607
+ group.message || '未知消息',
608
+ '',
609
+ '## 统计',
610
+ `- 出现次数:${group.count}`,
611
+ `- 首次:${group.first ?? '--'}`,
612
+ `- 末次:${group.last ?? '--'}`,
613
+ `- 级别:${group.level}`,
614
+ '',
615
+ '## 样例原文',
616
+ group.sample.split(/\r?\n/).slice(0, 11).join('\n'),
617
+ '',
618
+ '## 时间分布',
619
+ distribution || '暂无时间分布',
620
+ '',
621
+ '---',
622
+ '请分析根因并给出修复建议。',
623
+ ]
624
+ .join('\n')
625
+ .slice(0, 2000);
626
+ }
627
+ const hasFailedFiles = this.files.some((file) => file.status === 'failed');
628
+ const isProcessing = this.files.some((file) => file.status === 'processing');
629
+ const hasPendingFiles = this.files.some((file) => file.status === 'pending');
630
+ const status = this.cancelled
631
+ ? 'cancelled'
632
+ : isProcessing
633
+ ? 'processing'
634
+ : hasFailedFiles
635
+ ? 'partial'
636
+ : hasPendingFiles
637
+ ? 'pending'
638
+ : this.files.length
639
+ ? 'done'
640
+ : 'idle';
641
+ const reparseRecent = recentRule !== undefined &&
642
+ (recentRule.id !== this.activeRuleId ||
643
+ JSON.stringify(recentRule.rule) !== JSON.stringify(this.activeRule));
644
+ const recentEntries = reparseRecent && recentRule
645
+ ? this.entries
646
+ .map((entry) => ({
647
+ ...parseWithRule(entry.raw, entry.file, entry.line, '', recentRule.rule),
648
+ id: entry.id,
649
+ }))
650
+ .filter((entry) => entry.level === 'ERROR' || entry.level === 'WARN')
651
+ : this.entries.filter((entry) => entry.level === 'ERROR' || entry.level === 'WARN');
652
+ return {
653
+ sessionId: this.sessionId,
654
+ status,
655
+ files: this.files,
656
+ total: this.entries.length,
657
+ levels,
658
+ firstTimestamp: times[0],
659
+ lastTimestamp: times.at(-1),
660
+ durationCoverage: this.entries.length
661
+ ? this.entries.filter((entry) => entry.durationMs !== undefined).length /
662
+ this.entries.length
663
+ : 0,
664
+ topErrors: [...errorGroups.entries()]
665
+ .sort((a, b) => {
666
+ const score = (item) => item.count * (item.level === 'ERROR' ? 3 : 2);
667
+ return score(b[1]) - score(a[1]) || b[1].count - a[1].count || a[0].localeCompare(b[0]);
668
+ })
669
+ .slice(0, 10)
670
+ .map(([key, group]) => ({
671
+ ...group,
672
+ })),
673
+ recent: recentEntries.reverse(),
674
+ trend: [...groups.entries()].map(([bucket, group]) => ({ bucket, ...group })).slice(-120),
675
+ };
676
+ }
677
+ entry(id) {
678
+ return this.entries.find((item) => item.id === id);
679
+ }
680
+ addRecord(file, raw, lineNumber) {
681
+ const entry = parseRecord(raw, file.name, lineNumber, file.id, this.activeRule);
682
+ if (!matchesQuery(entry, this.activeQuery))
683
+ return;
684
+ this.entries.push(entry);
685
+ file.count++;
686
+ if (entry.level === 'ERROR')
687
+ file.errors++;
688
+ }
689
+ emit() {
690
+ const value = this.summary();
691
+ for (const listener of this.listeners)
692
+ listener(value);
693
+ }
694
+ }
695
+ function detectFormat(content) {
696
+ const lines = content.split(/\r?\n/).filter(Boolean).slice(0, 30);
697
+ if (!lines.length)
698
+ return 'unknown';
699
+ const json = lines.filter((line) => {
700
+ try {
701
+ const value = JSON.parse(line);
702
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
703
+ }
704
+ catch {
705
+ return false;
706
+ }
707
+ }).length;
708
+ if (json / lines.length > 0.7)
709
+ return 'json';
710
+ if (lines.some((line) => logHeader.test(line)))
711
+ return 'logback-spring';
712
+ if (lines.some((line) => timestamp.test(line) && level.test(line)))
713
+ return 'timestamp-level';
714
+ return 'unknown';
715
+ }
716
+ function limitSampleToCompleteRecords(content) {
717
+ const normalized = content.replace(/\r\n?/g, '\n');
718
+ if (!normalized.trim())
719
+ throw new Error('样例内容不能为空');
720
+ if (normalized.length <= MAX_SAMPLE_LENGTH) {
721
+ if (!splitRecords(normalized, null).length)
722
+ throw new Error('样例中没有完整日志记录');
723
+ return normalized;
724
+ }
725
+ const prefix = normalized.slice(0, MAX_SAMPLE_LENGTH);
726
+ const lastBreak = prefix.lastIndexOf('\n');
727
+ if (lastBreak <= 0)
728
+ throw new Error('样例在大小限制内没有完整日志记录');
729
+ const records = splitRecords(prefix.slice(0, lastBreak), null);
730
+ const complete = records.length > 1
731
+ ? records.slice(0, -1)
732
+ : records.length === 1 && records[0].raw.length <= lastBreak
733
+ ? records
734
+ : [];
735
+ if (!complete.length)
736
+ throw new Error('样例在大小限制内没有完整日志记录');
737
+ return complete.map((record) => record.raw).join('\n');
738
+ }
739
+ function splitRecords(content, rule) {
740
+ const output = [];
741
+ let record = '';
742
+ let recordLine = 1;
743
+ let line = 0;
744
+ for (const rawLine of content.split(/\r?\n/)) {
745
+ line++;
746
+ const start = rule ? matchesPattern(rule.recordStart, rawLine) : isLogRecordStart(rawLine);
747
+ if (start) {
748
+ if (record)
749
+ output.push({ raw: record, line: recordLine });
750
+ record = rawLine;
751
+ recordLine = line;
752
+ }
753
+ else if (record) {
754
+ record += `\n${rawLine}`;
755
+ }
756
+ }
757
+ if (record)
758
+ output.push({ raw: record, line: recordLine });
759
+ return output;
760
+ }
761
+ function sampleRecordsFrom(records, parsed) {
762
+ return records.map((record, index) => ({
763
+ raw: record.raw,
764
+ firstLine: record.raw.split(/\r?\n/, 1)[0]?.trim() || '--',
765
+ line: record.line,
766
+ lineCount: record.raw.split(/\r?\n/).length,
767
+ level: parsed[index]?.level ?? 'UNKNOWN',
768
+ timestamp: parsed[index]?.timestamp,
769
+ }));
770
+ }
771
+ function isLogRecordStart(raw) {
772
+ if (/^\s*\{/.test(raw)) {
773
+ try {
774
+ const value = JSON.parse(raw);
775
+ if (typeof value === 'object' && value !== null && !Array.isArray(value))
776
+ return true;
777
+ }
778
+ catch {
779
+ /* JSON log records are normally one physical line. */
780
+ }
781
+ }
782
+ return logbackStart.test(raw) || bracketLogbackStart.test(raw);
783
+ }
784
+ function parseRecord(raw, file, lineNumber, fileId, rule) {
785
+ if (rule)
786
+ return parseWithRule(raw, file, lineNumber, fileId, rule);
787
+ let json = {};
788
+ try {
789
+ const parsed = JSON.parse(raw);
790
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
791
+ json = parsed;
792
+ }
793
+ catch {
794
+ /* Non-JSON log record. */
795
+ }
796
+ const head = raw.split(/\r?\n/, 1)[0] ?? raw;
797
+ const jsonMessage = json.message ?? json.msg;
798
+ const text = typeof jsonMessage === 'string' ? jsonMessage : raw;
799
+ const header = head.match(logHeader);
800
+ const bracketHeader = head.match(bracketLogHeader);
801
+ const jsonLevel = typeof json.level === 'string' ? json.level.trim() : '';
802
+ const levelMatch = jsonLevel ||
803
+ header?.[2]?.toUpperCase() ||
804
+ bracketHeader?.[1]?.toUpperCase() ||
805
+ head.match(level)?.[1]?.toUpperCase();
806
+ const normalized = normalizeLevel(levelMatch);
807
+ const time = String(json.timestamp ??
808
+ json.time ??
809
+ header?.[1] ??
810
+ bracketHeader?.[2] ??
811
+ head.match(timestamp)?.[1] ??
812
+ '');
813
+ const durationMatch = text.match(duration);
814
+ const durationValue = json.durationMs ?? json.duration ?? durationMatch?.[1];
815
+ const durationMs = durationValue === undefined
816
+ ? undefined
817
+ : Number(durationValue) * (durationMatch?.[2]?.toLowerCase() === 's' ? 1000 : 1);
818
+ const serviceName = String(json.service ?? json.svc ?? text.match(service)?.[1] ?? '') || undefined;
819
+ const fields = { ...json };
820
+ if (header?.[3] && !fields.threadName)
821
+ fields.threadName = header[3];
822
+ if (header?.[4] && !fields.loggerName)
823
+ fields.loggerName = header[4].trim();
824
+ const statusCode = Number(json.statusCode ?? json.status ?? text.match(status)?.[1]);
825
+ return {
826
+ id: `${fileId}:${lineNumber}`,
827
+ timestamp: time || undefined,
828
+ level: normalized,
829
+ service: serviceName,
830
+ statusCode: Number.isFinite(statusCode) && statusCode ? statusCode : undefined,
831
+ durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
832
+ message: text,
833
+ raw,
834
+ file,
835
+ line: lineNumber,
836
+ fields: {
837
+ ...fields,
838
+ ...(header?.[3] ? { threadName: header[3] } : {}),
839
+ ...(header?.[4] ? { loggerName: header[4].trim() } : {}),
840
+ },
841
+ };
842
+ }
843
+ function parseWithRule(raw, file, lineNumber, fileId, rule) {
844
+ const extracted = {};
845
+ for (const [field, spec] of Object.entries(rule.fields)) {
846
+ if (!spec)
847
+ continue;
848
+ const match = raw.match(new RegExp(spec.pattern, 'm'));
849
+ if (match)
850
+ extracted[field] = match[1] ?? match[0];
851
+ }
852
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? raw;
853
+ const levelValue = normalizeLevel(extracted.level);
854
+ const msg = rule.fields.message ? (extracted.message ?? '') : '';
855
+ const fields = { ...extracted };
856
+ if (extracted.loggerName)
857
+ fields.loggerName = extracted.loggerName;
858
+ return {
859
+ id: `${fileId}:${lineNumber}`,
860
+ timestamp: extracted.timestamp ?? firstLine.match(timestamp)?.[1],
861
+ level: levelValue,
862
+ service: extracted.service,
863
+ message: msg,
864
+ raw,
865
+ file,
866
+ line: lineNumber,
867
+ fields,
868
+ };
869
+ }
870
+ function truncateSampleLines(sample, maxLength) {
871
+ return sample
872
+ .split(/\r?\n/)
873
+ .map((line) => line.slice(0, maxLength))
874
+ .join('\n');
875
+ }
876
+ function parseModelRuleJson(output) {
877
+ const text = output.replace(/^\uFEFF/, '').trim();
878
+ if (!text)
879
+ throw new TypeError('模型没有返回解析规则');
880
+ const firstJsonToken = text.search(/[\[{]/);
881
+ if (firstJsonToken === -1)
882
+ throw new TypeError('模型返回的内容中没有 JSON 对象');
883
+ if (text[firstJsonToken] === '[')
884
+ throw new TypeError('模型返回的是数组,不是解析规则对象');
885
+ const start = text.indexOf('{', firstJsonToken);
886
+ if (start === -1)
887
+ throw new TypeError('模型返回的内容中没有 JSON 对象');
888
+ let depth = 0;
889
+ let inString = false;
890
+ let escaped = false;
891
+ for (let index = start; index < text.length; index += 1) {
892
+ const char = text[index];
893
+ if (inString) {
894
+ if (escaped)
895
+ escaped = false;
896
+ else if (char === '\\')
897
+ escaped = true;
898
+ else if (char === '"')
899
+ inString = false;
900
+ continue;
901
+ }
902
+ if (char === '"') {
903
+ inString = true;
904
+ continue;
905
+ }
906
+ if (char === '{')
907
+ depth += 1;
908
+ if (char === '}') {
909
+ depth -= 1;
910
+ if (depth === 0) {
911
+ try {
912
+ return JSON.parse(text.slice(start, index + 1));
913
+ }
914
+ catch {
915
+ throw new TypeError('模型返回的 JSON 对象无法解析');
916
+ }
917
+ }
918
+ }
919
+ }
920
+ throw new TypeError('模型返回的 JSON 对象不完整');
921
+ }
922
+ function validateParserRule(value) {
923
+ if (!isRecord(value) ||
924
+ value.version !== 1 ||
925
+ typeof value.format !== 'string' ||
926
+ !value.format.trim() ||
927
+ typeof value.recordStart !== 'string' ||
928
+ !isRecord(value.fields) ||
929
+ value.continuation !== 'until-next-record') {
930
+ throw new TypeError('模型返回的解析规则结构无效');
931
+ }
932
+ const ruleKeys = new Set(['version', 'format', 'recordStart', 'fields', 'continuation']);
933
+ if (Object.keys(value).some((key) => !ruleKeys.has(key)))
934
+ throw new TypeError('解析规则包含未知字段');
935
+ const recordStart = validatePattern(value.recordStart, 'recordStart', true);
936
+ const fields = {};
937
+ for (const [key, spec] of Object.entries(value.fields)) {
938
+ if (!fieldsToCheck.includes(key) ||
939
+ !isRecord(spec) ||
940
+ typeof spec.pattern !== 'string')
941
+ throw new TypeError(`解析规则字段无效:${key}`);
942
+ if (Object.keys(spec).some((fieldKey) => fieldKey !== 'pattern'))
943
+ throw new TypeError(`解析规则字段包含未知属性:${key}`);
944
+ if (!spec.pattern.trim())
945
+ continue;
946
+ const pattern = validatePattern(spec.pattern, key, false, true);
947
+ fields[key] = { pattern };
948
+ }
949
+ return {
950
+ version: 1,
951
+ format: value.format.slice(0, 80),
952
+ recordStart,
953
+ fields,
954
+ continuation: 'until-next-record',
955
+ };
956
+ }
957
+ function validatePattern(pattern, label, anchored, requireCapture = false) {
958
+ // The parser always supplies the multiline flag, so remove the Java/PCRE inline form.
959
+ pattern = pattern.replace(/^\(\?m\)/, '');
960
+ let constraint;
961
+ if (pattern.length === 0)
962
+ constraint = '正则不能为空';
963
+ else if (pattern.length > 512)
964
+ constraint = '正则超过 512 个字符';
965
+ else if (anchored && !pattern.startsWith('^'))
966
+ constraint = 'recordStart 必须以 ^ 开头';
967
+ else if (/\(\?<?[=!]/.test(pattern))
968
+ constraint = '不支持前瞻或后顾断言';
969
+ else if (/\\[1-9]|\\k</.test(pattern))
970
+ constraint = '不支持反向引用';
971
+ else if (/\([^)]*[+*][^)]*\)[+*{]/.test(pattern))
972
+ constraint = '不支持含嵌套重复量词的分组';
973
+ else if (/\\[pP]\{/.test(pattern))
974
+ constraint = '不支持 Unicode 属性转义';
975
+ if (constraint)
976
+ throw new TypeError(`解析规则正则不安全或不符合约束:${displayParserField(label)}(${constraint};pattern=${JSON.stringify(pattern.slice(0, 180))})`);
977
+ try {
978
+ const expression = new RegExp(pattern, 'm');
979
+ if (anchored && expression.test(''))
980
+ throw new TypeError(`解析规则记录起始必须匹配非空内容:${displayParserField(label)}`);
981
+ if (requireCapture && !hasCaptureGroup(pattern))
982
+ throw new TypeError(`解析规则字段必须包含首个捕获组:${displayParserField(label)}`);
983
+ return pattern;
984
+ }
985
+ catch (error) {
986
+ if (error instanceof TypeError &&
987
+ (error.message.includes('首个捕获组') || error.message.includes('记录起始必须匹配非空内容')))
988
+ throw error;
989
+ throw new TypeError(`解析规则正则无效:${displayParserField(label)}(pattern=${JSON.stringify(pattern.slice(0, 180))})`);
990
+ }
991
+ }
992
+ function displayParserField(field) {
993
+ const labels = {
994
+ recordStart: '记录起始正则',
995
+ level: '级别',
996
+ timestamp: '时间',
997
+ exceptionType: '异常',
998
+ loggerName: '类名',
999
+ threadName: '线程',
1000
+ sourceLine: '行号',
1001
+ traceId: 'TraceId',
1002
+ spanId: 'SpanId',
1003
+ service: '服务',
1004
+ mdc: 'MDC',
1005
+ message: '消息',
1006
+ };
1007
+ return labels[field] ?? field;
1008
+ }
1009
+ function hasCaptureGroup(pattern) {
1010
+ let escaped = false;
1011
+ for (let index = 0; index < pattern.length - 1; index++) {
1012
+ const char = pattern[index];
1013
+ if (escaped) {
1014
+ escaped = false;
1015
+ continue;
1016
+ }
1017
+ if (char === '\\') {
1018
+ escaped = true;
1019
+ continue;
1020
+ }
1021
+ if (char === '(' && pattern[index + 1] !== '?')
1022
+ return true;
1023
+ }
1024
+ return false;
1025
+ }
1026
+ function matchesPattern(pattern, value) {
1027
+ try {
1028
+ return new RegExp(pattern).test(value);
1029
+ }
1030
+ catch {
1031
+ return false;
1032
+ }
1033
+ }
1034
+ function normalizeLevel(value) {
1035
+ const match = value?.toUpperCase();
1036
+ if (match === 'WARNING')
1037
+ return 'WARN';
1038
+ if (match === 'FATAL')
1039
+ return 'ERROR';
1040
+ return LEVELS.includes(match) ? match : 'UNKNOWN';
1041
+ }
1042
+ function exceptionName(entry) {
1043
+ const explicit = entry.fields.exceptionType;
1044
+ if (typeof explicit === 'string' && explicit.trim())
1045
+ return explicit.trim();
1046
+ return '';
1047
+ }
1048
+ function normalizeMessageForGrouping(value) {
1049
+ return value
1050
+ .replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<UUID>')
1051
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<IP>')
1052
+ .replace(/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b/g, '<TIME>')
1053
+ .replace(/\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b/g, '<TIME>')
1054
+ .replace(/(?:[A-Z]:)?(?:[\\/][\w.-]+){2,}/gi, '<PATH>')
1055
+ .replace(/\b0x[0-9a-f]+\b/gi, '<HEX>')
1056
+ .replace(/\b\d+\b/g, '<NUM>')
1057
+ .replace(/\s+/g, ' ')
1058
+ .trim();
1059
+ }
1060
+ function hasField(entry, field) {
1061
+ if (field === 'timestamp')
1062
+ return Boolean(entry.timestamp);
1063
+ if (field === 'level')
1064
+ return entry.level !== 'UNKNOWN';
1065
+ if (field === 'message')
1066
+ return Boolean(entry.message.trim());
1067
+ if (field === 'service')
1068
+ return Boolean(entry.fields.service ?? entry.service);
1069
+ return Boolean(entry.fields[field]);
1070
+ }
1071
+ function matchesQuery(entry, query) {
1072
+ return ((!query.level || entry.level === query.level) &&
1073
+ (!query.service || entry.service === query.service) &&
1074
+ (!query.file || entry.file === query.file) &&
1075
+ (!query.ids || query.ids.includes(entry.id)) &&
1076
+ (!query.statusCode || String(entry.statusCode) === query.statusCode) &&
1077
+ (!query.keyword ||
1078
+ safeQueryPattern(query.keyword).test(entry.message) ||
1079
+ safeQueryPattern(query.keyword).test(entry.raw)) &&
1080
+ (!query.from || (entry.timestamp ?? '') >= query.from) &&
1081
+ (!query.to || (entry.timestamp ?? '') <= query.to));
1082
+ }
1083
+ function safeQueryPattern(pattern) {
1084
+ try {
1085
+ return new RegExp(pattern, 'i');
1086
+ }
1087
+ catch {
1088
+ return /$a/;
1089
+ }
1090
+ }
1091
+ function isRecord(value) {
1092
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1093
+ }