Enhance console logging with improved context and lifecycle (#7536)
* Avoid null endpoint DTO metadata in tests * Enforce console logs hub read permission * Remove unused console logs hub import * Support mapped endpoint metadata in auth tests * Reduce console log capture throughput impact * Address Copilot console logs review * Refactor task scheduling to support tenant-level background work and enhance logging functionality. * Introduce ConsoleStreamHook for stdout/stderr tee and enhance logging validation. Adjust test cases and startup warnings for distributed lock provider usage. * Refactor console logging pipeline with capture optimization and new ConsoleLogsHost; update tests accordingly. * Add Ansi SGR parser for console logs and associated unit tests * Remove ANSI color renderings and parsers; integrate ConsoleLogScopeAccessor for improved logging context with workflow instance ID support. * Address console logs code quality feedback * Address PR review feedback * Preserve console logs extension points * Stabilize console logs host lifecycle * Address final automated review comments * Tighten console log capture shutdown * Address console log review feedback * Address follow-up review feedback * Cover final review feedback * Avoid recursive console provider initialization * Guard console host lease shutdown * Preserve console log scope and provider lifetime * Correlate console log scope fallback * Tighten console scope correlation * Expose host services during provider construction * Redact ANSI-normalized console lines * Remove `ConsoleCaptureTee` and related services and tests * Use pipeline contributors for console log context * Update CShells package versions to 0.0.24-preview.132 * Filter live console logs by workflow instance * Enhance console logging with activity execution metadata and extend test coverage. * Address console logs stream consumption comment
This commit is contained in:
parent
8e301d4e1e
commit
5245599131
|
|
@ -102,12 +102,12 @@
|
|||
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/>
|
||||
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/>
|
||||
<PackageVersion Include="Cronos" Version="0.11.1"/>
|
||||
<PackageVersion Include="CShells" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells.Abstractions" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells.AspNetCore" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells.FastEndpoints" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.24"/>
|
||||
<PackageVersion Include="CShells" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="CShells.Abstractions" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="CShells.AspNetCore" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="CShells.FastEndpoints" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.24-preview.132"/>
|
||||
<PackageVersion Include="Elsa.PackageManifest.Generator" Version="0.0.1-preview.43"/>
|
||||
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.32.0"/>
|
||||
<PackageVersion Include="DistributedLock" Version="2.7.1"/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Diagnostics Console Logs
|
||||
|
||||
`Elsa.Diagnostics.ConsoleLogs` is an opt-in module that captures raw `stdout` and `stderr` from the Elsa host process, buffers recent lines, and streams live console output to authorized callers over SignalR. It is intentionally separate from `Elsa.Diagnostics.StructuredLogs`; the two modules cover different diagnostic surfaces.
|
||||
`Elsa.Diagnostics.ConsoleLogs` is an opt-in module that hosts `ConsoleLogStream.Core`, adds Elsa workflow metadata, and streams recent plus live console output to authorized callers over REST and SignalR. It is intentionally separate from `Elsa.Diagnostics.StructuredLogs`; the two modules cover different diagnostic surfaces.
|
||||
|
||||
Start in [src/modules/Elsa.Diagnostics.ConsoleLogs](../../src/modules/Elsa.Diagnostics.ConsoleLogs).
|
||||
|
||||
|
|
@ -19,26 +19,22 @@ This module captures raw process console output only. It does not parse `ILogger
|
|||
[AddConsoleLogsServices](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs) registers:
|
||||
|
||||
- SignalR
|
||||
- `ConsoleLogsOptions`
|
||||
- source registry
|
||||
- redactor
|
||||
- line formatter
|
||||
- in-memory console log provider
|
||||
- `ConsoleLogStream.Core.Options.ConsoleLogOptions`
|
||||
- host-owned `ConsoleLogStream.Core` provider, source registry, redaction pipeline, formatter, capture services, and hosted service
|
||||
- `ConsoleLogContextAccessor` for ambient workflow metadata
|
||||
- subscription manager
|
||||
- `ConsoleCaptureTee` as `IConsoleLogCapture`
|
||||
- `ConsoleLogCaptureHostedService` hosted service
|
||||
|
||||
The hosted service installs a `TextWriter` tee that writes to both the original console destination and the capture pipeline. Original stdout and stderr destinations are preserved.
|
||||
The core hosted service initializes the process-wide `ConsoleLogStream.Core.ConsoleLogStreamHost`. Original stdout and stderr destinations are preserved by the core capture hook.
|
||||
|
||||
## Core Contracts
|
||||
|
||||
| Contract | Purpose |
|
||||
| --- | --- |
|
||||
| [IConsoleLogCapture](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs) | Capture pipeline entry point. |
|
||||
| [IConsoleLogProvider](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogProvider.cs) | REST/SignalR facade used by endpoints and subscription manager. |
|
||||
| [IConsoleLogSourceRegistry](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogSourceRegistry.cs) | Tracks source metadata and health. |
|
||||
| [IConsoleLogRedactor](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogRedactor.cs) | Redacts text before buffering and streaming. |
|
||||
| [IConsoleLogDroppedLineReporter](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogDroppedLineReporter.cs) | Reports dropped-line counts when buffers overflow. |
|
||||
| `ConsoleLogStream.Core.IConsoleLogCapture` | Capture pipeline entry point. |
|
||||
| `ConsoleLogStream.Core.IConsoleLogProvider` | Provider used by endpoints and subscription manager. |
|
||||
| `ConsoleLogStream.Core.IConsoleLogSourceRegistry` | Tracks source metadata and health. |
|
||||
| `ConsoleLogStream.Core.IConsoleLogRedactionPipeline` | Runs registered redactors before buffering and streaming. |
|
||||
| `ConsoleLogStream.Core.IConsoleLogDroppedLineReporter` | Reports dropped-line counts when buffers overflow. |
|
||||
|
||||
## Event Flow
|
||||
|
||||
|
|
@ -47,22 +43,22 @@ Captured console writes pass through redaction before reaching any consumer:
|
|||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Console as Process stdout/stderr
|
||||
participant Tee as ConsoleCaptureTee
|
||||
participant Hook as ConsoleLogStreamHost
|
||||
participant Original as Original TextWriter
|
||||
participant Redactor as IConsoleLogRedactor
|
||||
participant Buffer as ConsoleLineBuffer
|
||||
participant Manager as ConsoleLogSubscriptionManager
|
||||
participant Hub as ConsoleLogsHub
|
||||
participant Pipeline as Core redaction pipeline
|
||||
participant Provider as Core provider
|
||||
participant Manager as ConsoleLogStream.SignalR subscription manager
|
||||
participant Hub as ConsoleLogStream.SignalR hub
|
||||
participant Client as Authorized caller
|
||||
|
||||
Console->>Tee: write bytes/chars
|
||||
Tee->>Original: pass through (preserved)
|
||||
Tee->>Redactor: redact line text
|
||||
Redactor->>Buffer: append redacted line
|
||||
Redactor->>Manager: publish redacted line
|
||||
Console->>Hook: write bytes/chars
|
||||
Hook->>Original: pass through (preserved)
|
||||
Hook->>Pipeline: redact line and metadata
|
||||
Pipeline->>Provider: publish redacted line
|
||||
Provider->>Manager: live subscription event
|
||||
Manager->>Hub: live event
|
||||
Hub->>Client: SignalR stream
|
||||
Client->>Buffer: recent query through REST
|
||||
Client->>Provider: recent query through REST
|
||||
```
|
||||
|
||||
## REST And SignalR Surface
|
||||
|
|
@ -76,7 +72,7 @@ Endpoint code is under [Endpoints/ConsoleLogs](../../src/modules/Elsa.Diagnostic
|
|||
|
||||
SignalR:
|
||||
|
||||
- Hub: [ConsoleLogsHub](../../src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs)
|
||||
- Hub: `ConsoleLogStream.SignalR.ConsoleLogsHub`
|
||||
- Route: `/elsa/hubs/diagnostics/console-logs`
|
||||
- Mapping: [MapConsoleLogsHub](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs)
|
||||
- App extension: [UseConsoleLogs](../../src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ApplicationBuilderExtensions.cs)
|
||||
|
|
@ -87,11 +83,11 @@ All endpoints and the SignalR hub require `read:diagnostics:console-logs`, defin
|
|||
|
||||
## Safety Boundaries
|
||||
|
||||
- Redaction runs before recent buffering, live streaming, and endpoint responses.
|
||||
- ANSI escape sequences are stripped by default (`StripAnsiEscapeSequences = true`).
|
||||
- Redaction runs in `ConsoleLogStream.Core` before recent buffering, live streaming, and endpoint responses.
|
||||
- ANSI escape sequences are preserved by default; set `PreserveAnsi = false` to strip them server-side.
|
||||
- Partial writes (no trailing newline) are buffered until the line completes, reaches the maximum line length, or an idle flush occurs.
|
||||
- Lines longer than `MaxLineLength` are truncated to one event and marked as truncated.
|
||||
- Dropped-line counts are reported through `IConsoleLogDroppedLineReporter` when buffers or subscriber queues overflow.
|
||||
- Dropped-line counts are reported through `ConsoleLogStream.Core.IConsoleLogDroppedLineReporter` when buffers or subscriber queues overflow.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -100,11 +96,11 @@ services.AddElsa(elsa =>
|
|||
{
|
||||
elsa.UseConsoleLogs(options =>
|
||||
{
|
||||
options.RecentLogCapacity = 5_000;
|
||||
options.SubscriberChannelCapacity = 1_000;
|
||||
options.RecentCapacity = 5_000;
|
||||
options.SubscriberCapacity = 1_000;
|
||||
options.MaxRecentQuerySize = 1_000;
|
||||
options.MaxLineLength = 16_384;
|
||||
options.StripAnsiEscapeSequences = true;
|
||||
options.PreserveAnsi = true;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
|
@ -121,5 +117,6 @@ app.UseConsoleLogs();
|
|||
|
||||
## Tests
|
||||
|
||||
- [test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests](../../test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests): capture, filtering, redaction, buffering, source registry, and naming.
|
||||
- [test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests](../../test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests): module registration and naming.
|
||||
- [test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests](../../test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests): module registration, endpoint authorization, SignalR hub behavior, and recent query endpoint.
|
||||
- `ConsoleLogStream.Core` tests cover capture, filtering, redaction, buffering, source registry, and providers.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ConsoleLogStreaming.Core.Capture;
|
||||
using CShells.AspNetCore.Configuration;
|
||||
using CShells.AspNetCore.Extensions;
|
||||
using CShells.DependencyInjection;
|
||||
|
|
@ -69,4 +70,4 @@ app.MapShells();
|
|||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapSampleCatalog();
|
||||
app.Run();
|
||||
app.Run();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Console log filter accepted by Elsa REST and SignalR endpoints.
|
||||
/// </summary>
|
||||
public sealed record ElsaConsoleLogFilter
|
||||
{
|
||||
public string? SourceId { get; init; }
|
||||
public global::ConsoleLogStreaming.Contracts.ConsoleLogStreaming? Stream { get; init; }
|
||||
public string? Query { get; init; }
|
||||
public string? WorkflowInstanceId { get; init; }
|
||||
public string? WorkflowDefinitionId { get; init; }
|
||||
public string? WorkflowDefinitionVersionId { get; init; }
|
||||
public string? ActivityInstanceId { get; init; }
|
||||
public string? ActivityId { get; init; }
|
||||
public string? ActivityNodeId { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset? From { get; init; }
|
||||
public DateTimeOffset? To { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
[Obsolete("Console log capture is host-owned. Use ConsoleLogsHost.Capture for host-level operations.")]
|
||||
public interface IConsoleLogCapture : IAsyncDisposable
|
||||
{
|
||||
ValueTask StartAsync(CancellationToken cancellationToken = default);
|
||||
ValueTask StopAsync(CancellationToken cancellationToken = default);
|
||||
ValueTask FlushIdleAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Provides ambient metadata for console log capture.
|
||||
/// </summary>
|
||||
public interface IConsoleLogContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the metadata attached to the current async execution context.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, string> GetMetadata();
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a metadata value for the current async execution context.
|
||||
/// </summary>
|
||||
IDisposable PushMetadata(string key, string value);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the current workflow instance ID for console log ownership.
|
||||
/// </summary>
|
||||
IDisposable PushWorkflowInstanceId(string workflowInstanceId);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
public interface IConsoleLogDroppedLineReporter
|
||||
{
|
||||
void ReportDropped(ConsoleLogDroppedSummary summary);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
public interface IConsoleLogProvider
|
||||
{
|
||||
ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
|
||||
|
||||
IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
public interface IConsoleLogRedactor
|
||||
{
|
||||
ConsoleLogLine Redact(ConsoleLogLine line);
|
||||
|
||||
ConsoleLogSource Redact(ConsoleLogSource source);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
public interface IConsoleLogSourceRegistry
|
||||
{
|
||||
event Action<ConsoleLogSource>? SourceChanged;
|
||||
|
||||
ConsoleLogSource Current { get; }
|
||||
|
||||
void MarkSeen(string sourceId, DateTimeOffset timestamp);
|
||||
|
||||
IReadOnlyCollection<ConsoleLogSource> List();
|
||||
}
|
||||
|
|
@ -12,7 +12,11 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\ValenceWorks\console-log-stream\src\ConsoleLogStreaming.Contracts\ConsoleLogStreaming.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\ValenceWorks\console-log-stream\src\ConsoleLogStreaming.Core\ConsoleLogStreaming.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\ValenceWorks\console-log-stream\src\ConsoleLogStreaming.SignalR\ConsoleLogStreaming.SignalR.csproj" />
|
||||
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using Elsa.Abstractions;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Permissions;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent;
|
||||
|
||||
[PublicAPI]
|
||||
internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpoint<ConsoleLogFilter, RecentConsoleLogsResult>
|
||||
internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpoint<ElsaConsoleLogFilter, RecentConsoleLogsResult>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
|
|
@ -14,8 +17,9 @@ internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpoint<ConsoleLogF
|
|||
ConfigurePermissions(ConsoleLogsPermissions.Read);
|
||||
}
|
||||
|
||||
public override async Task<RecentConsoleLogsResult> ExecuteAsync(ConsoleLogFilter request, CancellationToken cancellationToken)
|
||||
public override async Task<RecentConsoleLogsResult> ExecuteAsync(ElsaConsoleLogFilter request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await provider.GetRecentAsync(request, cancellationToken);
|
||||
var result = await provider.GetRecentAsync(mapper.ToCore(ConsoleLogFilterMapper.ToStreamingFilter(request)), cancellationToken);
|
||||
return mapper.ToApi(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using Elsa.Abstractions;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Permissions;
|
||||
using JetBrains.Annotations;
|
||||
|
|
@ -5,7 +6,7 @@ using JetBrains.Annotations;
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources;
|
||||
|
||||
[PublicAPI]
|
||||
internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutRequest<IReadOnlyCollection<ConsoleLogSource>>
|
||||
internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpointWithoutRequest<IReadOnlyCollection<ConsoleLogSource>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
|
|
@ -15,6 +16,7 @@ internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutReque
|
|||
|
||||
public override async Task<IReadOnlyCollection<ConsoleLogSource>> ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await provider.ListSourcesAsync(cancellationToken);
|
||||
var sources = await provider.ListSourcesAsync(cancellationToken);
|
||||
return sources.Select(mapper.ToApi).ToList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
|
||||
internal static class ConsoleLogContextServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddConsoleLogContextServices(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(_ => ConsoleLogContextAccessor.Instance);
|
||||
services.TryAddSingleton<IConsoleLogContextAccessor>(sp => sp.GetRequiredService<ConsoleLogContextAccessor>());
|
||||
services.TryAddSingleton<IConsoleLogMetadataAccessor>(sp => sp.GetRequiredService<ConsoleLogContextAccessor>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Scoped<IWorkflowExecutionPipelineContributor, ConsoleLogWorkflowExecutionPipelineContributor>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Scoped<IActivityExecutionPipelineContributor, ConsoleLogActivityExecutionPipelineContributor>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
using ConsoleLogStreaming.SignalR;
|
||||
using Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
|
||||
|
|
@ -10,6 +13,12 @@ public static class EndpointRouteBuilderExtensions
|
|||
|
||||
public static void MapConsoleLogsHub(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHub<ConsoleLogsHub>(HubRoute);
|
||||
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<ConsoleLogStreamingSignalROptions>>().Value;
|
||||
var hub = endpoints.MapHub<ElsaConsoleLogsHub>(options.HubPath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AuthorizationPolicy))
|
||||
hub.RequireAuthorization(options.AuthorizationPolicy);
|
||||
|
||||
options.ConfigureHubEndpoint?.Invoke(hub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,32 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using ConsoleLogStreaming.Contracts;
|
||||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.Core.DependencyInjection;
|
||||
using ConsoleLogStreaming.Core.Options;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
using ConsoleLogStreaming.SignalR.DependencyInjection;
|
||||
using Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
|
||||
public static class HostServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the host-level console capture pipeline. Call this from <c>Program.cs</c> (ideally right
|
||||
/// after <c>ConsoleStreamHook.Install()</c>) so that the capture singleton, the in-memory ring buffer,
|
||||
/// the source registry, and the idle flush loop are owned by the root host rather than by any
|
||||
/// individual shell.
|
||||
///
|
||||
/// <para>
|
||||
/// Shell-level features that surface console logs (REST endpoints, SignalR hub) still call
|
||||
/// <c>AddConsoleLogsServices</c> on their own service collection — those registrations resolve to the
|
||||
/// same host-owned singletons exposed by <see cref="ConsoleLogsHost"/>.
|
||||
/// </para>
|
||||
/// Registers the host-level console capture pipeline.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddConsoleLogsHost(this IServiceCollection services, Action<ConsoleLogsOptions>? configure = null)
|
||||
public static IServiceCollection AddConsoleLogsHost(this IServiceCollection services, Action<ConsoleLogOptions>? configure = null)
|
||||
{
|
||||
ConsoleStreamHook.Install();
|
||||
|
||||
if (configure != null)
|
||||
ConsoleLogsHost.Configure(configure);
|
||||
|
||||
services.TryAddSingleton(_ => ConsoleLogsHost.ScopeAccessor);
|
||||
services.AddSingleton<ILoggerProvider>(sp => sp.GetRequiredService<ConsoleLogScopeAccessor>());
|
||||
#pragma warning disable CS0618
|
||||
services.TryAddSingleton<IConsoleLogCapture, ConsoleLogCaptureAdapter>();
|
||||
#pragma warning restore CS0618
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<Microsoft.Extensions.Hosting.IHostedService, ConsoleLogsHostedService>());
|
||||
services.AddConsoleLogContextServices();
|
||||
services.AddConsoleLogStreamingHost(options =>
|
||||
{
|
||||
ElsaConsoleLogOptions.ConfigureDefaults(options);
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute);
|
||||
services.TryAddSingleton<IConsoleLogStreamingHubAuthorizer, ElsaConsoleLogStreamingHubAuthorizer>();
|
||||
services.TryAddSingleton<ElsaConsoleLogSubscriptionManager>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ConsoleLogStreaming.Core.Options;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Features;
|
||||
using Elsa.Features.Services;
|
||||
|
||||
|
|
@ -10,4 +11,9 @@ public static class ModuleExtensions
|
|||
{
|
||||
return module.Use(configure);
|
||||
}
|
||||
|
||||
public static IModule UseConsoleLogs(this IModule module, Action<ConsoleLogOptions> configureOptions)
|
||||
{
|
||||
return module.UseConsoleLogs(feature => feature.ConfigureOptions = configureOptions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,32 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using ConsoleLogStreaming.Contracts;
|
||||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.Core.DependencyInjection;
|
||||
using ConsoleLogStreaming.Core.Options;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
using ConsoleLogStreaming.SignalR.DependencyInjection;
|
||||
using Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers shell-level console-log consumers (SignalR, subscription manager) and exposes the
|
||||
/// process-wide capture pipeline owned by <see cref="ConsoleLogsHost"/> through DI. Configuration
|
||||
/// supplied here applies only if the host has not yet been initialized (first-wins). Prefer calling
|
||||
/// <c>AddConsoleLogsHost</c> from <c>Program.cs</c> for deterministic host-level configuration.
|
||||
/// Registers shell-level console-log consumers and exposes the process-wide capture pipeline through DI.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action<ConsoleLogsOptions>? configureOptions = null)
|
||||
public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action<ConsoleLogOptions>? configureOptions = null)
|
||||
{
|
||||
ConsoleStreamHook.Install();
|
||||
var defaultProviderDescriptor = ServiceDescriptor.Singleton<IConsoleLogProvider>(_ => ConsoleLogsHost.Provider);
|
||||
|
||||
if (configureOptions != null)
|
||||
ConsoleLogsHost.Configure(configureOptions);
|
||||
|
||||
services.TryAdd(defaultProviderDescriptor);
|
||||
services.TryAddSingleton(new ConsoleLogsProviderRegistration(services, defaultProviderDescriptor));
|
||||
|
||||
ConsoleLogsProviderRegistration GetRegistration(IServiceProvider serviceProvider) =>
|
||||
serviceProvider.GetRequiredService<ConsoleLogsProviderRegistration>();
|
||||
|
||||
void ConfigureCustomProvider(IServiceProvider serviceProvider) =>
|
||||
GetRegistration(serviceProvider).ConfigureProvider(serviceProvider);
|
||||
|
||||
services.AddSignalR();
|
||||
|
||||
// Every shell resolves the same process-wide singletons — console output is a shared OS resource.
|
||||
services.TryAddSingleton<IOptions<ConsoleLogsOptions>>(sp =>
|
||||
services.AddConsoleLogContextServices();
|
||||
services.AddConsoleLogStreamingHost(options =>
|
||||
{
|
||||
if (GetRegistration(sp).TryGetHostOptions(out var options))
|
||||
return options;
|
||||
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.Options;
|
||||
ElsaConsoleLogOptions.ConfigureDefaults(options);
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
services.TryAddSingleton<IConsoleLogSourceRegistry>(sp =>
|
||||
{
|
||||
if (GetRegistration(sp).TryGetHostSourceRegistry(out var sourceRegistry))
|
||||
return sourceRegistry;
|
||||
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.SourceRegistry;
|
||||
});
|
||||
services.TryAddSingleton<IConsoleLogRedactor>(sp =>
|
||||
{
|
||||
if (GetRegistration(sp).TryGetHostRedactor(out var redactor))
|
||||
return redactor;
|
||||
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.Redactor;
|
||||
});
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
if (GetRegistration(sp).TryGetHostFormatter(out var formatter))
|
||||
return formatter;
|
||||
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.Formatter;
|
||||
});
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
if (GetRegistration(sp).TryGetHostScopeAccessor(out var scopeAccessor))
|
||||
return scopeAccessor;
|
||||
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.ScopeAccessor;
|
||||
});
|
||||
services.AddSingleton<ILoggerProvider>(sp =>
|
||||
{
|
||||
ConfigureCustomProvider(sp);
|
||||
return ConsoleLogsHost.ScopeAccessor;
|
||||
});
|
||||
#pragma warning disable CS0618
|
||||
services.TryAddSingleton<IConsoleLogCapture, ConsoleLogCaptureAdapter>();
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// The subscription manager wraps a per-shell SignalR hub context, so it must be per-shell.
|
||||
services.TryAddSingleton<ConsoleLogSubscriptionManager>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, ConsoleLogsHostedService>());
|
||||
services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute);
|
||||
services.TryAddSingleton<IConsoleLogStreamingHubAuthorizer, ElsaConsoleLogStreamingHubAuthorizer>();
|
||||
services.TryAddSingleton<ElsaConsoleLogSubscriptionManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ConsoleLogStreaming.Core.Options;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Features.Abstractions;
|
||||
|
|
@ -7,7 +8,7 @@ namespace Elsa.Diagnostics.ConsoleLogs.Features;
|
|||
|
||||
public class ConsoleLogsFeature(IModule module) : FeatureBase(module)
|
||||
{
|
||||
public Action<ConsoleLogsOptions>? ConfigureOptions { get; set; }
|
||||
public Action<ConsoleLogOptions>? ConfigureOptions { get; set; }
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record ConsoleLogDroppedSummary(
|
||||
string? SourceId,
|
||||
ConsoleLogStream? Stream,
|
||||
string Reason,
|
||||
long Count,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null);
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record ConsoleLogFilter
|
||||
{
|
||||
public string? SourceId { get; init; }
|
||||
public ConsoleLogStream? Stream { get; init; }
|
||||
public string? Query { get; init; }
|
||||
public string? WorkflowInstanceId { get; init; }
|
||||
public DateTimeOffset? From { get; init; }
|
||||
public DateTimeOffset? To { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record ConsoleLogLine
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString("N");
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset ReceivedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public long Sequence { get; init; }
|
||||
public ConsoleLogStream Stream { get; init; }
|
||||
public string Text { get; init; } = string.Empty;
|
||||
public ConsoleLogSource Source { get; init; } = default!;
|
||||
public string? WorkflowInstanceId { get; init; }
|
||||
public bool Truncated { get; init; }
|
||||
public ConsoleLogDroppedSummary? Dropped { get; init; }
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record ConsoleLogSource
|
||||
{
|
||||
public string Id { get; init; } = default!;
|
||||
public string DisplayName { get; init; } = default!;
|
||||
public string? ServiceName { get; init; }
|
||||
public int ProcessId { get; init; } = Environment.ProcessId;
|
||||
public string MachineName { get; init; } = Environment.MachineName;
|
||||
public string? PodName { get; init; }
|
||||
public string? ContainerName { get; init; }
|
||||
public string? Namespace { get; init; }
|
||||
public string? NodeName { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? LastSeen { get; init; }
|
||||
public ConsoleLogSourceHealth Health { get; init; } = ConsoleLogSourceHealth.Unknown;
|
||||
public IDictionary<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public enum ConsoleLogSourceHealth
|
||||
{
|
||||
Unknown = 0,
|
||||
Connected = 1,
|
||||
Stale = 2,
|
||||
Disconnected = 3
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public enum ConsoleLogStream
|
||||
{
|
||||
Stdout = 0,
|
||||
Stderr = 1
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record ConsoleLogStreamItem(
|
||||
ConsoleLogLine? Line = null,
|
||||
ConsoleLogDroppedSummary? DroppedLines = null,
|
||||
ConsoleLogSource? Source = null)
|
||||
{
|
||||
public static ConsoleLogStreamItem FromLine(ConsoleLogLine line) => new(Line: line);
|
||||
|
||||
public static ConsoleLogStreamItem FromDroppedLines(ConsoleLogDroppedSummary summary) => new(DroppedLines: summary);
|
||||
|
||||
public static ConsoleLogStreamItem FromSource(ConsoleLogSource source) => new(Source: source);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
|
||||
public record RecentConsoleLogsResult(
|
||||
IReadOnlyCollection<ConsoleLogLine> Items,
|
||||
IReadOnlyCollection<ConsoleLogDroppedSummary>? Dropped = null,
|
||||
IReadOnlyCollection<ConsoleLogSource>? Sources = null);
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Options;
|
||||
|
||||
public class ConsoleLogsOptions
|
||||
{
|
||||
public int RecentLogCapacity { get; set; } = 5_000;
|
||||
public int SubscriberChannelCapacity { get; set; } = 1_000;
|
||||
public int CaptureChannelCapacity { get; set; } = 5_000;
|
||||
public int MaxRecentQuerySize { get; set; } = 1_000;
|
||||
public int MaxLineLength { get; set; } = 16_384;
|
||||
public TimeSpan IdleFlushTimeout { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// When true, ANSI escape sequences (colors, cursor commands, etc.) are removed before the captured
|
||||
/// line is published. The default is <c>false</c> so the wire payload preserves the original bytes the
|
||||
/// process wrote; consumers (e.g., the Studio) decide whether to render or strip them client-side.
|
||||
/// </summary>
|
||||
public bool StripAnsiEscapeSequences { get; set; } = false;
|
||||
public TimeSpan SourceHeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
public bool IncludeConsoleLogsInternalLogs { get; set; }
|
||||
public string RedactionReplacement { get; set; } = "[Redacted]";
|
||||
|
||||
public ICollection<string> SensitiveNames { get; set; } =
|
||||
[
|
||||
"authorization",
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"api-key",
|
||||
"apikey",
|
||||
"cookie",
|
||||
"connection-string",
|
||||
"connectionstring"
|
||||
];
|
||||
|
||||
public ICollection<string> SensitiveTextPatterns { get; set; } =
|
||||
[
|
||||
"(?i)bearer\\s+[A-Za-z0-9._~+/=-]+",
|
||||
"(?i)(password|secret|token|api[-_]?key)\\s*[=:]\\s*[^\\s,;]+",
|
||||
"(?i)(AccountKey|SharedAccessKey)=([^;\\s]+)"
|
||||
];
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
using System.Threading.Channels;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
|
||||
public class InMemoryConsoleLogProvider(IOptions<ConsoleLogsOptions> options, IConsoleLogSourceRegistry sourceRegistry) : IConsoleLogProvider, IConsoleLogDroppedLineReporter
|
||||
{
|
||||
private readonly ConsoleLogsOptions _options = options.Value;
|
||||
private readonly RingBuffer<ConsoleLogLine> _recentLines = new(options.Value.RecentLogCapacity);
|
||||
private readonly object _subscribersLock = new();
|
||||
private readonly Dictionary<Guid, ConsoleLogSubscriber> _subscribers = new();
|
||||
private readonly object _droppedLock = new();
|
||||
private readonly Dictionary<(string? SourceId, ConsoleLogStream? Stream, string Reason), long> _dropped = new();
|
||||
|
||||
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_recentLines.Add(line);
|
||||
sourceRegistry.MarkSeen(line.Source.Id, line.ReceivedAt);
|
||||
|
||||
List<ConsoleLogSubscriber> subscribers;
|
||||
lock (_subscribersLock)
|
||||
subscribers = _subscribers.Values.ToList();
|
||||
|
||||
foreach (var subscriber in subscribers)
|
||||
subscriber.TryWrite(line, _options.SubscriberChannelCapacity);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var limit = Math.Clamp(filter.Limit ?? _options.MaxRecentQuerySize, 0, _options.MaxRecentQuerySize);
|
||||
var items = _recentLines
|
||||
.Snapshot()
|
||||
.Where(x => ConsoleLogFilterEvaluator.Matches(x, filter))
|
||||
.OrderBy(x => x.ReceivedAt)
|
||||
.ThenBy(x => x.Timestamp)
|
||||
.ThenBy(x => x.Source.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(x => x.Sequence)
|
||||
.ThenBy(x => x.Id, StringComparer.Ordinal)
|
||||
.TakeLast(limit)
|
||||
.ToList();
|
||||
|
||||
var dropped = ConsumeDroppedSummaries();
|
||||
|
||||
return ValueTask.FromResult(new RecentConsoleLogsResult(items, dropped));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var subscriberId = Guid.NewGuid();
|
||||
var subscriber = new ConsoleLogSubscriber(filter);
|
||||
|
||||
lock (_subscribersLock)
|
||||
_subscribers[subscriberId] = subscriber;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var item in subscriber.Channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
subscriber.MarkConsumed(item);
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_subscribersLock)
|
||||
_subscribers.Remove(subscriberId);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(sourceRegistry.List());
|
||||
}
|
||||
|
||||
public void ReportDropped(ConsoleLogDroppedSummary summary)
|
||||
{
|
||||
lock (_droppedLock)
|
||||
{
|
||||
var key = (summary.SourceId, summary.Stream, summary.Reason);
|
||||
_dropped[key] = _dropped.GetValueOrDefault(key) + summary.Count;
|
||||
}
|
||||
|
||||
List<ConsoleLogSubscriber> subscribers;
|
||||
lock (_subscribersLock)
|
||||
subscribers = _subscribers.Values.ToList();
|
||||
|
||||
foreach (var subscriber in subscribers)
|
||||
subscriber.TryWrite(summary, _options.SubscriberChannelCapacity);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<ConsoleLogDroppedSummary> ConsumeDroppedSummaries()
|
||||
{
|
||||
var summaries = new List<ConsoleLogDroppedSummary>();
|
||||
var recentDroppedCount = _recentLines.ConsumeDroppedCount();
|
||||
if (recentDroppedCount > 0)
|
||||
summaries.Add(new ConsoleLogDroppedSummary(null, null, "RecentBufferFull", recentDroppedCount));
|
||||
|
||||
lock (_droppedLock)
|
||||
{
|
||||
summaries.AddRange(_dropped.Select(x => new ConsoleLogDroppedSummary(x.Key.SourceId, x.Key.Stream, x.Key.Reason, x.Value)));
|
||||
_dropped.Clear();
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private sealed class ConsoleLogSubscriber(ConsoleLogFilter filter)
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private int _pendingItemCount;
|
||||
private long _droppedSinceLastSummary;
|
||||
private bool _summaryQueued;
|
||||
|
||||
public Channel<ConsoleLogStreamItem> Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded<ConsoleLogStreamItem>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void TryWrite(ConsoleLogLine line, int capacity)
|
||||
{
|
||||
if (!ConsoleLogFilterEvaluator.Matches(line, filter))
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pendingItemCount >= capacity)
|
||||
{
|
||||
_droppedSinceLastSummary++;
|
||||
QueueDroppedSummaryIfNeeded(line);
|
||||
return;
|
||||
}
|
||||
|
||||
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromLine(line));
|
||||
_pendingItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public void TryWrite(ConsoleLogDroppedSummary summary, int capacity)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pendingItemCount >= capacity)
|
||||
return;
|
||||
|
||||
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromDroppedLines(summary));
|
||||
_pendingItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkConsumed(ConsoleLogStreamItem item)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_pendingItemCount = Math.Max(0, _pendingItemCount - 1);
|
||||
|
||||
if (item.DroppedLines != null)
|
||||
{
|
||||
_summaryQueued = false;
|
||||
QueueDroppedSummaryIfNeeded(item.Line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueDroppedSummaryIfNeeded(ConsoleLogLine? line)
|
||||
{
|
||||
if (_summaryQueued || _droppedSinceLastSummary == 0)
|
||||
return;
|
||||
|
||||
var summary = new ConsoleLogDroppedSummary(line?.Source.Id, line?.Stream, "SubscriberChannelFull", _droppedSinceLastSummary);
|
||||
_droppedSinceLastSummary = 0;
|
||||
_summaryQueued = true;
|
||||
_pendingItemCount++;
|
||||
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromDroppedLines(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
|
||||
public class RingBuffer<T>
|
||||
{
|
||||
private readonly Queue<T> _items = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly int _capacity;
|
||||
|
||||
public RingBuffer(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero.");
|
||||
|
||||
_capacity = capacity;
|
||||
}
|
||||
|
||||
public long DroppedCount { get; private set; }
|
||||
|
||||
public void Add(T item)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_items.Count == _capacity)
|
||||
{
|
||||
_items.Dequeue();
|
||||
DroppedCount++;
|
||||
}
|
||||
|
||||
_items.Enqueue(item);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<T> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
return _items.ToList();
|
||||
}
|
||||
|
||||
public long ConsumeDroppedCount()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var droppedCount = DroppedCount;
|
||||
DroppedCount = 0;
|
||||
return droppedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Elsa Diagnostics Console Logs
|
||||
|
||||
`Elsa.Diagnostics.ConsoleLogs` is an opt-in Core module for operational diagnostics. It captures raw backend `stdout` and `stderr` lines, preserves the host's original console destinations, redacts data before provider boundaries, and exposes recent plus live console output to authorized callers.
|
||||
`Elsa.Diagnostics.ConsoleLogs` is an opt-in Core module for operational diagnostics. It hosts `ConsoleLogStreaming.Core`, adds Elsa workflow metadata, and exposes recent plus live console output to authorized callers.
|
||||
|
||||
## What It Captures
|
||||
|
||||
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
The module is separate from `Elsa.Diagnostics.StructuredLogs`. It does not parse `ILogger` records, provide durable audit storage, call orchestrator log APIs, or implement trace/metric exploration.
|
||||
|
||||
Capture, redaction, buffering, source tracking, provider contracts, and live subscriptions come from `ConsoleLogStreaming.Core`. Elsa keeps only the module wiring, workflow metadata accessor, authorization, REST endpoints, SignalR hub, and DTO mapping.
|
||||
|
||||
## Configure
|
||||
|
||||
```csharp
|
||||
|
|
@ -18,11 +20,11 @@ services.AddElsa(elsa =>
|
|||
{
|
||||
elsa.UseConsoleLogs(options =>
|
||||
{
|
||||
options.RecentLogCapacity = 5_000;
|
||||
options.SubscriberChannelCapacity = 1_000;
|
||||
options.RecentCapacity = 5_000;
|
||||
options.SubscriberCapacity = 1_000;
|
||||
options.MaxRecentQuerySize = 1_000;
|
||||
options.MaxLineLength = 16_384;
|
||||
options.StripAnsiEscapeSequences = false; // pass colors through to consumers
|
||||
options.PreserveAnsi = true; // pass colors through to consumers
|
||||
});
|
||||
});
|
||||
```
|
||||
|
|
@ -67,10 +69,12 @@ tee, the capture publishes them verbatim, and the Studio renders them.
|
|||
|
||||
Recent, source, and hub access all require the same permission.
|
||||
|
||||
Custom storage providers should implement `ConsoleLogStreaming.Core.IConsoleLogProvider` and use the core models. Elsa-specific values such as workflow instance IDs are represented as metadata internally and projected onto shared `ConsoleLogStreaming.Contracts` DTOs at the REST and SignalR boundaries.
|
||||
|
||||
## Safety Boundaries
|
||||
|
||||
- Redaction runs before recent buffering, live streaming, endpoint responses, and provider storage.
|
||||
- ANSI escape sequences are preserved by default; set `StripAnsiEscapeSequences = true` to strip them server-side.
|
||||
- ANSI escape sequences are preserved by default; set `PreserveAnsi = false` to strip them server-side.
|
||||
- Partial writes are buffered until newline, max line length, or idle flush.
|
||||
- Oversized lines are truncated to one event and marked as truncated.
|
||||
- Providers receive only redacted line text and redacted source metadata.
|
||||
- Core providers receive only redacted line text and redacted source metadata.
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Permissions;
|
||||
using FastEndpoints.Security;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
|
||||
[Authorize]
|
||||
public class ConsoleLogsHub(ConsoleLogSubscriptionManager subscriptionManager) : Hub<IConsoleLogsClient>
|
||||
{
|
||||
private const string ReadAllPermission = "read:*";
|
||||
private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ConsoleLogsPermissions.Read];
|
||||
|
||||
public Task SubscribeAsync(ConsoleLogFilter? filter)
|
||||
{
|
||||
EnsureCanReadConsoleLogs();
|
||||
return subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
public Task UpdateFilterAsync(ConsoleLogFilter? filter)
|
||||
{
|
||||
EnsureCanReadConsoleLogs();
|
||||
return subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync()
|
||||
{
|
||||
return subscriptionManager.UnsubscribeAsync(Context.ConnectionId);
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
await UnsubscribeAsync();
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private static ConsoleLogFilter ValidateFilter(ConsoleLogFilter? filter)
|
||||
{
|
||||
filter ??= new();
|
||||
|
||||
if (filter.From is { } from && filter.To is { } to && from > to)
|
||||
throw new HubException("The console log filter 'from' timestamp must be earlier than or equal to 'to'.");
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private void EnsureCanReadConsoleLogs()
|
||||
{
|
||||
var user = Context.User;
|
||||
|
||||
if (user?.Identity?.IsAuthenticated != true || !ReadPermissions.Any(user.HasPermission))
|
||||
throw new HubException("Access denied.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,49 @@
|
|||
using System.Collections.Concurrent;
|
||||
using ConsoleLogStreaming.Contracts;
|
||||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CoreConsoleLogSource = ConsoleLogStreaming.Core.Models.ConsoleLogSource;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
|
||||
public class ConsoleLogSubscriptionManager : IDisposable
|
||||
/// <summary>
|
||||
/// Manages pushed SignalR console log subscriptions.
|
||||
/// </summary>
|
||||
public sealed class ElsaConsoleLogSubscriptionManager : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConsoleLogSubscription> _subscriptions = new(StringComparer.Ordinal);
|
||||
private readonly IConsoleLogProvider _provider;
|
||||
private readonly IConsoleLogSourceRegistry _sourceRegistry;
|
||||
private readonly IHubContext<ConsoleLogsHub, IConsoleLogsClient> _hubContext;
|
||||
private readonly ILogger<ConsoleLogSubscriptionManager> _logger;
|
||||
private readonly IConsoleLogStreamingApiMapper _mapper;
|
||||
private readonly IHubContext<ElsaConsoleLogsHub, IConsoleLogsClient> _hubContext;
|
||||
private readonly ILogger<ElsaConsoleLogSubscriptionManager> _logger;
|
||||
|
||||
public ConsoleLogSubscriptionManager(
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the subscription manager.
|
||||
/// </summary>
|
||||
public ElsaConsoleLogSubscriptionManager(
|
||||
IConsoleLogProvider provider,
|
||||
IConsoleLogSourceRegistry sourceRegistry,
|
||||
IHubContext<ConsoleLogsHub, IConsoleLogsClient> hubContext,
|
||||
ILogger<ConsoleLogSubscriptionManager> logger)
|
||||
IConsoleLogStreamingApiMapper mapper,
|
||||
IHubContext<ElsaConsoleLogsHub, IConsoleLogsClient> hubContext,
|
||||
ILogger<ElsaConsoleLogSubscriptionManager> logger)
|
||||
{
|
||||
_provider = provider;
|
||||
_sourceRegistry = sourceRegistry;
|
||||
_mapper = mapper;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
_sourceRegistry.SourceChanged += OnSourceChanged;
|
||||
}
|
||||
|
||||
public Task SubscribeAsync(string connectionId, ConsoleLogFilter filter, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Starts a pushed subscription for a connection.
|
||||
/// </summary>
|
||||
public Task SubscribeAsync(string connectionId, ElsaConsoleLogFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
Unsubscribe(connectionId);
|
||||
var subscriptionCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
|
@ -36,17 +54,24 @@ public class ConsoleLogSubscriptionManager : IDisposable
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateFilterAsync(string connectionId, ConsoleLogFilter filter, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Replaces the active pushed subscription filter.
|
||||
/// </summary>
|
||||
public Task UpdateFilterAsync(string connectionId, ElsaConsoleLogFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
return SubscribeAsync(connectionId, filter, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pushed subscription.
|
||||
/// </summary>
|
||||
public Task UnsubscribeAsync(string connectionId)
|
||||
{
|
||||
Unsubscribe(connectionId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_sourceRegistry.SourceChanged -= OnSourceChanged;
|
||||
|
|
@ -60,20 +85,17 @@ public class ConsoleLogSubscriptionManager : IDisposable
|
|||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private async Task StreamAsync(string connectionId, ConsoleLogFilter filter, ConsoleLogSubscription subscription, CancellationToken cancellationToken)
|
||||
private async Task StreamAsync(string connectionId, ElsaConsoleLogFilter filter, ConsoleLogSubscription subscription, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var item in _provider.SubscribeAsync(filter, cancellationToken))
|
||||
await foreach (var item in _provider.SubscribeAsync(_mapper.ToCore(ConsoleLogFilterMapper.ToStreamingFilter(filter)), cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (item.Line != null)
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveConsoleLogLineAsync(item.Line, cancellationToken);
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveConsoleLogLineAsync(_mapper.ToApi(item.Line), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (item.DroppedLines != null)
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveDroppedLinesAsync(item.DroppedLines, cancellationToken);
|
||||
|
||||
if (item.Source != null)
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(item.Source, cancellationToken);
|
||||
if (item.Dropped != null)
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveDroppedLinesAsync(_mapper.ToApi(item.Dropped), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
|
|
@ -106,12 +128,12 @@ public class ConsoleLogSubscriptionManager : IDisposable
|
|||
subscription.CancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
private void OnSourceChanged(ConsoleLogSource source)
|
||||
private void OnSourceChanged(CoreConsoleLogSource source)
|
||||
{
|
||||
_ = BroadcastSourceChangedAsync(source, _subscriptions.ToArray());
|
||||
}
|
||||
|
||||
private async Task BroadcastSourceChangedAsync(ConsoleLogSource source, IReadOnlyCollection<KeyValuePair<string, ConsoleLogSubscription>> subscriptions)
|
||||
private async Task BroadcastSourceChangedAsync(CoreConsoleLogSource source, IReadOnlyCollection<KeyValuePair<string, ConsoleLogSubscription>> subscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -120,7 +142,7 @@ public class ConsoleLogSubscriptionManager : IDisposable
|
|||
if (!MatchesSource(source, subscription.Filter))
|
||||
continue;
|
||||
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(source, subscription.CancellationTokenSource.Token);
|
||||
await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(_mapper.ToApi(source), subscription.CancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
|
|
@ -133,10 +155,10 @@ public class ConsoleLogSubscriptionManager : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static bool MatchesSource(ConsoleLogSource source, ConsoleLogFilter filter)
|
||||
private static bool MatchesSource(CoreConsoleLogSource source, ElsaConsoleLogFilter filter)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(filter.SourceId) || string.Equals(source.Id, filter.SourceId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ConsoleLogSubscription(ConsoleLogFilter Filter, CancellationTokenSource CancellationTokenSource);
|
||||
private sealed record ConsoleLogSubscription(ElsaConsoleLogFilter Filter, CancellationTokenSource CancellationTokenSource);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using ConsoleLogStreaming.Contracts;
|
||||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for live Elsa console log streaming.
|
||||
/// </summary>
|
||||
public sealed class ElsaConsoleLogsHub(
|
||||
IConsoleLogProvider provider,
|
||||
IConsoleLogStreamingApiMapper mapper,
|
||||
IConsoleLogStreamingHubAuthorizer authorizer,
|
||||
ElsaConsoleLogSubscriptionManager subscriptionManager) : Hub<IConsoleLogsClient>
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams matching console log items as a SignalR streaming method.
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<ConsoleLogStreamingItem> StreamAsync(
|
||||
ElsaConsoleLogFilter? filter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureCanReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
filter = ValidateFilter(filter);
|
||||
|
||||
await foreach (var item in provider.SubscribeAsync(mapper.ToCore(ConsoleLogFilterMapper.ToStreamingFilter(filter)), cancellationToken).ConfigureAwait(false))
|
||||
yield return mapper.ToApi(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts pushing stream items to the caller through typed client methods.
|
||||
/// </summary>
|
||||
public async Task SubscribeAsync(ElsaConsoleLogFilter? filter)
|
||||
{
|
||||
await EnsureCanReadAsync(Context.ConnectionAborted).ConfigureAwait(false);
|
||||
await subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current pushed subscription filter.
|
||||
/// </summary>
|
||||
public async Task UpdateFilterAsync(ElsaConsoleLogFilter? filter)
|
||||
{
|
||||
await EnsureCanReadAsync(Context.ConnectionAborted).ConfigureAwait(false);
|
||||
await subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the current pushed subscription.
|
||||
/// </summary>
|
||||
public Task UnsubscribeAsync()
|
||||
{
|
||||
return subscriptionManager.UnsubscribeAsync(Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
await UnsubscribeAsync().ConfigureAwait(false);
|
||||
await base.OnDisconnectedAsync(exception).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ElsaConsoleLogFilter ValidateFilter(ElsaConsoleLogFilter? filter)
|
||||
{
|
||||
filter ??= new();
|
||||
|
||||
if (filter.From is { } from && filter.To is { } to && from > to)
|
||||
throw new HubException("The console log filter 'from' timestamp must be earlier than or equal to 'to'.");
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureCanReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await authorizer.CanReadAsync(Context, cancellationToken).ConfigureAwait(false))
|
||||
throw new HubException("Access denied.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
|
||||
public interface IConsoleLogsClient
|
||||
{
|
||||
Task ReceiveConsoleLogLineAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReceiveDroppedLinesAsync(ConsoleLogDroppedSummary summary, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReceiveSourceChangedAsync(ConsoleLogSource source, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
internal static class ConsoleAnsiEscapeSequences
|
||||
{
|
||||
private static readonly Regex AnsiRegex = new(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static string Strip(string text) => AnsiRegex.Replace(text, string.Empty);
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide adapter that turns raw stdout/stderr writes captured by <see cref="ConsoleStreamHook"/>
|
||||
/// into fully formed <see cref="ConsoleLogLine"/> records, then publishes them to the configured
|
||||
/// <see cref="IConsoleLogProvider"/> on a single background task.
|
||||
///
|
||||
/// <para>
|
||||
/// Owned by <see cref="ConsoleLogsHost"/> — there is one instance per process. Capture is wired up in the
|
||||
/// constructor so that lines emitted during host startup — before any hosted service has run — are not
|
||||
/// lost. The capture pipeline can never block the caller of <c>Console.Write*</c>: when the publish
|
||||
/// channel is full, the current line is reported as dropped to the provider.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// To prevent the publish path itself from feeding new lines back into capture (e.g. when the in-memory
|
||||
/// provider's SignalR fan-out logs internally), <see cref="ConsoleStreamHook.SuppressCapture"/> is set on
|
||||
/// the publisher's async context for the lifetime of each <see cref="IConsoleLogProvider.PublishAsync"/>
|
||||
/// invocation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ConsoleCaptureTee : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly IConsoleLogProvider _provider;
|
||||
private readonly IConsoleLogSourceRegistry _sourceRegistry;
|
||||
private readonly IConsoleLogRedactor _redactor;
|
||||
private readonly ConsoleLineFormatter _formatter;
|
||||
private readonly ConsoleLogScopeAccessor _scopeAccessor;
|
||||
private readonly ConsoleLineBuffer _stdoutBuffer;
|
||||
private readonly ConsoleLineBuffer _stderrBuffer;
|
||||
private readonly object _bufferLock = new();
|
||||
private readonly Channel<ConsoleLogLine> _publishChannel;
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
private readonly Task _publishTask;
|
||||
private readonly IDisposable _subscription;
|
||||
private long _sequence;
|
||||
private int _disposed;
|
||||
|
||||
public ConsoleCaptureTee(
|
||||
IConsoleLogProvider provider,
|
||||
IConsoleLogSourceRegistry sourceRegistry,
|
||||
IConsoleLogRedactor redactor,
|
||||
ConsoleLineFormatter formatter,
|
||||
ConsoleLogScopeAccessor scopeAccessor,
|
||||
IOptions<ConsoleLogsOptions> options)
|
||||
{
|
||||
_provider = provider;
|
||||
_sourceRegistry = sourceRegistry;
|
||||
_redactor = redactor;
|
||||
_formatter = formatter;
|
||||
_scopeAccessor = scopeAccessor;
|
||||
_stdoutBuffer = new(options);
|
||||
_stderrBuffer = new(options);
|
||||
|
||||
_publishChannel = Channel.CreateBounded<ConsoleLogLine>(new BoundedChannelOptions(Math.Max(1, options.Value.CaptureChannelCapacity))
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
_publishTask = Task.Run(() => PublishQueuedLinesAsync(_publishChannel.Reader, _shutdownCts.Token));
|
||||
|
||||
// Subscribe eagerly so that any output produced between this constructor and StartAsync is captured
|
||||
// (the hosted service starts the flush loop, not the capture itself).
|
||||
_subscription = ConsoleStreamHook.Subscribe(OnChunkCaptured);
|
||||
}
|
||||
|
||||
public ValueTask FlushIdleAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
Publish(ConsoleLogStream.Stdout, FlushIfIdle(_stdoutBuffer, now), now);
|
||||
Publish(ConsoleLogStream.Stderr, FlushIfIdle(_stderrBuffer, now), now);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
return;
|
||||
|
||||
_subscription.Dispose();
|
||||
|
||||
// Drain any pending buffered text before completing the channel.
|
||||
FlushRemaining(ConsoleLogStream.Stdout);
|
||||
FlushRemaining(ConsoleLogStream.Stderr);
|
||||
|
||||
_publishChannel.Writer.TryComplete();
|
||||
|
||||
try
|
||||
{
|
||||
await _publishTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_shutdownCts.Cancel();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during shutdown.
|
||||
}
|
||||
|
||||
_shutdownCts.Dispose();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// MS DI requires IDisposable on registered singletons even when the service exposes IAsyncDisposable.
|
||||
// Bridge to DisposeAsync so the publish task is drained instead of orphaned.
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private void OnChunkCaptured(CapturedChunk chunk)
|
||||
{
|
||||
IReadOnlyCollection<BufferedConsoleLine> lines;
|
||||
|
||||
var workflowInstanceId = _scopeAccessor.GetWorkflowInstanceId();
|
||||
|
||||
lock (_bufferLock)
|
||||
lines = GetBuffer(chunk.Stream).Append(chunk.Text, chunk.TimestampUtc, workflowInstanceId);
|
||||
|
||||
foreach (var line in lines)
|
||||
Publish(chunk.Stream, line, chunk.TimestampUtc);
|
||||
}
|
||||
|
||||
private BufferedConsoleLine? FlushIfIdle(ConsoleLineBuffer buffer, DateTimeOffset now)
|
||||
{
|
||||
lock (_bufferLock)
|
||||
return buffer.FlushIfIdle(now);
|
||||
}
|
||||
|
||||
private void FlushRemaining(ConsoleLogStream stream)
|
||||
{
|
||||
BufferedConsoleLine? line;
|
||||
lock (_bufferLock)
|
||||
line = GetBuffer(stream).Flush();
|
||||
|
||||
Publish(stream, line, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private ConsoleLineBuffer GetBuffer(ConsoleLogStream stream) =>
|
||||
stream == ConsoleLogStream.Stdout ? _stdoutBuffer : _stderrBuffer;
|
||||
|
||||
private void Publish(ConsoleLogStream stream, BufferedConsoleLine? capturedLine, DateTimeOffset timestamp)
|
||||
{
|
||||
if (capturedLine == null)
|
||||
return;
|
||||
|
||||
var text = capturedLine.Value.Text;
|
||||
var workflowInstanceId = capturedLine.Value.WorkflowInstanceId ?? _scopeAccessor.TakeLoggedWorkflowInstanceId(text, timestamp);
|
||||
var formatted = _formatter.Format(text);
|
||||
var line = new ConsoleLogLine
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
ReceivedAt = timestamp,
|
||||
Sequence = Interlocked.Increment(ref _sequence),
|
||||
Stream = stream,
|
||||
Text = formatted.Text,
|
||||
Source = _sourceRegistry.Current,
|
||||
WorkflowInstanceId = workflowInstanceId,
|
||||
Truncated = formatted.Truncated
|
||||
};
|
||||
|
||||
// The capture path must not block console writers. When the bounded channel is full, drop this line
|
||||
// and surface a summary so the UI can warn the operator.
|
||||
if (!_publishChannel.Writer.TryWrite(line) && _provider is IConsoleLogDroppedLineReporter reporter)
|
||||
reporter.ReportDropped(new ConsoleLogDroppedSummary(line.Source.Id, stream, "CaptureChannelFull", 1));
|
||||
}
|
||||
|
||||
private async Task PublishQueuedLinesAsync(ChannelReader<ConsoleLogLine> reader, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var line in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Suppress capture on this async context so that any writes the provider performs
|
||||
// (directly or via SignalR fan-out it invokes synchronously) do not loop back into us.
|
||||
ConsoleStreamHook.SuppressCapture = true;
|
||||
try
|
||||
{
|
||||
await _provider.PublishAsync(_redactor.Redact(line), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Never log from the publish path — that would route right back through the same tee.
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConsoleStreamHook.SuppressCapture = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles arbitrary stdout/stderr write chunks into complete lines. Lines are emitted whenever a
|
||||
/// terminator (<c>\n</c>) is encountered, when the buffer reaches <see cref="ConsoleLogsOptions.MaxLineLength"/>,
|
||||
/// or when <see cref="FlushIfIdle"/> is called after the configured idle timeout has elapsed.
|
||||
/// Empty lines (a bare <c>\n</c> with no preceding content on the current logical line) are preserved.
|
||||
/// </summary>
|
||||
public class ConsoleLineBuffer(IOptions<ConsoleLogsOptions> options)
|
||||
{
|
||||
private readonly StringBuilder _buffer = new();
|
||||
private readonly ConsoleLogsOptions _options = options.Value;
|
||||
private DateTimeOffset? _lastWriteAt;
|
||||
private bool _logicalLineHasContent;
|
||||
private bool _workflowInstanceIdCaptured;
|
||||
private string? _workflowInstanceId;
|
||||
|
||||
public IReadOnlyCollection<BufferedConsoleLine> Append(string value, DateTimeOffset now, string? workflowInstanceId = null)
|
||||
{
|
||||
var lines = new List<BufferedConsoleLine>();
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '\r')
|
||||
continue;
|
||||
|
||||
if (ch == '\n')
|
||||
{
|
||||
if (_buffer.Length > 0)
|
||||
lines.Add(FlushBuffer());
|
||||
else if (!_logicalLineHasContent)
|
||||
lines.Add(new(string.Empty, NormalizeWorkflowInstanceId(workflowInstanceId)));
|
||||
|
||||
_logicalLineHasContent = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
CaptureScope(workflowInstanceId);
|
||||
_buffer.Append(ch);
|
||||
_logicalLineHasContent = true;
|
||||
|
||||
if (_buffer.Length >= _options.MaxLineLength)
|
||||
lines.Add(FlushBuffer());
|
||||
}
|
||||
|
||||
// Track last activity only when characters remain pending so that FlushIfIdle has a deadline to act on.
|
||||
_lastWriteAt = _buffer.Length > 0 ? now : null;
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public BufferedConsoleLine? FlushIfIdle(DateTimeOffset now)
|
||||
{
|
||||
if (_buffer.Length == 0 || _lastWriteAt == null)
|
||||
return null;
|
||||
|
||||
return now - _lastWriteAt >= _options.IdleFlushTimeout ? FlushBuffer() : null;
|
||||
}
|
||||
|
||||
public BufferedConsoleLine? Flush()
|
||||
{
|
||||
return _buffer.Length == 0 ? null : FlushBuffer();
|
||||
}
|
||||
|
||||
private void CaptureScope(string? workflowInstanceId)
|
||||
{
|
||||
if (_workflowInstanceIdCaptured)
|
||||
return;
|
||||
|
||||
_workflowInstanceIdCaptured = true;
|
||||
_workflowInstanceId = NormalizeWorkflowInstanceId(workflowInstanceId);
|
||||
}
|
||||
|
||||
private static string? NormalizeWorkflowInstanceId(string? workflowInstanceId) =>
|
||||
string.IsNullOrWhiteSpace(workflowInstanceId) ? null : workflowInstanceId;
|
||||
|
||||
private BufferedConsoleLine FlushBuffer()
|
||||
{
|
||||
var line = _buffer.ToString();
|
||||
var workflowInstanceId = _workflowInstanceId;
|
||||
_buffer.Clear();
|
||||
_workflowInstanceIdCaptured = false;
|
||||
_workflowInstanceId = null;
|
||||
return new(line, workflowInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct BufferedConsoleLine(string Text, string? WorkflowInstanceId);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public class ConsoleLineFormatter(IOptions<ConsoleLogsOptions> options)
|
||||
{
|
||||
private readonly ConsoleLogsOptions _options = options.Value;
|
||||
|
||||
public FormattedConsoleLine Format(string text)
|
||||
{
|
||||
if (_options.StripAnsiEscapeSequences)
|
||||
text = ConsoleAnsiEscapeSequences.Strip(text);
|
||||
|
||||
var truncated = text.Length > _options.MaxLineLength;
|
||||
if (truncated)
|
||||
text = text[.._options.MaxLineLength];
|
||||
|
||||
return new(text, truncated);
|
||||
}
|
||||
}
|
||||
|
||||
public record FormattedConsoleLine(string Text, bool Truncated);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adds console log metadata for activity execution, including detached background activity execution.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogActivityExecutionMiddleware(
|
||||
ActivityMiddlewareDelegate next,
|
||||
IConsoleLogContextAccessor consoleLogContextAccessor) : IActivityExecutionMiddleware
|
||||
{
|
||||
public async ValueTask InvokeAsync(ActivityExecutionContext context)
|
||||
{
|
||||
using var scope = consoleLogContextAccessor.PushActivityExecution(context);
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Installs console log metadata capture into activity execution.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogActivityExecutionPipelineContributor : IActivityExecutionPipelineContributor
|
||||
{
|
||||
public void Configure(IActivityExecutionPipelineBuilder builder)
|
||||
{
|
||||
builder.UseMiddleware<ConsoleLogActivityExecutionMiddleware>();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
[Obsolete("Console log capture is host-owned. Use ConsoleLogsHost.Capture for host-level operations.")]
|
||||
public sealed class ConsoleLogCaptureAdapter : IConsoleLogCapture
|
||||
#pragma warning restore CS0618
|
||||
{
|
||||
public ValueTask StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ConsoleLogsHost.EnsureInitialized();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask StopAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask FlushIdleAsync(CancellationToken cancellationToken = default) =>
|
||||
ConsoleLogsHost.Capture.FlushIdleAsync(cancellationToken);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient console log context backed by the current async execution context.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogContextAccessor : IConsoleLogContextAccessor, IConsoleLogMetadataAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<MetadataFrame?> CurrentFrame = new();
|
||||
private static readonly IReadOnlyDictionary<string, string> Empty = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process-wide Elsa console log context accessor.
|
||||
/// </summary>
|
||||
public static ConsoleLogContextAccessor Instance { get; } = new();
|
||||
|
||||
private ConsoleLogContextAccessor()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetMetadata()
|
||||
{
|
||||
var metadata = CurrentFrame.Value?.Metadata;
|
||||
return metadata == null || metadata.Count == 0
|
||||
? Empty
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable PushMetadata(string key, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("A metadata key is required.", nameof(key));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("A metadata value is required.", nameof(value));
|
||||
|
||||
var previous = CurrentFrame.Value;
|
||||
var metadata = previous?.Metadata != null
|
||||
? new Dictionary<string, string>(previous.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
metadata[key] = value;
|
||||
CurrentFrame.Value = new(metadata);
|
||||
return new MetadataScope(previous);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable PushWorkflowInstanceId(string workflowInstanceId) =>
|
||||
PushMetadata(ConsoleLogMetadataKeys.WorkflowInstanceId, workflowInstanceId);
|
||||
|
||||
private sealed record MetadataFrame(IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
private sealed class MetadataScope(MetadataFrame? previous) : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
CurrentFrame.Value = previous;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
internal static class ConsoleLogContextAccessorExtensions
|
||||
{
|
||||
public static IDisposable PushWorkflowExecution(this IConsoleLogContextAccessor accessor, WorkflowExecutionContext context)
|
||||
{
|
||||
var identity = context.Workflow.Identity;
|
||||
return new CompositeScope(
|
||||
accessor.PushMetadata(ConsoleLogMetadataKeys.WorkflowInstanceId, context.Id),
|
||||
accessor.PushMetadata(ConsoleLogMetadataKeys.WorkflowDefinitionId, identity.DefinitionId),
|
||||
accessor.PushMetadata(ConsoleLogMetadataKeys.WorkflowDefinitionVersionId, identity.Id));
|
||||
}
|
||||
|
||||
public static IDisposable PushActivityExecution(this IConsoleLogContextAccessor accessor, ActivityExecutionContext context)
|
||||
{
|
||||
return new CompositeScope(
|
||||
accessor.PushWorkflowExecution(context.WorkflowExecutionContext),
|
||||
PushMetadataIfNotEmpty(accessor, ConsoleLogMetadataKeys.ActivityInstanceId, context.Id),
|
||||
PushMetadataIfNotEmpty(accessor, ConsoleLogMetadataKeys.ActivityId, context.Activity.Id),
|
||||
PushMetadataIfNotEmpty(accessor, ConsoleLogMetadataKeys.ActivityNodeId, context.NodeId));
|
||||
}
|
||||
|
||||
private static IDisposable PushMetadataIfNotEmpty(IConsoleLogContextAccessor accessor, string key, string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? EmptyScope.Instance : accessor.PushMetadata(key, value);
|
||||
|
||||
private sealed class EmptyScope : IDisposable
|
||||
{
|
||||
public static readonly EmptyScope Instance = new();
|
||||
|
||||
private EmptyScope()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CompositeScope(params IDisposable[] scopes) : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
for (var i = scopes.Length - 1; i >= 0; i--)
|
||||
scopes[i].Dispose();
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public static class ConsoleLogFilterEvaluator
|
||||
{
|
||||
public static bool Matches(ConsoleLogLine line, ConsoleLogFilter filter)
|
||||
{
|
||||
if (!EqualsFilter(line.Source.Id, filter.SourceId))
|
||||
return false;
|
||||
|
||||
if (filter.Stream is { } stream && line.Stream != stream)
|
||||
return false;
|
||||
|
||||
if (!ContainsText(line, filter.Query))
|
||||
return false;
|
||||
|
||||
if (!EqualsFilter(line.WorkflowInstanceId, filter.WorkflowInstanceId))
|
||||
return false;
|
||||
|
||||
if (filter.From is { } from && line.ReceivedAt < from)
|
||||
return false;
|
||||
|
||||
if (filter.To is { } to && line.ReceivedAt > to)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EqualsFilter(string? value, string? filter) => string.IsNullOrWhiteSpace(filter) || string.Equals(value, filter, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool Contains(string? value, string? filter) => string.IsNullOrWhiteSpace(filter) || value?.Contains(filter, StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
private static bool ContainsText(ConsoleLogLine line, string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
return true;
|
||||
|
||||
return Contains(line.Text, filter)
|
||||
|| Contains(line.Source.Id, filter)
|
||||
|| Contains(line.Source.DisplayName, filter)
|
||||
|| Contains(line.Source.ServiceName, filter)
|
||||
|| Contains(line.Source.MachineName, filter)
|
||||
|| line.Source.Metadata.Any(x => Contains(x.Key, filter) || Contains(x.Value, filter));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using ConsoleLogStreaming.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
internal static class ConsoleLogFilterMapper
|
||||
{
|
||||
public static ConsoleLogFilter ToStreamingFilter(ElsaConsoleLogFilter filter)
|
||||
{
|
||||
var metadata = filter.Metadata != null
|
||||
? new Dictionary<string, string>(filter.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.WorkflowInstanceId, filter.WorkflowInstanceId);
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.WorkflowDefinitionId, filter.WorkflowDefinitionId);
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.WorkflowDefinitionVersionId, filter.WorkflowDefinitionVersionId);
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.ActivityInstanceId, filter.ActivityInstanceId);
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.ActivityId, filter.ActivityId);
|
||||
AddMetadata(metadata, ConsoleLogMetadataKeys.ActivityNodeId, filter.ActivityNodeId);
|
||||
|
||||
return new()
|
||||
{
|
||||
SourceId = filter.SourceId,
|
||||
Stream = filter.Stream,
|
||||
Query = filter.Query,
|
||||
Metadata = metadata,
|
||||
From = filter.From,
|
||||
To = filter.To,
|
||||
Limit = filter.Limit
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddMetadata(IDictionary<string, string> metadata, string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known metadata keys for console log enrichment.
|
||||
/// </summary>
|
||||
public static class ConsoleLogMetadataKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the workflow instance that owned a console write.
|
||||
/// </summary>
|
||||
public const string WorkflowInstanceId = "elsa.workflowInstanceId";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the workflow definition that owned a console write.
|
||||
/// </summary>
|
||||
public const string WorkflowDefinitionId = "elsa.workflowDefinitionId";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the workflow definition version that owned a console write.
|
||||
/// </summary>
|
||||
public const string WorkflowDefinitionVersionId = "elsa.workflowDefinitionVersionId";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the activity execution instance that owned a console write.
|
||||
/// </summary>
|
||||
public const string ActivityInstanceId = "elsa.activityInstanceId";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the logical activity that owned a console write.
|
||||
/// </summary>
|
||||
public const string ActivityId = "elsa.activityId";
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the activity node that owned a console write.
|
||||
/// </summary>
|
||||
public const string ActivityNodeId = "elsa.activityNodeId";
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public class ConsoleLogRedactor(IOptions<ConsoleLogsOptions> options) : IConsoleLogRedactor
|
||||
{
|
||||
private readonly ConsoleLogsOptions _options = options.Value;
|
||||
private readonly HashSet<string> _sensitiveNames = options.Value.SensitiveNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IReadOnlyCollection<Regex> _sensitiveTextPatterns = options.Value.SensitiveTextPatterns
|
||||
.Select(pattern => new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant))
|
||||
.ToList();
|
||||
|
||||
public ConsoleLogLine Redact(ConsoleLogLine line)
|
||||
{
|
||||
return line with
|
||||
{
|
||||
Text = RedactValue("text", line.Text) ?? string.Empty,
|
||||
Source = Redact(line.Source)
|
||||
};
|
||||
}
|
||||
|
||||
public ConsoleLogSource Redact(ConsoleLogSource source)
|
||||
{
|
||||
return source with
|
||||
{
|
||||
Id = RedactValue("id", source.Id) ?? source.Id,
|
||||
DisplayName = RedactValue("displayName", source.DisplayName) ?? source.DisplayName,
|
||||
ServiceName = RedactValue("serviceName", source.ServiceName),
|
||||
MachineName = RedactValue("machineName", source.MachineName) ?? source.MachineName,
|
||||
PodName = RedactValue("podName", source.PodName),
|
||||
ContainerName = RedactValue("containerName", source.ContainerName),
|
||||
Namespace = RedactValue("namespace", source.Namespace),
|
||||
NodeName = RedactValue("nodeName", source.NodeName),
|
||||
Metadata = RedactDictionary(source.Metadata)
|
||||
};
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> RedactDictionary(IDictionary<string, string?> values)
|
||||
{
|
||||
return values.ToDictionary(x => x.Key, x => RedactValue(x.Key, x.Value), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string? RedactValue(string name, string? value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (IsSensitiveName(name))
|
||||
return _options.RedactionReplacement;
|
||||
|
||||
return RedactSensitiveText(value);
|
||||
}
|
||||
|
||||
private string RedactSensitiveText(string value)
|
||||
{
|
||||
var redacted = ApplySensitiveTextPatterns(value);
|
||||
var normalized = ConsoleAnsiEscapeSequences.Strip(value);
|
||||
if (normalized == value)
|
||||
return redacted;
|
||||
|
||||
var normalizedRedacted = ApplySensitiveTextPatterns(normalized);
|
||||
return normalizedRedacted == normalized ? redacted : normalizedRedacted;
|
||||
}
|
||||
|
||||
private string ApplySensitiveTextPatterns(string value) =>
|
||||
_sensitiveTextPatterns.Aggregate(value, (current, pattern) => pattern.Replace(current, _options.RedactionReplacement));
|
||||
|
||||
private bool IsSensitiveName(string name) => _sensitiveNames.Any(sensitiveName => name.Contains(sensitiveName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public sealed class ConsoleLogScopeAccessor : ILoggerProvider, ISupportExternalScope
|
||||
{
|
||||
private const string WorkflowInstanceIdKey = "WorkflowInstanceId";
|
||||
private const int PendingWorkflowLogsCapacity = 1024;
|
||||
private static readonly TimeSpan PendingWorkflowLogTtl = TimeSpan.FromSeconds(30);
|
||||
private readonly IExternalScopeProvider _defaultScopeProvider = new LoggerExternalScopeProvider();
|
||||
private readonly object _lock = new();
|
||||
private readonly object _pendingWorkflowLogsLock = new();
|
||||
private readonly List<PendingWorkflowLog> _pendingWorkflowLogs = [];
|
||||
private WeakReference<IExternalScopeProvider>[] _scopeProviders;
|
||||
|
||||
public ConsoleLogScopeAccessor()
|
||||
{
|
||||
_scopeProviders = [new(_defaultScopeProvider)];
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new ScopeCapturingLogger(this);
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void SetScopeProvider(IExternalScopeProvider scopeProvider)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var scopeProviders = GetLiveScopeProviders();
|
||||
if (scopeProviders.Any(x => ReferenceEquals(x, scopeProvider)))
|
||||
return;
|
||||
|
||||
_scopeProviders = [.. _scopeProviders, new WeakReference<IExternalScopeProvider>(scopeProvider)];
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetWorkflowInstanceId()
|
||||
{
|
||||
var scopeProviders = GetLiveScopeProviders();
|
||||
string? value = null;
|
||||
|
||||
foreach (var scopeProvider in scopeProviders)
|
||||
{
|
||||
var state = new ScopeSearchState(WorkflowInstanceIdKey);
|
||||
scopeProvider.ForEachScope((scope, searchState) => searchState.Capture(scope), state);
|
||||
if (state.Value != null)
|
||||
value = state.Value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
internal string? TakeLoggedWorkflowInstanceId(string text, DateTimeOffset capturedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
lock (_pendingWorkflowLogsLock)
|
||||
{
|
||||
PruneExpiredPendingWorkflowLogs(capturedAt);
|
||||
|
||||
var index = _pendingWorkflowLogs.FindIndex(x => IsCorrelated(text, x.Message));
|
||||
if (index < 0)
|
||||
return null;
|
||||
|
||||
var workflowInstanceId = _pendingWorkflowLogs[index].WorkflowInstanceId;
|
||||
_pendingWorkflowLogs.RemoveAt(index);
|
||||
return workflowInstanceId;
|
||||
}
|
||||
}
|
||||
|
||||
private IExternalScopeProvider[] GetLiveScopeProviders()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var liveScopeProviders = _scopeProviders
|
||||
.Select(reference => (Reference: reference, Provider: reference.TryGetTarget(out var provider) ? provider : null))
|
||||
.Where(x => x.Provider != null)
|
||||
.ToArray();
|
||||
|
||||
if (liveScopeProviders.Length != _scopeProviders.Length)
|
||||
_scopeProviders = [.. liveScopeProviders.Select(x => x.Reference)];
|
||||
|
||||
return [.. liveScopeProviders.Select(x => x.Provider!)];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScopeSearchState(string key)
|
||||
{
|
||||
public string? Value { get; private set; }
|
||||
|
||||
public void Capture(object? scope)
|
||||
{
|
||||
var value = TryGetScopeValue(scope, key);
|
||||
if (value != null)
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetScopeValue(object? scope, string key)
|
||||
{
|
||||
if (scope is not IEnumerable values)
|
||||
return null;
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (value == null)
|
||||
continue;
|
||||
|
||||
if (value is KeyValuePair<string, object?> kvp)
|
||||
{
|
||||
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return kvp.Value?.ToString();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(KeyValuePair<,>))
|
||||
continue;
|
||||
|
||||
var itemKey = type.GetProperty(nameof(KeyValuePair<string, object?>.Key))?.GetValue(value) as string;
|
||||
if (!string.Equals(itemKey, key, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
return type.GetProperty(nameof(KeyValuePair<string, object?>.Value))?.GetValue(value)?.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CaptureLoggedWorkflowInstanceId<TState>(
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var workflowInstanceId = GetWorkflowInstanceId();
|
||||
if (string.IsNullOrWhiteSpace(workflowInstanceId))
|
||||
return;
|
||||
|
||||
string message;
|
||||
try
|
||||
{
|
||||
message = formatter(state, exception);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_pendingWorkflowLogsLock)
|
||||
{
|
||||
PruneExpiredPendingWorkflowLogs(now);
|
||||
|
||||
while (_pendingWorkflowLogs.Count >= PendingWorkflowLogsCapacity)
|
||||
_pendingWorkflowLogs.RemoveAt(0);
|
||||
|
||||
_pendingWorkflowLogs.Add(new PendingWorkflowLog(message, workflowInstanceId, now));
|
||||
}
|
||||
}
|
||||
|
||||
private void PruneExpiredPendingWorkflowLogs(DateTimeOffset now)
|
||||
{
|
||||
for (var i = _pendingWorkflowLogs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (now - _pendingWorkflowLogs[i].CapturedAt <= PendingWorkflowLogTtl)
|
||||
continue;
|
||||
|
||||
_pendingWorkflowLogs.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCorrelated(string capturedLine, string loggedMessage)
|
||||
{
|
||||
var trimmedLine = capturedLine.Trim();
|
||||
var trimmedMessage = loggedMessage.Trim();
|
||||
|
||||
return trimmedLine.Equals(trimmedMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class ScopeCapturingLogger(ConsoleLogScopeAccessor accessor) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (IsEnabled(logLevel))
|
||||
accessor.CaptureLoggedWorkflowInstanceId(state, exception, formatter);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingWorkflowLog(string Message, string WorkflowInstanceId, DateTimeOffset CapturedAt);
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public class ConsoleLogSourceRegistry : IConsoleLogSourceRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConsoleLogSource> _sources = new();
|
||||
private readonly ConsoleLogsOptions _options;
|
||||
|
||||
public ConsoleLogSourceRegistry(IOptions<ConsoleLogsOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
Current = CreateCurrentSource();
|
||||
_sources[Current.Id] = Current;
|
||||
}
|
||||
|
||||
public event Action<ConsoleLogSource>? SourceChanged;
|
||||
|
||||
public ConsoleLogSource Current { get; }
|
||||
|
||||
public void MarkSeen(string sourceId, DateTimeOffset timestamp)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_sources.TryGetValue(sourceId, out var existing))
|
||||
{
|
||||
var current = ApplyCurrentHealth(existing, DateTimeOffset.UtcNow);
|
||||
var updated = current with { LastSeen = timestamp, Health = ConsoleLogSourceHealth.Connected };
|
||||
if (!_sources.TryUpdate(sourceId, updated, existing))
|
||||
continue;
|
||||
|
||||
if (current.Health != updated.Health)
|
||||
SourceChanged?.Invoke(updated);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var source = new ConsoleLogSource
|
||||
{
|
||||
Id = sourceId,
|
||||
DisplayName = sourceId,
|
||||
MachineName = "",
|
||||
ProcessId = 0,
|
||||
LastSeen = timestamp,
|
||||
Health = ConsoleLogSourceHealth.Connected
|
||||
};
|
||||
|
||||
if (!_sources.TryAdd(sourceId, source))
|
||||
continue;
|
||||
|
||||
SourceChanged?.Invoke(source);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ConsoleLogSource> List()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return _sources
|
||||
.Select(entry => RefreshHealth(entry.Key, entry.Value, now))
|
||||
.OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private ConsoleLogSource RefreshHealth(string sourceId, ConsoleLogSource source, DateTimeOffset now)
|
||||
{
|
||||
var updated = ApplyCurrentHealth(source, now);
|
||||
if (updated.Health == source.Health)
|
||||
return source;
|
||||
|
||||
if (!_sources.TryUpdate(sourceId, updated, source))
|
||||
return source;
|
||||
|
||||
SourceChanged?.Invoke(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private ConsoleLogSource ApplyCurrentHealth(ConsoleLogSource source, DateTimeOffset now)
|
||||
{
|
||||
var staleBefore = now.Subtract(_options.SourceHeartbeatTimeout);
|
||||
return source.LastSeen < staleBefore ? source with { Health = ConsoleLogSourceHealth.Stale } : source;
|
||||
}
|
||||
|
||||
private static ConsoleLogSource CreateCurrentSource()
|
||||
{
|
||||
var podName = Environment.GetEnvironmentVariable("HOSTNAME");
|
||||
var serviceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? AppDomain.CurrentDomain.FriendlyName;
|
||||
var sourceId = $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var displayName = !string.IsNullOrWhiteSpace(podName) ? podName : sourceId;
|
||||
|
||||
return new()
|
||||
{
|
||||
Id = sourceId,
|
||||
DisplayName = displayName,
|
||||
ServiceName = serviceName,
|
||||
PodName = podName,
|
||||
Namespace = Environment.GetEnvironmentVariable("POD_NAMESPACE"),
|
||||
ContainerName = Environment.GetEnvironmentVariable("CONTAINER_NAME"),
|
||||
NodeName = Environment.GetEnvironmentVariable("NODE_NAME"),
|
||||
StartedAt = DateTimeOffset.UtcNow,
|
||||
LastSeen = DateTimeOffset.UtcNow,
|
||||
Health = ConsoleLogSourceHealth.Connected
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adds console log metadata for workflow execution.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogWorkflowExecutionMiddleware(
|
||||
WorkflowMiddlewareDelegate next,
|
||||
IConsoleLogContextAccessor consoleLogContextAccessor) : IWorkflowExecutionMiddleware
|
||||
{
|
||||
public async ValueTask InvokeAsync(WorkflowExecutionContext context)
|
||||
{
|
||||
using var scope = consoleLogContextAccessor.PushWorkflowExecution(context);
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Installs console log metadata capture into workflow execution.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogWorkflowExecutionPipelineContributor : IWorkflowExecutionPipelineContributor
|
||||
{
|
||||
public void Configure(IWorkflowExecutionPipelineBuilder builder)
|
||||
{
|
||||
builder.UseMiddleware<ConsoleLogWorkflowExecutionMiddleware>();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide owner of the console capture pipeline. Console output is, by nature, a single shared OS
|
||||
/// resource — there is exactly one stdout and one stderr per process — so the capture, the in-memory ring
|
||||
/// buffer, and the source registry are likewise single instances regardless of how many DI containers
|
||||
/// (shells, tenants) want to consume them. This class is the seam that makes that fact explicit.
|
||||
///
|
||||
/// <para>
|
||||
/// Configuration follows a first-wins model: <see cref="Configure"/> calls made before the host is first
|
||||
/// accessed are applied to a single <see cref="ConsoleLogsOptions"/> instance. Subsequent calls are
|
||||
/// ignored because the configuration would not apply to anything already captured. Call
|
||||
/// <c>AddConsoleLogsHost</c> from <c>Program.cs</c> to set host-wide options deterministically.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Initialization is lazy and thread-safe: the first access from any thread builds the pipeline. The host
|
||||
/// also auto-installs <see cref="ConsoleStreamHook"/>, so simply touching <see cref="Provider"/>,
|
||||
/// <see cref="SourceRegistry"/>, or <see cref="Capture"/> is enough to start capturing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConsoleLogsHost
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static Action<ConsoleLogsOptions>? _pendingConfigure;
|
||||
private static Func<ConsoleLogsProviderContext, IConsoleLogProvider>? _providerFactory;
|
||||
private static Lazy<HostState> _state = CreateLazy();
|
||||
private static int _leases;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a configuration delegate to the host options if the host has not yet been initialized.
|
||||
/// Calls after initialization are ignored to preserve consistency between configuration and captured
|
||||
/// data. Returns <c>true</c> when the delegate was accepted, <c>false</c> when the host had already
|
||||
/// initialized.
|
||||
/// </summary>
|
||||
public static bool Configure(Action<ConsoleLogsOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_state.IsValueCreated)
|
||||
return false;
|
||||
|
||||
_pendingConfigure = (Action<ConsoleLogsOptions>?)Delegate.Combine(_pendingConfigure, configure);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the provider used by the process-wide capture pipeline. The factory must be registered
|
||||
/// before the host is initialized.
|
||||
/// </summary>
|
||||
public static bool ConfigureProvider(Func<IOptions<ConsoleLogsOptions>, IConsoleLogSourceRegistry, IConsoleLogProvider> providerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerFactory);
|
||||
return ConfigureProvider(context => providerFactory(context.Options, context.SourceRegistry));
|
||||
}
|
||||
|
||||
internal static bool ConfigureProvider(Func<ConsoleLogsProviderContext, IConsoleLogProvider> providerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerFactory);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_state.IsValueCreated || _providerFactory != null)
|
||||
return false;
|
||||
|
||||
_providerFactory = providerFactory;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static IOptions<ConsoleLogsOptions> Options => _state.Value.Options;
|
||||
public static IConsoleLogSourceRegistry SourceRegistry => _state.Value.SourceRegistry;
|
||||
public static IConsoleLogRedactor Redactor => _state.Value.Redactor;
|
||||
public static ConsoleLineFormatter Formatter => _state.Value.Formatter;
|
||||
public static ConsoleLogScopeAccessor ScopeAccessor => _state.Value.ScopeAccessor;
|
||||
public static IConsoleLogProvider Provider => _state.Value.Provider;
|
||||
public static ConsoleCaptureTee Capture => _state.Value.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Triggers initialization without resolving any specific component. Returns <c>true</c> when this call
|
||||
/// performed the initialization, <c>false</c> when the host was already initialized.
|
||||
/// </summary>
|
||||
public static bool EnsureInitialized()
|
||||
{
|
||||
var wasCreated = _state.IsValueCreated;
|
||||
_ = _state.Value;
|
||||
return !wasCreated;
|
||||
}
|
||||
|
||||
public static void AddReference()
|
||||
{
|
||||
lock (Lock)
|
||||
_leases++;
|
||||
}
|
||||
|
||||
public static ValueTask ReleaseReferenceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Lazy<HostState> state;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_leases <= 0)
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
_leases--;
|
||||
if (_leases > 0)
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
state = ResetState();
|
||||
}
|
||||
|
||||
return DisposeStateAsync(state, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tears down the host pipeline. Intended for tests and for orderly host shutdown.
|
||||
/// </summary>
|
||||
public static async ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Lazy<HostState> state;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
state = ResetState();
|
||||
_leases = 0;
|
||||
}
|
||||
|
||||
await DisposeStateAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Lazy<HostState> CreateLazy() => new(BuildState, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private static Lazy<HostState> ResetState()
|
||||
{
|
||||
var state = _state;
|
||||
_state = CreateLazy();
|
||||
_pendingConfigure = null;
|
||||
_providerFactory = null;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static async ValueTask DisposeStateAsync(Lazy<HostState> state, CancellationToken cancellationToken)
|
||||
{
|
||||
if (state.IsValueCreated)
|
||||
await state.Value.Capture.DisposeAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static HostState BuildState()
|
||||
{
|
||||
var options = new ConsoleLogsOptions();
|
||||
|
||||
Func<ConsoleLogsProviderContext, IConsoleLogProvider>? providerFactory;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
_pendingConfigure?.Invoke(options);
|
||||
providerFactory = _providerFactory;
|
||||
}
|
||||
|
||||
var wrappedOptions = Microsoft.Extensions.Options.Options.Create(options);
|
||||
var sourceRegistry = new ConsoleLogSourceRegistry(wrappedOptions);
|
||||
var redactor = new ConsoleLogRedactor(wrappedOptions);
|
||||
var formatter = new ConsoleLineFormatter(wrappedOptions);
|
||||
var scopeAccessor = new ConsoleLogScopeAccessor();
|
||||
var providerContext = new ConsoleLogsProviderContext(wrappedOptions, sourceRegistry, redactor, formatter, scopeAccessor);
|
||||
var provider = providerFactory?.Invoke(providerContext) ?? new InMemoryConsoleLogProvider(wrappedOptions, sourceRegistry);
|
||||
var capture = new ConsoleCaptureTee(provider, sourceRegistry, redactor, formatter, scopeAccessor, wrappedOptions);
|
||||
|
||||
return new HostState(wrappedOptions, sourceRegistry, redactor, formatter, scopeAccessor, provider, capture);
|
||||
}
|
||||
|
||||
private sealed record HostState(
|
||||
IOptions<ConsoleLogsOptions> Options,
|
||||
IConsoleLogSourceRegistry SourceRegistry,
|
||||
IConsoleLogRedactor Redactor,
|
||||
ConsoleLineFormatter Formatter,
|
||||
ConsoleLogScopeAccessor ScopeAccessor,
|
||||
IConsoleLogProvider Provider,
|
||||
ConsoleCaptureTee Capture);
|
||||
}
|
||||
|
||||
internal sealed record ConsoleLogsProviderContext(
|
||||
IOptions<ConsoleLogsOptions> Options,
|
||||
IConsoleLogSourceRegistry SourceRegistry,
|
||||
IConsoleLogRedactor Redactor,
|
||||
ConsoleLineFormatter Formatter,
|
||||
ConsoleLogScopeAccessor ScopeAccessor);
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Host-level hosted service that ensures the console capture is initialized at host build time,
|
||||
/// periodically flushes idle partial lines, and drains pending writes during host shutdown.
|
||||
/// </summary>
|
||||
public sealed class ConsoleLogsHostedService : BackgroundService
|
||||
{
|
||||
private readonly Action? _configureHost;
|
||||
|
||||
public ConsoleLogsHostedService() : this((Action?)null)
|
||||
{
|
||||
}
|
||||
|
||||
public ConsoleLogsHostedService(IServiceProvider serviceProvider) : this(() =>
|
||||
{
|
||||
if (serviceProvider.GetService(typeof(ConsoleLogsProviderRegistration)) is ConsoleLogsProviderRegistration registration)
|
||||
registration.ConfigureProvider(serviceProvider);
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
public ConsoleLogsHostedService(Action? configureHost)
|
||||
{
|
||||
_configureHost = configureHost;
|
||||
}
|
||||
|
||||
public override Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_configureHost?.Invoke();
|
||||
|
||||
// Touch the host so initialization happens before the first request, not lazily on first capture.
|
||||
ConsoleLogsHost.AddReference();
|
||||
ConsoleLogsHost.EnsureInitialized();
|
||||
return base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ConsoleLogsHost.ReleaseReferenceAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var interval = TimeSpan.FromMilliseconds(Math.Max(100, ConsoleLogsHost.Options.Value.IdleFlushTimeout.TotalMilliseconds / 2));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
await ConsoleLogsHost.Capture.FlushIdleAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
internal sealed class ConsoleLogsProviderRegistration(IServiceCollection services, ServiceDescriptor defaultProviderDescriptor)
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private ConsoleLogsProviderContext? _hostContext;
|
||||
|
||||
public void ConfigureProvider(IServiceProvider serviceProvider)
|
||||
{
|
||||
var descriptor = GetCustomProviderDescriptor();
|
||||
if (descriptor == null)
|
||||
return;
|
||||
|
||||
ConsoleLogsHost.ConfigureProvider(context => ResolveProvider(serviceProvider, context));
|
||||
}
|
||||
|
||||
public bool TryGetHostOptions([NotNullWhen(true)] out IOptions<ConsoleLogsOptions>? options)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
options = _hostContext?.Options;
|
||||
return options != null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetHostSourceRegistry([NotNullWhen(true)] out IConsoleLogSourceRegistry? sourceRegistry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
sourceRegistry = _hostContext?.SourceRegistry;
|
||||
return sourceRegistry != null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetHostRedactor([NotNullWhen(true)] out IConsoleLogRedactor? redactor)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
redactor = _hostContext?.Redactor;
|
||||
return redactor != null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetHostFormatter([NotNullWhen(true)] out ConsoleLineFormatter? formatter)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
formatter = _hostContext?.Formatter;
|
||||
return formatter != null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetHostScopeAccessor([NotNullWhen(true)] out ConsoleLogScopeAccessor? scopeAccessor)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
scopeAccessor = _hostContext?.ScopeAccessor;
|
||||
return scopeAccessor != null;
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceDescriptor? GetCustomProviderDescriptor() =>
|
||||
services.LastOrDefault(x => x.ServiceType == typeof(IConsoleLogProvider) && !ReferenceEquals(x, defaultProviderDescriptor));
|
||||
|
||||
private IConsoleLogProvider ResolveProvider(IServiceProvider serviceProvider, ConsoleLogsProviderContext context)
|
||||
{
|
||||
lock (_lock)
|
||||
_hostContext = context;
|
||||
|
||||
try
|
||||
{
|
||||
return serviceProvider.GetRequiredService<IConsoleLogProvider>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock)
|
||||
_hostContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Installs a process-wide tee on <see cref="Console.Out"/> and <see cref="Console.Error"/> so that every
|
||||
/// captured write is fanned out to all currently registered subscribers, in addition to the original writers.
|
||||
///
|
||||
/// <para>
|
||||
/// Downstream logger providers (e.g. <c>Microsoft.Extensions.Logging.Console</c>) capture <c>Console.Out</c>
|
||||
/// once at construction. Therefore the tee must be installed BEFORE any such provider is constructed —
|
||||
/// ideally as the very first line of <c>Program.cs</c>. Calling <see cref="Subscribe"/> implicitly installs
|
||||
/// the tee, so a host can also rely on subscribers attaching early in the pipeline.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Writes captured before any subscriber attaches are kept in a bounded replay buffer and delivered to the
|
||||
/// first subscriber (when <c>replayPreviousWrites</c> is true), preserving original timestamps. This bridges
|
||||
/// the gap between <see cref="Install"/> and the first <see cref="Subscribe"/> call, which typically happens
|
||||
/// when the DI container is built and the capture singleton is resolved.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Subscribers that themselves write to the console while handling a capture would cause infinite recursion.
|
||||
/// Set <see cref="SuppressCapture"/> to true on the publish path's <see cref="AsyncLocal{T}"/> context to
|
||||
/// short-circuit dispatch for the duration of that asynchronous flow.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConsoleStreamHook
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static readonly AsyncLocal<bool> SuppressFlag = new();
|
||||
private static volatile Subscription[] _subscriptions = [];
|
||||
private static readonly ReplayBuffer Replay = new(capacity: 4096);
|
||||
private static volatile bool _installed;
|
||||
private static TextWriter? _originalOut;
|
||||
private static TextWriter? _originalError;
|
||||
|
||||
/// <summary>
|
||||
/// While true on the current async context, captured writes will not be dispatched to subscribers.
|
||||
/// Subscribers must set this to true while running their publish pipeline so that any logging triggered
|
||||
/// by that pipeline does not feed back into capture.
|
||||
/// </summary>
|
||||
public static bool SuppressCapture
|
||||
{
|
||||
get => SuppressFlag.Value;
|
||||
set => SuppressFlag.Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the tee if not already installed. Safe to call multiple times.
|
||||
/// </summary>
|
||||
public static void Install()
|
||||
{
|
||||
if (_installed)
|
||||
return;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_installed)
|
||||
return;
|
||||
|
||||
_originalOut = Console.Out;
|
||||
_originalError = Console.Error;
|
||||
Console.SetOut(new ConsoleStreamTeeWriter(_originalOut, ConsoleLogStream.Stdout, Dispatch));
|
||||
Console.SetError(new ConsoleStreamTeeWriter(_originalError, ConsoleLogStream.Stderr, Dispatch));
|
||||
_installed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the original writers and clears all subscribers. Intended for tests.
|
||||
/// </summary>
|
||||
public static void Uninstall()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_installed)
|
||||
return;
|
||||
|
||||
if (_originalOut != null)
|
||||
Console.SetOut(_originalOut);
|
||||
|
||||
if (_originalError != null)
|
||||
Console.SetError(_originalError);
|
||||
|
||||
_originalOut = null;
|
||||
_originalError = null;
|
||||
_subscriptions = [];
|
||||
Replay.Clear();
|
||||
_installed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a subscriber that receives every captured write. Disposing the returned token removes the
|
||||
/// subscription. Calling Subscribe auto-installs the tee. When <paramref name="replayPreviousWrites"/>
|
||||
/// is true, the buffered writes captured since install are replayed to this subscriber synchronously.
|
||||
/// </summary>
|
||||
public static IDisposable Subscribe(Action<CapturedChunk> handler, bool replayPreviousWrites = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
Install();
|
||||
var subscription = new Subscription(handler);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
_subscriptions = [.._subscriptions, subscription];
|
||||
|
||||
if (replayPreviousWrites)
|
||||
{
|
||||
foreach (var chunk in Replay.Snapshot())
|
||||
InvokeSafely(subscription, chunk);
|
||||
|
||||
Replay.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
internal static void Remove(Subscription subscription)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_subscriptions = _subscriptions.Where(x => !ReferenceEquals(x, subscription)).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Dispatch(ConsoleLogStream stream, string value)
|
||||
{
|
||||
if (SuppressFlag.Value || value.Length == 0)
|
||||
return;
|
||||
|
||||
var chunk = new CapturedChunk(stream, value, DateTimeOffset.UtcNow);
|
||||
var subscriptions = _subscriptions;
|
||||
|
||||
if (subscriptions.Length == 0)
|
||||
{
|
||||
// No subscriber yet — retain for replay to the first one that attaches.
|
||||
Replay.Add(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var subscription in subscriptions)
|
||||
InvokeSafely(subscription, chunk);
|
||||
}
|
||||
|
||||
private static void InvokeSafely(Subscription subscription, CapturedChunk chunk)
|
||||
{
|
||||
try
|
||||
{
|
||||
subscription.Handler(chunk);
|
||||
}
|
||||
catch (Exception e) when (!IsCriticalException(e))
|
||||
{
|
||||
// Never let a subscriber take down the writer. Subscribers are responsible for their own resilience.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCriticalException(Exception exception) =>
|
||||
exception is OutOfMemoryException
|
||||
or StackOverflowException
|
||||
or AccessViolationException
|
||||
or AppDomainUnloadedException
|
||||
or BadImageFormatException
|
||||
or CannotUnloadAppDomainException;
|
||||
|
||||
internal sealed class Subscription(Action<CapturedChunk> handler) : IDisposable
|
||||
{
|
||||
public Action<CapturedChunk> Handler { get; } = handler;
|
||||
public void Dispose() => Remove(this);
|
||||
}
|
||||
|
||||
private sealed class ReplayBuffer(int capacity)
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Queue<CapturedChunk> _items = new(capacity);
|
||||
|
||||
public void Add(CapturedChunk chunk)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_items.Count == capacity)
|
||||
_items.Dequeue();
|
||||
|
||||
_items.Enqueue(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<CapturedChunk> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
return _items.ToArray();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
_items.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single chunk of text captured from stdout/stderr together with the moment of capture.
|
||||
/// </summary>
|
||||
public readonly record struct CapturedChunk(ConsoleLogStream Stream, string Text, DateTimeOffset TimestampUtc);
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TextWriter"/> that forwards every write to the original underlying writer and a configurable capture sink.
|
||||
/// </summary>
|
||||
internal sealed class ConsoleStreamTeeWriter(TextWriter original, ConsoleLogStream stream, Action<ConsoleLogStream, string> capture) : TextWriter
|
||||
{
|
||||
public TextWriter Original => original;
|
||||
|
||||
public override Encoding Encoding => original.Encoding;
|
||||
public override IFormatProvider FormatProvider => original.FormatProvider;
|
||||
[AllowNull]
|
||||
public override string NewLine { get => original.NewLine; set => original.NewLine = value; }
|
||||
|
||||
// ── Write overloads ──
|
||||
|
||||
public override void Write(char value)
|
||||
{
|
||||
original.Write(value);
|
||||
capture(stream, value.ToString());
|
||||
}
|
||||
|
||||
public override void Write(char[]? buffer)
|
||||
{
|
||||
original.Write(buffer);
|
||||
if (buffer is { Length: > 0 })
|
||||
capture(stream, new string(buffer));
|
||||
}
|
||||
|
||||
public override void Write(char[] buffer, int index, int count)
|
||||
{
|
||||
original.Write(buffer, index, count);
|
||||
if (count > 0)
|
||||
capture(stream, new string(buffer, index, count));
|
||||
}
|
||||
|
||||
public override void Write(ReadOnlySpan<char> buffer)
|
||||
{
|
||||
original.Write(buffer);
|
||||
if (!buffer.IsEmpty)
|
||||
capture(stream, buffer.ToString());
|
||||
}
|
||||
|
||||
public override void Write(bool value) { original.Write(value); capture(stream, value ? "True" : "False"); }
|
||||
public override void Write(int value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(uint value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(long value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(ulong value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(float value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(double value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
public override void Write(decimal value) { original.Write(value); capture(stream, value.ToString(FormatProvider)); }
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
original.Write(value);
|
||||
if (value != null)
|
||||
capture(stream, value);
|
||||
}
|
||||
|
||||
public override void Write(object? value)
|
||||
{
|
||||
original.Write(value);
|
||||
var text = value?.ToString();
|
||||
if (text != null)
|
||||
capture(stream, text);
|
||||
}
|
||||
|
||||
public override void Write(StringBuilder? value)
|
||||
{
|
||||
original.Write(value);
|
||||
if (value is { Length: > 0 })
|
||||
capture(stream, value.ToString());
|
||||
}
|
||||
|
||||
public override void Write(string format, object? arg0) { var text = string.Format(FormatProvider, format, arg0); original.Write(text); capture(stream, text); }
|
||||
public override void Write(string format, object? arg0, object? arg1) { var text = string.Format(FormatProvider, format, arg0, arg1); original.Write(text); capture(stream, text); }
|
||||
public override void Write(string format, object? arg0, object? arg1, object? arg2) { var text = string.Format(FormatProvider, format, arg0, arg1, arg2); original.Write(text); capture(stream, text); }
|
||||
public override void Write(string format, params object?[] arg) { var text = string.Format(FormatProvider, format, arg); original.Write(text); capture(stream, text); }
|
||||
#if NET9_0_OR_GREATER
|
||||
public override void Write(string format, params ReadOnlySpan<object?> arg) { var text = string.Format(FormatProvider, format, arg); original.Write(text); capture(stream, text); }
|
||||
#endif
|
||||
|
||||
// ── WriteLine overloads ──
|
||||
|
||||
public override void WriteLine() { original.WriteLine(); capture(stream, "\n"); }
|
||||
public override void WriteLine(char value) { original.WriteLine(value); capture(stream, value + "\n"); }
|
||||
|
||||
public override void WriteLine(char[]? buffer)
|
||||
{
|
||||
original.WriteLine(buffer);
|
||||
capture(stream, (buffer is { Length: > 0 } ? new string(buffer) : "") + "\n");
|
||||
}
|
||||
|
||||
public override void WriteLine(char[] buffer, int index, int count)
|
||||
{
|
||||
original.WriteLine(buffer, index, count);
|
||||
capture(stream, new string(buffer, index, count) + "\n");
|
||||
}
|
||||
|
||||
public override void WriteLine(ReadOnlySpan<char> buffer)
|
||||
{
|
||||
original.WriteLine(buffer);
|
||||
capture(stream, string.Concat(buffer, "\n"));
|
||||
}
|
||||
|
||||
public override void WriteLine(bool value) { original.WriteLine(value); capture(stream, (value ? "True" : "False") + "\n"); }
|
||||
public override void WriteLine(int value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(uint value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(long value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(ulong value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(float value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(double value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
public override void WriteLine(decimal value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); }
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
{
|
||||
original.WriteLine(value);
|
||||
capture(stream, (value ?? "") + "\n");
|
||||
}
|
||||
|
||||
public override void WriteLine(object? value)
|
||||
{
|
||||
original.WriteLine(value);
|
||||
capture(stream, (value?.ToString() ?? "") + "\n");
|
||||
}
|
||||
|
||||
public override void WriteLine(StringBuilder? value)
|
||||
{
|
||||
original.WriteLine(value);
|
||||
capture(stream, (value is { Length: > 0 } ? value.ToString() : "") + "\n");
|
||||
}
|
||||
|
||||
public override void WriteLine(string format, object? arg0) { var text = string.Format(FormatProvider, format, arg0); original.WriteLine(text); capture(stream, text + "\n"); }
|
||||
public override void WriteLine(string format, object? arg0, object? arg1) { var text = string.Format(FormatProvider, format, arg0, arg1); original.WriteLine(text); capture(stream, text + "\n"); }
|
||||
public override void WriteLine(string format, object? arg0, object? arg1, object? arg2) { var text = string.Format(FormatProvider, format, arg0, arg1, arg2); original.WriteLine(text); capture(stream, text + "\n"); }
|
||||
public override void WriteLine(string format, params object?[] arg) { var text = string.Format(FormatProvider, format, arg); original.WriteLine(text); capture(stream, text + "\n"); }
|
||||
#if NET9_0_OR_GREATER
|
||||
public override void WriteLine(string format, params ReadOnlySpan<object?> arg) { var text = string.Format(FormatProvider, format, arg); original.WriteLine(text); capture(stream, text + "\n"); }
|
||||
#endif
|
||||
|
||||
// ── Async Write overloads ──
|
||||
|
||||
public override async Task WriteAsync(char value) { await original.WriteAsync(value); capture(stream, value.ToString()); }
|
||||
|
||||
public override async Task WriteAsync(string? value)
|
||||
{
|
||||
await original.WriteAsync(value);
|
||||
if (value != null)
|
||||
capture(stream, value);
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(char[] buffer, int index, int count)
|
||||
{
|
||||
await original.WriteAsync(buffer, index, count);
|
||||
if (count > 0)
|
||||
capture(stream, new string(buffer, index, count));
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(ReadOnlyMemory<char> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await original.WriteAsync(buffer, cancellationToken);
|
||||
if (!buffer.IsEmpty)
|
||||
capture(stream, buffer.ToString());
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await original.WriteAsync(value, cancellationToken);
|
||||
if (value is { Length: > 0 })
|
||||
capture(stream, value.ToString());
|
||||
}
|
||||
|
||||
// ── Async WriteLine overloads ──
|
||||
|
||||
public override async Task WriteLineAsync() { await original.WriteLineAsync(); capture(stream, "\n"); }
|
||||
public override async Task WriteLineAsync(char value) { await original.WriteLineAsync(value); capture(stream, value + "\n"); }
|
||||
|
||||
public override async Task WriteLineAsync(string? value)
|
||||
{
|
||||
await original.WriteLineAsync(value);
|
||||
capture(stream, (value ?? "") + "\n");
|
||||
}
|
||||
|
||||
public override async Task WriteLineAsync(char[] buffer, int index, int count)
|
||||
{
|
||||
await original.WriteLineAsync(buffer, index, count);
|
||||
capture(stream, new string(buffer, index, count) + "\n");
|
||||
}
|
||||
|
||||
public override async Task WriteLineAsync(ReadOnlyMemory<char> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await original.WriteLineAsync(buffer, cancellationToken);
|
||||
capture(stream, string.Concat(buffer.Span, "\n"));
|
||||
}
|
||||
|
||||
public override async Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await original.WriteLineAsync(value, cancellationToken);
|
||||
capture(stream, (value is { Length: > 0 } ? value.ToString() : "") + "\n");
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
public override void Flush() => original.Flush();
|
||||
public override Task FlushAsync() => original.FlushAsync();
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) => original.FlushAsync(cancellationToken);
|
||||
public override void Close() => original.Close();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
using ConsoleLogStreaming.Core.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
internal static class ElsaConsoleLogOptions
|
||||
{
|
||||
public static void ConfigureDefaults(ConsoleLogOptions options)
|
||||
{
|
||||
var sourceId = $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var podName = Environment.GetEnvironmentVariable("HOSTNAME");
|
||||
|
||||
options.SourceId = sourceId;
|
||||
options.SourceDisplayName = !string.IsNullOrWhiteSpace(podName) ? podName : sourceId;
|
||||
options.ServiceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? AppDomain.CurrentDomain.FriendlyName;
|
||||
options.PreserveAnsi = true;
|
||||
|
||||
SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesPodName, podName);
|
||||
SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesNamespace, Environment.GetEnvironmentVariable("POD_NAMESPACE"));
|
||||
SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.ContainerName, Environment.GetEnvironmentVariable("CONTAINER_NAME"));
|
||||
SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesNodeName, Environment.GetEnvironmentVariable("NODE_NAME"));
|
||||
SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.ProcessStartedAt, DateTimeOffset.UtcNow.ToString("O"));
|
||||
}
|
||||
|
||||
private static void SetMetadata(ConsoleLogOptions options, string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
options.SourceMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using ConsoleLogStreaming.SignalR;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Permissions;
|
||||
using FastEndpoints.Security;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
public sealed class ElsaConsoleLogStreamingHubAuthorizer : IConsoleLogStreamingHubAuthorizer
|
||||
{
|
||||
private const string ReadAllPermission = "read:*";
|
||||
private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ConsoleLogsPermissions.Read];
|
||||
|
||||
public ValueTask<bool> CanReadAsync(HubCallerContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var user = context.User;
|
||||
return ValueTask.FromResult(user?.Identity?.IsAuthenticated == true && ReadPermissions.Any(user.HasPermission));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using CShells.AspNetCore.Features;
|
||||
using CShells.FastEndpoints.Features;
|
||||
using CShells.Features;
|
||||
using ConsoleLogStreaming.Core.Options;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
|
@ -15,24 +16,23 @@ namespace Elsa.Diagnostics.ConsoleLogs.ShellFeatures;
|
|||
[ShellFeature(
|
||||
DisplayName = "Console Logs",
|
||||
Description = "Provides live raw console log streaming over REST and SignalR",
|
||||
DependsOn = ["ElsaFastEndpoints"])]
|
||||
DependsOn = ["ElsaFastEndpoints", "Workflows"])]
|
||||
[UsedImplicitly]
|
||||
public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature
|
||||
{
|
||||
private static readonly ConsoleLogsOptions DefaultOptions = new();
|
||||
private static readonly ConsoleLogOptions DefaultOptions = new();
|
||||
|
||||
public int RecentLogCapacity { get; set; } = DefaultOptions.RecentLogCapacity;
|
||||
public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberChannelCapacity;
|
||||
public int RecentLogCapacity { get; set; } = DefaultOptions.RecentCapacity;
|
||||
public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberCapacity;
|
||||
public int CaptureChannelCapacity { get; set; } = DefaultOptions.CaptureChannelCapacity;
|
||||
public int MaxRecentQuerySize { get; set; } = DefaultOptions.MaxRecentQuerySize;
|
||||
public int MaxLineLength { get; set; } = DefaultOptions.MaxLineLength;
|
||||
public TimeSpan IdleFlushTimeout { get; set; } = DefaultOptions.IdleFlushTimeout;
|
||||
public bool StripAnsiEscapeSequences { get; set; } = DefaultOptions.StripAnsiEscapeSequences;
|
||||
public bool StripAnsiEscapeSequences { get; set; } = !DefaultOptions.PreserveAnsi;
|
||||
public TimeSpan SourceHeartbeatTimeout { get; set; } = DefaultOptions.SourceHeartbeatTimeout;
|
||||
public bool IncludeConsoleLogsInternalLogs { get; set; } = DefaultOptions.IncludeConsoleLogsInternalLogs;
|
||||
public string RedactionReplacement { get; set; } = DefaultOptions.RedactionReplacement;
|
||||
public ICollection<string> SensitiveNames { get; set; } = [..DefaultOptions.SensitiveNames];
|
||||
public ICollection<string> SensitiveTextPatterns { get; set; } = [..DefaultOptions.SensitiveTextPatterns];
|
||||
public ICollection<string> SensitiveTextPatterns { get; set; } = [..DefaultOptions.RedactionRules.Select(x => x.Pattern)];
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
|
|
@ -44,19 +44,27 @@ public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature
|
|||
endpoints.MapConsoleLogsHub();
|
||||
}
|
||||
|
||||
private void ConfigureOptions(ConsoleLogsOptions options)
|
||||
private void ConfigureOptions(ConsoleLogOptions options)
|
||||
{
|
||||
options.RecentLogCapacity = RecentLogCapacity;
|
||||
options.SubscriberChannelCapacity = SubscriberChannelCapacity;
|
||||
options.RecentCapacity = RecentLogCapacity;
|
||||
options.SubscriberCapacity = SubscriberChannelCapacity;
|
||||
options.CaptureChannelCapacity = CaptureChannelCapacity;
|
||||
options.MaxRecentQuerySize = MaxRecentQuerySize;
|
||||
options.MaxLineLength = MaxLineLength;
|
||||
options.IdleFlushTimeout = IdleFlushTimeout;
|
||||
options.StripAnsiEscapeSequences = StripAnsiEscapeSequences;
|
||||
options.PreserveAnsi = !StripAnsiEscapeSequences;
|
||||
options.SourceHeartbeatTimeout = SourceHeartbeatTimeout;
|
||||
options.IncludeConsoleLogsInternalLogs = IncludeConsoleLogsInternalLogs;
|
||||
options.RedactionReplacement = RedactionReplacement;
|
||||
options.SensitiveNames = [..SensitiveNames];
|
||||
options.SensitiveTextPatterns = [..SensitiveTextPatterns];
|
||||
options.RedactionRules.Clear();
|
||||
foreach (var pattern in SensitiveTextPatterns)
|
||||
{
|
||||
options.RedactionRules.Add(new()
|
||||
{
|
||||
Name = "Sensitive text",
|
||||
Pattern = pattern,
|
||||
Replacement = RedactionReplacement
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
global using System.Runtime.CompilerServices;
|
||||
global using ConsoleLogStreaming.Contracts;
|
||||
global using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
global using Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
global using Elsa.Diagnostics.ConsoleLogs.Options;
|
||||
global using Elsa.Workflows;
|
||||
global using Elsa.Workflows.Pipelines.ActivityExecution;
|
||||
global using Elsa.Workflows.Pipelines.WorkflowExecution;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
using Elsa.Workflows.Pipelines.ActivityExecution;
|
||||
|
||||
namespace Elsa.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Contributes middleware to the activity execution pipeline.
|
||||
/// </summary>
|
||||
public interface IActivityExecutionPipelineContributor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the order in which the contributor is applied.
|
||||
/// </summary>
|
||||
int Order => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the activity execution pipeline.
|
||||
/// </summary>
|
||||
void Configure(IActivityExecutionPipelineBuilder builder);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using Elsa.Workflows.Pipelines.WorkflowExecution;
|
||||
|
||||
namespace Elsa.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Contributes middleware to the workflow execution pipeline.
|
||||
/// </summary>
|
||||
public interface IWorkflowExecutionPipelineContributor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the order in which the contributor is applied.
|
||||
/// </summary>
|
||||
int Order => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the workflow execution pipeline.
|
||||
/// </summary>
|
||||
void Configure(IWorkflowExecutionPipelineBuilder builder);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Pipelines.ActivityExecution;
|
||||
using Elsa.Workflows.Pipelines.WorkflowExecution;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Extensions;
|
||||
|
||||
internal static class PipelineContributorExtensions
|
||||
{
|
||||
public static IWorkflowExecutionPipelineBuilder UseWorkflowExecutionPipelineContributors(this IWorkflowExecutionPipelineBuilder builder, IServiceProvider serviceProvider)
|
||||
{
|
||||
foreach (var contributor in serviceProvider.GetServices<IWorkflowExecutionPipelineContributor>().OrderBy(x => x.Order))
|
||||
contributor.Configure(builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IActivityExecutionPipelineBuilder UseActivityExecutionPipelineContributors(this IActivityExecutionPipelineBuilder builder, IServiceProvider serviceProvider)
|
||||
{
|
||||
foreach (var contributor in serviceProvider.GetServices<IActivityExecutionPipelineContributor>().OrderBy(x => x.Order))
|
||||
contributor.Configure(builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
|
@ -233,8 +233,16 @@ public class WorkflowsFeature : FeatureBase
|
|||
.AddTransient<IIncidentStrategy, ContinueWithIncidentsStrategy>()
|
||||
|
||||
// Pipelines.
|
||||
.AddScoped<IActivityExecutionPipeline>(sp => new ActivityExecutionPipeline(sp, ActivityExecutionPipeline))
|
||||
.AddScoped<IWorkflowExecutionPipeline>(sp => new WorkflowExecutionPipeline(sp, WorkflowExecutionPipeline))
|
||||
.AddScoped<IActivityExecutionPipeline>(sp => new ActivityExecutionPipeline(sp, builder =>
|
||||
{
|
||||
builder.UseActivityExecutionPipelineContributors(sp);
|
||||
ActivityExecutionPipeline(builder);
|
||||
}))
|
||||
.AddScoped<IWorkflowExecutionPipeline>(sp => new WorkflowExecutionPipeline(sp, builder =>
|
||||
{
|
||||
builder.UseWorkflowExecutionPipelineContributors(sp);
|
||||
WorkflowExecutionPipeline(builder);
|
||||
}))
|
||||
|
||||
// Built-in activity services.
|
||||
.AddScoped<IActivityResolver, PropertyBasedActivityResolver>()
|
||||
|
|
|
|||
|
|
@ -115,8 +115,16 @@ public class WorkflowsFeature : IShellFeature
|
|||
.AddTransient<IIncidentStrategy, ContinueWithIncidentsStrategy>()
|
||||
|
||||
// Pipelines.
|
||||
.AddScoped<IActivityExecutionPipeline>(sp => new ActivityExecutionPipeline(sp, ActivityExecutionPipeline))
|
||||
.AddScoped<IWorkflowExecutionPipeline>(sp => new WorkflowExecutionPipeline(sp, WorkflowExecutionPipeline))
|
||||
.AddScoped<IActivityExecutionPipeline>(sp => new ActivityExecutionPipeline(sp, builder =>
|
||||
{
|
||||
builder.UseActivityExecutionPipelineContributors(sp);
|
||||
ActivityExecutionPipeline(builder);
|
||||
}))
|
||||
.AddScoped<IWorkflowExecutionPipeline>(sp => new WorkflowExecutionPipeline(sp, builder =>
|
||||
{
|
||||
builder.UseWorkflowExecutionPipelineContributors(sp);
|
||||
WorkflowExecutionPipeline(builder);
|
||||
}))
|
||||
|
||||
// Built-in activity services.
|
||||
.AddScoped<IActivityResolver, PropertyBasedActivityResolver>()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using ConsoleLogStreaming.Contracts;
|
||||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Features;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Permissions;
|
||||
using Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
|
@ -15,14 +18,6 @@ namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
|
|||
|
||||
public class ConsoleLogsAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void HubAuthorization_RequiresAuthenticatedUser()
|
||||
{
|
||||
var authorize = Assert.Single(typeof(ConsoleLogsHub).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true).Cast<AuthorizeAttribute>());
|
||||
|
||||
Assert.Null(authorize.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HubSubscribe_WithoutConsoleLogsPermission_DeniesAccess()
|
||||
{
|
||||
|
|
@ -60,10 +55,125 @@ public class ConsoleLogsAuthorizationTests
|
|||
Assert.Contains(ConsoleLogsPermissions.Read, permissions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecentEndpoint_MapsWorkflowInstanceIdToMetadataFilter()
|
||||
{
|
||||
var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint", throwOnError: true)!;
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var endpoint = Activator.CreateInstance(endpointType, provider, new ConsoleLogStreamingApiMapper())!;
|
||||
var (requestDtoType, _) = GetEndpointDtoTypes(endpointType);
|
||||
var request = JsonSerializer.Deserialize(
|
||||
"""
|
||||
{
|
||||
"workflowInstanceId": "workflow-instance-a"
|
||||
}
|
||||
""",
|
||||
requestDtoType,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]);
|
||||
await Assert.IsAssignableFrom<Task>(result);
|
||||
|
||||
Assert.NotNull(provider.LastFilter);
|
||||
var metadata = provider.LastFilter.Metadata;
|
||||
Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId));
|
||||
Assert.Equal("workflow-instance-a", workflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecentEndpoint_MapsActivityFiltersToMetadataFilter()
|
||||
{
|
||||
var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint", throwOnError: true)!;
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var endpoint = Activator.CreateInstance(endpointType, provider, new ConsoleLogStreamingApiMapper())!;
|
||||
var (requestDtoType, _) = GetEndpointDtoTypes(endpointType);
|
||||
var request = JsonSerializer.Deserialize(
|
||||
"""
|
||||
{
|
||||
"workflowInstanceId": "workflow-instance-a",
|
||||
"activityInstanceId": "activity-instance-a",
|
||||
"activityId": "activity-a",
|
||||
"activityNodeId": "node-a"
|
||||
}
|
||||
""",
|
||||
requestDtoType,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]);
|
||||
await Assert.IsAssignableFrom<Task>(result);
|
||||
|
||||
Assert.NotNull(provider.LastFilter);
|
||||
AssertActivityMetadata(provider.LastFilter.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HubStream_MapsWorkflowInstanceIdToMetadataFilter()
|
||||
{
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var hub = CreateHub(provider, ConsoleLogsPermissions.Read);
|
||||
|
||||
await foreach (var _ in hub.StreamAsync(new ElsaConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" }, CancellationToken.None))
|
||||
{
|
||||
// Intentionally consume the stream to trigger provider subscription/filter mapping side effects.
|
||||
}
|
||||
|
||||
Assert.NotNull(provider.LastSubscriptionFilter);
|
||||
var metadata = provider.LastSubscriptionFilter.Metadata;
|
||||
Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId));
|
||||
Assert.Equal("workflow-instance-a", workflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HubStream_MapsActivityFiltersToMetadataFilter()
|
||||
{
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var hub = CreateHub(provider, ConsoleLogsPermissions.Read);
|
||||
|
||||
await foreach (var _ in hub.StreamAsync(new ElsaConsoleLogFilter
|
||||
{
|
||||
WorkflowInstanceId = "workflow-instance-a",
|
||||
ActivityInstanceId = "activity-instance-a",
|
||||
ActivityId = "activity-a",
|
||||
ActivityNodeId = "node-a"
|
||||
}, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(provider.LastSubscriptionFilter);
|
||||
AssertActivityMetadata(provider.LastSubscriptionFilter.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HubSubscribe_MapsWorkflowInstanceIdToMetadataFilter()
|
||||
{
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var hub = CreateHub(provider, ConsoleLogsPermissions.Read);
|
||||
|
||||
await hub.SubscribeAsync(new ElsaConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" });
|
||||
var filter = await provider.WaitForSubscriptionFilterAsync();
|
||||
|
||||
var metadata = filter.Metadata;
|
||||
Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId));
|
||||
Assert.Equal("workflow-instance-a", workflowInstanceId);
|
||||
|
||||
await hub.UnsubscribeAsync();
|
||||
}
|
||||
|
||||
private static void AssertActivityMetadata(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
Assert.Equal("workflow-instance-a", metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
|
||||
Assert.Equal("activity-instance-a", metadata[ConsoleLogMetadataKeys.ActivityInstanceId]);
|
||||
Assert.Equal("activity-a", metadata[ConsoleLogMetadataKeys.ActivityId]);
|
||||
Assert.Equal("node-a", metadata[ConsoleLogMetadataKeys.ActivityNodeId]);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> GetConfiguredPermissions(string endpointTypeName)
|
||||
{
|
||||
var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!;
|
||||
var endpoint = Activator.CreateInstance(endpointType, new TestConsoleLogProvider())!;
|
||||
var mapper = new ConsoleLogStreamingApiMapper();
|
||||
var endpoint = endpointTypeName.EndsWith(".Recent.Endpoint", StringComparison.Ordinal)
|
||||
? Activator.CreateInstance(endpointType, new TestConsoleLogProvider(), mapper)!
|
||||
: Activator.CreateInstance(endpointType, new TestConsoleLogProvider(), mapper)!;
|
||||
var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType);
|
||||
var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType);
|
||||
|
||||
|
|
@ -108,14 +218,20 @@ public class ConsoleLogsAuthorizationTests
|
|||
throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'.");
|
||||
}
|
||||
|
||||
private static ConsoleLogsHub CreateHub(params string[] permissions)
|
||||
private static ElsaConsoleLogsHub CreateHub(params string[] permissions)
|
||||
{
|
||||
var provider = new TestConsoleLogProvider();
|
||||
var sourceRegistry = new TestConsoleLogSourceRegistry();
|
||||
var hubContext = new TestHubContext();
|
||||
var subscriptionManager = new ConsoleLogSubscriptionManager(provider, sourceRegistry, hubContext, NullLogger<ConsoleLogSubscriptionManager>.Instance);
|
||||
return CreateHub(new TestConsoleLogProvider(), permissions);
|
||||
}
|
||||
|
||||
return new ConsoleLogsHub(subscriptionManager)
|
||||
private static ElsaConsoleLogsHub CreateHub(TestConsoleLogProvider provider, params string[] permissions)
|
||||
{
|
||||
var sourceRegistry = new TestConsoleLogSourceRegistry();
|
||||
var mapper = new ConsoleLogStreamingApiMapper();
|
||||
var hubContext = new TestHubContext();
|
||||
var subscriptionManager = new ElsaConsoleLogSubscriptionManager(provider, sourceRegistry, mapper, hubContext, NullLogger<ElsaConsoleLogSubscriptionManager>.Instance);
|
||||
var authorizer = new ElsaConsoleLogStreamingHubAuthorizer();
|
||||
|
||||
return new ElsaConsoleLogsHub(provider, mapper, authorizer, subscriptionManager)
|
||||
{
|
||||
Context = new TestHubCallerContext(CreateUser(permissions))
|
||||
};
|
||||
|
|
@ -134,52 +250,66 @@ public class ConsoleLogsAuthorizationTests
|
|||
|
||||
private class TestConsoleLogProvider : IConsoleLogProvider
|
||||
{
|
||||
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
private readonly TaskCompletionSource<ConsoleLogStreaming.Core.Models.ConsoleLogFilter> _subscriptionFilterSet = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public ConsoleLogStreaming.Core.Models.ConsoleLogFilter? LastFilter { get; private set; }
|
||||
public ConsoleLogStreaming.Core.Models.ConsoleLogFilter? LastSubscriptionFilter { get; private set; }
|
||||
|
||||
public Task<ConsoleLogStreaming.Core.Models.ConsoleLogFilter> WaitForSubscriptionFilterAsync() =>
|
||||
_subscriptionFilterSet.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
|
||||
public ValueTask<ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult> GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(new RecentConsoleLogsResult([], []));
|
||||
LastFilter = filter;
|
||||
return ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult());
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
|
||||
public IAsyncEnumerable<ConsoleLogStreaming.Core.Models.ConsoleLogStreamingItem> SubscribeAsync(
|
||||
ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AsyncEnumerable.Empty<ConsoleLogStreamItem>();
|
||||
LastSubscriptionFilter = filter;
|
||||
_subscriptionFilterSet.TrySetResult(filter);
|
||||
return AsyncEnumerable.Empty<ConsoleLogStreaming.Core.Models.ConsoleLogStreamingItem>();
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogStreaming.Core.Models.ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
|
||||
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogStreaming.Core.Models.ConsoleLogSource>>([]);
|
||||
}
|
||||
}
|
||||
|
||||
private class TestConsoleLogSourceRegistry : IConsoleLogSourceRegistry
|
||||
{
|
||||
public event Action<ConsoleLogSource>? SourceChanged
|
||||
public event Action<ConsoleLogStreaming.Core.Models.ConsoleLogSource>? SourceChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public ConsoleLogSource Current { get; } = new()
|
||||
public ConsoleLogStreaming.Core.Models.ConsoleLogSource Current { get; } = new()
|
||||
{
|
||||
Id = "test",
|
||||
DisplayName = "Test"
|
||||
};
|
||||
|
||||
public void MarkSeen(string sourceId, DateTimeOffset timestamp)
|
||||
public ConsoleLogStreaming.Core.Models.ConsoleLogSource MarkSeen(ConsoleLogStreaming.Core.Models.ConsoleLogSource source, DateTimeOffset timestamp)
|
||||
{
|
||||
return source with { LastSeen = timestamp };
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ConsoleLogSource> List()
|
||||
public IReadOnlyCollection<ConsoleLogStreaming.Core.Models.ConsoleLogSource> List()
|
||||
{
|
||||
return [Current];
|
||||
}
|
||||
}
|
||||
|
||||
private class TestHubContext : IHubContext<ConsoleLogsHub, IConsoleLogsClient>
|
||||
private class TestHubContext : IHubContext<ElsaConsoleLogsHub, IConsoleLogsClient>
|
||||
{
|
||||
public IHubClients<IConsoleLogsClient> Clients { get; } = new TestHubClients();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.RealTime;
|
||||
using ConsoleLogStreaming.SignalR;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ public class ConsoleLogsHubTests
|
|||
[Fact]
|
||||
public void Hub_ExposesSubscriptionMethods()
|
||||
{
|
||||
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.SubscribeAsync)));
|
||||
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UpdateFilterAsync)));
|
||||
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UnsubscribeAsync)));
|
||||
Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.SubscribeAsync)));
|
||||
Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.UpdateFilterAsync)));
|
||||
Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.UnsubscribeAsync)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,325 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
|
||||
|
||||
[Collection(ConsoleHostStateCollection.Name)]
|
||||
public class ConsoleCaptureTeeTests : IDisposable
|
||||
{
|
||||
private readonly TextWriter _originalOut = Console.Out;
|
||||
private readonly TextWriter _originalError = Console.Error;
|
||||
private readonly StringWriter _consoleOutput = new();
|
||||
private readonly CapturingProvider _provider = new();
|
||||
private readonly IOptions<ConsoleLogsOptions> _options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions());
|
||||
private readonly ConsoleLogScopeAccessor _scopeAccessor = new();
|
||||
|
||||
public ConsoleCaptureTeeTests()
|
||||
{
|
||||
// Tear down any process-wide host instance so its hook subscription doesn't double-publish.
|
||||
ConsoleLogsHost.ShutdownAsync().AsTask().GetAwaiter().GetResult();
|
||||
ConsoleStreamHook.Uninstall();
|
||||
Console.SetOut(_consoleOutput);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ConsoleStreamHook.Uninstall();
|
||||
ConsoleLogsHost.ShutdownAsync().AsTask().GetAwaiter().GetResult();
|
||||
Console.SetOut(_originalOut);
|
||||
Console.SetError(_originalError);
|
||||
_consoleOutput.Dispose();
|
||||
}
|
||||
|
||||
private ConsoleCaptureTee CreateCapture() => new(
|
||||
_provider,
|
||||
new ConsoleLogSourceRegistry(_options),
|
||||
new ConsoleLogRedactor(_options),
|
||||
new ConsoleLineFormatter(_options),
|
||||
_scopeAccessor,
|
||||
_options);
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_PreservesOriginalConsoleOutputAndPublishesLine()
|
||||
{
|
||||
await using var capture = CreateCapture();
|
||||
|
||||
Console.WriteLine("hello");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal($"hello{Environment.NewLine}", _consoleOutput.ToString());
|
||||
var line = Assert.Single(_provider.Lines);
|
||||
Assert.Equal("hello", line.Text);
|
||||
Assert.Equal(ConsoleLogStream.Stdout, line.Stream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_RedactsBeforePublishingToProvider()
|
||||
{
|
||||
await using var capture = CreateCapture();
|
||||
|
||||
Console.WriteLine("token=secret-value");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal("[Redacted]", _provider.Lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_AfterDispose_StopsPublishing()
|
||||
{
|
||||
var capture = CreateCapture();
|
||||
|
||||
Console.WriteLine("first");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
await capture.DisposeAsync();
|
||||
|
||||
Console.WriteLine("second");
|
||||
await Task.Delay(50);
|
||||
|
||||
Assert.Single(_provider.Lines);
|
||||
Assert.Equal("first", _provider.Lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_FansOutToMultipleConcurrentSubscribers()
|
||||
{
|
||||
var secondProvider = new CapturingProvider();
|
||||
await using var first = CreateCapture();
|
||||
await using var second = new ConsoleCaptureTee(
|
||||
secondProvider,
|
||||
new ConsoleLogSourceRegistry(_options),
|
||||
new ConsoleLogRedactor(_options),
|
||||
new ConsoleLineFormatter(_options),
|
||||
_scopeAccessor,
|
||||
_options);
|
||||
|
||||
Console.WriteLine("broadcast");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
await WaitForLinesAsync(secondProvider, 1);
|
||||
|
||||
Assert.Equal("broadcast", _provider.Lines[^1].Text);
|
||||
Assert.Equal("broadcast", secondProvider.Lines[^1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_ReplaysStartupWritesOnlyOnce()
|
||||
{
|
||||
ConsoleStreamHook.Install();
|
||||
Console.WriteLine("startup");
|
||||
|
||||
var secondProvider = new CapturingProvider();
|
||||
await using var first = CreateCapture();
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
await using var second = new ConsoleCaptureTee(
|
||||
secondProvider,
|
||||
new ConsoleLogSourceRegistry(_options),
|
||||
new ConsoleLogRedactor(_options),
|
||||
new ConsoleLineFormatter(_options),
|
||||
_scopeAccessor,
|
||||
_options);
|
||||
await Task.Delay(50);
|
||||
|
||||
Assert.Equal("startup", _provider.Lines[0].Text);
|
||||
Assert.Empty(secondProvider.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_SuppressesPublishPathReentrancy()
|
||||
{
|
||||
var loggingProvider = new ReentrantProvider();
|
||||
await using var capture = new ConsoleCaptureTee(
|
||||
loggingProvider,
|
||||
new ConsoleLogSourceRegistry(_options),
|
||||
new ConsoleLogRedactor(_options),
|
||||
new ConsoleLineFormatter(_options),
|
||||
_scopeAccessor,
|
||||
_options);
|
||||
|
||||
Console.WriteLine("trigger");
|
||||
await WaitForLinesAsync(loggingProvider, 1);
|
||||
await Task.Delay(50); // Give any reentrant publish a chance to misbehave.
|
||||
|
||||
Assert.Single(loggingProvider.Lines);
|
||||
Assert.Equal("trigger", loggingProvider.Lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_ContinuesAfterProviderOperationCanceledException()
|
||||
{
|
||||
var provider = new OperationCanceledOnceProvider();
|
||||
await using var capture = new ConsoleCaptureTee(
|
||||
provider,
|
||||
new ConsoleLogSourceRegistry(_options),
|
||||
new ConsoleLogRedactor(_options),
|
||||
new ConsoleLineFormatter(_options),
|
||||
_scopeAccessor,
|
||||
_options);
|
||||
|
||||
Console.WriteLine("first");
|
||||
Console.WriteLine("second");
|
||||
await WaitForLinesAsync(provider, 1);
|
||||
|
||||
var line = Assert.Single(provider.Lines);
|
||||
Assert.Equal("second", line.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_AttachesCurrentWorkflowInstanceId()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
var logger = loggerFactory.CreateLogger("workflow");
|
||||
await using var capture = CreateCapture();
|
||||
using var scope = logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-a"
|
||||
});
|
||||
|
||||
Console.WriteLine("scoped");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_AttachesInnermostWorkflowInstanceId()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
var logger = loggerFactory.CreateLogger("workflow");
|
||||
await using var capture = CreateCapture();
|
||||
using var outerScope = logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-a"
|
||||
});
|
||||
using var innerScope = logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-b"
|
||||
});
|
||||
|
||||
Console.WriteLine("scoped");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal("workflow-instance-b", _provider.Lines[0].WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_PreservesScopeAcrossMultipleLoggerFactories()
|
||||
{
|
||||
using var firstLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
using var secondLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
var logger = firstLoggerFactory.CreateLogger("workflow");
|
||||
await using var capture = CreateCapture();
|
||||
using var scope = logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-a"
|
||||
});
|
||||
|
||||
_ = secondLoggerFactory.CreateLogger("other");
|
||||
Console.WriteLine("scoped");
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_AttachesWorkflowInstanceIdCapturedBeforeAsyncConsoleWrite()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
var logger = loggerFactory.CreateLogger("workflow");
|
||||
await using var capture = CreateCapture();
|
||||
|
||||
using (logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-a"
|
||||
}))
|
||||
{
|
||||
logger.LogInformation("scoped");
|
||||
}
|
||||
|
||||
await Task.Run(() => Console.WriteLine("scoped"));
|
||||
await WaitForLinesAsync(_provider, 1);
|
||||
|
||||
Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_DoesNotAttachCapturedWorkflowInstanceIdToUnrelatedConsoleWrite()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor));
|
||||
var logger = loggerFactory.CreateLogger("workflow");
|
||||
await using var capture = CreateCapture();
|
||||
|
||||
using (logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["WorkflowInstanceId"] = "workflow-instance-a"
|
||||
}))
|
||||
{
|
||||
logger.LogInformation("scoped");
|
||||
}
|
||||
|
||||
await Task.Run(() => Console.WriteLine("not scoped"));
|
||||
await Task.Run(() => Console.WriteLine("scoped"));
|
||||
await WaitForLinesAsync(_provider, 2);
|
||||
|
||||
Assert.Null(_provider.Lines[0].WorkflowInstanceId);
|
||||
Assert.Equal("workflow-instance-a", _provider.Lines[1].WorkflowInstanceId);
|
||||
}
|
||||
|
||||
private static async Task WaitForLinesAsync(CapturingProvider provider, int count)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
while (provider.Lines.Count < count && DateTimeOffset.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
private class CapturingProvider : IConsoleLogProvider
|
||||
{
|
||||
public List<ConsoleLogLine> Lines { get; } = [];
|
||||
|
||||
public virtual ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (Lines)
|
||||
Lines.Add(line);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new RecentConsoleLogsResult(Lines));
|
||||
|
||||
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
foreach (var line in Lines)
|
||||
yield return ConsoleLogStreamItem.FromLine(line);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
|
||||
}
|
||||
|
||||
private sealed class ReentrantProvider : CapturingProvider
|
||||
{
|
||||
public override ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simulate a provider whose internals write to the console (e.g., SignalR diagnostics).
|
||||
// With SuppressCapture set on this async context, this MUST NOT recurse.
|
||||
Console.WriteLine("[provider-internal-log]");
|
||||
return base.PublishAsync(line, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OperationCanceledOnceProvider : CapturingProvider
|
||||
{
|
||||
private int _throwCount;
|
||||
|
||||
public override ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref _throwCount) == 1)
|
||||
throw new OperationCanceledException();
|
||||
|
||||
return base.PublishAsync(line, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
|
||||
|
||||
public class ConsoleLineBufferTests
|
||||
{
|
||||
private readonly ConsoleLineBuffer _buffer = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 5, IdleFlushTimeout = TimeSpan.FromSeconds(1) }));
|
||||
|
||||
[Fact]
|
||||
public void Append_BuffersPartialWritesUntilNewline()
|
||||
{
|
||||
Assert.Empty(_buffer.Append("hel", DateTimeOffset.UtcNow));
|
||||
|
||||
var lines = _buffer.Append("lo\n", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(["hello"], lines.Select(x => x.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_CompletesLineAtMaximumLength()
|
||||
{
|
||||
var lines = _buffer.Append("hello!", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(["hello"], lines.Select(x => x.Text));
|
||||
Assert.Equal("!", _buffer.Flush()?.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushIfIdle_CompletesBufferedLineAfterTimeout()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
_buffer.Append("tail", now);
|
||||
|
||||
Assert.Null(_buffer.FlushIfIdle(now.AddMilliseconds(500)));
|
||||
Assert.Equal("tail", _buffer.FlushIfIdle(now.AddSeconds(2))?.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_PreservesEmptyLinesBetweenWrites()
|
||||
{
|
||||
var lines = _buffer.Append("a\n\nb\n", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(["a", "", "b"], lines.Select(x => x.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_PreservesWorkflowInstanceIdAcrossPartialWrites()
|
||||
{
|
||||
_buffer.Append("hel", DateTimeOffset.UtcNow, "workflow-instance-a");
|
||||
|
||||
var line = Assert.Single(_buffer.Append("lo\n", DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.Equal("hello", line.Text);
|
||||
Assert.Equal("workflow-instance-a", line.WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_DoesNotAssignWorkflowInstanceIdAfterUnscopedPartialWrite()
|
||||
{
|
||||
_buffer.Append("hel", DateTimeOffset.UtcNow);
|
||||
|
||||
var line = Assert.Single(_buffer.Append("lo\n", DateTimeOffset.UtcNow, "workflow-instance-a"));
|
||||
|
||||
Assert.Equal("hello", line.Text);
|
||||
Assert.Null(line.WorkflowInstanceId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
|
||||
|
||||
public class ConsoleLineFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Format_PreservesAnsiByDefault()
|
||||
{
|
||||
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
|
||||
|
||||
var result = formatter.Format("\u001b[31mred\u001b[0m");
|
||||
|
||||
Assert.Equal("\u001b[31mred\u001b[0m", result.Text);
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_StripsAnsiWhenConfigured()
|
||||
{
|
||||
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { StripAnsiEscapeSequences = true }));
|
||||
|
||||
var result = formatter.Format("\u001b[31mred\u001b[0m");
|
||||
|
||||
Assert.Equal("red", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_PreservesAnsiWhenConfigured()
|
||||
{
|
||||
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { StripAnsiEscapeSequences = false }));
|
||||
|
||||
var result = formatter.Format("\u001b[31mred\u001b[0m");
|
||||
|
||||
Assert.Equal("\u001b[31mred\u001b[0m", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_TruncatesOversizedLine()
|
||||
{
|
||||
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 3 }));
|
||||
|
||||
var result = formatter.Format("abcdef");
|
||||
|
||||
Assert.Equal("abc", result.Text);
|
||||
Assert.True(result.Truncated);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using ConsoleLogStreaming.Core;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection used by every test class that mutates the process-wide <see cref="ConsoleStreamHook"/>
|
||||
/// or <see cref="ConsoleLogsHost"/>. Members of this collection run sequentially, preventing parallel
|
||||
/// xUnit collection used by every test class that mutates the process-wide console stream hook
|
||||
/// or <see cref="ConsoleLogStreamingHost"/>. Members of this collection run sequentially, preventing parallel
|
||||
/// tests from racing on the same static state.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class ConsoleHostStateCollection
|
||||
{
|
||||
public const string Name = "ConsoleHostState";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.Core.Capture;
|
||||
using ConsoleLogStreaming.Core.DependencyInjection;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Pipelines.ActivityExecution;
|
||||
using Elsa.Workflows.Pipelines.WorkflowExecution;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
|
||||
|
||||
[Collection(ConsoleHostStateCollection.Name)]
|
||||
public class ConsoleLogContextAccessorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConsoleLogContextAccessor _accessor = ConsoleLogContextAccessor.Instance;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
ConsoleStreamHook.Uninstall();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
ConsoleStreamHook.Uninstall();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PushMetadata_RestoresNestedCaseInsensitiveMetadata()
|
||||
{
|
||||
using (_accessor.PushMetadata("Tenant", "tenant-a"))
|
||||
{
|
||||
Assert.Equal("tenant-a", _accessor.GetMetadata()["tenant"]);
|
||||
|
||||
using (_accessor.PushMetadata("tenant", "tenant-b"))
|
||||
Assert.Equal("tenant-b", _accessor.GetMetadata()["TENANT"]);
|
||||
|
||||
Assert.Equal("tenant-a", _accessor.GetMetadata()["tenant"]);
|
||||
}
|
||||
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushWorkflowInstanceId_IsIsolatedAcrossConcurrentAsyncFlows()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 20).Select(index => Task.Run(async () =>
|
||||
{
|
||||
var workflowInstanceId = $"workflow-{index}";
|
||||
|
||||
using (_accessor.PushWorkflowInstanceId(workflowInstanceId))
|
||||
{
|
||||
await Task.Yield();
|
||||
await Task.Delay(1);
|
||||
return _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId];
|
||||
}
|
||||
}));
|
||||
|
||||
var workflowInstanceIds = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(Enumerable.Range(0, 20).Select(index => $"workflow-{index}"), workflowInstanceIds);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecentQuery_FiltersDuplicateConsoleLinesByWorkflowMetadata()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddConsoleLogStreaming(options => options.SourceId = "test-source")
|
||||
.BuildServiceProvider();
|
||||
|
||||
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
|
||||
await provider.PublishAsync(CreateLine("duplicate", "workflow-a", 1));
|
||||
await provider.PublishAsync(CreateLine("duplicate", "workflow-b", 2));
|
||||
|
||||
var result = await provider.GetRecentAsync(new ConsoleLogStreaming.Core.Models.ConsoleLogFilter
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-b"
|
||||
}
|
||||
});
|
||||
|
||||
var line = Assert.Single(result.Items);
|
||||
Assert.Equal("duplicate", line.Text);
|
||||
Assert.Equal("workflow-b", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsoleLineBuffer_PreservesWorkflowMetadataForMultilineOutput()
|
||||
{
|
||||
var buffer = new ConsoleLineBuffer(Microsoft.Extensions.Options.Options.Create(new ConsoleLogStreaming.Core.Options.ConsoleLogOptions()));
|
||||
IReadOnlyCollection<BufferedConsoleLine> lines;
|
||||
|
||||
using (_accessor.PushWorkflowInstanceId("workflow-multiline"))
|
||||
lines = buffer.Append("first line\nsecond line\n", DateTimeOffset.UtcNow, _accessor.GetMetadata());
|
||||
|
||||
Assert.Collection(
|
||||
lines,
|
||||
line => Assert.Equal("workflow-multiline", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]),
|
||||
line => Assert.Equal("workflow-multiline", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsoleCapture_AttachesWorkflowMetadataToNonLoggerConsoleWrites()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogMetadataAccessor>(_accessor)
|
||||
.AddConsoleLogStreaming(options => options.SourceId = "test-source")
|
||||
.BuildServiceProvider();
|
||||
|
||||
var capture = serviceProvider.GetRequiredService<IConsoleLogCapture>();
|
||||
await capture.StartAsync();
|
||||
|
||||
using (_accessor.PushWorkflowInstanceId("workflow-console"))
|
||||
Console.WriteLine("plain console output");
|
||||
|
||||
await capture.StopAsync();
|
||||
|
||||
var result = await serviceProvider.GetRequiredService<IConsoleLogProvider>().GetRecentAsync(new ConsoleLogStreaming.Core.Models.ConsoleLogFilter
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-console"
|
||||
}
|
||||
});
|
||||
|
||||
var line = Assert.Single(result.Items, x => x.Text == "plain console output");
|
||||
Assert.Equal("workflow-console", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowExecutionMiddleware_PushesWorkflowInstanceMetadata()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.BuildServiceProvider();
|
||||
var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync();
|
||||
var context = activityExecutionContext.WorkflowExecutionContext;
|
||||
string? workflowInstanceId = null;
|
||||
var pipeline = new WorkflowExecutionPipeline(serviceProvider, builder => builder
|
||||
.UseMiddleware<ConsoleLogWorkflowExecutionMiddleware>()
|
||||
.Use(_ => executionContext =>
|
||||
{
|
||||
workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId];
|
||||
return ValueTask.CompletedTask;
|
||||
}));
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
Assert.Equal(context.Id, workflowInstanceId);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowExecutionContributor_PushesWorkflowInstanceMetadata()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.BuildServiceProvider();
|
||||
var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync();
|
||||
var context = activityExecutionContext.WorkflowExecutionContext;
|
||||
string? workflowInstanceId = null;
|
||||
var pipeline = new WorkflowExecutionPipeline(serviceProvider, builder =>
|
||||
{
|
||||
new ConsoleLogWorkflowExecutionPipelineContributor().Configure(builder);
|
||||
builder.Use(_ => executionContext =>
|
||||
{
|
||||
workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId];
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
Assert.Equal(context.Id, workflowInstanceId);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowFeature_AppliesWorkflowExecutionContributors()
|
||||
{
|
||||
var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync();
|
||||
var context = activityExecutionContext.WorkflowExecutionContext;
|
||||
string? workflowInstanceId = null;
|
||||
var feature = new Elsa.Workflows.ShellFeatures.WorkflowsFeature
|
||||
{
|
||||
WorkflowExecutionPipeline = builder => builder.Use(_ => executionContext =>
|
||||
{
|
||||
workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId];
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
};
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.AddScoped<IWorkflowExecutionPipelineContributor, ConsoleLogWorkflowExecutionPipelineContributor>();
|
||||
feature.ConfigureServices(services);
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var pipeline = serviceProvider.GetRequiredService<IWorkflowExecutionPipeline>();
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
Assert.Equal(context.Id, workflowInstanceId);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityExecutionMiddleware_PushesWorkflowInstanceMetadata()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.BuildServiceProvider();
|
||||
var context = await CreateActivityContextAsync();
|
||||
IReadOnlyDictionary<string, string>? metadata = null;
|
||||
var pipeline = new ActivityExecutionPipeline(serviceProvider, builder => builder
|
||||
.UseMiddleware<ConsoleLogActivityExecutionMiddleware>()
|
||||
.Use(_ => executionContext =>
|
||||
{
|
||||
metadata = _accessor.GetMetadata();
|
||||
return ValueTask.CompletedTask;
|
||||
}));
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
AssertActivityMetadata(context, metadata);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityExecutionContributor_PushesWorkflowInstanceMetadata()
|
||||
{
|
||||
await using var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.BuildServiceProvider();
|
||||
var context = await CreateActivityContextAsync();
|
||||
IReadOnlyDictionary<string, string>? metadata = null;
|
||||
var pipeline = new ActivityExecutionPipeline(serviceProvider, builder =>
|
||||
{
|
||||
new ConsoleLogActivityExecutionPipelineContributor().Configure(builder);
|
||||
builder.Use(_ => executionContext =>
|
||||
{
|
||||
metadata = _accessor.GetMetadata();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
AssertActivityMetadata(context, metadata);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowFeature_AppliesActivityExecutionContributors()
|
||||
{
|
||||
var context = await CreateActivityContextAsync();
|
||||
IReadOnlyDictionary<string, string>? metadata = null;
|
||||
var feature = new Elsa.Workflows.ShellFeatures.WorkflowsFeature
|
||||
{
|
||||
ActivityExecutionPipeline = builder => builder.Use(_ => executionContext =>
|
||||
{
|
||||
metadata = _accessor.GetMetadata();
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
};
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IConsoleLogContextAccessor>(_accessor)
|
||||
.AddScoped<IActivityExecutionPipelineContributor, ConsoleLogActivityExecutionPipelineContributor>();
|
||||
feature.ConfigureServices(services);
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var pipeline = serviceProvider.GetRequiredService<IActivityExecutionPipeline>();
|
||||
|
||||
await pipeline.ExecuteAsync(context);
|
||||
|
||||
AssertActivityMetadata(context, metadata);
|
||||
Assert.Empty(_accessor.GetMetadata());
|
||||
}
|
||||
|
||||
private static void AssertActivityMetadata(ActivityExecutionContext context, IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(context.WorkflowExecutionContext.Id, metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
|
||||
Assert.Equal(context.Id, metadata[ConsoleLogMetadataKeys.ActivityInstanceId]);
|
||||
Assert.Equal(context.Activity.Id, metadata[ConsoleLogMetadataKeys.ActivityId]);
|
||||
Assert.Equal(context.NodeId, metadata[ConsoleLogMetadataKeys.ActivityNodeId]);
|
||||
}
|
||||
|
||||
private static async Task<ActivityExecutionContext> CreateActivityContextAsync()
|
||||
{
|
||||
var context = await new ActivityTestFixture(new TestActivity()).BuildAsync();
|
||||
context.Id = "activity-instance-a";
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ConsoleLogStreaming.Core.Models.ConsoleLogLine CreateLine(string text, string workflowInstanceId, long sequence) => new()
|
||||
{
|
||||
Text = text,
|
||||
Sequence = sequence,
|
||||
Stream = ConsoleLogStreaming.Core.Models.ConsoleStream.Stdout,
|
||||
Source = new ConsoleLogStreaming.Core.Models.ConsoleLogSource { Id = "test-source" },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[ConsoleLogMetadataKeys.WorkflowInstanceId] = workflowInstanceId
|
||||
}
|
||||
};
|
||||
|
||||
private class TestActivity : CodeActivity
|
||||
{
|
||||
public TestActivity()
|
||||
{
|
||||
Id = "activity-a";
|
||||
NodeId = "node-a";
|
||||
}
|
||||
|
||||
protected override void Execute(ActivityExecutionContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
using ConsoleLogStreaming.Core;
|
||||
using ConsoleLogStreaming.Core.Capture;
|
||||
using ConsoleLogStreaming.Core.Hosting;
|
||||
using ConsoleLogStreaming.Core.Providers;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Extensions;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
|
||||
|
|
@ -14,13 +17,13 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime
|
|||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await ConsoleLogsHost.ShutdownAsync();
|
||||
await ConsoleLogStreamingHost.ShutdownAsync();
|
||||
ConsoleStreamHook.Uninstall();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await ConsoleLogsHost.ShutdownAsync();
|
||||
await ConsoleLogStreamingHost.ShutdownAsync();
|
||||
ConsoleStreamHook.Uninstall();
|
||||
}
|
||||
|
||||
|
|
@ -33,85 +36,44 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime
|
|||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
Assert.IsType<InMemoryConsoleLogProvider>(serviceProvider.GetRequiredService<IConsoleLogProvider>());
|
||||
Assert.IsType<ConsoleLogSourceRegistry>(serviceProvider.GetRequiredService<IConsoleLogSourceRegistry>());
|
||||
Assert.IsType<ConsoleLogRedactor>(serviceProvider.GetRequiredService<IConsoleLogRedactor>());
|
||||
Assert.Same(ConsoleLogsHost.ScopeAccessor, serviceProvider.GetRequiredService<ConsoleLogScopeAccessor>());
|
||||
Assert.Contains(serviceProvider.GetServices<ILoggerProvider>(), x => ReferenceEquals(x, ConsoleLogsHost.ScopeAccessor));
|
||||
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType() == typeof(ConsoleLogsHostedService));
|
||||
#pragma warning disable CS0618
|
||||
Assert.IsType<ConsoleLogCaptureAdapter>(serviceProvider.GetRequiredService<IConsoleLogCapture>());
|
||||
#pragma warning restore CS0618
|
||||
var contextAccessor = serviceProvider.GetRequiredService<ConsoleLogContextAccessor>();
|
||||
Assert.Same(ConsoleLogStreamingHost.SourceRegistry, serviceProvider.GetRequiredService<IConsoleLogSourceRegistry>());
|
||||
Assert.Same(ConsoleLogStreamingHost.RedactionPipeline, serviceProvider.GetRequiredService<IConsoleLogRedactionPipeline>());
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogContextAccessor>());
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogMetadataAccessor>());
|
||||
AssertConsoleLogPipelineContributors(serviceProvider);
|
||||
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService));
|
||||
Assert.Same(ConsoleLogStreamingHost.Capture, serviceProvider.GetRequiredService<IConsoleLogCapture>());
|
||||
|
||||
// Same instance must be visible across separate shell containers — console output is process-wide.
|
||||
var services2 = new ServiceCollection();
|
||||
services2.AddConsoleLogsServices();
|
||||
await using var serviceProvider2 = services2.BuildServiceProvider();
|
||||
|
||||
Assert.Same(serviceProvider.GetRequiredService<IConsoleLogProvider>(), serviceProvider2.GetRequiredService<IConsoleLogProvider>());
|
||||
Assert.Same(serviceProvider.GetRequiredService<IConsoleLogSourceRegistry>(), serviceProvider2.GetRequiredService<IConsoleLogSourceRegistry>());
|
||||
Assert.Same(serviceProvider.GetRequiredService<ConsoleLogContextAccessor>(), serviceProvider2.GetRequiredService<ConsoleLogContextAccessor>());
|
||||
Assert.Same(contextAccessor, ConsoleLogStreamingHost.MetadataAccessor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddConsoleLogsServices_UsesCustomProviderForCapturePipeline()
|
||||
public async Task AddConsoleLogsServices_RegistersHostOwnedDependencies()
|
||||
{
|
||||
var provider = new CustomProvider();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConsoleLogProvider>(provider);
|
||||
services.AddConsoleLogsServices();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
_ = serviceProvider.GetRequiredService<ConsoleLogScopeAccessor>();
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Same(provider, serviceProvider.GetRequiredService<IConsoleLogProvider>());
|
||||
Assert.Same(provider, ConsoleLogsHost.Provider);
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddConsoleLogsServices_UsesCustomProviderRegisteredAfterConsoleLogsServices()
|
||||
{
|
||||
var provider = new CustomProvider();
|
||||
var services = new ServiceCollection();
|
||||
services.AddConsoleLogsServices();
|
||||
services.AddSingleton<IConsoleLogProvider>(provider);
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Same(provider, serviceProvider.GetRequiredService<IConsoleLogProvider>());
|
||||
Assert.Same(provider, ConsoleLogsHost.Provider);
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddConsoleLogsServices_CreatesCustomProviderWithHostOwnedDependencies()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddConsoleLogsServices();
|
||||
services.AddSingleton<IConsoleLogProvider, ProviderWithHostDependencies>();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StartAsync(CancellationToken.None);
|
||||
|
||||
var provider = Assert.IsType<ProviderWithHostDependencies>(ConsoleLogsHost.Provider);
|
||||
Assert.Same(serviceProvider.GetRequiredService<IConsoleLogProvider>(), provider);
|
||||
Assert.Same(ConsoleLogsHost.Options, provider.Options);
|
||||
Assert.Same(ConsoleLogsHost.SourceRegistry, provider.SourceRegistry);
|
||||
Assert.Same(ConsoleLogsHost.Redactor, provider.Redactor);
|
||||
Assert.Same(ConsoleLogsHost.Formatter, provider.Formatter);
|
||||
Assert.Same(ConsoleLogsHost.ScopeAccessor, provider.ScopeAccessor);
|
||||
var contextAccessor = serviceProvider.GetRequiredService<ConsoleLogContextAccessor>();
|
||||
Assert.Same(ConsoleLogStreamingHost.Provider, serviceProvider.GetRequiredService<IConsoleLogProvider>());
|
||||
Assert.Same(ConsoleLogStreamingHost.SourceRegistry, serviceProvider.GetRequiredService<IConsoleLogSourceRegistry>());
|
||||
Assert.Same(ConsoleLogStreamingHost.RedactionPipeline, serviceProvider.GetRequiredService<IConsoleLogRedactionPipeline>());
|
||||
Assert.Same(ConsoleLogStreamingHost.Formatter, serviceProvider.GetRequiredService<ConsoleLineFormatter>());
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogContextAccessor>());
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogMetadataAccessor>());
|
||||
AssertConsoleLogPipelineContributors(serviceProvider);
|
||||
|
||||
foreach (var hostedService in serviceProvider.GetServices<IHostedService>())
|
||||
await hostedService.StopAsync(CancellationToken.None);
|
||||
|
|
@ -120,18 +82,18 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime
|
|||
[Fact]
|
||||
public async Task ShutdownAsync_ResetsReferenceLeases()
|
||||
{
|
||||
ConsoleLogsHost.AddReference();
|
||||
ConsoleLogsHost.EnsureInitialized();
|
||||
ConsoleLogStreamingHost.AddReference();
|
||||
ConsoleLogStreamingHost.EnsureInitialized();
|
||||
|
||||
await ConsoleLogsHost.ShutdownAsync();
|
||||
await ConsoleLogStreamingHost.ShutdownAsync();
|
||||
|
||||
ConsoleLogsHost.AddReference();
|
||||
ConsoleLogsHost.EnsureInitialized();
|
||||
await ConsoleLogsHost.ReleaseReferenceAsync();
|
||||
ConsoleLogStreamingHost.AddReference();
|
||||
ConsoleLogStreamingHost.EnsureInitialized();
|
||||
await ConsoleLogStreamingHost.ReleaseReferenceAsync();
|
||||
|
||||
var provider = new CustomProvider();
|
||||
Assert.True(ConsoleLogsHost.ConfigureProvider((_, _) => provider));
|
||||
Assert.Same(provider, ConsoleLogsHost.Provider);
|
||||
Assert.True(ConsoleLogStreamingHost.ConfigureProvider(_ => provider));
|
||||
Assert.Same(provider, ConsoleLogStreamingHost.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -142,17 +104,22 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime
|
|||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType() == typeof(ConsoleLogsHostedService));
|
||||
Assert.Contains(serviceProvider.GetServices<ILoggerProvider>(), x => ReferenceEquals(x, ConsoleLogsHost.ScopeAccessor));
|
||||
var contextAccessor = serviceProvider.GetRequiredService<ConsoleLogContextAccessor>();
|
||||
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService));
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogContextAccessor>());
|
||||
Assert.Same(contextAccessor, serviceProvider.GetRequiredService<IConsoleLogMetadataAccessor>());
|
||||
AssertConsoleLogPipelineContributors(serviceProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConsoleLogsHost_AppliesConfigurationWhenCalledBeforeFirstAccess()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddConsoleLogsHost(options => options.RecentLogCapacity = 17);
|
||||
services.AddConsoleLogsHost(options => options.RecentCapacity = 17);
|
||||
|
||||
Assert.Equal(17, ConsoleLogsHost.Options.Value.RecentLogCapacity);
|
||||
using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
Assert.Equal(17, serviceProvider.GetRequiredService<IOptions<ConsoleLogStreaming.Core.Options.ConsoleLogOptions>>().Value.RecentCapacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -161,52 +128,35 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime
|
|||
var services = new ServiceCollection();
|
||||
services.AddConsoleLogsHost();
|
||||
|
||||
Assert.True(ConsoleLogsHost.Configure(options => options.RecentLogCapacity = 23));
|
||||
Assert.Equal(23, ConsoleLogsHost.Options.Value.RecentLogCapacity);
|
||||
Assert.True(ConsoleLogStreamingHost.Configure(options => options.RecentCapacity = 23));
|
||||
|
||||
using var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
Assert.Equal(23, serviceProvider.GetRequiredService<IOptions<ConsoleLogStreaming.Core.Options.ConsoleLogOptions>>().Value.RecentCapacity);
|
||||
}
|
||||
|
||||
private static void AssertConsoleLogPipelineContributors(IServiceProvider serviceProvider)
|
||||
{
|
||||
Assert.Contains(serviceProvider.GetServices<IWorkflowExecutionPipelineContributor>(), x => x.GetType() == typeof(ConsoleLogWorkflowExecutionPipelineContributor));
|
||||
Assert.Contains(serviceProvider.GetServices<IActivityExecutionPipelineContributor>(), x => x.GetType() == typeof(ConsoleLogActivityExecutionPipelineContributor));
|
||||
}
|
||||
|
||||
private sealed class CustomProvider : IConsoleLogProvider
|
||||
{
|
||||
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new RecentConsoleLogsResult([]));
|
||||
public ValueTask<ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult> GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult());
|
||||
|
||||
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<ConsoleLogStreaming.Core.Models.ConsoleLogStreamingItem> SubscribeAsync(
|
||||
ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
|
||||
}
|
||||
|
||||
private sealed class ProviderWithHostDependencies(
|
||||
IOptions<ConsoleLogsOptions> options,
|
||||
IConsoleLogSourceRegistry sourceRegistry,
|
||||
IConsoleLogRedactor redactor,
|
||||
ConsoleLineFormatter formatter,
|
||||
ConsoleLogScopeAccessor scopeAccessor) : IConsoleLogProvider
|
||||
{
|
||||
public IOptions<ConsoleLogsOptions> Options { get; } = options;
|
||||
public IConsoleLogSourceRegistry SourceRegistry { get; } = sourceRegistry;
|
||||
public IConsoleLogRedactor Redactor { get; } = redactor;
|
||||
public ConsoleLineFormatter Formatter { get; } = formatter;
|
||||
public ConsoleLogScopeAccessor ScopeAccessor { get; } = scopeAccessor;
|
||||
|
||||
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new RecentConsoleLogsResult([]));
|
||||
|
||||
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogStreaming.Core.Models.ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyCollection<ConsoleLogStreaming.Core.Models.ConsoleLogSource>>([]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Diagnostics.ConsoleLogs\Elsa.Diagnostics.ConsoleLogs.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Filtering;
|
||||
|
||||
public class ConsoleLogFilterTests
|
||||
{
|
||||
private readonly ConsoleLogLine _line = new()
|
||||
{
|
||||
Text = "workflow started",
|
||||
Stream = ConsoleLogStream.Stdout,
|
||||
Source = new ConsoleLogSource { Id = "source-a", DisplayName = "Source A", MachineName = "machine" },
|
||||
WorkflowInstanceId = "workflow-instance-a",
|
||||
ReceivedAt = DateTimeOffset.Parse("2026-05-18T10:00:00Z")
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Matches_FiltersBySource()
|
||||
{
|
||||
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-a" }));
|
||||
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-b" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_FiltersByStream()
|
||||
{
|
||||
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stdout }));
|
||||
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_FiltersByTextAndTime()
|
||||
{
|
||||
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "started", From = _line.ReceivedAt.AddSeconds(-1), To = _line.ReceivedAt.AddSeconds(1) }));
|
||||
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "missing" }));
|
||||
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { From = _line.ReceivedAt.AddSeconds(1) }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_FiltersByWorkflowInstanceId()
|
||||
{
|
||||
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" }));
|
||||
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { WorkflowInstanceId = "workflow-instance-b" }));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
|
||||
|
||||
public class InMemoryConsoleLogProviderDroppedLineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_IncludesDroppedSummaryWhenRecentBufferOverflows()
|
||||
{
|
||||
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
|
||||
|
||||
await provider.PublishAsync(Line("one"));
|
||||
await provider.PublishAsync(Line("two"));
|
||||
|
||||
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
|
||||
|
||||
Assert.Single(result.Dropped!);
|
||||
Assert.Equal("RecentBufferFull", result.Dropped!.Single().Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_ConsumesDroppedSummaries()
|
||||
{
|
||||
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
|
||||
|
||||
await provider.PublishAsync(Line("one"));
|
||||
await provider.PublishAsync(Line("two"));
|
||||
|
||||
Assert.NotEmpty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!);
|
||||
Assert.Empty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_IncludesReportedCaptureDrops()
|
||||
{
|
||||
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
|
||||
|
||||
provider.ReportDropped(new ConsoleLogDroppedSummary("source", ConsoleLogStream.Stdout, "CaptureChannelFull", 2));
|
||||
|
||||
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
|
||||
|
||||
var dropped = Assert.Single(result.Dropped!);
|
||||
Assert.Equal("CaptureChannelFull", dropped.Reason);
|
||||
Assert.Equal(2, dropped.Count);
|
||||
}
|
||||
|
||||
private static ConsoleLogLine Line(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
Source = new ConsoleLogSource { Id = "source", DisplayName = "source", MachineName = "machine" }
|
||||
};
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
|
||||
|
||||
public class InMemoryConsoleLogProviderSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_OrdersOverlappingSourcesDeterministically()
|
||||
{
|
||||
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
await provider.PublishAsync(Line("b", "b", timestamp, 1));
|
||||
await provider.PublishAsync(Line("a", "a", timestamp, 1));
|
||||
|
||||
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
|
||||
|
||||
Assert.Equal(["a", "b"], result.Items.Select(x => x.Text));
|
||||
}
|
||||
|
||||
private static ConsoleLogLine Line(string text, string sourceId, DateTimeOffset timestamp, long sequence) => new()
|
||||
{
|
||||
Text = text,
|
||||
Source = new ConsoleLogSource { Id = sourceId, DisplayName = sourceId, MachineName = "machine" },
|
||||
Timestamp = timestamp,
|
||||
ReceivedAt = timestamp,
|
||||
Sequence = sequence
|
||||
};
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
|
||||
|
||||
public class InMemoryConsoleLogProviderTests
|
||||
{
|
||||
private readonly ConsoleLogSource _source = new() { Id = "local", DisplayName = "local", MachineName = "machine", Health = ConsoleLogSourceHealth.Connected };
|
||||
private readonly InMemoryConsoleLogProvider _provider;
|
||||
|
||||
public InMemoryConsoleLogProviderTests()
|
||||
{
|
||||
_provider = new(
|
||||
Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 2, MaxRecentQuerySize = 1, SubscriberChannelCapacity = 10 }),
|
||||
new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_ClampsLimitAndOrdersByReceivedAt()
|
||||
{
|
||||
await _provider.PublishAsync(Line("one", 1));
|
||||
await _provider.PublishAsync(Line("two", 2));
|
||||
|
||||
var result = await _provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
|
||||
|
||||
var line = Assert.Single(result.Items);
|
||||
Assert.Equal("two", line.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_StreamsMatchingLines()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var readTask = ReadOneAsync(_provider.SubscribeAsync(new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr }, cancellation.Token), cancellation.Token);
|
||||
|
||||
await _provider.PublishAsync(Line("ignored", 1));
|
||||
await _provider.PublishAsync(Line("error", 2, ConsoleLogStream.Stderr));
|
||||
|
||||
var item = await readTask;
|
||||
Assert.Equal("error", item.Line?.Text);
|
||||
}
|
||||
|
||||
private ConsoleLogLine Line(string text, long sequence, ConsoleLogStream stream = ConsoleLogStream.Stdout) => new()
|
||||
{
|
||||
Text = text,
|
||||
Sequence = sequence,
|
||||
Stream = stream,
|
||||
Source = _source,
|
||||
Timestamp = DateTimeOffset.UnixEpoch.AddSeconds(sequence),
|
||||
ReceivedAt = DateTimeOffset.UnixEpoch.AddSeconds(sequence)
|
||||
};
|
||||
|
||||
private static async Task<ConsoleLogStreamItem> ReadOneAsync(IAsyncEnumerable<ConsoleLogStreamItem> items, CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var item in items.WithCancellation(cancellationToken))
|
||||
return item;
|
||||
|
||||
throw new InvalidOperationException("No item was streamed.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Contracts;
|
||||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction;
|
||||
|
||||
[Collection(ConsoleHostStateCollection.Name)]
|
||||
public class ConsoleLogProviderRedactionTests : IAsyncLifetime
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await ConsoleLogsHost.ShutdownAsync();
|
||||
ConsoleStreamHook.Uninstall();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await ConsoleLogsHost.ShutdownAsync();
|
||||
ConsoleStreamHook.Uninstall();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureTee_PublishesRedactedLinesToProvider()
|
||||
{
|
||||
var originalOut = Console.Out;
|
||||
var provider = new CapturingProvider();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions());
|
||||
|
||||
try
|
||||
{
|
||||
ConsoleStreamHook.Uninstall();
|
||||
Console.SetOut(TextWriter.Null);
|
||||
await using var capture = new ConsoleCaptureTee(
|
||||
provider,
|
||||
new ConsoleLogSourceRegistry(options),
|
||||
new ConsoleLogRedactor(options),
|
||||
new ConsoleLineFormatter(options),
|
||||
new ConsoleLogScopeAccessor(),
|
||||
options);
|
||||
|
||||
Console.WriteLine(string.Concat("pass", "word", "=", "sample-value"));
|
||||
await WaitForLineAsync(provider);
|
||||
|
||||
Assert.Equal("[Redacted]", Assert.Single(provider.Lines).Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConsoleStreamHook.Uninstall();
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForLineAsync(CapturingProvider provider)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
while (provider.Lines.Count == 0 && DateTimeOffset.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
private sealed class CapturingProvider : IConsoleLogProvider
|
||||
{
|
||||
public List<ConsoleLogLine> Lines { get; } = [];
|
||||
|
||||
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (Lines)
|
||||
Lines.Add(line);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentConsoleLogsResult(Lines));
|
||||
|
||||
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction;
|
||||
|
||||
public class ConsoleLogRedactorTests
|
||||
{
|
||||
private readonly ConsoleLogRedactor _redactor = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
|
||||
|
||||
[Fact]
|
||||
public void Redact_MasksSensitiveLineText()
|
||||
{
|
||||
var line = CreateLine(string.Concat("Authorization: ", "Bearer ", "sample-token"));
|
||||
|
||||
var redacted = _redactor.Redact(line);
|
||||
|
||||
Assert.Equal("Authorization: [Redacted]", redacted.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_MasksSensitiveLineTextSplitByAnsi()
|
||||
{
|
||||
var line = CreateLine("to\u001b[31mken=sample-token");
|
||||
|
||||
var redacted = _redactor.Redact(line);
|
||||
|
||||
Assert.Equal("[Redacted]", redacted.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_PreservesAnsiWhenLineTextIsNotSensitive()
|
||||
{
|
||||
var line = CreateLine("\u001b[31mhello\u001b[0m");
|
||||
|
||||
var redacted = _redactor.Redact(line);
|
||||
|
||||
Assert.Equal("\u001b[31mhello\u001b[0m", redacted.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_MasksSensitiveSourceMetadata()
|
||||
{
|
||||
var line = CreateLine("hello") with
|
||||
{
|
||||
Source = CreateSource() with
|
||||
{
|
||||
Metadata = new Dictionary<string, string?> { ["apiKey"] = "secret-value" }
|
||||
}
|
||||
};
|
||||
|
||||
var redacted = _redactor.Redact(line);
|
||||
|
||||
Assert.Equal("[Redacted]", redacted.Source.Metadata["apiKey"]);
|
||||
}
|
||||
|
||||
private static ConsoleLogLine CreateLine(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
Source = CreateSource()
|
||||
};
|
||||
|
||||
private static ConsoleLogSource CreateSource() => new()
|
||||
{
|
||||
Id = "source",
|
||||
DisplayName = "source",
|
||||
MachineName = "machine"
|
||||
};
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
using Elsa.Diagnostics.ConsoleLogs.Services;
|
||||
|
||||
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Sources;
|
||||
|
||||
public class ConsoleLogSourceRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarkSeen_AddsUnknownSourceAndRaisesChange()
|
||||
{
|
||||
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
|
||||
ConsoleLogSource? changed = null;
|
||||
registry.SourceChanged += source => changed = source;
|
||||
|
||||
registry.MarkSeen("remote", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotNull(changed);
|
||||
Assert.Contains(registry.List(), x => x.Id == "remote" && x.MachineName == "" && x.Health == ConsoleLogSourceHealth.Connected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_MarksOldSourcesStale()
|
||||
{
|
||||
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) }));
|
||||
|
||||
registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
|
||||
Assert.Contains(registry.List(), x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSeen_AfterSourceBecameStale_RaisesConnectedChange()
|
||||
{
|
||||
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) }));
|
||||
var changes = new List<ConsoleLogSource>();
|
||||
registry.SourceChanged += changes.Add;
|
||||
|
||||
registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
registry.List();
|
||||
registry.MarkSeen("remote", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale);
|
||||
Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Connected);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
global using System.Runtime.CompilerServices;
|
||||
global using Elsa.Diagnostics.ConsoleLogs.Models;
|
||||
global using Elsa.Diagnostics.ConsoleLogs.Options;
|
||||
global using ConsoleLogStreaming.Contracts;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Xunit;
|
||||
|
|
|
|||
Loading…
Reference in a new issue