From 81c68417b85fb518cbdffbf75ea8b9a57a18106e Mon Sep 17 00:00:00 2001 From: juliangiebel Date: Sun, 22 Jun 2025 18:50:41 +0200 Subject: [PATCH 1/5] Implement logs csv export --- .../AdminLogs/Export/ExportConfiguration.cs | 16 +++ .../AdminLogs/Export/ExportProcessItem.cs | 21 ++++ .../Export/LogExportBackgroundService.cs | 42 +++++++ .../AdminLogs/Export/LogExportExtension.cs | 47 +++++++ SS14.Admin/AdminLogs/Export/LogExportQueue.cs | 96 +++++++++++++++ SS14.Admin/AdminLogs/Export/LogExporter.cs | 107 ++++++++++++++++ SS14.Admin/Pages/Logs/Export.cshtml | 115 ++++++++++++++++++ SS14.Admin/Pages/Logs/Export.cshtml.cs | 83 +++++++++++++ SS14.Admin/Startup.cs | 6 + 9 files changed, 533 insertions(+) create mode 100644 SS14.Admin/AdminLogs/Export/ExportConfiguration.cs create mode 100644 SS14.Admin/AdminLogs/Export/ExportProcessItem.cs create mode 100644 SS14.Admin/AdminLogs/Export/LogExportBackgroundService.cs create mode 100644 SS14.Admin/AdminLogs/Export/LogExportExtension.cs create mode 100644 SS14.Admin/AdminLogs/Export/LogExportQueue.cs create mode 100644 SS14.Admin/AdminLogs/Export/LogExporter.cs create mode 100644 SS14.Admin/Pages/Logs/Export.cshtml create mode 100644 SS14.Admin/Pages/Logs/Export.cshtml.cs diff --git a/SS14.Admin/AdminLogs/Export/ExportConfiguration.cs b/SS14.Admin/AdminLogs/Export/ExportConfiguration.cs new file mode 100644 index 0000000..c5748ab --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/ExportConfiguration.cs @@ -0,0 +1,16 @@ +namespace SS14.Admin.AdminLogs.Export; + +public class ExportConfiguration +{ + public const string Name = "Export"; + + /// + /// The maximum amount of export processes that can be queued up before new export requests will be rejected + /// + public int ProcessQueueMaxSize { get; set; } = 6; + + /// + /// This is the directory for containing generated exports + /// + public string ExportDirectory { get; set; } = "export"; +} diff --git a/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs b/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs new file mode 100644 index 0000000..e2d3e4a --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs @@ -0,0 +1,21 @@ +namespace SS14.Admin.AdminLogs.Export; + +public sealed class ExportProcessItem +{ + public DateTime? FromDate { get; } + public DateTime? ToDate { get; } + public int? RoundId { get; } + + public string? Search { get; } + + public bool UseCompression { get; } + + public ExportProcessItem(string? search = null, DateTime? fromDate = null, DateTime? toDate = null, int? roundId = null, bool useCompression = false) + { + Search = search; + FromDate = fromDate; + ToDate = toDate; + UseCompression = useCompression; + RoundId = roundId; + } +} diff --git a/SS14.Admin/AdminLogs/Export/LogExportBackgroundService.cs b/SS14.Admin/AdminLogs/Export/LogExportBackgroundService.cs new file mode 100644 index 0000000..7795eb4 --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/LogExportBackgroundService.cs @@ -0,0 +1,42 @@ +using Serilog; +using ILogger = Serilog.ILogger; + +namespace SS14.Admin.AdminLogs.Export; + +public sealed class LogExportBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly LogExportQueue _queue; + private readonly ILogger _log; + + public LogExportBackgroundService(IServiceProvider provider, LogExportQueue queue, IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _queue = queue; + + _log = Log.ForContext(); + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _log.Information("{ServiceName} started", nameof(LogExportBackgroundService)); + return ProcessQueueAsync(stoppingToken); + } + + private async Task ProcessQueueAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var item = await _queue.DequeueAsync(ct); + await ProcessTask(item, ct); + } + } + + private async Task ProcessTask(ExportProcessItem item, CancellationToken ct) + { + using var scope = _serviceProvider.CreateScope(); + var exporter = scope.ServiceProvider.GetRequiredService(); + var filename = await exporter.Export(item, ct); + await _queue.ReportFinishedExport(filename); + } +} diff --git a/SS14.Admin/AdminLogs/Export/LogExportExtension.cs b/SS14.Admin/AdminLogs/Export/LogExportExtension.cs new file mode 100644 index 0000000..312956f --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/LogExportExtension.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.Extensions.Options; + +namespace SS14.Admin.AdminLogs.Export; + +public static class LogExportExtension +{ + public static void MapLogExportEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/logs/export/poll", async (CancellationToken ct, LogExportQueue queue) => + { + var timoutCt = CancellationTokenSource.CreateLinkedTokenSource(ct); + timoutCt.CancelAfter(TimeSpan.FromSeconds(120)); + using var channel = queue.CreateReportChannel(); + var filename = await channel.Listen(timoutCt.Token); + + return Results.Ok(filename); + }).RequireAuthorization(); + + endpoints.MapGet("/logs/export/list", async (IOptions config) => + { + var exportPath = Path.Combine(Directory.GetCurrentDirectory(), config.Value.ExportDirectory); + var files = Directory.EnumerateFiles(exportPath, "*.csv*"); + + return Results.Ok(files.Select(Path.GetFileName)); + + }).RequireAuthorization(); + + endpoints.MapGet("/logs/export/download/{filename}", async (string filename, IOptions config) => + { + var extension = Path.GetExtension(filename); + var mimetype = extension switch + { + ".gz" => "application/x-gzip", + ".csv" => "text/csv", + _ => null + }; + + if (mimetype == null) + return Results.NotFound(); + var basePath = Path.Combine(Directory.GetCurrentDirectory(), config.Value.ExportDirectory); + var path = Path.Combine(basePath, filename); + return !File.Exists(path) ? Results.NotFound() : Results.File(path, contentType: mimetype); + }).RequireAuthorization(); + } +} diff --git a/SS14.Admin/AdminLogs/Export/LogExportQueue.cs b/SS14.Admin/AdminLogs/Export/LogExportQueue.cs new file mode 100644 index 0000000..3af171a --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/LogExportQueue.cs @@ -0,0 +1,96 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Options; + +namespace SS14.Admin.AdminLogs.Export; + +public sealed class LogExportQueue +{ + private readonly IOptions _configuration; + private readonly Channel _queue; + + private readonly List> _reportChannels = []; + + public int MaxItemCount => _configuration.Value.ProcessQueueMaxSize; + public int Count => _queue.Reader.Count; + + public LogExportQueue(IOptions configuration) + { + _configuration = configuration; + + var channelConfiguration = new BoundedChannelOptions(MaxItemCount) + { + FullMode = BoundedChannelFullMode.DropWrite + }; + + _queue = Channel.CreateBounded(channelConfiguration); + } + + public async Task TryQueueProcessItem(ExportProcessItem item) + { + if (Count == MaxItemCount) + return false; + + await _queue.Writer.WriteAsync(item); + return true; + } + + public async ValueTask DequeueAsync(CancellationToken cancellationToken) + { + return await _queue.Reader.ReadAsync(cancellationToken); + } + + public ReportChannel CreateReportChannel() + { + var channelOptions = new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + FullMode = BoundedChannelFullMode.DropOldest, + }; + + var channel = Channel.CreateBounded(channelOptions); + _reportChannels.Add(channel); + + return new ReportChannel( + channel, + new WeakReference>>(_reportChannels) + ); + } + + public async Task ReportFinishedExport(string filename) + { + foreach (var channel in _reportChannels) + { + await channel.Writer.WriteAsync(filename); + } + } + + public sealed record ReportChannel : IDisposable + { + private readonly Channel _channel; + private readonly WeakReference>> _channels; + + public ReportChannel(Channel channel, WeakReference>> channels) + { + _channel = channel; + _channels = channels; + } + + + public async ValueTask Listen(CancellationToken ct) + { + return await _channel.Reader.ReadAsync(ct); + } + + public void Dispose() + { + _channel.Writer.TryComplete(); + + if (!_channels.TryGetTarget(out var channels)) + return; + + channels.Remove(_channel); + } + } +} diff --git a/SS14.Admin/AdminLogs/Export/LogExporter.cs b/SS14.Admin/AdminLogs/Export/LogExporter.cs new file mode 100644 index 0000000..4f1dc9d --- /dev/null +++ b/SS14.Admin/AdminLogs/Export/LogExporter.cs @@ -0,0 +1,107 @@ +using System.IO.Compression; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace SS14.Admin.AdminLogs.Export; + +public sealed class LogExporter +{ + private const char ColumnSeparator = ','; + private const char RowSeparator = ';'; + private const char Quote = '"'; + private const string EscapedQuote = "\"\""; + + private readonly PostgresServerDbContext _context; + private readonly IOptions _configuration; + + public LogExporter(PostgresServerDbContext context, IOptions configuration) + { + _context = context; + _configuration = configuration; + } + + public async Task Export(ExportProcessItem item, CancellationToken ct) + { + // Prevent accidentally exporting all logs + if ((!item.FromDate.HasValue || !item.ToDate.HasValue) && !item.RoundId.HasValue) + throw new Exception("Neither date or round id filter is set correctly for log export."); + + var filename = $"{DateTime.UtcNow.ToShortDateString()}-{Guid.NewGuid()}_log_export.csv{(item.UseCompression ? ".gz" : "")}"; + var path = Path.Combine(_configuration.Value.ExportDirectory, filename); + await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write); + + if (item.UseCompression) + { + await using var compressionStream = new GZipStream(fileStream, CompressionMode.Compress, leaveOpen: true); + await using var writer = new StreamWriter(compressionStream); + await WriteCsv(writer, item, ct); + } + else + { + await using var writer = new StreamWriter(fileStream); + await WriteCsv(writer, item, ct); + } + + return filename; + } + + private async Task WriteCsv(StreamWriter writer, ExportProcessItem item, CancellationToken ct) + { + var query = _context.AdminLog + .AsNoTracking() + .AsQueryable(); + + if (item.RoundId.HasValue) + query = query.Where(e => e.RoundId == item.RoundId); + + if (item is { FromDate: not null, ToDate: not null }) + query = query.Where(e => item.FromDate.Value.Date.ToUniversalTime() <= e.Date + && e.Date <= item.ToDate.Value.Date.ToUniversalTime()); + + if (item.Search != null) + query = query.Where(e => EF.Functions.ToTsVector("english", e.Message).Matches(item.Search)); + + await WriteCsvHeader(writer); + + await foreach (var log in query.AsAsyncEnumerable().WithCancellation(ct)) + { + await writer.WriteAsync(log.Id.ToString()); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(log.RoundId.ToString()); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(log.Date.ToString("O")); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(log.Type.ToString()); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(Quote); + await writer.WriteAsync(log.Message.Replace(Quote.ToString(), EscapedQuote)); + await writer.WriteAsync(Quote); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(Quote); + await writer.WriteAsync(log.Json.RootElement.GetRawText().Replace(Quote.ToString(), EscapedQuote)); + await writer.WriteAsync(Quote); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync(log.Impact.ToString()); + await writer.WriteLineAsync(RowSeparator); + } + } + + private async Task WriteCsvHeader(StreamWriter writer) + { + await writer.WriteAsync("id"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("round_id"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("timestamp"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("type"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("message"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("json"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("impact"); + await writer.WriteLineAsync(RowSeparator); + } +} diff --git a/SS14.Admin/Pages/Logs/Export.cshtml b/SS14.Admin/Pages/Logs/Export.cshtml new file mode 100644 index 0000000..cd7cfb4 --- /dev/null +++ b/SS14.Admin/Pages/Logs/Export.cshtml @@ -0,0 +1,115 @@ +@page +@model SS14.Admin.Pages.Logs.Export + +@{ + ViewData["Title"] = "Admin Logs"; +} + +
+
+

Export admin logs as CSV. A maximum of @Export.MaxDays days can be exported at once.

+

Check use round id to use the round id instead of the date

+
+ + + + + @**@ +
+
+ + +
+
+ + +
+
+ @Model.ErrorMessage + + + + + +
+
+
+ +
+
    +@foreach (var filename in Model.ListExportedFiles()) +{ +
  • @filename
  • +} +@if (Model.Processing) +{ +
  • +
    +
    + Loading... +
    +
    + +
  • +} +
+
+ +@section scripts { + + @if (Model.Processing) + { + + } + +} + diff --git a/SS14.Admin/Pages/Logs/Export.cshtml.cs b/SS14.Admin/Pages/Logs/Export.cshtml.cs new file mode 100644 index 0000000..f09c0a8 --- /dev/null +++ b/SS14.Admin/Pages/Logs/Export.cshtml.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; +using SS14.Admin.AdminLogs.Export; + +namespace SS14.Admin.Pages.Logs; + +public class Export : PageModel +{ + public const int MaxDays = 4; + public const string FromAfterToError = "From date needs to be before to date"; + public const string DateRangeToLargeError = "Selected date range larger than 7 days"; + public const string RoundIdMissingError = "Round id is required"; + + private readonly LogExportQueue _queue; + private readonly IOptions _config; + + [BindProperty, DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)] + public DateTime FromDate { get; set; } = DateTime.Now.AddDays(-1); + + [BindProperty, DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)] + public DateTime ToDate { get; set; } = DateTime.Now; + + [BindProperty] + public bool UseRoundId { get; set; } + + [BindProperty] + public int? RoundId { get; set; } + + [BindProperty] + public string? SearchText { get; set; } + + [BindProperty] + public bool UseCompression { get; set; } = true; + + public string? ErrorMessage { get; set; } + + public bool Processing { get; set; } + + public Export(LogExportQueue queue, IOptions config) + { + _queue = queue; + _config = config; + } + + public void OnGet() + { + + } + + public async Task OnPost(CancellationToken ct) + { + switch (UseRoundId) + { + case false when FromDate > ToDate: + ErrorMessage = FromAfterToError; + return; + case false when (ToDate - FromDate).TotalDays > MaxDays: + ErrorMessage = DateRangeToLargeError; + return; + case true when !RoundId.HasValue: + ErrorMessage = RoundIdMissingError; + break; + } + + DateTime? from = !UseRoundId ? FromDate : null; + DateTime? to = !UseRoundId ? ToDate : null; + var roundId = UseRoundId ? RoundId : null; + + var item = new ExportProcessItem(SearchText, from, to, roundId, UseCompression); + await _queue.TryQueueProcessItem(item); + Processing = true; + } + + public List ListExportedFiles() + { + var exportPath = Path.Combine(Directory.GetCurrentDirectory(), _config.Value.ExportDirectory); + var files = Directory.EnumerateFiles(exportPath, "*.csv*"); + + return files.Select(Path.GetFileName).ToList(); + } +} diff --git a/SS14.Admin/Startup.cs b/SS14.Admin/Startup.cs index 1b36179..2e00589 100644 --- a/SS14.Admin/Startup.cs +++ b/SS14.Admin/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Serilog; +using SS14.Admin.AdminLogs.Export; using SS14.Admin.Helpers; using SS14.Admin.SignIn; @@ -26,8 +27,12 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddHttpContextAccessor(); + services.AddHostedService(); + var connStr = Configuration.GetConnectionString("DefaultConnection"); if (connStr == null) throw new InvalidOperationException("Need to specify DefaultConnection connection string"); @@ -124,6 +129,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapRazorPages(); endpoints.MapControllers(); + endpoints.MapLogExportEndpoints(); }); } } From 243d7d018b315bf61466b45c1e697331c828392c Mon Sep 17 00:00:00 2001 From: juliangiebel Date: Sun, 22 Jun 2025 19:03:57 +0200 Subject: [PATCH 2/5] Add link to export page in nav menu --- SS14.Admin/Pages/Shared/_Layout.cshtml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SS14.Admin/Pages/Shared/_Layout.cshtml b/SS14.Admin/Pages/Shared/_Layout.cshtml index 23bee72..6c54dfa 100644 --- a/SS14.Admin/Pages/Shared/_Layout.cshtml +++ b/SS14.Admin/Pages/Shared/_Layout.cshtml @@ -67,6 +67,9 @@ + From 1abbca6d939c1123f6563fa514f9652cb2dbd625 Mon Sep 17 00:00:00 2001 From: juliangiebel Date: Mon, 23 Jun 2025 00:24:38 +0200 Subject: [PATCH 3/5] Change export process item to be a record --- .../AdminLogs/Export/ExportProcessItem.cs | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs b/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs index e2d3e4a..dc51bf9 100644 --- a/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs +++ b/SS14.Admin/AdminLogs/Export/ExportProcessItem.cs @@ -1,21 +1,9 @@ namespace SS14.Admin.AdminLogs.Export; -public sealed class ExportProcessItem -{ - public DateTime? FromDate { get; } - public DateTime? ToDate { get; } - public int? RoundId { get; } - - public string? Search { get; } - - public bool UseCompression { get; } - - public ExportProcessItem(string? search = null, DateTime? fromDate = null, DateTime? toDate = null, int? roundId = null, bool useCompression = false) - { - Search = search; - FromDate = fromDate; - ToDate = toDate; - UseCompression = useCompression; - RoundId = roundId; - } -} +public sealed record ExportProcessItem( + string? Search = null, + DateTime? FromDate = null, + DateTime? ToDate = null, + int? RoundId = null, + bool UseCompression = false +); From f87fb053287ba5ebb696d08f85d9ea65d14c23f4 Mon Sep 17 00:00:00 2001 From: juliangiebel Date: Mon, 23 Jun 2025 00:33:43 +0200 Subject: [PATCH 4/5] Fix potential path traversal vuln --- SS14.Admin/AdminLogs/Export/LogExportExtension.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SS14.Admin/AdminLogs/Export/LogExportExtension.cs b/SS14.Admin/AdminLogs/Export/LogExportExtension.cs index 312956f..e80f8d4 100644 --- a/SS14.Admin/AdminLogs/Export/LogExportExtension.cs +++ b/SS14.Admin/AdminLogs/Export/LogExportExtension.cs @@ -40,7 +40,7 @@ public static void MapLogExportEndpoints(this IEndpointRouteBuilder endpoints) if (mimetype == null) return Results.NotFound(); var basePath = Path.Combine(Directory.GetCurrentDirectory(), config.Value.ExportDirectory); - var path = Path.Combine(basePath, filename); + var path = Path.Combine(basePath, Path.GetFileName(filename)); return !File.Exists(path) ? Results.NotFound() : Results.File(path, contentType: mimetype); }).RequireAuthorization(); } From 2c06084c4d286da64c234a67c550bf6ad9a4b794 Mon Sep 17 00:00:00 2001 From: juliangiebel Date: Wed, 25 Jun 2025 09:23:03 +0200 Subject: [PATCH 5/5] Reorder csv fields --- SS14.Admin/AdminLogs/Export/LogExporter.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/SS14.Admin/AdminLogs/Export/LogExporter.cs b/SS14.Admin/AdminLogs/Export/LogExporter.cs index 4f1dc9d..ac7525d 100644 --- a/SS14.Admin/AdminLogs/Export/LogExporter.cs +++ b/SS14.Admin/AdminLogs/Export/LogExporter.cs @@ -8,7 +8,6 @@ namespace SS14.Admin.AdminLogs.Export; public sealed class LogExporter { private const char ColumnSeparator = ','; - private const char RowSeparator = ';'; private const char Quote = '"'; private const string EscapedQuote = "\"\""; @@ -66,11 +65,13 @@ private async Task WriteCsv(StreamWriter writer, ExportProcessItem item, Cancell await foreach (var log in query.AsAsyncEnumerable().WithCancellation(ct)) { + await writer.WriteAsync(log.Date.ToString("O")); + await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync(log.Id.ToString()); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync(log.RoundId.ToString()); await writer.WriteAsync(ColumnSeparator); - await writer.WriteAsync(log.Date.ToString("O")); + await writer.WriteAsync(log.Impact.ToString()); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync(log.Type.ToString()); await writer.WriteAsync(ColumnSeparator); @@ -81,27 +82,23 @@ private async Task WriteCsv(StreamWriter writer, ExportProcessItem item, Cancell await writer.WriteAsync(Quote); await writer.WriteAsync(log.Json.RootElement.GetRawText().Replace(Quote.ToString(), EscapedQuote)); await writer.WriteAsync(Quote); - await writer.WriteAsync(ColumnSeparator); - await writer.WriteAsync(log.Impact.ToString()); - await writer.WriteLineAsync(RowSeparator); } } private async Task WriteCsvHeader(StreamWriter writer) { - await writer.WriteAsync("id"); + await writer.WriteAsync("timestamp"); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync("round_id"); await writer.WriteAsync(ColumnSeparator); - await writer.WriteAsync("timestamp"); + await writer.WriteAsync("id"); + await writer.WriteAsync(ColumnSeparator); + await writer.WriteAsync("impact"); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync("type"); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync("message"); await writer.WriteAsync(ColumnSeparator); await writer.WriteAsync("json"); - await writer.WriteAsync(ColumnSeparator); - await writer.WriteAsync("impact"); - await writer.WriteLineAsync(RowSeparator); } }