diff --git a/doc/wiki/workflow-runtime.md b/doc/wiki/workflow-runtime.md
index 889c8146b..33e7f4a6c 100644
--- a/doc/wiki/workflow-runtime.md
+++ b/doc/wiki/workflow-runtime.md
@@ -172,14 +172,15 @@ Distributed runtime support lives in [Elsa.Workflows.Runtime.Distributed](../../
### Distributed Lock Provider Safety
-The default workflow runtime lock provider is file-system based and writes under `App_Data/locks`. That provider is useful for single-host development and tests, but it is not safe for clustered deployments where nodes have separate file systems. When `UseDistributedRuntime()` is enabled, startup fails if Elsa detects the default file-system provider or the no-op provider unless the host explicitly opts in to local-only lock semantics:
+The default workflow runtime lock provider is file-system based and writes under `App_Data/locks`. That provider is useful for single-host development and tests, but it is not safe for clustered deployments where nodes have separate file systems. When `UseDistributedRuntime()` is enabled, Elsa logs a startup warning if it detects the default file-system provider or the no-op provider unless the host explicitly acknowledges local-only lock semantics:
```csharp
elsa.UseWorkflowRuntime(runtime =>
{
runtime.UseDistributedRuntime();
- // Single-host development/test only. Do not use this for clustered production deployments.
+ // Single-host development/test only. Suppresses the startup warning.
+ // Do not use this for clustered production deployments.
runtime.DistributedLockingOptions = options => options.AllowLocalLockProviderInDistributedRuntime = true;
});
```
diff --git a/src/apps/Elsa.ModularServer.Web/Program.cs b/src/apps/Elsa.ModularServer.Web/Program.cs
index 4e0505c5a..b8a840d0e 100644
--- a/src/apps/Elsa.ModularServer.Web/Program.cs
+++ b/src/apps/Elsa.ModularServer.Web/Program.cs
@@ -1,6 +1,8 @@
using CShells.AspNetCore.Configuration;
using CShells.AspNetCore.Extensions;
using CShells.DependencyInjection;
+using Elsa.Diagnostics.ConsoleLogs.Extensions;
+using Elsa.Diagnostics.ConsoleLogs.Services;
using Elsa.ModularServer.Web;
using Elsa.ModularServer.Web.Catalog;
using Elsa.ShellFeatures;
@@ -13,10 +15,19 @@ using Nuplane;
using Nuplane.Loading.Hosting.Builder;
using Nuplane.Sources.Directory.Configuration;
+// Install the console stream tee as early as possible — before WebApplication.CreateBuilder and
+// any logger provider construction — so that downstream loggers (Microsoft.Extensions.Logging.Console,
+// Kestrel/Hosting loggers, etc.) capture our tee writer instead of the raw Console.Out / Console.Error.
+ConsoleStreamHook.Install();
+
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;
+// Console output is a single process-wide resource; the capture pipeline is owned by the root host so
+// every shell's diagnostics endpoints (REST + SignalR) read from the same in-memory ring buffer.
+services.AddConsoleLogsHost();
+
var nuplaneConfiguration = configuration.GetSection("Nuplane");
services.AddNuplane(nuplaneConfiguration, nuplane =>
diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json
index ae813e68c..3b0d7ad3e 100644
--- a/src/apps/Elsa.ModularServer.Web/appsettings.json
+++ b/src/apps/Elsa.ModularServer.Web/appsettings.json
@@ -5,6 +5,11 @@
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Nuplane": "Warning"
+ },
+ "Console": {
+ "FormatterOptions": {
+ "ColorBehavior": "Enabled"
+ }
}
},
"AllowedHosts": "*",
diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs
index 6f3c615d5..b09694ddf 100644
--- a/src/apps/Elsa.Server.Web/Program.cs
+++ b/src/apps/Elsa.Server.Web/Program.cs
@@ -91,7 +91,7 @@ services
runtime.UseEntityFrameworkCore(ef => ef.UseSqlite());
runtime.UseCache();
runtime.UseDistributedRuntime();
- // This sample host uses single-host file-system locks only for development or explicit single-host opt-in.
+ // This sample host acknowledges single-host file-system locks in development or explicit single-host opt-in.
runtime.DistributedLockingOptions = options => options.AllowLocalLockProviderInDistributedRuntime = allowLocalDistributedRuntimeLockProvider;
})
.UseWorkflowsApi()
diff --git a/src/modules/Elsa.Common/Contracts/BackgroundTask.cs b/src/modules/Elsa.Common/Contracts/BackgroundTask.cs
index 758d04516..1da99a40f 100644
--- a/src/modules/Elsa.Common/Contracts/BackgroundTask.cs
+++ b/src/modules/Elsa.Common/Contracts/BackgroundTask.cs
@@ -5,4 +5,4 @@ public abstract class BackgroundTask : IBackgroundTask
public virtual Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public virtual Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public virtual Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-}
\ No newline at end of file
+}
diff --git a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
index 2323304c6..27db12285 100644
--- a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
+++ b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
@@ -14,8 +14,8 @@ public class DistributedLockingOptions
public TimeSpan LockAcquisitionTimeout { get; set; } = TimeSpan.FromMinutes(10);
///
- /// Allows the distributed workflow runtime to use a lock provider that only coordinates within this node or local file system.
- /// Enable this only for single-host development or tests. Clustered deployments should configure a cross-node provider such as Redis, SQL Server, or PostgreSQL.
+ /// Acknowledges that the distributed workflow runtime is using a lock provider that only coordinates within this node or local file system.
+ /// Enable this for single-host development or tests to suppress the startup warning. Clustered deployments should configure a cross-node provider such as Redis, SQL Server, or PostgreSQL.
///
public bool AllowLocalLockProviderInDistributedRuntime { get; set; }
}
diff --git a/src/modules/Elsa.Common/Features/MultitenancyFeature.cs b/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
index b0d7a1689..d96aceee9 100644
--- a/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
+++ b/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
@@ -2,6 +2,7 @@ using Elsa.Common.Multitenancy;
using Elsa.Common.Multitenancy.EventHandlers;
using Elsa.Common.Multitenancy.HostedServices;
using Elsa.Common.RecurringTasks;
+using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -50,8 +51,10 @@ public class MultitenancyFeature(IModule module) : FeatureBase(module)
.AddSingleton()
.AddScoped()
.AddScoped()
+ .AddScoped()
.AddScoped()
.AddScoped()
+ .AddBackgroundTask()
.AddScoped(_tenantsProviderFactory);
}
-}
\ No newline at end of file
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantBackgroundWorkQueue.cs b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantBackgroundWorkQueue.cs
new file mode 100644
index 000000000..b46b1e434
--- /dev/null
+++ b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantBackgroundWorkQueue.cs
@@ -0,0 +1,9 @@
+namespace Elsa.Common.Multitenancy;
+
+public delegate Task TenantBackgroundWorkItem(IServiceProvider serviceProvider, CancellationToken cancellationToken);
+
+public interface ITenantBackgroundWorkQueue
+{
+ ValueTask EnqueueAsync(TenantBackgroundWorkItem workItem, CancellationToken cancellationToken = default);
+ IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueue.cs b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueue.cs
new file mode 100644
index 000000000..6009c4d05
--- /dev/null
+++ b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueue.cs
@@ -0,0 +1,23 @@
+using System.Threading.Channels;
+
+namespace Elsa.Common.Multitenancy;
+
+public class TenantBackgroundWorkQueue : ITenantBackgroundWorkQueue
+{
+ private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false
+ });
+
+ public ValueTask EnqueueAsync(TenantBackgroundWorkItem workItem, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(workItem);
+ return _channel.Writer.WriteAsync(workItem, cancellationToken);
+ }
+
+ public IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken = default)
+ {
+ return _channel.Reader.ReadAllAsync(cancellationToken);
+ }
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueueWorker.cs b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueueWorker.cs
new file mode 100644
index 000000000..2352739c7
--- /dev/null
+++ b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantBackgroundWorkQueueWorker.cs
@@ -0,0 +1,37 @@
+using Elsa.Common.Helpers;
+using Microsoft.Extensions.Logging;
+
+namespace Elsa.Common.Multitenancy;
+
+public class TenantBackgroundWorkQueueWorker(
+ ITenantBackgroundWorkQueue workQueue,
+ IServiceProvider serviceProvider,
+ ILogger logger) : BackgroundTask
+{
+ public override async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await using var enumerator = workQueue.DequeueAllAsync(cancellationToken).GetAsyncEnumerator(cancellationToken);
+ while (await enumerator.MoveNextAsync())
+ {
+ try
+ {
+ await enumerator.Current(serviceProvider, cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception e) when (!e.IsFatal())
+ {
+ logger.LogError(e, "A tenant background work item failed.");
+ }
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ logger.LogDebug("Tenant background work queue worker stopped because cancellation was requested.");
+ }
+ }
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Implementations/TenantTaskLifecycleCoordinator.cs b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantTaskLifecycleCoordinator.cs
index 3ec498510..3c3d249c5 100644
--- a/src/modules/Elsa.Common/Multitenancy/Implementations/TenantTaskLifecycleCoordinator.cs
+++ b/src/modules/Elsa.Common/Multitenancy/Implementations/TenantTaskLifecycleCoordinator.cs
@@ -92,27 +92,38 @@ public class TenantTaskLifecycleCoordinator(RecurringTaskScheduleManager schedul
await taskExecutor.ExecuteTaskAsync(task, cancellationToken);
}
- private Task RunBackgroundTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, TenantRuntimeState state, CancellationToken cancellationToken)
+ private async Task RunBackgroundTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, TenantRuntimeState state, CancellationToken cancellationToken)
{
- var backgroundTasks = tenantScope.ServiceProvider.GetServices();
+ var backgroundTasks = tenantScope.ServiceProvider.GetServices().ToList();
var backgroundTaskStarter = tenantScope.ServiceProvider.GetRequiredService();
var tenantCancellationToken = state.CancellationTokenSource?.Token ?? cancellationToken;
foreach (var backgroundTask in backgroundTasks)
{
- var task = backgroundTaskStarter
- .StartAsync(backgroundTask, tenantCancellationToken)
- .ContinueWith(_ => taskExecutor.ExecuteTaskAsync(backgroundTask, tenantCancellationToken),
- cancellationToken,
- TaskContinuationOptions.RunContinuationsAsynchronously,
- TaskScheduler.Default)
- .Unwrap();
+ await backgroundTaskStarter.StartAsync(backgroundTask, tenantCancellationToken);
+ state.BackgroundTasks.Add(new(backgroundTask, backgroundTaskStarter));
+
+ var task = RunBackgroundTaskAsync(backgroundTask, taskExecutor, tenantCancellationToken);
if (!task.IsCompleted)
state.RunningBackgroundTasks.Add(task);
}
+ }
- return Task.CompletedTask;
+ private async Task RunBackgroundTaskAsync(IBackgroundTask backgroundTask, ITaskExecutor taskExecutor, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await taskExecutor.ExecuteTaskAsync(backgroundTask, cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ logger.LogInformation("Background task {TaskType} was cancelled", backgroundTask.GetType().Name);
+ }
+ catch (Exception e) when (!e.IsFatal())
+ {
+ logger.LogError(e, "Background task {TaskType} failed with an error", backgroundTask.GetType().Name);
+ }
}
private async Task StartRecurringTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, TenantRuntimeState state, CancellationToken cancellationToken)
@@ -225,6 +236,23 @@ public class TenantTaskLifecycleCoordinator(RecurringTaskScheduleManager schedul
}
state.ScheduledTimers.Clear();
+ foreach (var backgroundTask in state.BackgroundTasks)
+ {
+ try
+ {
+ await backgroundTask.Starter.StopAsync(backgroundTask.Task, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected if caller requested cancellation during tenant deactivation.
+ }
+ catch (Exception e) when (!e.IsFatal())
+ {
+ logger.LogError(e, "Failed to stop background task {TaskType}", backgroundTask.Task.GetType().Name);
+ }
+ }
+ state.BackgroundTasks.Clear();
+
if (state.RunningBackgroundTasks.Count > 0)
{
try
@@ -290,8 +318,11 @@ public class TenantTaskLifecycleCoordinator(RecurringTaskScheduleManager schedul
// Since we exclusively use WaitAsync(), no kernel handle is ever created and disposal is a no-op.
public SemaphoreSlim Gate { get; } = new(1, 1);
public List RunningBackgroundTasks { get; } = [];
+ public List BackgroundTasks { get; } = [];
public List ScheduledTimers { get; } = [];
public List RecurringTasks { get; } = [];
public CancellationTokenSource? CancellationTokenSource { get; set; }
}
+
+ private record BackgroundTaskRegistration(IBackgroundTask Task, IBackgroundTaskStarter Starter);
}
diff --git a/src/modules/Elsa.Common/ShellFeatures/MultitenancyFeature.cs b/src/modules/Elsa.Common/ShellFeatures/MultitenancyFeature.cs
index da3d0136c..5fc8bc239 100644
--- a/src/modules/Elsa.Common/ShellFeatures/MultitenancyFeature.cs
+++ b/src/modules/Elsa.Common/ShellFeatures/MultitenancyFeature.cs
@@ -5,6 +5,7 @@ using Elsa.Common.Multitenancy.EventHandlers;
using Elsa.Common.Multitenancy.HostedServices;
using Elsa.Common.RecurringTasks;
using Elsa.Common.ShellHandlers;
+using Elsa.Extensions;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Common.ShellFeatures;
@@ -35,8 +36,10 @@ public class MultitenancyFeature : IShellFeature
.AddSingleton()
.AddScoped()
.AddScoped()
+ .AddScoped()
.AddScoped()
.AddScoped()
+ .AddBackgroundTask()
.AddScoped(_tenantsProviderFactory)
// Transient per CShells 0.0.15 convention — the registry resolves IEnumerable and
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs
index 8d671d222..4138d9953 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs
@@ -1,10 +1,9 @@
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/Extensions/HostServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs
new file mode 100644
index 000000000..7391e8a6d
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs
@@ -0,0 +1,39 @@
+using Elsa.Diagnostics.ConsoleLogs.Contracts;
+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 .
+ ///
+ ///
+ 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());
+ return services;
+ }
+}
+
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
index 4f373975c..f14b947f5 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs
@@ -1,27 +1,94 @@
-using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
+using Elsa.Diagnostics.ConsoleLogs.Contracts;
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.
+ ///
public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action? configureOptions = null)
{
+ ConsoleStreamHook.Install();
+ var defaultProviderDescriptor = ServiceDescriptor.Singleton(_ => ConsoleLogsHost.Provider);
+
if (configureOptions != null)
- services.Configure(configureOptions);
+ 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();
- services.AddOptions();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
+
+ // Every shell resolves the same process-wide singletons — console output is a shared OS resource.
+ services.TryAddSingleton>(sp =>
+ {
+ if (GetRegistration(sp).TryGetHostOptions(out var options))
+ return options;
+
+ ConfigureCustomProvider(sp);
+ return ConsoleLogsHost.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.TryAddSingleton();
- services.AddHostedService();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
return services;
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs
index 7415394f5..44f55afa4 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs
@@ -5,6 +5,7 @@ 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
index 836bef7a0..df1736442 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs
@@ -9,6 +9,7 @@ public record ConsoleLogLine
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/Options/ConsoleLogsOptions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs
index d488b3843..46f74a1e4 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs
@@ -8,7 +8,13 @@ public class ConsoleLogsOptions
public int MaxRecentQuerySize { get; set; } = 1_000;
public int MaxLineLength { get; set; } = 16_384;
public TimeSpan IdleFlushTimeout { get; set; } = TimeSpan.FromSeconds(1);
- public bool StripAnsiEscapeSequences { get; set; } = true;
+
+ ///
+ /// 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]";
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
index 37f99faaf..2fdb2ba8f 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md
@@ -22,7 +22,7 @@ services.AddElsa(elsa =>
options.SubscriberChannelCapacity = 1_000;
options.MaxRecentQuerySize = 1_000;
options.MaxLineLength = 16_384;
- options.StripAnsiEscapeSequences = true;
+ options.StripAnsiEscapeSequences = false; // pass colors through to consumers
});
});
```
@@ -33,6 +33,31 @@ Map the live hub:
app.UseConsoleLogs();
```
+## Capturing Colors
+
+The capture passes raw stdout/stderr bytes through to consumers. ANSI escape sequences (colors, cursor
+moves) are preserved by default; consumers (e.g. the Studio's `Raw ANSI` toggle) decide whether to render
+them or strip them on display.
+
+Whether colors actually arrive at the capture depends on whether the .NET console logger emits them.
+`Microsoft.Extensions.Logging.Console`'s `SimpleConsoleFormatter` defaults to `LoggerColorBehavior.Default`,
+which suppresses colors whenever `Console.IsOutputRedirected` is `true` — that includes most Docker/Kubernetes
+deployments, IDE-hosted runs (JetBrains Rider, VS Code), and any piped stdout. To force colors regardless,
+add the following to `appsettings.json` (no module dependency required):
+
+```json
+"Logging": {
+ "Console": {
+ "FormatterOptions": {
+ "ColorBehavior": "Enabled"
+ }
+ }
+}
+```
+
+With `ColorBehavior = Enabled`, the logger writes ANSI sequences such as `\x1b[32m` and `\x1b[0m` into the
+tee, the capture publishes them verbatim, and the Studio renders them.
+
## Contracts
- Recent lines: `POST /diagnostics/console-logs/recent`
@@ -45,7 +70,7 @@ Recent, source, and hub access all require the same permission.
## Safety Boundaries
- Redaction runs before recent buffering, live streaming, endpoint responses, and provider storage.
-- ANSI escape sequences are stripped by default.
+- ANSI escape sequences are preserved by default; set `StripAnsiEscapeSequences = true` 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.
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs
new file mode 100644
index 000000000..8dea2fadc
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleAnsiEscapeSequences.cs
@@ -0,0 +1,10 @@
+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
index 3bd64ac61..e04a08201 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs
@@ -1,151 +1,179 @@
-using System.Text;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
-public class ConsoleCaptureTee(
- IConsoleLogProvider provider,
- IConsoleLogSourceRegistry sourceRegistry,
- IConsoleLogRedactor redactor,
- ConsoleLineFormatter formatter,
- IOptions options) : TextWriter, IConsoleLogCapture
+///
+/// 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 object _lock = new();
- private readonly ConsoleLogsOptions _options = options.Value;
- private readonly ConsoleLineBuffer _stdoutBuffer = new(options);
- private readonly ConsoleLineBuffer _stderrBuffer = new(options);
- private TextWriter? _originalOut;
- private TextWriter? _originalError;
- private Channel? _publishChannel;
- private CancellationTokenSource? _publishCancellation;
- private Task? _publishTask;
- private int _startCount;
+ 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 override Encoding Encoding => _originalOut?.Encoding ?? Encoding.UTF8;
-
- public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ public ConsoleCaptureTee(
+ IConsoleLogProvider provider,
+ IConsoleLogSourceRegistry sourceRegistry,
+ IConsoleLogRedactor redactor,
+ ConsoleLineFormatter formatter,
+ ConsoleLogScopeAccessor scopeAccessor,
+ IOptions options)
{
- lock (_lock)
+ _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))
{
- if (_startCount++ > 0)
- return ValueTask.CompletedTask;
+ FullMode = BoundedChannelFullMode.Wait,
+ SingleReader = true,
+ SingleWriter = false
+ });
- _publishCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- _publishChannel = Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, _options.CaptureChannelCapacity))
- {
- FullMode = BoundedChannelFullMode.Wait,
- SingleReader = true,
- SingleWriter = false
- });
- _publishTask = PublishQueuedLinesAsync(_publishChannel.Reader, _publishCancellation.Token);
- _originalOut = Console.Out;
- _originalError = Console.Error;
- Console.SetOut(new TeeWriter(_originalOut, this, ConsoleLogStream.Stdout));
- Console.SetError(new TeeWriter(_originalError, this, ConsoleLogStream.Stderr));
- }
+ _publishTask = Task.Run(() => PublishQueuedLinesAsync(_publishChannel.Reader, _shutdownCts.Token));
- return ValueTask.CompletedTask;
- }
-
- public ValueTask StopAsync(CancellationToken cancellationToken = default)
- {
- Task? publishTask;
- CancellationTokenSource? publishCancellation;
-
- lock (_lock)
- {
- if (_startCount == 0)
- return ValueTask.CompletedTask;
-
- if (--_startCount > 0)
- return ValueTask.CompletedTask;
-
- if (_originalOut != null)
- Console.SetOut(_originalOut);
-
- if (_originalError != null)
- Console.SetError(_originalError);
-
- FlushRemaining(ConsoleLogStream.Stdout);
- FlushRemaining(ConsoleLogStream.Stderr);
- _publishChannel?.Writer.TryComplete();
- publishTask = _publishTask;
- publishCancellation = _publishCancellation;
- _originalOut = null;
- _originalError = null;
- _publishChannel = null;
- _publishCancellation = null;
- _publishTask = null;
- }
-
- return AwaitPublisherAsync(publishTask, publishCancellation, cancellationToken);
- }
-
- public override void Write(char value)
- {
- Capture(ConsoleLogStream.Stdout, value.ToString());
- }
-
- public override ValueTask DisposeAsync()
- {
- return StopAsync();
+ // 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)
{
- foreach (var stream in new[] { ConsoleLogStream.Stdout, ConsoleLogStream.Stderr })
- {
- string? line;
- lock (_lock)
- line = GetBuffer(stream).FlushIfIdle(DateTimeOffset.UtcNow);
+ var now = DateTimeOffset.UtcNow;
- if (line != null)
- Publish(stream, line);
- }
+ Publish(ConsoleLogStream.Stdout, FlushIfIdle(_stdoutBuffer, now), now);
+ Publish(ConsoleLogStream.Stderr, FlushIfIdle(_stderrBuffer, now), now);
return ValueTask.CompletedTask;
}
- private void Capture(ConsoleLogStream stream, string value)
+ public async ValueTask DisposeAsync()
{
- IReadOnlyCollection lines;
- lock (_lock)
- lines = GetBuffer(stream).Append(value, DateTimeOffset.UtcNow);
+ 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(stream, line);
+ Publish(chunk.Stream, line, chunk.TimestampUtc);
+ }
+
+ private BufferedConsoleLine? FlushIfIdle(ConsoleLineBuffer buffer, DateTimeOffset now)
+ {
+ lock (_bufferLock)
+ return buffer.FlushIfIdle(now);
}
private void FlushRemaining(ConsoleLogStream stream)
{
- var line = GetBuffer(stream).Flush();
- if (line != null)
- Publish(stream, line);
+ BufferedConsoleLine? line;
+ lock (_bufferLock)
+ line = GetBuffer(stream).Flush();
+
+ Publish(stream, line, DateTimeOffset.UtcNow);
}
- private ConsoleLineBuffer GetBuffer(ConsoleLogStream stream) => stream == ConsoleLogStream.Stdout ? _stdoutBuffer : _stderrBuffer;
+ private ConsoleLineBuffer GetBuffer(ConsoleLogStream stream) =>
+ stream == ConsoleLogStream.Stdout ? _stdoutBuffer : _stderrBuffer;
- private void Publish(ConsoleLogStream stream, string text)
+ private void Publish(ConsoleLogStream stream, BufferedConsoleLine? capturedLine, DateTimeOffset timestamp)
{
- var formatted = formatter.Format(text);
- var now = DateTimeOffset.UtcNow;
+ 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 = now,
- ReceivedAt = now,
+ Timestamp = timestamp,
+ ReceivedAt = timestamp,
Sequence = Interlocked.Increment(ref _sequence),
Stream = stream,
Text = formatted.Text,
- Source = sourceRegistry.Current,
+ Source = _sourceRegistry.Current,
+ WorkflowInstanceId = workflowInstanceId,
Truncated = formatted.Truncated
};
- if (_publishChannel?.Writer.TryWrite(line) != false)
- return;
-
- if (provider is IConsoleLogDroppedLineReporter reporter)
+ // 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));
}
@@ -153,11 +181,14 @@ public class ConsoleCaptureTee(
{
try
{
- await foreach (var line in reader.ReadAllAsync(cancellationToken))
+ 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);
+ await _provider.PublishAsync(_redactor.Redact(line), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -165,54 +196,17 @@ public class ConsoleCaptureTee(
}
catch (Exception)
{
- // Avoid logging from the console capture path; doing so would recurse through the same tee.
+ // Never log from the publish path — that would route right back through the same tee.
+ }
+ finally
+ {
+ ConsoleStreamHook.SuppressCapture = false;
}
}
}
catch (OperationCanceledException)
{
- // Normal shutdown completes or cancels the publish pump.
- }
- }
-
- private static async ValueTask AwaitPublisherAsync(Task? publishTask, CancellationTokenSource? publishCancellation, CancellationToken cancellationToken)
- {
- if (publishTask == null)
- return;
-
- using var cancellation = publishCancellation;
-
- try
- {
- await publishTask.WaitAsync(cancellationToken);
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- cancellation?.Cancel();
- throw;
- }
- }
-
- private sealed class TeeWriter(TextWriter original, ConsoleCaptureTee capture, ConsoleLogStream stream) : TextWriter
- {
- public override Encoding Encoding => original.Encoding;
-
- public override void Write(char value)
- {
- original.Write(value);
- capture.Capture(stream, value.ToString());
- }
-
- public override void Write(string? value)
- {
- original.Write(value);
- if (value != null)
- capture.Capture(stream, value);
- }
-
- public override void Flush()
- {
- original.Flush();
+ // Normal shutdown.
}
}
}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs
index 98df9ed72..01ce458fa 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs
@@ -3,37 +3,56 @@ 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)
+ public IReadOnlyCollection Append(string value, DateTimeOffset now, string? workflowInstanceId = null)
{
- _lastWriteAt = now;
- var lines = new List();
+ var lines = new List();
- foreach (var ch in value.Where(x => x != '\r'))
+ 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 string? FlushIfIdle(DateTimeOffset now)
+ public BufferedConsoleLine? FlushIfIdle(DateTimeOffset now)
{
if (_buffer.Length == 0 || _lastWriteAt == null)
return null;
@@ -41,16 +60,32 @@ public class ConsoleLineBuffer(IOptions options)
return now - _lastWriteAt >= _options.IdleFlushTimeout ? FlushBuffer() : null;
}
- public string? Flush()
+ public BufferedConsoleLine? Flush()
{
return _buffer.Length == 0 ? null : FlushBuffer();
}
- private string 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();
- _lastWriteAt = null;
- return line;
+ _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
index 674ca6165..b562901c4 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs
@@ -1,17 +1,15 @@
-using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLineFormatter(IOptions options)
{
- private static readonly Regex AnsiRegex = new(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly ConsoleLogsOptions _options = options.Value;
public FormattedConsoleLine Format(string text)
{
if (_options.StripAnsiEscapeSequences)
- text = AnsiRegex.Replace(text, string.Empty);
+ text = ConsoleAnsiEscapeSequences.Strip(text);
var truncated = text.Length > _options.MaxLineLength;
if (truncated)
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs
new file mode 100644
index 000000000..40729a45d
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureAdapter.cs
@@ -0,0 +1,22 @@
+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/ConsoleLogCaptureHostedService.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs
deleted file mode 100644
index 5cf68d18b..000000000
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Options;
-
-namespace Elsa.Diagnostics.ConsoleLogs.Services;
-
-public class ConsoleLogCaptureHostedService(IConsoleLogCapture capture, IOptions options) : BackgroundService
-{
- public override async Task StartAsync(CancellationToken cancellationToken)
- {
- await capture.StartAsync(cancellationToken);
- await base.StartAsync(cancellationToken);
- }
-
- public override async Task StopAsync(CancellationToken cancellationToken)
- {
- await base.StopAsync(cancellationToken);
- await capture.StopAsync(cancellationToken);
- }
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- var interval = TimeSpan.FromMilliseconds(Math.Max(100, options.Value.IdleFlushTimeout.TotalMilliseconds / 2));
-
- while (!stoppingToken.IsCancellationRequested)
- {
- await Task.Delay(interval, stoppingToken);
- await capture.FlushIdleAsync(stoppingToken);
- }
- }
-}
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs
index 04b9a7244..2e018ddfd 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs
@@ -13,6 +13,9 @@ public static class ConsoleLogFilterEvaluator
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;
diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs
index 6f87d4a39..10b769839 100644
--- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs
@@ -49,8 +49,22 @@ public class ConsoleLogRedactor(IOptions options) : IConsole
if (IsSensitiveName(name))
return _options.RedactionReplacement;
- return _sensitiveTextPatterns.Aggregate(value, (current, pattern) => pattern.Replace(current, _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
new file mode 100644
index 000000000..7812df454
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogScopeAccessor.cs
@@ -0,0 +1,217 @@
+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/ConsoleLogsHost.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHost.cs
new file mode 100644
index 000000000..deb976cfd
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHost.cs
@@ -0,0 +1,194 @@
+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
new file mode 100644
index 000000000..f92395f80
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsHostedService.cs
@@ -0,0 +1,57 @@
+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
new file mode 100644
index 000000000..6de9e9a79
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogsProviderRegistration.cs
@@ -0,0 +1,85 @@
+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
new file mode 100644
index 000000000..1d28e3276
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamHook.cs
@@ -0,0 +1,207 @@
+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
new file mode 100644
index 000000000..577ca49cd
--- /dev/null
+++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleStreamTeeWriter.cs
@@ -0,0 +1,212 @@
+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