Skip to content
Merged
395 changes: 395 additions & 0 deletions DevProxy.Tests/LogSinceFilterTests.cs

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions DevProxy.Tests/LoggingConfigurationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using DevProxy.Commands;
using DevProxy.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Globalization;

using Xunit;

namespace DevProxy.Tests;

public class LoggingConfigurationTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ConfigureDevProxyLogging_Interactive_HonorsShowTimestamps(bool showTimestamps)
{
using var configuration = new ConfigurationManager();
configuration["showTimestamps"] = showTimestamps.ToString();
var services = new ServiceCollection();
services.AddLogging(builder => builder.ConfigureDevProxyLogging(configuration, new DevProxyConfigOptions()));
using var provider = services.BuildServiceProvider();

var options = provider.GetRequiredService<IOptions<ProxyConsoleFormatterOptions>>().Value;

Assert.Equal(showTimestamps, options.ShowTimestamps);
Assert.False(options.IncludeFullTimestamps);
}

[Theory]
[InlineData(LogLevel.Warning)]
[InlineData(LogLevel.Error)]
[InlineData(LogLevel.Critical)]
[InlineData(LogLevel.Information)]
[InlineData(LogLevel.Debug)]
[InlineData(LogLevel.Trace)]
public async Task Formatter_DetachedSurvivingRecord_RetainsFullTimestampForSnapshotsAndFollowers(LogLevel level)
{
var formatter = new ProxyConsoleFormatter(Options.Create(new ProxyConsoleFormatterOptions
{
ShowTimestamps = false,
IncludeFullTimestamps = true
}));
using var writer = new StringWriter(CultureInfo.InvariantCulture);
var entry = new LogEntry<string>(level, "Tests", new EventId(0), "surviving record", null, (state, _) => state);
var cutoff = DateTimeOffset.UtcNow;

formatter.Write(entry, null, writer);

var line = writer.ToString().TrimEnd('\r', '\n');
var timestamp = Assert.Single(LogSinceFilter.GetLineTimes([line], cutoff.AddHours(-1)));
Assert.InRange(timestamp, cutoff, DateTimeOffset.UtcNow);
Assert.Equal([line], LogSinceFilter.Filter([line], cutoff.AddHours(-1), cutoff));
Assert.Empty(LogSinceFilter.Filter([line], cutoff.AddHours(-1), cutoff.AddHours(1)));

var path = Path.GetTempFileName();
try
{
using var reader = new LogFileReader(path, cutoff.AddHours(-1), cutoff);
await File.AppendAllTextAsync(path, line + "\n");

Assert.Equal([line], await reader.ReadLinesAsync(CancellationToken.None));
}
finally
{
File.Delete(path);
}
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void Formatter_InteractiveWarning_DoesNotAddDetachedTimestampMetadata(bool showTimestamps)
{
var formatter = new ProxyConsoleFormatter(Options.Create(new ProxyConsoleFormatterOptions
{
ShowTimestamps = showTimestamps
}));
using var writer = new StringWriter(CultureInfo.InvariantCulture);
var entry = new LogEntry<string>(LogLevel.Warning, "Tests", new EventId(0), "warning", null, (state, _) => state);

formatter.Write(entry, null, writer);

Assert.DoesNotMatch(@"\[\d{4}-\d{2}-\d{2}T", writer.ToString());
}
}
16 changes: 16 additions & 0 deletions DevProxy.Tests/LogsCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ namespace DevProxy.Tests;

public class LogsCommandTests
{
[Fact]
public void GetLogStartTime_AfterHotReload_UsesProcessStartRatherThanLatestState()
{
using var process = System.Diagnostics.Process.GetCurrentProcess();
var state = new ProxyInstanceState
{
Pid = process.Id,
StartedAt = DateTimeOffset.Now.AddMinutes(5)
};

var logStartTime = LogsCommand.GetLogStartTime(state);

Assert.Equal(process.StartTime, logStartTime);
Assert.Empty(Logging.LogSinceFilter.Filter([" info Original startup"], logStartTime, state.StartedAt.LocalDateTime));
}

[Fact]
public void GetMultipleInstancesNotice_SingleInstance_ReturnsNull()
{
Expand Down
87 changes: 10 additions & 77 deletions DevProxy/ApiControllers/ProxyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using DevProxy.Abstractions.Proxy;
using DevProxy.Commands;
using DevProxy.Jwt;
using DevProxy.Logging;
using DevProxy.Proxy;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
Expand Down Expand Up @@ -190,37 +191,24 @@ private async Task WriteLogsAsync(string logFile, int lineCount, string? since,

private async Task StreamLogsAsync(string logFile, int initialLines, string? since, bool useJson, CancellationToken cancellationToken)
{
// Write initial lines
var allLines = await ReadAllLinesAsync(logFile, cancellationToken);
var filteredLines = FilterLines(allLines, since).TakeLast(initialLines).ToList();
using var process = System.Diagnostics.Process.GetCurrentProcess();
using var reader = new LogFileReader(logFile, process.StartTime, LogSinceFilter.ParseSinceOption(since));
var filteredLines = (await reader.ReadLinesAsync(cancellationToken)).TakeLast(initialLines);

foreach (var line in filteredLines)
{
await WriteSseEventAsync(line, useJson, cancellationToken);
}

// Follow new lines
var lastPosition = new FileInfo(logFile).Length;

while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(500, cancellationToken);

try
{
using var fs = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
if (fs.Length > lastPosition)
foreach (var line in await reader.ReadLinesAsync(cancellationToken))
{
_ = fs.Seek(lastPosition, SeekOrigin.Begin);
using var reader = new StreamReader(fs);

string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
await WriteSseEventAsync(line, useJson, cancellationToken);
}

lastPosition = fs.Length;
await WriteSseEventAsync(line, useJson, cancellationToken);
}
}
catch (IOException)
Expand All @@ -246,76 +234,21 @@ private async Task WriteSseEventAsync(string line, bool useJson, CancellationTok
await Response.Body.FlushAsync(cancellationToken);
}

private static IEnumerable<string> FilterLines(IList<string> lines, string? since)
private static IEnumerable<string> FilterLines(List<string> lines, string? since)
{
if (string.IsNullOrEmpty(since))
{
return lines;
}

var sinceTime = ParseSinceOption(since);
var sinceTime = LogSinceFilter.ParseSinceOption(since);
if (sinceTime == null)
{
return lines;
}

return lines.Where(line => LineMatchesSince(line, sinceTime.Value));
}

private static DateTime? ParseSinceOption(string? since)
{
if (string.IsNullOrEmpty(since))
{
return null;
}

// Try parsing as a duration (e.g., "5m", "1h", "30s")
if (since.Length >= 2)
{
var unit = since[^1];
if (int.TryParse(since[..^1], out var value))
{
return unit switch
{
's' => DateTime.Now.AddSeconds(-value),
'm' => DateTime.Now.AddMinutes(-value),
'h' => DateTime.Now.AddHours(-value),
'd' => DateTime.Now.AddDays(-value),
_ => null
};
}
}

// Try parsing as a datetime
if (DateTime.TryParse(since, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTime))
{
return dateTime;
}

return null;
}

private static bool LineMatchesSince(string line, DateTime sinceTime)
{
// Parse timestamp from line format: [HH:mm:ss.fff] ...
if (line.Length < 14 || line[0] != '[' || line[13] != ']')
{
return true;
}

var timestampStr = line[1..13];
if (TimeSpan.TryParseExact(timestampStr, "hh\\:mm\\:ss\\.fff", System.Globalization.CultureInfo.InvariantCulture, out var timeOfDay))
{
var lineTime = DateTime.Today.Add(timeOfDay);
if (lineTime > DateTime.Now)
{
lineTime = lineTime.AddDays(-1);
}

return lineTime >= sinceTime;
}

return true;
using var process = System.Diagnostics.Process.GetCurrentProcess();
return LogSinceFilter.Filter(lines, process.StartTime, sinceTime.Value);
}

private static LogEntryDto ParseLogLine(string line)
Expand Down
Loading
Loading