diff --git a/Directory.Packages.props b/Directory.Packages.props
index cf643d6ea..a20a362ba 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -102,12 +102,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/doc/wiki/diagnostics-console-logs.md b/doc/wiki/diagnostics-console-logs.md
index 47fbc528f..b4097fa70 100644
--- a/doc/wiki/diagnostics-console-logs.md
+++ b/doc/wiki/diagnostics-console-logs.md
@@ -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.
diff --git a/src/apps/Elsa.ModularServer.Web/Program.cs b/src/apps/Elsa.ModularServer.Web/Program.cs
index b8a840d0e..981f34116 100644
--- a/src/apps/Elsa.ModularServer.Web/Program.cs
+++ b/src/apps/Elsa.ModularServer.Web/Program.cs
@@ -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();
\ No newline at end of file
+app.Run();
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs
new file mode 100644
index 000000000..8571ce119
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs
@@ -0,0 +1,21 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
+
+///
+/// Console log filter accepted by Elsa REST and SignalR endpoints.
+///
+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? Metadata { get; init; }
+ public DateTimeOffset? From { get; init; }
+ public DateTimeOffset? To { get; init; }
+ public int? Limit { get; init; }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs
deleted file mode 100644
index 4138d9953..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs
+++ /dev/null
@@ -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);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogContextAccessor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogContextAccessor.cs
new file mode 100644
index 000000000..1354ce24f
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogContextAccessor.cs
@@ -0,0 +1,22 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
+
+///
+/// Provides ambient metadata for console log capture.
+///
+public interface IConsoleLogContextAccessor
+{
+ ///
+ /// Gets the metadata attached to the current async execution context.
+ ///
+ IReadOnlyDictionary GetMetadata();
+
+ ///
+ /// Pushes a metadata value for the current async execution context.
+ ///
+ IDisposable PushMetadata(string key, string value);
+
+ ///
+ /// Pushes the current workflow instance ID for console log ownership.
+ ///
+ IDisposable PushWorkflowInstanceId(string workflowInstanceId);
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogDroppedLineReporter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogDroppedLineReporter.cs
deleted file mode 100644
index 866c610eb..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogDroppedLineReporter.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
-
-public interface IConsoleLogDroppedLineReporter
-{
- void ReportDropped(ConsoleLogDroppedSummary summary);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogProvider.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogProvider.cs
deleted file mode 100644
index 2e1373823..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogProvider.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
-
-public interface IConsoleLogProvider
-{
- ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
-
- ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
-
- IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
-
- ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogRedactor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogRedactor.cs
deleted file mode 100644
index b53e2ebbb..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogRedactor.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
-
-public interface IConsoleLogRedactor
-{
- ConsoleLogLine Redact(ConsoleLogLine line);
-
- ConsoleLogSource Redact(ConsoleLogSource source);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogSourceRegistry.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogSourceRegistry.cs
deleted file mode 100644
index cdb587550..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogSourceRegistry.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
-
-public interface IConsoleLogSourceRegistry
-{
- event Action? SourceChanged;
-
- ConsoleLogSource Current { get; }
-
- void MarkSeen(string sourceId, DateTimeOffset timestamp);
-
- IReadOnlyCollection List();
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj
index 02f31f498..d1b95cc5b 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj
@@ -12,7 +12,11 @@
+
+
+
+
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs
index 8c6f597e6..906218c13 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs
@@ -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
+internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpoint
{
public override void Configure()
{
@@ -14,8 +17,9 @@ internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpoint ExecuteAsync(ConsoleLogFilter request, CancellationToken cancellationToken)
+ public override async Task 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);
}
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs
index 851b0a795..98e1ed557 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs
@@ -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>
+internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpointWithoutRequest>
{
public override void Configure()
{
@@ -15,6 +16,7 @@ internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutReque
public override async Task> ExecuteAsync(CancellationToken cancellationToken)
{
- return await provider.ListSourcesAsync(cancellationToken);
+ var sources = await provider.ListSourcesAsync(cancellationToken);
+ return sources.Select(mapper.ToApi).ToList();
}
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs
new file mode 100644
index 000000000..0463ef364
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs
@@ -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(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddEnumerable(ServiceDescriptor.Scoped());
+ services.TryAddEnumerable(ServiceDescriptor.Scoped());
+ return services;
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs
index 8bbc25d9a..6401544fb 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs
@@ -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(HubRoute);
+ var options = endpoints.ServiceProvider.GetRequiredService>().Value;
+ var hub = endpoints.MapHub(options.HubPath);
+
+ if (!string.IsNullOrWhiteSpace(options.AuthorizationPolicy))
+ hub.RequireAuthorization(options.AuthorizationPolicy);
+
+ options.ConfigureHubEndpoint?.Invoke(hub);
}
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs
index 7391e8a6d..93d996cc1 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs
@@ -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
{
///
- /// Registers the host-level console capture pipeline. Call this from Program.cs (ideally right
- /// after ConsoleStreamHook.Install()) 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.
- ///
- ///
- /// Shell-level features that surface console logs (REST endpoints, SignalR hub) still call
- /// AddConsoleLogsServices on their own service collection — those registrations resolve to the
- /// same host-owned singletons exposed by .
- ///
+ /// Registers the host-level console capture pipeline.
///
- public static IServiceCollection AddConsoleLogsHost(this IServiceCollection services, Action? configure = null)
+ public static IServiceCollection AddConsoleLogsHost(this IServiceCollection services, Action? configure = null)
{
- ConsoleStreamHook.Install();
-
- if (configure != null)
- ConsoleLogsHost.Configure(configure);
-
- services.TryAddSingleton(_ => ConsoleLogsHost.ScopeAccessor);
- services.AddSingleton(sp => sp.GetRequiredService());
-#pragma warning disable CS0618
- services.TryAddSingleton();
-#pragma warning restore CS0618
- services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.AddConsoleLogContextServices();
+ services.AddConsoleLogStreamingHost(options =>
+ {
+ ElsaConsoleLogOptions.ConfigureDefaults(options);
+ configure?.Invoke(options);
+ });
+ services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
return services;
}
}
-
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ModuleExtensions.cs
index a10139812..b2ff192ee 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ModuleExtensions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ModuleExtensions.cs
@@ -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 configureOptions)
+ {
+ return module.UseConsoleLogs(feature => feature.ConfigureOptions = configureOptions);
+ }
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
index f14b947f5..4cf635467 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
@@ -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
{
///
- /// Registers shell-level console-log consumers (SignalR, subscription manager) and exposes the
- /// process-wide capture pipeline owned by through DI. Configuration
- /// supplied here applies only if the host has not yet been initialized (first-wins). Prefer calling
- /// AddConsoleLogsHost from Program.cs for deterministic host-level configuration.
+ /// Registers shell-level console-log consumers and exposes the process-wide capture pipeline through DI.
///
- public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action? configureOptions = null)
+ public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action? configureOptions = null)
{
- ConsoleStreamHook.Install();
- var defaultProviderDescriptor = ServiceDescriptor.Singleton(_ => ConsoleLogsHost.Provider);
-
- if (configureOptions != null)
- ConsoleLogsHost.Configure(configureOptions);
-
- services.TryAdd(defaultProviderDescriptor);
- services.TryAddSingleton(new ConsoleLogsProviderRegistration(services, defaultProviderDescriptor));
-
- ConsoleLogsProviderRegistration GetRegistration(IServiceProvider serviceProvider) =>
- serviceProvider.GetRequiredService();
-
- 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>(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(sp =>
- {
- if (GetRegistration(sp).TryGetHostSourceRegistry(out var sourceRegistry))
- return sourceRegistry;
-
- ConfigureCustomProvider(sp);
- return ConsoleLogsHost.SourceRegistry;
- });
- services.TryAddSingleton(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(sp =>
- {
- ConfigureCustomProvider(sp);
- return ConsoleLogsHost.ScopeAccessor;
- });
-#pragma warning disable CS0618
- services.TryAddSingleton();
-#pragma warning restore CS0618
-
- // The subscription manager wraps a per-shell SignalR hub context, so it must be per-shell.
- services.TryAddSingleton();
- services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
return services;
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Features/ConsoleLogsFeature.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Features/ConsoleLogsFeature.cs
index c8eca90ca..cc8491717 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Features/ConsoleLogsFeature.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Features/ConsoleLogsFeature.cs
@@ -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? ConfigureOptions { get; set; }
+ public Action? ConfigureOptions { get; set; }
public override void Configure()
{
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogDroppedSummary.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogDroppedSummary.cs
deleted file mode 100644
index 2af1f116b..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogDroppedSummary.cs
+++ /dev/null
@@ -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);
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs
deleted file mode 100644
index 44f55afa4..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Models;
-
-public record ConsoleLogFilter
-{
- public string? SourceId { get; init; }
- public ConsoleLogStream? Stream { get; init; }
- public string? Query { get; init; }
- public string? WorkflowInstanceId { get; init; }
- public DateTimeOffset? From { get; init; }
- public DateTimeOffset? To { get; init; }
- public int? Limit { get; init; }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs
deleted file mode 100644
index df1736442..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs
+++ /dev/null
@@ -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; }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSource.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSource.cs
deleted file mode 100644
index 74b8e47c6..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSource.cs
+++ /dev/null
@@ -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 Metadata { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSourceHealth.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSourceHealth.cs
deleted file mode 100644
index 70736c2c1..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSourceHealth.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Models;
-
-public enum ConsoleLogSourceHealth
-{
- Unknown = 0,
- Connected = 1,
- Stale = 2,
- Disconnected = 3
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStream.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStream.cs
deleted file mode 100644
index a15d0b6cf..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStream.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Models;
-
-public enum ConsoleLogStream
-{
- Stdout = 0,
- Stderr = 1
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStreamItem.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStreamItem.cs
deleted file mode 100644
index 5d46cb791..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogStreamItem.cs
+++ /dev/null
@@ -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);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/RecentConsoleLogsResult.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/RecentConsoleLogsResult.cs
deleted file mode 100644
index 5cf721db0..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/RecentConsoleLogsResult.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Models;
-
-public record RecentConsoleLogsResult(
- IReadOnlyCollection Items,
- IReadOnlyCollection? Dropped = null,
- IReadOnlyCollection? Sources = null);
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs
deleted file mode 100644
index 46f74a1e4..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Options;
-
-public class ConsoleLogsOptions
-{
- public int RecentLogCapacity { get; set; } = 5_000;
- public int SubscriberChannelCapacity { get; set; } = 1_000;
- public int CaptureChannelCapacity { get; set; } = 5_000;
- public int MaxRecentQuerySize { get; set; } = 1_000;
- public int MaxLineLength { get; set; } = 16_384;
- public TimeSpan IdleFlushTimeout { get; set; } = TimeSpan.FromSeconds(1);
-
- ///
- /// When true, ANSI escape sequences (colors, cursor commands, etc.) are removed before the captured
- /// line is published. The default is false 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.
- ///
- 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 SensitiveNames { get; set; } =
- [
- "authorization",
- "token",
- "password",
- "secret",
- "api-key",
- "apikey",
- "cookie",
- "connection-string",
- "connectionstring"
- ];
-
- public ICollection SensitiveTextPatterns { get; set; } =
- [
- "(?i)bearer\\s+[A-Za-z0-9._~+/=-]+",
- "(?i)(password|secret|token|api[-_]?key)\\s*[=:]\\s*[^\\s,;]+",
- "(?i)(AccountKey|SharedAccessKey)=([^;\\s]+)"
- ];
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs
deleted file mode 100644
index f3fbf2bf6..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs
+++ /dev/null
@@ -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 options, IConsoleLogSourceRegistry sourceRegistry) : IConsoleLogProvider, IConsoleLogDroppedLineReporter
-{
- private readonly ConsoleLogsOptions _options = options.Value;
- private readonly RingBuffer _recentLines = new(options.Value.RecentLogCapacity);
- private readonly object _subscribersLock = new();
- private readonly Dictionary _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 subscribers;
- lock (_subscribersLock)
- subscribers = _subscribers.Values.ToList();
-
- foreach (var subscriber in subscribers)
- subscriber.TryWrite(line, _options.SubscriberChannelCapacity);
-
- return ValueTask.CompletedTask;
- }
-
- public ValueTask 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 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> 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 subscribers;
- lock (_subscribersLock)
- subscribers = _subscribers.Values.ToList();
-
- foreach (var subscriber in subscribers)
- subscriber.TryWrite(summary, _options.SubscriberChannelCapacity);
- }
-
- private IReadOnlyCollection ConsumeDroppedSummaries()
- {
- var summaries = new List();
- 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 Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded(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));
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/RingBuffer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/RingBuffer.cs
deleted file mode 100644
index 23f55d476..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/RingBuffer.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
-
-public class RingBuffer
-{
- private readonly Queue _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 Snapshot()
- {
- lock (_lock)
- return _items.ToList();
- }
-
- public long ConsumeDroppedCount()
- {
- lock (_lock)
- {
- var droppedCount = DroppedCount;
- DroppedCount = 0;
- return droppedCount;
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
index 2fdb2ba8f..96c3c6c13 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
@@ -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.
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs
deleted file mode 100644
index be37a50af..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs
+++ /dev/null
@@ -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
-{
- 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.");
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogSubscriptionManager.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs
similarity index 60%
rename from src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogSubscriptionManager.cs
rename to src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs
index a4e5322ec..ecc1237f2 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogSubscriptionManager.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs
@@ -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
+///
+/// Manages pushed SignalR console log subscriptions.
+///
+public sealed class ElsaConsoleLogSubscriptionManager : IDisposable
{
private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal);
private readonly IConsoleLogProvider _provider;
private readonly IConsoleLogSourceRegistry _sourceRegistry;
- private readonly IHubContext _hubContext;
- private readonly ILogger _logger;
+ private readonly IConsoleLogStreamingApiMapper _mapper;
+ private readonly IHubContext _hubContext;
+ private readonly ILogger _logger;
- public ConsoleLogSubscriptionManager(
+ ///
+ /// Initializes a new instance of the subscription manager.
+ ///
+ public ElsaConsoleLogSubscriptionManager(
IConsoleLogProvider provider,
IConsoleLogSourceRegistry sourceRegistry,
- IHubContext hubContext,
- ILogger logger)
+ IConsoleLogStreamingApiMapper mapper,
+ IHubContext hubContext,
+ ILogger logger)
{
_provider = provider;
_sourceRegistry = sourceRegistry;
+ _mapper = mapper;
_hubContext = hubContext;
_logger = logger;
_sourceRegistry.SourceChanged += OnSourceChanged;
}
- public Task SubscribeAsync(string connectionId, ConsoleLogFilter filter, CancellationToken cancellationToken)
+ ///
+ /// Starts a pushed subscription for a connection.
+ ///
+ 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)
+ ///
+ /// Replaces the active pushed subscription filter.
+ ///
+ public Task UpdateFilterAsync(string connectionId, ElsaConsoleLogFilter filter, CancellationToken cancellationToken)
{
return SubscribeAsync(connectionId, filter, cancellationToken);
}
+ ///
+ /// Removes a pushed subscription.
+ ///
public Task UnsubscribeAsync(string connectionId)
{
Unsubscribe(connectionId);
return Task.CompletedTask;
}
+ ///
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> subscriptions)
+ private async Task BroadcastSourceChangedAsync(CoreConsoleLogSource source, IReadOnlyCollection> 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);
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogsHub.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogsHub.cs
new file mode 100644
index 000000000..592e619d1
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogsHub.cs
@@ -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;
+
+///
+/// SignalR hub for live Elsa console log streaming.
+///
+public sealed class ElsaConsoleLogsHub(
+ IConsoleLogProvider provider,
+ IConsoleLogStreamingApiMapper mapper,
+ IConsoleLogStreamingHubAuthorizer authorizer,
+ ElsaConsoleLogSubscriptionManager subscriptionManager) : Hub
+{
+ ///
+ /// Streams matching console log items as a SignalR streaming method.
+ ///
+ public async IAsyncEnumerable 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);
+ }
+
+ ///
+ /// Starts pushing stream items to the caller through typed client methods.
+ ///
+ public async Task SubscribeAsync(ElsaConsoleLogFilter? filter)
+ {
+ await EnsureCanReadAsync(Context.ConnectionAborted).ConfigureAwait(false);
+ await subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted).ConfigureAwait(false);
+ }
+
+ ///
+ /// Replaces the current pushed subscription filter.
+ ///
+ public async Task UpdateFilterAsync(ElsaConsoleLogFilter? filter)
+ {
+ await EnsureCanReadAsync(Context.ConnectionAborted).ConfigureAwait(false);
+ await subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted).ConfigureAwait(false);
+ }
+
+ ///
+ /// Stops the current pushed subscription.
+ ///
+ public Task UnsubscribeAsync()
+ {
+ return subscriptionManager.UnsubscribeAsync(Context.ConnectionId);
+ }
+
+ ///
+ 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.");
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/IConsoleLogsClient.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/IConsoleLogsClient.cs
deleted file mode 100644
index 153e2d5bf..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/IConsoleLogsClient.cs
+++ /dev/null
@@ -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);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs
deleted file mode 100644
index 8dea2fadc..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs
+++ /dev/null
@@ -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);
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs
deleted file mode 100644
index e04a08201..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs
+++ /dev/null
@@ -1,212 +0,0 @@
-using System.Threading.Channels;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// Process-wide adapter that turns raw stdout/stderr writes captured by
-/// into fully formed records, then publishes them to the configured
-/// on a single background task.
-///
-///
-/// Owned by — 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 Console.Write*: when the publish
-/// channel is full, the current line is reported as dropped to the provider.
-///
-///
-///
-/// 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), is set on
-/// the publisher's async context for the lifetime of each
-/// invocation.
-///
-///
-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 _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 options)
- {
- _provider = provider;
- _sourceRegistry = sourceRegistry;
- _redactor = redactor;
- _formatter = formatter;
- _scopeAccessor = scopeAccessor;
- _stdoutBuffer = new(options);
- _stderrBuffer = new(options);
-
- _publishChannel = Channel.CreateBounded(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 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 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.
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs
deleted file mode 100644
index 01ce458fa..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Text;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// Reassembles arbitrary stdout/stderr write chunks into complete lines. Lines are emitted whenever a
-/// terminator (\n) is encountered, when the buffer reaches ,
-/// or when is called after the configured idle timeout has elapsed.
-/// Empty lines (a bare \n with no preceding content on the current logical line) are preserved.
-///
-public class ConsoleLineBuffer(IOptions 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 Append(string value, DateTimeOffset now, string? workflowInstanceId = null)
- {
- var lines = new List();
-
- 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);
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs
deleted file mode 100644
index b562901c4..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-public class ConsoleLineFormatter(IOptions 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);
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionMiddleware.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionMiddleware.cs
new file mode 100644
index 000000000..7e8cbea2a
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionMiddleware.cs
@@ -0,0 +1,15 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Adds console log metadata for activity execution, including detached background activity execution.
+///
+public sealed class ConsoleLogActivityExecutionMiddleware(
+ ActivityMiddlewareDelegate next,
+ IConsoleLogContextAccessor consoleLogContextAccessor) : IActivityExecutionMiddleware
+{
+ public async ValueTask InvokeAsync(ActivityExecutionContext context)
+ {
+ using var scope = consoleLogContextAccessor.PushActivityExecution(context);
+ await next(context);
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionPipelineContributor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionPipelineContributor.cs
new file mode 100644
index 000000000..d7ad32dc1
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogActivityExecutionPipelineContributor.cs
@@ -0,0 +1,12 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Installs console log metadata capture into activity execution.
+///
+public sealed class ConsoleLogActivityExecutionPipelineContributor : IActivityExecutionPipelineContributor
+{
+ public void Configure(IActivityExecutionPipelineBuilder builder)
+ {
+ builder.UseMiddleware();
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs
deleted file mode 100644
index 40729a45d..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs
+++ /dev/null
@@ -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;
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs
new file mode 100644
index 000000000..dbe4b7fb8
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs
@@ -0,0 +1,70 @@
+using ConsoleLogStreaming.Core;
+using Elsa.Diagnostics.ConsoleLogs.Contracts;
+
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Ambient console log context backed by the current async execution context.
+///
+public sealed class ConsoleLogContextAccessor : IConsoleLogContextAccessor, IConsoleLogMetadataAccessor
+{
+ private static readonly AsyncLocal CurrentFrame = new();
+ private static readonly IReadOnlyDictionary Empty = new Dictionary();
+
+ ///
+ /// Gets the process-wide Elsa console log context accessor.
+ ///
+ public static ConsoleLogContextAccessor Instance { get; } = new();
+
+ private ConsoleLogContextAccessor()
+ {
+ }
+
+ ///
+ public IReadOnlyDictionary GetMetadata()
+ {
+ var metadata = CurrentFrame.Value?.Metadata;
+ return metadata == null || metadata.Count == 0
+ ? Empty
+ : new Dictionary(metadata, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ 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(previous.Metadata, StringComparer.OrdinalIgnoreCase)
+ : new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ metadata[key] = value;
+ CurrentFrame.Value = new(metadata);
+ return new MetadataScope(previous);
+ }
+
+ ///
+ public IDisposable PushWorkflowInstanceId(string workflowInstanceId) =>
+ PushMetadata(ConsoleLogMetadataKeys.WorkflowInstanceId, workflowInstanceId);
+
+ private sealed record MetadataFrame(IReadOnlyDictionary Metadata);
+
+ private sealed class MetadataScope(MetadataFrame? previous) : IDisposable
+ {
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ CurrentFrame.Value = previous;
+ _disposed = true;
+ }
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs
new file mode 100644
index 000000000..7344aa62e
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs
deleted file mode 100644
index 2e018ddfd..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs
+++ /dev/null
@@ -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));
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs
new file mode 100644
index 000000000..b07cbe4ac
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs
@@ -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(filter.Metadata, StringComparer.OrdinalIgnoreCase)
+ : new Dictionary(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 metadata, string key, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ metadata[key] = value;
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogMetadataKeys.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogMetadataKeys.cs
new file mode 100644
index 000000000..da7f0867f
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogMetadataKeys.cs
@@ -0,0 +1,37 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Well-known metadata keys for console log enrichment.
+///
+public static class ConsoleLogMetadataKeys
+{
+ ///
+ /// Identifies the workflow instance that owned a console write.
+ ///
+ public const string WorkflowInstanceId = "elsa.workflowInstanceId";
+
+ ///
+ /// Identifies the workflow definition that owned a console write.
+ ///
+ public const string WorkflowDefinitionId = "elsa.workflowDefinitionId";
+
+ ///
+ /// Identifies the workflow definition version that owned a console write.
+ ///
+ public const string WorkflowDefinitionVersionId = "elsa.workflowDefinitionVersionId";
+
+ ///
+ /// Identifies the activity execution instance that owned a console write.
+ ///
+ public const string ActivityInstanceId = "elsa.activityInstanceId";
+
+ ///
+ /// Identifies the logical activity that owned a console write.
+ ///
+ public const string ActivityId = "elsa.activityId";
+
+ ///
+ /// Identifies the activity node that owned a console write.
+ ///
+ public const string ActivityNodeId = "elsa.activityNodeId";
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs
deleted file mode 100644
index 10b769839..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using System.Text.RegularExpressions;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-public class ConsoleLogRedactor(IOptions options) : IConsoleLogRedactor
-{
- private readonly ConsoleLogsOptions _options = options.Value;
- private readonly HashSet _sensitiveNames = options.Value.SensitiveNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
- private readonly IReadOnlyCollection _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 RedactDictionary(IDictionary 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));
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogScopeAccessor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogScopeAccessor.cs
deleted file mode 100644
index 7812df454..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogScopeAccessor.cs
+++ /dev/null
@@ -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 _pendingWorkflowLogs = [];
- private WeakReference[] _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(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 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.Key))?.GetValue(value) as string;
- if (!string.Equals(itemKey, key, StringComparison.OrdinalIgnoreCase))
- continue;
-
- return type.GetProperty(nameof(KeyValuePair.Value))?.GetValue(value)?.ToString();
- }
-
- return null;
- }
-
- private void CaptureLoggedWorkflowInstanceId(
- TState state,
- Exception? exception,
- Func 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 state) where TState : notnull => NullScope.Instance;
-
- public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
-
- public void Log(
- LogLevel logLevel,
- EventId eventId,
- TState state,
- Exception? exception,
- Func 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()
- {
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogSourceRegistry.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogSourceRegistry.cs
deleted file mode 100644
index 8e1ef152a..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogSourceRegistry.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System.Collections.Concurrent;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-public class ConsoleLogSourceRegistry : IConsoleLogSourceRegistry
-{
- private readonly ConcurrentDictionary _sources = new();
- private readonly ConsoleLogsOptions _options;
-
- public ConsoleLogSourceRegistry(IOptions options)
- {
- _options = options.Value;
- Current = CreateCurrentSource();
- _sources[Current.Id] = Current;
- }
-
- public event Action? 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 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
- };
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionMiddleware.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionMiddleware.cs
new file mode 100644
index 000000000..09351724e
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionMiddleware.cs
@@ -0,0 +1,15 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Adds console log metadata for workflow execution.
+///
+public sealed class ConsoleLogWorkflowExecutionMiddleware(
+ WorkflowMiddlewareDelegate next,
+ IConsoleLogContextAccessor consoleLogContextAccessor) : IWorkflowExecutionMiddleware
+{
+ public async ValueTask InvokeAsync(WorkflowExecutionContext context)
+ {
+ using var scope = consoleLogContextAccessor.PushWorkflowExecution(context);
+ await next(context);
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionPipelineContributor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionPipelineContributor.cs
new file mode 100644
index 000000000..ba42bde2d
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogWorkflowExecutionPipelineContributor.cs
@@ -0,0 +1,12 @@
+namespace Elsa.Diagnostics.ConsoleLogs.Services;
+
+///
+/// Installs console log metadata capture into workflow execution.
+///
+public sealed class ConsoleLogWorkflowExecutionPipelineContributor : IWorkflowExecutionPipelineContributor
+{
+ public void Configure(IWorkflowExecutionPipelineBuilder builder)
+ {
+ builder.UseMiddleware();
+ }
+}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHost.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHost.cs
deleted file mode 100644
index deb976cfd..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHost.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// 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.
-///
-///
-/// Configuration follows a first-wins model: calls made before the host is first
-/// accessed are applied to a single instance. Subsequent calls are
-/// ignored because the configuration would not apply to anything already captured. Call
-/// AddConsoleLogsHost from Program.cs to set host-wide options deterministically.
-///
-///
-///
-/// Initialization is lazy and thread-safe: the first access from any thread builds the pipeline. The host
-/// also auto-installs , so simply touching ,
-/// , or is enough to start capturing.
-///
-///
-public static class ConsoleLogsHost
-{
- private static readonly object Lock = new();
- private static Action? _pendingConfigure;
- private static Func? _providerFactory;
- private static Lazy _state = CreateLazy();
- private static int _leases;
-
- ///
- /// 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 true when the delegate was accepted, false when the host had already
- /// initialized.
- ///
- public static bool Configure(Action configure)
- {
- ArgumentNullException.ThrowIfNull(configure);
-
- lock (Lock)
- {
- if (_state.IsValueCreated)
- return false;
-
- _pendingConfigure = (Action?)Delegate.Combine(_pendingConfigure, configure);
- return true;
- }
- }
-
- ///
- /// Configures the provider used by the process-wide capture pipeline. The factory must be registered
- /// before the host is initialized.
- ///
- public static bool ConfigureProvider(Func, IConsoleLogSourceRegistry, IConsoleLogProvider> providerFactory)
- {
- ArgumentNullException.ThrowIfNull(providerFactory);
- return ConfigureProvider(context => providerFactory(context.Options, context.SourceRegistry));
- }
-
- internal static bool ConfigureProvider(Func providerFactory)
- {
- ArgumentNullException.ThrowIfNull(providerFactory);
-
- lock (Lock)
- {
- if (_state.IsValueCreated || _providerFactory != null)
- return false;
-
- _providerFactory = providerFactory;
- return true;
- }
- }
-
- public static IOptions 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;
-
- ///
- /// Triggers initialization without resolving any specific component. Returns true when this call
- /// performed the initialization, false when the host was already initialized.
- ///
- 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 state;
-
- lock (Lock)
- {
- if (_leases <= 0)
- return ValueTask.CompletedTask;
-
- _leases--;
- if (_leases > 0)
- return ValueTask.CompletedTask;
-
- state = ResetState();
- }
-
- return DisposeStateAsync(state, cancellationToken);
- }
-
- ///
- /// Tears down the host pipeline. Intended for tests and for orderly host shutdown.
- ///
- public static async ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
- {
- Lazy state;
-
- lock (Lock)
- {
- state = ResetState();
- _leases = 0;
- }
-
- await DisposeStateAsync(state, cancellationToken).ConfigureAwait(false);
- }
-
- private static Lazy CreateLazy() => new(BuildState, LazyThreadSafetyMode.ExecutionAndPublication);
-
- private static Lazy ResetState()
- {
- var state = _state;
- _state = CreateLazy();
- _pendingConfigure = null;
- _providerFactory = null;
- return state;
- }
-
- private static async ValueTask DisposeStateAsync(Lazy state, CancellationToken cancellationToken)
- {
- if (state.IsValueCreated)
- await state.Value.Capture.DisposeAsync(cancellationToken).ConfigureAwait(false);
- }
-
- private static HostState BuildState()
- {
- var options = new ConsoleLogsOptions();
-
- Func? 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 Options,
- IConsoleLogSourceRegistry SourceRegistry,
- IConsoleLogRedactor Redactor,
- ConsoleLineFormatter Formatter,
- ConsoleLogScopeAccessor ScopeAccessor,
- IConsoleLogProvider Provider,
- ConsoleCaptureTee Capture);
-}
-
-internal sealed record ConsoleLogsProviderContext(
- IOptions Options,
- IConsoleLogSourceRegistry SourceRegistry,
- IConsoleLogRedactor Redactor,
- ConsoleLineFormatter Formatter,
- ConsoleLogScopeAccessor ScopeAccessor);
-
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHostedService.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHostedService.cs
deleted file mode 100644
index f92395f80..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHostedService.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using Microsoft.Extensions.Hosting;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// 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.
-///
-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);
- }
- }
-}
-
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsProviderRegistration.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsProviderRegistration.cs
deleted file mode 100644
index 6de9e9a79..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsProviderRegistration.cs
+++ /dev/null
@@ -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? 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();
- }
- finally
- {
- lock (_lock)
- _hostContext = null;
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamHook.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamHook.cs
deleted file mode 100644
index 1d28e3276..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamHook.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// Installs a process-wide tee on and so that every
-/// captured write is fanned out to all currently registered subscribers, in addition to the original writers.
-///
-///
-/// Downstream logger providers (e.g. Microsoft.Extensions.Logging.Console) capture Console.Out
-/// once at construction. Therefore the tee must be installed BEFORE any such provider is constructed —
-/// ideally as the very first line of Program.cs. Calling implicitly installs
-/// the tee, so a host can also rely on subscribers attaching early in the pipeline.
-///
-///
-///
-/// Writes captured before any subscriber attaches are kept in a bounded replay buffer and delivered to the
-/// first subscriber (when replayPreviousWrites is true), preserving original timestamps. This bridges
-/// the gap between and the first call, which typically happens
-/// when the DI container is built and the capture singleton is resolved.
-///
-///
-///
-/// Subscribers that themselves write to the console while handling a capture would cause infinite recursion.
-/// Set to true on the publish path's context to
-/// short-circuit dispatch for the duration of that asynchronous flow.
-///
-///
-public static class ConsoleStreamHook
-{
- private static readonly object Lock = new();
- private static readonly AsyncLocal 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;
-
- ///
- /// 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.
- ///
- public static bool SuppressCapture
- {
- get => SuppressFlag.Value;
- set => SuppressFlag.Value = value;
- }
-
- ///
- /// Installs the tee if not already installed. Safe to call multiple times.
- ///
- 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;
- }
- }
-
- ///
- /// Restores the original writers and clears all subscribers. Intended for tests.
- ///
- 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;
- }
- }
-
- ///
- /// Registers a subscriber that receives every captured write. Disposing the returned token removes the
- /// subscription. Calling Subscribe auto-installs the tee. When
- /// is true, the buffered writes captured since install are replayed to this subscriber synchronously.
- ///
- public static IDisposable Subscribe(Action 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 handler) : IDisposable
- {
- public Action Handler { get; } = handler;
- public void Dispose() => Remove(this);
- }
-
- private sealed class ReplayBuffer(int capacity)
- {
- private readonly object _lock = new();
- private readonly Queue _items = new(capacity);
-
- public void Add(CapturedChunk chunk)
- {
- lock (_lock)
- {
- if (_items.Count == capacity)
- _items.Dequeue();
-
- _items.Enqueue(chunk);
- }
- }
-
- public IReadOnlyList Snapshot()
- {
- lock (_lock)
- return _items.ToArray();
- }
-
- public void Clear()
- {
- lock (_lock)
- _items.Clear();
- }
- }
-}
-
-///
-/// A single chunk of text captured from stdout/stderr together with the moment of capture.
-///
-public readonly record struct CapturedChunk(ConsoleLogStream Stream, string Text, DateTimeOffset TimestampUtc);
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamTeeWriter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamTeeWriter.cs
deleted file mode 100644
index 577ca49cd..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamTeeWriter.cs
+++ /dev/null
@@ -1,212 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using System.Text;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-///
-/// A that forwards every write to the original underlying writer and a configurable capture sink.
-///
-internal sealed class ConsoleStreamTeeWriter(TextWriter original, ConsoleLogStream stream, Action 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 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