-
Notifications
You must be signed in to change notification settings - Fork 4
Distributed log analysis system — single-process implementation #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
36df773
ecd507f
d177c15
d6ec292
88429a4
4baf647
2762155
7baf3c5
1ced60b
8ec8a2e
32edae6
173f010
b1a658a
702f691
2557f99
49fa5ee
ecf586c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Build output | ||
| bin/ | ||
| obj/ | ||
|
|
||
| # IDE / user files | ||
| .vs/ | ||
| .vscode/ | ||
| .idea/ | ||
| *.user | ||
| *.suo | ||
|
|
||
| # Runtime artifacts produced by the app | ||
| *.db | ||
| *.db-shm | ||
| *.db-wal | ||
| alerts.log | ||
|
|
||
| # OS | ||
| .DS_Store | ||
| Thumbs.db |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| If you ran 'dotnet run' right now, the app would: | ||
| 1. Initialize SQLite (logs.db created, schema | ||
| applied). | ||
| 2. Start IngestionHost, which starts | ||
| FileIngestionSource. | ||
| 3. Stream src/apache_log.txt → parse → push to | ||
| channel → batch-write to SQLite. | ||
| 4. Sit idle once the file is exhausted, waiting | ||
| for more producers (there are none). | ||
| 5. GET /logs would return 404 because we haven't | ||
| mapped the endpoints yet. | ||
| 6. The alert worker is registered but throws on | ||
| first tick because it's still a stub. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <Solution> | ||
| <Folder Name="/src/"> | ||
| <Project Path="src/LogAnalysis.System/LogAnalysis.System.csproj" /> | ||
| </Folder> | ||
| </Solution> |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| using LogAnalysis.System.Storage; | ||
|
|
||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public sealed class AlertWorker( | ||
| IEnumerable<IAlertRule> rules, | ||
| IEnumerable<IAlertSink> sinks, | ||
| ILogRepository repository, | ||
| IConfiguration config, | ||
| ILogger<AlertWorker> logger) : BackgroundService | ||
| { | ||
| protected override Task ExecuteAsync(CancellationToken ct) | ||
| => throw new NotImplementedException(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public sealed class FileAlertSink( | ||
| IConfiguration config, | ||
| ILogger<FileAlertSink> logger) : IAlertSink | ||
| { | ||
| public Task WriteAsync(Alert alert, CancellationToken ct) | ||
| => throw new NotImplementedException(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| using LogAnalysis.System.Storage; | ||
|
|
||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public interface IAlertRule | ||
| { | ||
| string Name { get; } | ||
|
|
||
| Task<Alert?> EvaluateAsync(ILogRepository repository, DateTimeOffset now, CancellationToken ct); | ||
| } | ||
|
|
||
| public sealed record Alert(string RuleName, string Detail, DateTimeOffset FiredAt); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public interface IAlertSink | ||
| { | ||
| Task WriteAsync(Alert alert, CancellationToken ct); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| using LogAnalysis.System.Storage; | ||
|
|
||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public sealed class PatternRule( | ||
| string name, | ||
| string substring, | ||
| TimeSpan window) : IAlertRule | ||
| { | ||
| public string Name => name; | ||
|
|
||
| public Task<Alert?> EvaluateAsync(ILogRepository repository, DateTimeOffset now, CancellationToken ct) | ||
| => throw new NotImplementedException(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| using LogAnalysis.System.Domain; | ||
| using LogAnalysis.System.Storage; | ||
| using LogLevel = LogAnalysis.System.Domain.LogLevel; | ||
|
|
||
| namespace LogAnalysis.System.Alerting; | ||
|
|
||
| public sealed class ThresholdRule( | ||
| string name, | ||
| LogLevel level, | ||
| long threshold, | ||
| TimeSpan window) : IAlertRule | ||
| { | ||
| public string Name => name; | ||
|
|
||
| public Task<Alert?> EvaluateAsync(ILogRepository repository, DateTimeOffset now, CancellationToken ct) | ||
| => throw new NotImplementedException(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| using LogAnalysis.System.Storage; | ||
|
|
||
| namespace LogAnalysis.System.Api; | ||
|
|
||
| public static class QueryEndpoints | ||
| { | ||
| public static IEndpointRouteBuilder MapQueryEndpoints(this IEndpointRouteBuilder app) | ||
| { | ||
| // GET /logs?startTime&endTime&level&service&limit&cursor | ||
| // GET /logs/aggregate?startTime&endTime&groupBy=level|service | ||
|
|
||
| // --- Production ingestion path (commented out for the demo) ---------- | ||
| // POST /logs: accepts a JSON LogEntry, returns 202 Accepted, writes | ||
| // straight into the same LogChannel that FileIngestionSource uses. | ||
| // The pipeline downstream is source-agnostic, so enabling this is a | ||
| // one-method change. The demo replays src/apache_log.txt instead. | ||
| // --------------------------------------------------------------------- | ||
|
|
||
| return app; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| namespace LogAnalysis.System.Domain; | ||
|
|
||
| public sealed record LogEntry( | ||
| DateTimeOffset Timestamp, | ||
| LogLevel Level, | ||
| string Service, | ||
| string Message); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace LogAnalysis.System.Domain; | ||
|
|
||
| public enum LogLevel | ||
| { | ||
| Debug = 0, | ||
| Info = 1, | ||
| Warn = 2, | ||
| Error = 3, | ||
| Fatal = 4 | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,39 @@ | ||||||||||||||||||||||||||||||||
| using System.Globalization; | ||||||||||||||||||||||||||||||||
| using System.Text.RegularExpressions; | ||||||||||||||||||||||||||||||||
| using LogAnalysis.System.Domain; | ||||||||||||||||||||||||||||||||
| using LogLevel = LogAnalysis.System.Domain.LogLevel; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| namespace LogAnalysis.System.Ingestion; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public sealed partial class ApacheLogParser : ILogParser | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| private const string DateFormat = "dd/MMM/yyyy:HH:mm:ss zzz"; | ||||||||||||||||||||||||||||||||
| private const string ServiceTag = "apache"; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| [GeneratedRegex( | ||||||||||||||||||||||||||||||||
| @"^\S+ \S+ \S+ \[(?<ts>[^\]]+)\] ""(?<req>[^""]*)"" (?<status>\d{3}) ", | ||||||||||||||||||||||||||||||||
| RegexOptions.Compiled)] | ||||||||||||||||||||||||||||||||
| private static partial Regex Pattern(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public bool CanParse(string line) => Pattern().IsMatch(line); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public LogEntry? Parse(string line) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| var m = Pattern().Match(line); | ||||||||||||||||||||||||||||||||
| if (!m.Success) return null; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!DateTimeOffset.TryParseExact( | ||||||||||||||||||||||||||||||||
| m.Groups["ts"].Value, DateFormat, | ||||||||||||||||||||||||||||||||
| CultureInfo.InvariantCulture, DateTimeStyles.None, out var ts)) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| ts = DateTimeOffset.UtcNow; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent fallback to current time masks data quality issues. When timestamp parsing fails, the code silently substitutes
Consider logging a warning when the fallback occurs, or using a sentinel timestamp that's more obviously invalid. 🛡️ Proposed fix to add logging if (!DateTimeOffset.TryParseExact(
m.Groups["ts"].Value, DateFormat,
CultureInfo.InvariantCulture, DateTimeStyles.None, out var ts))
{
+ // Log the issue but continue processing - better to have a bad timestamp than lose the log
+ // In production, consider: Dead-letter queue, metrics counter, or sentinel value
ts = DateTimeOffset.UtcNow;
+ // Note: Would need ILogger injected into Parse or passed as parameter
}Alternatively, inject 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| var status = int.Parse(m.Groups["status"].Value, CultureInfo.InvariantCulture); | ||||||||||||||||||||||||||||||||
| var level = status >= 500 ? LogLevel.Error | ||||||||||||||||||||||||||||||||
| : status >= 400 ? LogLevel.Warn | ||||||||||||||||||||||||||||||||
| : LogLevel.Info; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return new LogEntry(ts.ToUniversalTime(), level, ServiceTag, m.Groups["req"].Value); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| using System.Runtime.CompilerServices; | ||
|
|
||
| namespace LogAnalysis.System.Ingestion; | ||
|
|
||
| public sealed class FileIngestionSource( | ||
| IngestionPipeline pipeline, | ||
| IConfiguration config, | ||
| ILogger<FileIngestionSource> logger) : IIngestionSource | ||
| { | ||
| public async Task RunAsync(CancellationToken ct) | ||
| { | ||
| var path = config["Ingestion:FilePath"]; | ||
| if (string.IsNullOrWhiteSpace(path)) | ||
| { | ||
| logger.LogInformation("FileIngestionSource disabled — no Ingestion:FilePath configured"); | ||
| return; | ||
| } | ||
| if (!File.Exists(path)) | ||
| { | ||
| logger.LogWarning("Log file not found: {Path}", path); | ||
| return; | ||
| } | ||
|
|
||
| logger.LogInformation("Replaying {Path}", path); | ||
|
|
||
| var count = 0; | ||
| await foreach (var line in ReadLinesAsync(path, ct)) | ||
| { | ||
| await pipeline.IngestAsync(line, ct); | ||
| count++; | ||
| } | ||
|
|
||
| logger.LogInformation("File ingestion complete: {Count} line(s) from {Path}", count, path); | ||
| } | ||
|
|
||
| private static async IAsyncEnumerable<string> ReadLinesAsync( | ||
| string path, [EnumeratorCancellation] CancellationToken ct) | ||
| { | ||
| using var reader = new StreamReader(path); | ||
| string? line; | ||
| while ((line = await reader.ReadLineAsync(ct)) is not null) | ||
| yield return line; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| namespace LogAnalysis.System.Ingestion; | ||
|
|
||
| public interface IIngestionSource | ||
| { | ||
| Task RunAsync(CancellationToken ct); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| using LogAnalysis.System.Domain; | ||
|
|
||
| namespace LogAnalysis.System.Ingestion; | ||
|
|
||
| public interface ILogParser | ||
| { | ||
| bool CanParse(string line); | ||
| LogEntry? Parse(string line); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| namespace LogAnalysis.System.Ingestion; | ||
|
|
||
| public sealed class IngestionHost( | ||
| IEnumerable<IIngestionSource> sources, | ||
| ILogger<IngestionHost> logger) : BackgroundService | ||
| { | ||
| protected override Task ExecuteAsync(CancellationToken ct) | ||
| { | ||
| var tasks = sources.Select(s => RunSafe(s, ct)).ToArray(); | ||
|
|
||
| if (tasks.Length == 0) | ||
| { | ||
| logger.LogInformation("No ingestion sources registered"); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| logger.LogInformation("Starting {Count} ingestion source(s)", tasks.Length); | ||
| return Task.WhenAll(tasks); | ||
| } | ||
|
|
||
| private async Task RunSafe(IIngestionSource source, CancellationToken ct) | ||
| { | ||
| try | ||
| { | ||
| await source.RunAsync(ct); | ||
| } | ||
| catch (OperationCanceledException) { } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Ingestion source {Type} failed", source.GetType().Name); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| using LogAnalysis.System.Pipeline; | ||
|
|
||
| namespace LogAnalysis.System.Ingestion; | ||
|
|
||
| public sealed class IngestionPipeline( | ||
| IEnumerable<ILogParser> parsers, | ||
| LogChannel channel, | ||
| ILogger<IngestionPipeline> logger) | ||
| { | ||
| private readonly ILogParser[] _parsers = parsers.ToArray(); | ||
|
|
||
| public async ValueTask IngestAsync(string line, CancellationToken ct) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(line)) return; | ||
|
|
||
| foreach (var parser in _parsers) | ||
| { | ||
| if (!parser.CanParse(line)) continue; | ||
|
|
||
| var entry = parser.Parse(line); | ||
| if (entry is null) continue; | ||
|
|
||
| await channel.Writer.WriteAsync(entry, ct); | ||
| return; | ||
| } | ||
|
|
||
| logger.LogDebug("No parser matched line: {Preview}", Preview(line)); | ||
| } | ||
|
|
||
| private static string Preview(string line) => | ||
| line.Length <= 80 ? line : line[..80]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFramework>net10.0</TargetFramework> | ||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <RootNamespace>LogAnalysis.System</RootNamespace> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" /> | ||
| </ItemGroup> | ||
|
Comment on lines
+4
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: The latest stable version of Microsoft.Data.Sqlite is 10.0.5, released on 2026-03-12, which explicitly targets net10.0 and is fully compatible with TargetFramework net10.0 [1][2]. Version 9.0.x versions (latest patch 9.0.14) rely on netstandard2.0 targeting, which works with net10.0 but can cause runtime assembly loading issues if not directly referenced in the net10.0 project (e.g., when pulled transitively from netstandard libraries), requiring explicit PackageReference in the executable project [3]. Staying on 9.0.0 specifically (an early 9.0 release from late 2024) with.NET 10 misses bug fixes, security patches, and performance improvements from later 9.0.x and 10.0.x releases, and lacks native net10.0 optimizations like DateOnly/TimeOnly support without polyfills [2]. Upgrading to 10.0 introduces breaking changes in DateTimeOffset handling: textual timestamps without offset are now assumed UTC (previously local), DateTimeOffset to REAL columns are converted to UTC before writing, and GetDateTime on offset timestamps returns UTC with DateTimeKind.Utc [4]. No ongoing support issues for 9.0.x on net10.0 beyond resolution caveats, but 10.0 is recommended for best compatibility [2]. Citations:
Upgrade Microsoft.Data.Sqlite to version 10.0.5 for explicit net10.0 support. Version 9.0.0 is outdated (latest in 9.x series is 9.0.14) and uses netstandard2.0 targeting. Version 10.0.5 (released March 2026) explicitly targets net10.0 and provides native optimizations, security patches, and performance improvements. Note that upgrading introduces breaking changes in DateTimeOffset handling: textual timestamps without offset are now assumed UTC (previously local), and GetDateTime on offset timestamps returns UTC with DateTimeKind.Utc. Review these changes against your codebase before upgrading. 🤖 Prompt for AI Agents |
||
|
|
||
| </Project> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| using System.Threading.Channels; | ||
| using LogAnalysis.System.Domain; | ||
|
|
||
| namespace LogAnalysis.System.Pipeline; | ||
|
|
||
| public sealed class LogChannel | ||
| { | ||
| private const int Capacity = 10_000; | ||
|
|
||
| private readonly Channel<LogEntry> _channel = Channel.CreateBounded<LogEntry>( | ||
| new BoundedChannelOptions(Capacity) | ||
| { | ||
| FullMode = BoundedChannelFullMode.DropOldest, | ||
| SingleReader = true, | ||
| SingleWriter = false | ||
| }); | ||
|
|
||
| public ChannelWriter<LogEntry> Writer => _channel.Writer; | ||
| public ChannelReader<LogEntry> Reader => _channel.Reader; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct documentation: AlertWorker is not registered.
The documentation states "The alert worker is registered but throws on first tick because it's still a stub," but
Program.csline 31 showsAlertWorkeris commented out and not registered. The alert worker will not run and will not throw. Update the documentation to reflect that alerting is disabled.📝 Proposed correction
📝 Committable suggestion
🤖 Prompt for AI Agents