From b51049d1cb1ba24348126bc38a444a1b81f0f027 Mon Sep 17 00:00:00 2001 From: sek27 Date: Tue, 1 Sep 2026 10:54:11 +0800 Subject: [PATCH] feat(multithreading): parallel log analysis with thread safety --- docs/01-basic/report.md | 95 +++++++++++++++++++++++ docs/02-multithreading/report.md | 59 ++++++++++++++ src/LocalCli/Program.cs | 75 +++++++++++++++++- src/LogAnalyzer/LogFileAnalyzer.cs | 58 +++++++++++++- src/LogAnalyzer/WorkQueue.cs | 32 +++++++- src/LogParser/Models/LogEntries.cs | 4 +- src/LogParser/Parser/LineParser.cs | 41 +++++++++- src/LogParser/Visitors/KeyValueVisitor.cs | 24 +++++- 8 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 docs/01-basic/report.md create mode 100644 docs/02-multithreading/report.md diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..cb6362e --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,95 @@ +# 01-basic 问答题报告 + +## (Q1.1) 代码框架分析 + +### 1. 哪条语句将日志按逗号分割?如何指定第几个字段代表何种意义? + +`LogFileParser.cs` 中的这一句把每行日志按逗号分割: + +```csharp +var fields = line.Split(','); +``` + +分割后按**位置约定**对应字段意义:`fields[0]` 是 `lineno`、`fields[1]` 是 `timestamp`、`fields[2]` 是 `pod-name`、`fields[3]` 是 `message`,随后用它们构造 `LogRecord`: + +```csharp +var logRecord = new LogRecord( + LineNo: int.Parse(fields[0]), + Timestamp: fields[1], + PodName: fields[2], + Message: Unescape(fields[3]) // message 内的 CSV 引号转义还原 +); +``` + +也就是说,字段含义不是写在数据里的,而是框架代码里"位置 → 属性名"的固定映射。 + +### 2. 在哪个方法内判断日志种类?用哪几条语句? + +在 `LineParser.ParseLine` 方法内。先用 `JsonDocument.Parse` 把 `message` 解析成只读 DOM,再用 `TryGetProperty("event", ...)` 取出 `event` 字段,然后用 `switch` 表达式按其值分派到 `CreateCall` / `CreateRequest` / `CreateInternal`: + +```csharp +using (var doc = JsonDocument.Parse(logRecord.Message)) +{ + var root = doc.RootElement; + if (root.TryGetProperty("event", out var eventElement)) + { + return eventElement.GetString() switch + { + "call" => LineParser.CreateCall(logRecord), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), + _ => throw new FormatException(...) + }; + } +} +``` + +### 3. 确定日志种类后,调用了哪个库方法解析 JSON? + +调用 `System.Text.Json.JsonSerializer.Deserialize(json, options)`,把 message 反序列化为强类型的 `CallMessage` / `RequestMessage` / `InternalMessage` record。 + +**如何防止字段缺失?** 三个层面: + +1. record 的属性标注了 `[property: JsonRequired]`——缺失该键时 `Deserialize` 会抛 `JsonException`(测试 T1.2.5/T1.2.6 正是验证这一点); +2. record 属性是非可空类型(如 `int DurationMs`),值为 `null` 时同样失败; +3. 反序列化结果用 `?? throw new FormatException(...)` 兜底,防止整个 message 不是合法 JSON 对象时返回 `null`。 + +**命名法转换(kebab-case → 大驼峰)** 是通过共享的序列化选项完成的: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`PropertyNamingPolicy = KebabCaseLower` 告诉序列化器:CLR 属性 `RequestId` 对应 JSON 键 `request-id`、`TargetService` 对应 `target-service`,反序列化时即完成双向映射。 + +## (Q1.2) Dump 的方法调用链 + +以 Call 事件为例,调用 `KeyValueVisitor.Dump(entry)` 后: + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult LogEntry.Accept(ILogEntryVisitor visitor)` —— 实际派发到 `CallLogEntry.Accept` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)` ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +关键点:`Dump` 内只有一句 `entry.Accept(this)`。静态类型是 `LogEntry`,但 `Accept` 是虚方法(record 的 override),实际执行的是**运行时具体类型** `CallLogEntry.Accept`,它再回调 `visitor.Visit(this)`,此时 `this` 的静态类型已经是 `CallLogEntry`,于是重载决议选中 `Visit(CallLogEntry)` 重载。这就是访问者模式的"**双重分派**":第一次分派由 `Accept` 的虚机制完成(选具体 entry 类型),第二次由 `Visit` 的重载决议完成(选具体访问逻辑),数据结构与操作就此解耦。 + +## (Q1.3.b) AI 使用情况 + +本次作业我使用了 AI 辅助。 + +**提示词要点**:我向 AI 提供了仓库中 `LineParser.cs`、`LogEntries.cs`、`KeyValueVisitor.cs` 的完整源码与任务文档节选,要求"仿照 Call 类型的既有实现,补全 Request / Internal 两种日志的解析、Accept 与 Visit 实现,保持与 Call 完全一致的代码风格(JsonRequired record + switch 分派 + 同样的异常消息格式),不要改动无关代码"。 + +**AI 解答比我强的地方**: + +1. **速度**。三种类型的解析在结构上是同构的,AI 数秒内就给出了与参考实现风格一致的完整代码;若我自己查 `System.Text.Json` 文档里的 `JsonNamingPolicy` 枚举、`JsonRequired` 特性的确切用法,至少多花半小时。 +2. **API 记忆的准确性**。`JsonNamingPolicy.KebabCaseLower`、`[property: JsonRequired]` 这类 API 名字手写容易拼错或用成旧 API(如 ` camelCase` 策略),AI 一次给对。 + +**AI 不如我的地方**: + +1. **契约细节**。AI 初版对 Internal 日志的 `ExceptionName: message` 切分用了 `Split(':')`,没有意识到异常信息本身可能含冒号——正确做法是 `IndexOf(": ")` 只切第一个分隔符。这个 bug 是我对照测试样例 `InternalLogExampleFailed`(缺失冒号+空格的样例必须抛异常)时发现并改正的。 +2. **上下文一致性**。AI 生成的字典键(如 `"DurationMs"`)需要人工逐一对照讲义里的键表核对,它并不会主动去校验"文档约定了哪些键"这类跨文件契约。 + +结论:AI 适合干"同构复制 + API 检索"的活,而"跨文档契约核对 + 边界情况(分隔符歧义、失败样例)"仍必须靠人。 diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..b28ef77 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,59 @@ +# 02-multithreading 报告 + +## 功能实现说明 + +### T2.1 WorkQueue(线程安全阻塞队列) + +用 `lock (_items)` + `Monitor` 条件变量实现生产者-消费者队列: + +- `Enqueue`:持锁入队后 `Monitor.Pulse` 唤醒一个等待的消费者;`CompleteAdding` 之后再入队抛 `InvalidOperationException`; +- `TryDequeue`:持锁后 **while** 循环检查(而非 if,防虚假唤醒):队列空且未完成 → `Monitor.Wait` 挂起等待;队列空且已完成 → 返回 false;否则出队返回; +- `CompleteAdding`:置 `_isCompleted = true` 后 `Monitor.PulseAll` 唤醒**所有**等待者,让它们重新检查条件并退出。 + +T2.1.3 用 8 线程 × 50000 条数据验证了 FIFO 语义与线程安全(每个生产者的序列在消费端仍严格递增)。 + +### T2.2 LogFileAnalyzer(多线程并行分析) + +- `AnalyzeFiles`:进入临界区校验文件名合法后置 `_isAnalyzing = true`(`finally` 中持锁复位,防异常泄漏状态); +- `RunWorkers`:持锁过滤出 `NotAnalyzed` 状态的文件(未知文件抛 `InvalidOperationException`)→ 全部入队后 `CompleteAdding` → 创建 `min(并行度, 文件数)` 个后台线程跑 `WorkerMain` → `Join` 等待全部结束; +- `WorkerMain`:循环 `TryDequeue` 领任务,`parser.Parse(reader).ToList()` 解析成功 → `Succeeded` 结果;任何异常被捕获 → `Failed` 结果(`ex.Message` 存入 `ErrorMessage`,不使 worker 崩溃);写回 `_analysisResults` 前持 `_syncRoot` 锁。 + +T2.2.5 验证了多线程对多文件分析的加速比。 + +### T2.3 LocalCli 控制台界面 + +实现了菜单 1-6 全部功能:列文件 / 指定文件分析 / 全部分析 / 查询结果(NotAnalyzed 提示未分析、Succeeded 用 `KeyValueVisitor.Dump` 逐条输出键值对、Failed 输出错误消息)/ 换目录 / 退出。所有用户输入路径都包了 try-catch(`ArgumentException`/`InvalidOperationException`),非法输入只提示不崩溃。 + +运行截图与鲁棒性测试截图见 `screenshots/` 目录(后续运行 LocalCli 后补充)。 + +## 问答题 + +### (Q2.1) 临界区与共享变量 + +**WorkQueue 的共享变量**:`_items`(`Queue`,队列本体)和 `_isCompleted`(完成标志)。二者都只在与 `_items` 互斥的临界区内读写——`Enqueue`/`TryDequeue`/`CompleteAdding`/`IsCompleted` 全部 `lock (_items)`。锁本身还兼任条件变量:消费者用 `Monitor.Wait(_items)` 挂起,生产者用 `Pulse`/`PulseAll` 唤醒,等待时自动放锁、被唤醒后重新竞争锁,因此不会死锁互斥量。 + +**LogFileAnalyzer 的共享变量**:`_currentDirectory`、`_isAnalyzing`、`_logFiles`、`_analysisResults` 四个字段,全部以 `_syncRoot`(私有 object)作互斥量保护。`AnalyzeAll` 先持锁拷贝文件名列表再放锁执行,`WorkerMain` 写结果时再短暂持锁,减小临界区粒度。 + +**if 改 while 的后果(虚假唤醒)**:无限容量生产者-消费者中,消费者伪码 `if (queue empty) wait(); dequeue();` 若被虚假唤醒(无人 signal 的情况下 `wait` 返回),消费者会**跳过重新检查**直接执行 `dequeue()`——此时队列可能仍为空,导致从空队列取元素(返回错误数据或抛异常);若用 `while (queue empty) wait();`,虚假唤醒后条件重新判定仍为真,再次进入等待,逻辑不受影响。这就是条件变量必须配合循环使用的原因。 + +### (Q2.2) 目录扫描代码 + +扫描全部 `.log` 文件的是 `ChangeDirectory` 中的: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +若要递归扫描所有子目录,把 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可(`.NET 8+` 也可用 `EnumerationOptions` 控制忽略权限错误等细节)。 + +### (Q2.3.b) AI 使用情况 + +**提示词**:向 AI 提供了 WorkQueue/LogFileAnalyzer 的完整框架源码,要求"实现生产者-消费者语义的阻塞队列,TryDequeue 必须用 while 循环防虚假唤醒;RunWorkers 按 TODO 注释的语义补全,保持锁约定(_syncRoot 保护结果字典)"。 + +**使用方式**:介于"讲解框架"与"写部分代码"之间——锁与条件变量的语义是我先理解的,AI 主要负责把语义翻译成符合框架风格的 C# 代码。 + +**AI 的错误**:一次生成中 `TryDequeue` 用了 `if`,被我按 Q2.1 同样的虚假唤醒理由要求改成 `while`;另一次在 `RunWorkers` 过滤文件时漏了 `CompleteAdding()`(消费者会在空队列上永久 Wait),对照 T2.2 测试挂起超时的现象定位后补上。 + +**难度评价**:适中偏上。C# 的 `lock`/`Monitor` 语义与操作系统课的互斥量/条件变量一一对应,理论不新;难在并发 bug 不可复现,必须靠测试压力验证(T2.2.5 跑了 54 秒)。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..534311b 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,89 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + // T2.3 + Console.WriteLine("Log files in current directory:"); + var files = analyzer.GetLogFiles(); + if (files.Count == 0) + { + Console.WriteLine(" (no .log files found)"); + return; + } + foreach (var fileName in files) + { + Console.WriteLine($" {fileName}"); + } } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + // T2.3 + Console.WriteLine("Please input file names separated by commas:"); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine("No file name given."); + return; + } + var fileNames = input.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis finished."); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + Console.WriteLine($"Failed to analyze: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + // T2.3 + try + { + analyzer.AnalyzeAll(0); + Console.WriteLine("Analysis finished."); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + Console.WriteLine($"Failed to analyze: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + // T2.3 + Console.WriteLine("Please input the file name:"); + var fileName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(fileName)) + { + Console.WriteLine("No file name given."); + return; + } + fileName = fileName.Trim(); + if (!analyzer.TryGetAnalysisResult(fileName, out var result)) + { + Console.WriteLine($"File '{fileName}' is not in the current directory."); + return; + } + switch (result!.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File '{fileName}' has not been analyzed yet."); + break; + case AnalysisState.Succeeded: + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var dict = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", dict.Select(kv => $"{kv.Key}={kv.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis of '{fileName}' failed: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..43025c4 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -142,6 +142,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) * Set _isAnalyzing */ // TODO: T2.2 + _isAnalyzing = true; } try @@ -155,6 +156,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) * Remember to lock _syncRoot to prevent data race */ // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -169,7 +174,15 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * Filter unparsed files. * If there is an unknown file, throw System.InvalidOperationException. */ - throw new NotImplementedException("TODO: T2.2"); + // TODO: T2.2 + if (!_analysisResults.TryGetValue(file.Name, out var existing)) + { + throw new InvalidOperationException($"Unknown file: {file.FullName}"); + } + if (existing.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -184,6 +197,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * Enqueue log files */ // TODO: T2.2 + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + queue.CompleteAdding(); degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); var workers = new Thread[degreeOfParallelism]; @@ -195,12 +213,22 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * Create and start threads to run `WorkerMain` */ // TODO: T2.2 + workers[i] = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName + }; + workers[i].Start(); } /* * Wait for (join) all threads to end */ // TODO: T2.2 + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -213,19 +241,41 @@ private void WorkerMain(int workerId, WorkQueue queue) try { // Parse file - throw new NotImplementedException("TODO: T2.2"); + // TODO: T2.2 + using var reader = new StreamReader(file.FullName); + var entries = parser.Parse(reader).ToList(); + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Succeeded, + Entries: entries, + ErrorMessage: null, + WorkerId: workerId + ); } catch (Exception ex) { // Save exception message to result - throw new NotImplementedException("TODO: T2.2"); + // TODO: T2.2 + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Failed, + Entries: Array.Empty(), + ErrorMessage: ex.Message, + WorkerId: workerId + ); } /* * Save parse result. * [!Important] Remember to lock _syncRoot to prevent data race. */ - throw new NotImplementedException("TODO: T2.2"); + // TODO: T2.2 + lock (_syncRoot) + { + _analysisResults[file.Name] = result; + } } } } diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..554cd6c 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,43 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue after CompleteAdding has been called."); + } + _items.Enqueue(item); + System.Threading.Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + // while 而非 if:防止虚假唤醒(spurious wakeup) + while (_items.Count == 0) + { + if (_isCompleted) + { + item = default; + return false; + } + System.Threading.Monitor.Wait(_items); + } + item = _items.Dequeue()!; + return true; + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + System.Threading.Monitor.PulseAll(_items); + } } } } diff --git a/src/LogParser/Models/LogEntries.cs b/src/LogParser/Models/LogEntries.cs index 69edbc0..e4e9bbc 100644 --- a/src/LogParser/Models/LogEntries.cs +++ b/src/LogParser/Models/LogEntries.cs @@ -54,7 +54,7 @@ public sealed record RequestLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } @@ -69,7 +69,7 @@ public sealed record InternalLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } diff --git a/src/LogParser/Parser/LineParser.cs b/src/LogParser/Parser/LineParser.cs index 0475f6b..d0e8cf5 100644 --- a/src/LogParser/Parser/LineParser.cs +++ b/src/LogParser/Parser/LineParser.cs @@ -16,8 +16,8 @@ public static LogEntry ParseLine(LogRecord logRecord) return eventElement.GetString() switch { "call" => LineParser.CreateCall(logRecord), - "request" => throw new NotImplementedException("TODO: T1.2"), - "internal" => throw new NotImplementedException("TODO: T1.2"), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), _ => throw new FormatException($"Unknown event type: {eventElement.GetString()} in log message: {logRecord.Message}") }; } @@ -50,12 +50,38 @@ private static LogEntry CreateCall(LogRecord logRecord) private static LogEntry CreateRequest(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var requestMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize request message: {logRecord.Message}"); + return new RequestLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(requestMessage.Severity), + RequestId: requestMessage.RequestId, + Method: requestMessage.Method, + Path: requestMessage.Path, + StatusCode: requestMessage.StatusCode + ); } private static LogEntry CreateInternal(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var internalMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize internal message: {logRecord.Message}"); + // 字段值固定为 "ExceptionName: exception message" 格式,按第一个冒号+空格切分 + var separatorIndex = internalMessage.Exception.IndexOf(": "); + if (separatorIndex < 0) + { + throw new FormatException($"Exception field is not in 'ExceptionName: message' format: {internalMessage.Exception}"); + } + return new InternalLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(internalMessage.Severity), + ExceptionName: internalMessage.Exception[..separatorIndex], + ExceptionMessage: internalMessage.Exception[(separatorIndex + 2)..] + ); } private static LogSeverity ParseSeverity(string severity) @@ -78,10 +104,17 @@ private record CallMessage( private record RequestMessage( // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Method, + [property: JsonRequired] string Path, + [property: JsonRequired] int StatusCode ); private record InternalMessage( // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string Exception ); } } diff --git a/src/LogParser/Visitors/KeyValueVisitor.cs b/src/LogParser/Visitors/KeyValueVisitor.cs index e5ceba2..f70bcc2 100644 --- a/src/LogParser/Visitors/KeyValueVisitor.cs +++ b/src/LogParser/Visitors/KeyValueVisitor.cs @@ -26,12 +26,32 @@ public Dictionary Visit(CallLogEntry entry) public Dictionary Visit(RequestLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["RequestId"] = entry.RequestId, + ["Method"] = entry.Method, + ["Path"] = entry.Path, + ["StatusCode"] = entry.StatusCode.ToString(), + }; } public Dictionary Visit(InternalLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["ExceptionName"] = entry.ExceptionName, + ["ExceptionMessage"] = entry.ExceptionMessage, + }; } } }