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:
Sipke Schoorstra 2026-05-27 00:14:28 +02:00 committed by GitHub
parent 8e301d4e1e
commit 5245599131
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 1269 additions and 3185 deletions

View file

@ -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"/>

View file

@ -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.

View file

@ -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();

View file

@ -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; }
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,6 +0,0 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogDroppedLineReporter
{
void ReportDropped(ConsoleLogDroppedSummary summary);
}

View file

@ -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);
}

View file

@ -1,8 +0,0 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogRedactor
{
ConsoleLogLine Redact(ConsoleLogLine line);
ConsoleLogSource Redact(ConsoleLogSource source);
}

View file

@ -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();
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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()
{

View file

@ -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);

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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);
}

View file

@ -1,9 +0,0 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public enum ConsoleLogSourceHealth
{
Unknown = 0,
Connected = 1,
Stale = 2,
Disconnected = 3
}

View file

@ -1,7 +0,0 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public enum ConsoleLogStream
{
Stdout = 0,
Stderr = 1
}

View file

@ -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);
}

View file

@ -1,6 +0,0 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record RecentConsoleLogsResult(
IReadOnlyCollection<ConsoleLogLine> Items,
IReadOnlyCollection<ConsoleLogDroppedSummary>? Dropped = null,
IReadOnlyCollection<ConsoleLogSource>? Sources = null);

View file

@ -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]+)"
];
}

View file

@ -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));
}
}
}

View file

@ -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;
}
}
}

View file

@ -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.

View file

@ -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.");
}
}

View file

@ -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);
}

View file

@ -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.");
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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.
}
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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>();
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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));
}
}

View file

@ -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;
}
}

View file

@ -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";
}

View file

@ -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));
}

View file

@ -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()
{
}
}
}

View file

@ -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
};
}
}

View file

@ -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);
}
}

View file

@ -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>();
}
}

View file

@ -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);

View file

@ -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);
}
}
}

View file

@ -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;
}
}
}

View file

@ -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);

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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
});
}
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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>()

View file

@ -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>()

View file

@ -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();

View file

@ -1,4 +1,4 @@
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using ConsoleLogStreaming.SignalR;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;

View file

@ -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)));
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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";
}

View file

@ -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)
{
}
}
}

View file

@ -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>>([]);
}
}

View file

@ -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>

View file

@ -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" }));
}
}

View file

@ -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" }
};
}

View file

@ -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
};
}

View file

@ -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.");
}
}

View file

@ -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>>([]);
}
}

View file

@ -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"
};
}

View file

@ -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);
}
}

View file

@ -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;