Skip to content

Add logfile actuator #1499

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

Draft
wants to merge 25 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
37d4b74
Add logfile actuator
tscrypter Apr 3, 2025
a0e8fe2
fix build warnings
tscrypter Apr 3, 2025
711c98f
fix style in Logfile actuator files
tscrypter Apr 3, 2025
ee6cf15
♻️ Rename Logfile to LogFile actuator
tscrypter Apr 4, 2025
7f8d4f9
Merge branch 'main' into main
tscrypter Apr 4, 2025
e48a003
suggested doc update for LogFile svc extensions
tscrypter Apr 7, 2025
d361f82
Suggested docs update for LogFile options
tscrypter Apr 7, 2025
26511de
Replace delimited with separated (#1500)
bart-vmware Apr 4, 2025
f1f808b
Address PR feedback
tscrypter Apr 8, 2025
ee63559
Address PR feedback
tscrypter Apr 8, 2025
88324e5
🚨 Fix public API entries
tscrypter Apr 8, 2025
52cd3b0
Merge branch 'main' into main
tscrypter Apr 8, 2025
5f98eca
Allow network share paths with disk health contributor, better except…
TimHess Apr 8, 2025
04eb095
Merge branch 'SteeltoeOSS:main' into main
tscrypter Apr 9, 2025
013e333
Update src/Management/src/Endpoint/Actuators/LogFile/LogFileEndpointH…
tscrypter Apr 9, 2025
5367550
Update src/Management/test/Endpoint.Test/Actuators/LogFile/EndpointMi…
tscrypter Apr 9, 2025
2b016ef
Update src/Management/test/Endpoint.Test/Actuators/LogFile/EndpointMi…
tscrypter Apr 9, 2025
416957d
🎨 Fix casing in namespace
tscrypter Apr 9, 2025
a65eb25
restore PublicAPI.Unshipped.txt
tscrypter Apr 15, 2025
361636e
start adding range header support
tscrypter Apr 15, 2025
629afc2
add public api changes
tscrypter Apr 15, 2025
5c1f8aa
fix some build errors
tscrypter Apr 15, 2025
5679892
Use default Encoding for this .NET
tscrypter Apr 15, 2025
fefff15
Merge branch 'main' into main
tscrypter Apr 15, 2025
f106e48
Merge branch 'main' into main
tscrypter Apr 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Steeltoe.Management.Endpoint.Actuators.HttpExchanges;
using Steeltoe.Management.Endpoint.Actuators.Hypermedia;
using Steeltoe.Management.Endpoint.Actuators.Info;
using Steeltoe.Management.Endpoint.Actuators.LogFile;
using Steeltoe.Management.Endpoint.Actuators.Loggers;
using Steeltoe.Management.Endpoint.Actuators.Refresh;
using Steeltoe.Management.Endpoint.Actuators.RouteMappings;
Expand Down Expand Up @@ -70,6 +71,7 @@ public static IServiceCollection AddAllActuators(this IServiceCollection service
services.AddRouteMappingsActuator(configureMiddleware);
services.AddRefreshActuator(configureMiddleware);
services.AddServicesActuator(configureMiddleware);
services.AddLogFileActuator(configureMiddleware);

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using Microsoft.Extensions.Configuration;
using Steeltoe.Management.Endpoint.Configuration;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

internal sealed class ConfigureLogFileEndpointOptions(IConfiguration configuration)
: ConfigureEndpointOptions<LogFileEndpointOptions>(configuration, ManagementInfoPrefix, "logfile")
{
private const string ManagementInfoPrefix = "management:endpoints:logfile";

public override void Configure(LogFileEndpointOptions options)
{
options.AllowedVerbs.Add("Get");
options.AllowedVerbs.Add("Head");
Comment on lines +17 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EndpointOptions provides a virtual method GetDefaultAllowedVerbs to set these. For example usage, see LoggersEndpointOptions.

base.Configure(options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using Microsoft.Extensions.DependencyInjection;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

internal static class EndpointServiceCollectionExtensions
{
/// <summary>
/// Adds the logfile actuator to the service container and configures the ASP.NET Core middleware pipeline.
/// </summary>
/// <param name="services">
/// The <see cref="IServiceCollection" /> to add services to.
/// </param>
/// <returns>
/// The incoming <paramref name="services" /> so that additional calls can be chained.
/// </returns>
public static IServiceCollection AddLogFileActuator(this IServiceCollection services)
{
return AddLogFileActuator(services, true);
}

/// <summary>
/// Adds the logfile actuator to the service container.
/// </summary>
/// <param name="services">
/// The <see cref="IServiceCollection" /> to add services to.
/// </param>
/// <param name="configureMiddleware">
/// When <c>false</c>, skips configuration of the ASP.NET Core middleware pipeline. While this provides full control over the pipeline order, it requires
/// manual addition of the appropriate middleware for actuators to work correctly.
/// </param>
/// <returns>
/// The incoming <paramref name="services" /> so that additional calls can be chained.
/// </returns>
public static IServiceCollection AddLogFileActuator(this IServiceCollection services, bool configureMiddleware)
{
ArgumentNullException.ThrowIfNull(services);

services.AddCoreActuatorServices<LogFileEndpointOptions, ConfigureLogFileEndpointOptions, LogFileEndpointMiddleware,
ILogFileEndpointHandler, LogFileEndpointHandler, LogFileEndpointRequest?, LogFileEndpointResponse>(configureMiddleware);

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

public interface ILogFileEndpointHandler : IEndpointHandler<LogFileEndpointRequest?, LogFileEndpointResponse>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Reflection;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Steeltoe.Management.Configuration;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

internal sealed class LogFileEndpointHandler : ILogFileEndpointHandler
{
private readonly IOptionsMonitor<LogFileEndpointOptions> _optionsMonitor;
private readonly ILogger<LogFileEndpointHandler> _logger;

public EndpointOptions Options => _optionsMonitor.CurrentValue;

public LogFileEndpointHandler(IOptionsMonitor<LogFileEndpointOptions> optionsMonitor, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(optionsMonitor);
ArgumentNullException.ThrowIfNull(loggerFactory);

_optionsMonitor = optionsMonitor;
_logger = loggerFactory.CreateLogger<LogFileEndpointHandler>();
}

public async Task<LogFileEndpointResponse> InvokeAsync(LogFileEndpointRequest? argument, CancellationToken cancellationToken)
{
_logger.LogTrace("Invoking {Handler} with argument: {Argument}", nameof(LogFileEndpointHandler), argument);
cancellationToken.ThrowIfCancellationRequested();

string logFilePath = GetLogFilePath();

if (!string.IsNullOrEmpty(logFilePath))
{
FileInfo logFile = new FileInfo(logFilePath);
var logFileResult = new LogFileEndpointResponse(await File.ReadAllTextAsync(logFilePath, cancellationToken),
logFile.Length,
Encoding.Default, // StreamReader.CurrentEncoding is not reliable based on unit tests, so just use default for this .NET implementation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't make assumptions about the file encoding. It may vary per logger provider, over which Steeltoe has no control. For example, in Serilog, it can be overridden.

At the HTTP level, we're using bytes for ranges. The easiest would be to leave out charset in the response Content-Type header. If we want to include it, it should originate from the endpoint configuration that Steeltoe users must set explicitly, depending on their logger framework.

logFile.LastWriteTimeUtc);
return logFileResult;
}

return new LogFileEndpointResponse();
}

internal string GetLogFilePath()
{
_logger.LogTrace("Getting log file path");

if (!string.IsNullOrEmpty(_optionsMonitor.CurrentValue.FilePath))
{
string entryAssemblyDirectory = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!;
return Path.Combine(entryAssemblyDirectory, _optionsMonitor.CurrentValue.FilePath ?? string.Empty);
}

_logger.LogWarning("File path is not set");
return string.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Steeltoe.Management.Endpoint.Configuration;
using Steeltoe.Management.Endpoint.Middleware;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

internal sealed class LogFileEndpointMiddleware(
ILogFileEndpointHandler endpointHandler, IOptionsMonitor<ManagementOptions> managementOptionsMonitor, ILoggerFactory loggerFactory)
: EndpointMiddleware<LogFileEndpointRequest?, LogFileEndpointResponse>(endpointHandler, managementOptionsMonitor, loggerFactory)
{
protected override async Task<LogFileEndpointResponse> InvokeEndpointHandlerAsync(LogFileEndpointRequest? request, CancellationToken cancellationToken)
{
var logFileResponse = await EndpointHandler.InvokeAsync(request, cancellationToken);
return logFileResponse;
}

protected override async Task<LogFileEndpointRequest?> ParseRequestAsync(HttpContext httpContext, CancellationToken cancellationToken)
{
return httpContext.Request.Method.Equals(HttpMethod.Head.Method, StringComparison.OrdinalIgnoreCase)
? await Task.FromResult(new LogFileEndpointRequest(null, null, false))
: null;
}

protected override async Task WriteResponseAsync(LogFileEndpointResponse response, HttpContext httpContext, CancellationToken cancellationToken)
{
if (response.LogFileEncoding != null)
{
httpContext.Response.ContentType = $"text/plain; charset={response.LogFileEncoding?.BodyName}";
}
else
{
httpContext.Response.ContentType = "text/plain;";
}

httpContext.Response.ContentLength = response.ContentLength;
httpContext.Response.StatusCode = StatusCodes.Status200OK;

if (httpContext.Request.Method.Equals(HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase))
{

if (response.Content != null)
{
await httpContext.Response.WriteAsync(response.Content, cancellationToken);
}
}
else
{
httpContext.Response.Headers.AcceptRanges = "bytes";
httpContext.Response.Headers.LastModified = response.LastModified?.ToString("R", CultureInfo.InvariantCulture);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using Steeltoe.Management.Configuration;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

public sealed class LogFileEndpointOptions : EndpointOptions
{
/// <summary>
/// Gets or sets the path to the log file on disk. The path can be absolute, or relative to
/// <see cref="System.Reflection.Assembly.GetEntryAssembly()" />.
/// </summary>
public string? FilePath { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

public class LogFileEndpointRequest(long? rangeStartByte, long? rangeLastByte, bool readContent)
{
public long? RangeStartByte { get; } = rangeStartByte;
public long? RangeLastByte { get; } = rangeLastByte;
public bool ReadContent { get; } = readContent;
Comment on lines +9 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As described here, both the start and end positions are optional, so using nullable is good here. But let's make LogFileEndpointRequest in the pipeline signature non-nullable. This is a public API. Otherwise, if we need to pass extra required information in the future, we need to take a breaking change. Adding an internal static get-only None property is fine if you're concerned with reducing allocations.


public LogFileEndpointRequest()
: this(null, null, true)
{
}

public LogFileEndpointRequest(long? rangeStartByte, long? rangeLastByte)
: this(rangeStartByte, rangeLastByte, true)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Text;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

public class LogFileEndpointResponse(string? content, long? contentLength, Encoding? logFileEncoding, DateTime? lastModified)
{
public string? Content { get; } = content;
public Encoding? LogFileEncoding { get; } = logFileEncoding;
public long? ContentLength { get; } = contentLength;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this, we may want to add the total file size, so it can be returned in the Content-Range header.

public DateTime? LastModified { get; } = lastModified;
public LogFileEndpointResponse()
: this(null, null, null, null)
{
}
}
52 changes: 52 additions & 0 deletions src/Management/src/Endpoint/Actuators/LogFile/LogFileStreamer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Runtime.CompilerServices;

namespace Steeltoe.Management.Endpoint.Actuators.LogFile;

internal static class LogFileStreamer
{
public static async IAsyncEnumerable<byte[]> ReadLogFileAsync(string fullPath, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, useAsync: true);
byte[] buffer = new byte[4096];
int bytesRead;

while ((bytesRead = await fileStream.ReadAsync(buffer, cancellationToken)) > 0)
{
yield return buffer[..bytesRead];
}
}

public static async IAsyncEnumerable<byte[]> ReadLogFileAsync(string fullPath, int startIndex, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, useAsync: true);
byte[] buffer = new byte[4096];
int bytesRead;
// Seek to the start index
fileStream.Seek(startIndex, SeekOrigin.Begin);
while ((bytesRead = await fileStream.ReadAsync(buffer, cancellationToken)) > 0)
{
yield return buffer[..bytesRead];
}
}

public static async IAsyncEnumerable<byte[]> ReadLogFileAsync(string fullPath, int startIndex, int endIndex, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, useAsync: true);
byte[] buffer = new byte[4096];
int bytesRead;
int remainingBytes = endIndex - startIndex;

// Seek to the start index
fileStream.Seek(startIndex, SeekOrigin.Begin);

while (remainingBytes > 0 && (bytesRead = await fileStream.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, remainingBytes)), cancellationToken)) > 0)
{
yield return buffer[..bytesRead];
remainingBytes -= bytesRead;
}
}
}
Loading
Loading