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 arg) { var text = string.Format(FormatProvider, format, arg); original.Write(text); capture(stream, text); } -#endif - - // ── WriteLine overloads ── - - public override void WriteLine() { original.WriteLine(); capture(stream, "\n"); } - public override void WriteLine(char value) { original.WriteLine(value); capture(stream, value + "\n"); } - - public override void WriteLine(char[]? buffer) - { - original.WriteLine(buffer); - capture(stream, (buffer is { Length: > 0 } ? new string(buffer) : "") + "\n"); - } - - public override void WriteLine(char[] buffer, int index, int count) - { - original.WriteLine(buffer, index, count); - capture(stream, new string(buffer, index, count) + "\n"); - } - - public override void WriteLine(ReadOnlySpan buffer) - { - original.WriteLine(buffer); - capture(stream, string.Concat(buffer, "\n")); - } - - public override void WriteLine(bool value) { original.WriteLine(value); capture(stream, (value ? "True" : "False") + "\n"); } - public override void WriteLine(int value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(uint value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(long value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(ulong value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(float value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(double value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - public override void WriteLine(decimal value) { original.WriteLine(value); capture(stream, value.ToString(FormatProvider) + "\n"); } - - public override void WriteLine(string? value) - { - original.WriteLine(value); - capture(stream, (value ?? "") + "\n"); - } - - public override void WriteLine(object? value) - { - original.WriteLine(value); - capture(stream, (value?.ToString() ?? "") + "\n"); - } - - public override void WriteLine(StringBuilder? value) - { - original.WriteLine(value); - capture(stream, (value is { Length: > 0 } ? value.ToString() : "") + "\n"); - } - - public override void WriteLine(string format, object? arg0) { var text = string.Format(FormatProvider, format, arg0); original.WriteLine(text); capture(stream, text + "\n"); } - public override void WriteLine(string format, object? arg0, object? arg1) { var text = string.Format(FormatProvider, format, arg0, arg1); original.WriteLine(text); capture(stream, text + "\n"); } - public override void WriteLine(string format, object? arg0, object? arg1, object? arg2) { var text = string.Format(FormatProvider, format, arg0, arg1, arg2); original.WriteLine(text); capture(stream, text + "\n"); } - public override void WriteLine(string format, params object?[] arg) { var text = string.Format(FormatProvider, format, arg); original.WriteLine(text); capture(stream, text + "\n"); } -#if NET9_0_OR_GREATER - public override void WriteLine(string format, params ReadOnlySpan arg) { var text = string.Format(FormatProvider, format, arg); original.WriteLine(text); capture(stream, text + "\n"); } -#endif - - // ── Async Write overloads ── - - public override async Task WriteAsync(char value) { await original.WriteAsync(value); capture(stream, value.ToString()); } - - public override async Task WriteAsync(string? value) - { - await original.WriteAsync(value); - if (value != null) - capture(stream, value); - } - - public override async Task WriteAsync(char[] buffer, int index, int count) - { - await original.WriteAsync(buffer, index, count); - if (count > 0) - capture(stream, new string(buffer, index, count)); - } - - public override async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - await original.WriteAsync(buffer, cancellationToken); - if (!buffer.IsEmpty) - capture(stream, buffer.ToString()); - } - - public override async Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = default) - { - await original.WriteAsync(value, cancellationToken); - if (value is { Length: > 0 }) - capture(stream, value.ToString()); - } - - // ── Async WriteLine overloads ── - - public override async Task WriteLineAsync() { await original.WriteLineAsync(); capture(stream, "\n"); } - public override async Task WriteLineAsync(char value) { await original.WriteLineAsync(value); capture(stream, value + "\n"); } - - public override async Task WriteLineAsync(string? value) - { - await original.WriteLineAsync(value); - capture(stream, (value ?? "") + "\n"); - } - - public override async Task WriteLineAsync(char[] buffer, int index, int count) - { - await original.WriteLineAsync(buffer, index, count); - capture(stream, new string(buffer, index, count) + "\n"); - } - - public override async Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - await original.WriteLineAsync(buffer, cancellationToken); - capture(stream, string.Concat(buffer.Span, "\n")); - } - - public override async Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = default) - { - await original.WriteLineAsync(value, cancellationToken); - capture(stream, (value is { Length: > 0 } ? value.ToString() : "") + "\n"); - } - - // ── Lifecycle ── - - public override void Flush() => original.Flush(); - public override Task FlushAsync() => original.FlushAsync(); - public override Task FlushAsync(CancellationToken cancellationToken) => original.FlushAsync(cancellationToken); - public override void Close() => original.Close(); -} - diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs new file mode 100644 index 000000000..e9ac0fa91 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs @@ -0,0 +1,29 @@ +using ConsoleLogStreaming.Core.Options; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +internal static class ElsaConsoleLogOptions +{ + public static void ConfigureDefaults(ConsoleLogOptions options) + { + var sourceId = $"{Environment.MachineName}-{Environment.ProcessId}"; + var podName = Environment.GetEnvironmentVariable("HOSTNAME"); + + options.SourceId = sourceId; + options.SourceDisplayName = !string.IsNullOrWhiteSpace(podName) ? podName : sourceId; + options.ServiceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? AppDomain.CurrentDomain.FriendlyName; + options.PreserveAnsi = true; + + SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesPodName, podName); + SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesNamespace, Environment.GetEnvironmentVariable("POD_NAMESPACE")); + SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.ContainerName, Environment.GetEnvironmentVariable("CONTAINER_NAME")); + SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.KubernetesNodeName, Environment.GetEnvironmentVariable("NODE_NAME")); + SetMetadata(options, ConsoleLogStreamingApiMetadataKeys.ProcessStartedAt, DateTimeOffset.UtcNow.ToString("O")); + } + + private static void SetMetadata(ConsoleLogOptions options, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + options.SourceMetadata[key] = value; + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs new file mode 100644 index 000000000..2961c06a4 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs @@ -0,0 +1,19 @@ +using ConsoleLogStreaming.SignalR; +using Elsa.Diagnostics.ConsoleLogs.Permissions; +using FastEndpoints.Security; +using Microsoft.AspNetCore.SignalR; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +public sealed class ElsaConsoleLogStreamingHubAuthorizer : IConsoleLogStreamingHubAuthorizer +{ + private const string ReadAllPermission = "read:*"; + private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ConsoleLogsPermissions.Read]; + + public ValueTask CanReadAsync(HubCallerContext context, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var user = context.User; + return ValueTask.FromResult(user?.Identity?.IsAuthenticated == true && ReadPermissions.Any(user.HasPermission)); + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs index 36aa0d62e..ada74e6f4 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs @@ -1,6 +1,7 @@ using CShells.AspNetCore.Features; using CShells.FastEndpoints.Features; using CShells.Features; +using ConsoleLogStreaming.Core.Options; using Elsa.Diagnostics.ConsoleLogs.Extensions; using JetBrains.Annotations; using Microsoft.AspNetCore.Routing; @@ -15,24 +16,23 @@ namespace Elsa.Diagnostics.ConsoleLogs.ShellFeatures; [ShellFeature( DisplayName = "Console Logs", Description = "Provides live raw console log streaming over REST and SignalR", - DependsOn = ["ElsaFastEndpoints"])] + DependsOn = ["ElsaFastEndpoints", "Workflows"])] [UsedImplicitly] public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature { - private static readonly ConsoleLogsOptions DefaultOptions = new(); + private static readonly ConsoleLogOptions DefaultOptions = new(); - public int RecentLogCapacity { get; set; } = DefaultOptions.RecentLogCapacity; - public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberChannelCapacity; + public int RecentLogCapacity { get; set; } = DefaultOptions.RecentCapacity; + public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberCapacity; public int CaptureChannelCapacity { get; set; } = DefaultOptions.CaptureChannelCapacity; public int MaxRecentQuerySize { get; set; } = DefaultOptions.MaxRecentQuerySize; public int MaxLineLength { get; set; } = DefaultOptions.MaxLineLength; public TimeSpan IdleFlushTimeout { get; set; } = DefaultOptions.IdleFlushTimeout; - public bool StripAnsiEscapeSequences { get; set; } = DefaultOptions.StripAnsiEscapeSequences; + public bool StripAnsiEscapeSequences { get; set; } = !DefaultOptions.PreserveAnsi; public TimeSpan SourceHeartbeatTimeout { get; set; } = DefaultOptions.SourceHeartbeatTimeout; - public bool IncludeConsoleLogsInternalLogs { get; set; } = DefaultOptions.IncludeConsoleLogsInternalLogs; public string RedactionReplacement { get; set; } = DefaultOptions.RedactionReplacement; public ICollection SensitiveNames { get; set; } = [..DefaultOptions.SensitiveNames]; - public ICollection SensitiveTextPatterns { get; set; } = [..DefaultOptions.SensitiveTextPatterns]; + public ICollection SensitiveTextPatterns { get; set; } = [..DefaultOptions.RedactionRules.Select(x => x.Pattern)]; public void ConfigureServices(IServiceCollection services) { @@ -44,19 +44,27 @@ public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature endpoints.MapConsoleLogsHub(); } - private void ConfigureOptions(ConsoleLogsOptions options) + private void ConfigureOptions(ConsoleLogOptions options) { - options.RecentLogCapacity = RecentLogCapacity; - options.SubscriberChannelCapacity = SubscriberChannelCapacity; + options.RecentCapacity = RecentLogCapacity; + options.SubscriberCapacity = SubscriberChannelCapacity; options.CaptureChannelCapacity = CaptureChannelCapacity; options.MaxRecentQuerySize = MaxRecentQuerySize; options.MaxLineLength = MaxLineLength; options.IdleFlushTimeout = IdleFlushTimeout; - options.StripAnsiEscapeSequences = StripAnsiEscapeSequences; + options.PreserveAnsi = !StripAnsiEscapeSequences; options.SourceHeartbeatTimeout = SourceHeartbeatTimeout; - options.IncludeConsoleLogsInternalLogs = IncludeConsoleLogsInternalLogs; options.RedactionReplacement = RedactionReplacement; options.SensitiveNames = [..SensitiveNames]; - options.SensitiveTextPatterns = [..SensitiveTextPatterns]; + options.RedactionRules.Clear(); + foreach (var pattern in SensitiveTextPatterns) + { + options.RedactionRules.Add(new() + { + Name = "Sensitive text", + Pattern = pattern, + Replacement = RedactionReplacement + }); + } } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs index 95fdcefbb..0fd2c0638 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs @@ -1,4 +1,6 @@ global using System.Runtime.CompilerServices; +global using ConsoleLogStreaming.Contracts; global using Elsa.Diagnostics.ConsoleLogs.Contracts; -global using Elsa.Diagnostics.ConsoleLogs.Models; -global using Elsa.Diagnostics.ConsoleLogs.Options; +global using Elsa.Workflows; +global using Elsa.Workflows.Pipelines.ActivityExecution; +global using Elsa.Workflows.Pipelines.WorkflowExecution; diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionPipelineContributor.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionPipelineContributor.cs new file mode 100644 index 000000000..5f5da969c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionPipelineContributor.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows.Pipelines.ActivityExecution; + +namespace Elsa.Workflows; + +/// +/// Contributes middleware to the activity execution pipeline. +/// +public interface IActivityExecutionPipelineContributor +{ + /// + /// Gets the order in which the contributor is applied. + /// + int Order => 0; + + /// + /// Configures the activity execution pipeline. + /// + void Configure(IActivityExecutionPipelineBuilder builder); +} diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionPipelineContributor.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionPipelineContributor.cs new file mode 100644 index 000000000..dca14b600 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionPipelineContributor.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows.Pipelines.WorkflowExecution; + +namespace Elsa.Workflows; + +/// +/// Contributes middleware to the workflow execution pipeline. +/// +public interface IWorkflowExecutionPipelineContributor +{ + /// + /// Gets the order in which the contributor is applied. + /// + int Order => 0; + + /// + /// Configures the workflow execution pipeline. + /// + void Configure(IWorkflowExecutionPipelineBuilder builder); +} diff --git a/src/modules/Elsa.Workflows.Core/Extensions/PipelineContributorExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/PipelineContributorExtensions.cs new file mode 100644 index 000000000..6a7ef3f84 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Extensions/PipelineContributorExtensions.cs @@ -0,0 +1,25 @@ +using Elsa.Workflows; +using Elsa.Workflows.Pipelines.ActivityExecution; +using Elsa.Workflows.Pipelines.WorkflowExecution; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Extensions; + +internal static class PipelineContributorExtensions +{ + public static IWorkflowExecutionPipelineBuilder UseWorkflowExecutionPipelineContributors(this IWorkflowExecutionPipelineBuilder builder, IServiceProvider serviceProvider) + { + foreach (var contributor in serviceProvider.GetServices().OrderBy(x => x.Order)) + contributor.Configure(builder); + + return builder; + } + + public static IActivityExecutionPipelineBuilder UseActivityExecutionPipelineContributors(this IActivityExecutionPipelineBuilder builder, IServiceProvider serviceProvider) + { + foreach (var contributor in serviceProvider.GetServices().OrderBy(x => x.Order)) + contributor.Configure(builder); + + return builder; + } +} diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 05c84b7d2..4822214f9 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -233,8 +233,16 @@ public class WorkflowsFeature : FeatureBase .AddTransient() // Pipelines. - .AddScoped(sp => new ActivityExecutionPipeline(sp, ActivityExecutionPipeline)) - .AddScoped(sp => new WorkflowExecutionPipeline(sp, WorkflowExecutionPipeline)) + .AddScoped(sp => new ActivityExecutionPipeline(sp, builder => + { + builder.UseActivityExecutionPipelineContributors(sp); + ActivityExecutionPipeline(builder); + })) + .AddScoped(sp => new WorkflowExecutionPipeline(sp, builder => + { + builder.UseWorkflowExecutionPipelineContributors(sp); + WorkflowExecutionPipeline(builder); + })) // Built-in activity services. .AddScoped() diff --git a/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs index e5768fe8a..b3617a45a 100644 --- a/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs @@ -115,8 +115,16 @@ public class WorkflowsFeature : IShellFeature .AddTransient() // Pipelines. - .AddScoped(sp => new ActivityExecutionPipeline(sp, ActivityExecutionPipeline)) - .AddScoped(sp => new WorkflowExecutionPipeline(sp, WorkflowExecutionPipeline)) + .AddScoped(sp => new ActivityExecutionPipeline(sp, builder => + { + builder.UseActivityExecutionPipelineContributors(sp); + ActivityExecutionPipeline(builder); + })) + .AddScoped(sp => new WorkflowExecutionPipeline(sp, builder => + { + builder.UseWorkflowExecutionPipelineContributors(sp); + WorkflowExecutionPipeline(builder); + })) // Built-in activity services. .AddScoped() diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs index 1387ee8f3..74969a8fd 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs @@ -1,12 +1,15 @@ using System.Reflection; using System.Security.Claims; +using System.Text.Json; +using ConsoleLogStreaming.Contracts; +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.SignalR; using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Features; -using Elsa.Diagnostics.ConsoleLogs.Models; using Elsa.Diagnostics.ConsoleLogs.Permissions; using Elsa.Diagnostics.ConsoleLogs.RealTime; +using Elsa.Diagnostics.ConsoleLogs.Services; using FastEndpoints; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; @@ -15,14 +18,6 @@ namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests; public class ConsoleLogsAuthorizationTests { - [Fact] - public void HubAuthorization_RequiresAuthenticatedUser() - { - var authorize = Assert.Single(typeof(ConsoleLogsHub).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true).Cast()); - - Assert.Null(authorize.Policy); - } - [Fact] public async Task HubSubscribe_WithoutConsoleLogsPermission_DeniesAccess() { @@ -60,10 +55,125 @@ public class ConsoleLogsAuthorizationTests Assert.Contains(ConsoleLogsPermissions.Read, permissions); } + [Fact] + public async Task RecentEndpoint_MapsWorkflowInstanceIdToMetadataFilter() + { + var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint", throwOnError: true)!; + var provider = new TestConsoleLogProvider(); + var endpoint = Activator.CreateInstance(endpointType, provider, new ConsoleLogStreamingApiMapper())!; + var (requestDtoType, _) = GetEndpointDtoTypes(endpointType); + var request = JsonSerializer.Deserialize( + """ + { + "workflowInstanceId": "workflow-instance-a" + } + """, + requestDtoType, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]); + await Assert.IsAssignableFrom(result); + + Assert.NotNull(provider.LastFilter); + var metadata = provider.LastFilter.Metadata; + Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId)); + Assert.Equal("workflow-instance-a", workflowInstanceId); + } + + [Fact] + public async Task RecentEndpoint_MapsActivityFiltersToMetadataFilter() + { + var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint", throwOnError: true)!; + var provider = new TestConsoleLogProvider(); + var endpoint = Activator.CreateInstance(endpointType, provider, new ConsoleLogStreamingApiMapper())!; + var (requestDtoType, _) = GetEndpointDtoTypes(endpointType); + var request = JsonSerializer.Deserialize( + """ + { + "workflowInstanceId": "workflow-instance-a", + "activityInstanceId": "activity-instance-a", + "activityId": "activity-a", + "activityNodeId": "node-a" + } + """, + requestDtoType, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]); + await Assert.IsAssignableFrom(result); + + Assert.NotNull(provider.LastFilter); + AssertActivityMetadata(provider.LastFilter.Metadata); + } + + [Fact] + public async Task HubStream_MapsWorkflowInstanceIdToMetadataFilter() + { + var provider = new TestConsoleLogProvider(); + var hub = CreateHub(provider, ConsoleLogsPermissions.Read); + + await foreach (var _ in hub.StreamAsync(new ElsaConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" }, CancellationToken.None)) + { + // Intentionally consume the stream to trigger provider subscription/filter mapping side effects. + } + + Assert.NotNull(provider.LastSubscriptionFilter); + var metadata = provider.LastSubscriptionFilter.Metadata; + Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId)); + Assert.Equal("workflow-instance-a", workflowInstanceId); + } + + [Fact] + public async Task HubStream_MapsActivityFiltersToMetadataFilter() + { + var provider = new TestConsoleLogProvider(); + var hub = CreateHub(provider, ConsoleLogsPermissions.Read); + + await foreach (var _ in hub.StreamAsync(new ElsaConsoleLogFilter + { + WorkflowInstanceId = "workflow-instance-a", + ActivityInstanceId = "activity-instance-a", + ActivityId = "activity-a", + ActivityNodeId = "node-a" + }, CancellationToken.None)) + { + } + + Assert.NotNull(provider.LastSubscriptionFilter); + AssertActivityMetadata(provider.LastSubscriptionFilter.Metadata); + } + + [Fact] + public async Task HubSubscribe_MapsWorkflowInstanceIdToMetadataFilter() + { + var provider = new TestConsoleLogProvider(); + var hub = CreateHub(provider, ConsoleLogsPermissions.Read); + + await hub.SubscribeAsync(new ElsaConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" }); + var filter = await provider.WaitForSubscriptionFilterAsync(); + + var metadata = filter.Metadata; + Assert.True(metadata.TryGetValue(ConsoleLogMetadataKeys.WorkflowInstanceId, out var workflowInstanceId)); + Assert.Equal("workflow-instance-a", workflowInstanceId); + + await hub.UnsubscribeAsync(); + } + + private static void AssertActivityMetadata(IReadOnlyDictionary metadata) + { + Assert.Equal("workflow-instance-a", metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]); + Assert.Equal("activity-instance-a", metadata[ConsoleLogMetadataKeys.ActivityInstanceId]); + Assert.Equal("activity-a", metadata[ConsoleLogMetadataKeys.ActivityId]); + Assert.Equal("node-a", metadata[ConsoleLogMetadataKeys.ActivityNodeId]); + } + private static IReadOnlyCollection GetConfiguredPermissions(string endpointTypeName) { var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!; - var endpoint = Activator.CreateInstance(endpointType, new TestConsoleLogProvider())!; + var mapper = new ConsoleLogStreamingApiMapper(); + var endpoint = endpointTypeName.EndsWith(".Recent.Endpoint", StringComparison.Ordinal) + ? Activator.CreateInstance(endpointType, new TestConsoleLogProvider(), mapper)! + : Activator.CreateInstance(endpointType, new TestConsoleLogProvider(), mapper)!; var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType); var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType); @@ -108,14 +218,20 @@ public class ConsoleLogsAuthorizationTests throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'."); } - private static ConsoleLogsHub CreateHub(params string[] permissions) + private static ElsaConsoleLogsHub CreateHub(params string[] permissions) { - var provider = new TestConsoleLogProvider(); - var sourceRegistry = new TestConsoleLogSourceRegistry(); - var hubContext = new TestHubContext(); - var subscriptionManager = new ConsoleLogSubscriptionManager(provider, sourceRegistry, hubContext, NullLogger.Instance); + return CreateHub(new TestConsoleLogProvider(), permissions); + } - return new ConsoleLogsHub(subscriptionManager) + private static ElsaConsoleLogsHub CreateHub(TestConsoleLogProvider provider, params string[] permissions) + { + var sourceRegistry = new TestConsoleLogSourceRegistry(); + var mapper = new ConsoleLogStreamingApiMapper(); + var hubContext = new TestHubContext(); + var subscriptionManager = new ElsaConsoleLogSubscriptionManager(provider, sourceRegistry, mapper, hubContext, NullLogger.Instance); + var authorizer = new ElsaConsoleLogStreamingHubAuthorizer(); + + return new ElsaConsoleLogsHub(provider, mapper, authorizer, subscriptionManager) { Context = new TestHubCallerContext(CreateUser(permissions)) }; @@ -134,52 +250,66 @@ public class ConsoleLogsAuthorizationTests private class TestConsoleLogProvider : IConsoleLogProvider { - public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + private readonly TaskCompletionSource _subscriptionFilterSet = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ConsoleLogStreaming.Core.Models.ConsoleLogFilter? LastFilter { get; private set; } + public ConsoleLogStreaming.Core.Models.ConsoleLogFilter? LastSubscriptionFilter { get; private set; } + + public Task WaitForSubscriptionFilterAsync() => + _subscriptionFilterSet.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; } - public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + public ValueTask GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default) { - return ValueTask.FromResult(new RecentConsoleLogsResult([], [])); + LastFilter = filter; + return ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult()); } - public IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + public IAsyncEnumerable SubscribeAsync( + ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, + CancellationToken cancellationToken = default) { - return AsyncEnumerable.Empty(); + LastSubscriptionFilter = filter; + _subscriptionFilterSet.TrySetResult(filter); + return AsyncEnumerable.Empty(); } - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) { - return ValueTask.FromResult>([]); + return ValueTask.FromResult>([]); } } private class TestConsoleLogSourceRegistry : IConsoleLogSourceRegistry { - public event Action? SourceChanged + public event Action? SourceChanged { add { } remove { } } - public ConsoleLogSource Current { get; } = new() + public ConsoleLogStreaming.Core.Models.ConsoleLogSource Current { get; } = new() { Id = "test", DisplayName = "Test" }; - public void MarkSeen(string sourceId, DateTimeOffset timestamp) + public ConsoleLogStreaming.Core.Models.ConsoleLogSource MarkSeen(ConsoleLogStreaming.Core.Models.ConsoleLogSource source, DateTimeOffset timestamp) { + return source with { LastSeen = timestamp }; } - public IReadOnlyCollection List() + public IReadOnlyCollection List() { return [Current]; } } - private class TestHubContext : IHubContext + private class TestHubContext : IHubContext { public IHubClients Clients { get; } = new TestHubClients(); diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs index 99cd913e2..ca31b3050 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs @@ -1,4 +1,4 @@ -using Elsa.Diagnostics.ConsoleLogs.RealTime; +using ConsoleLogStreaming.SignalR; namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests; diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubTests.cs index 7945869f9..d15b28578 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubTests.cs @@ -7,8 +7,8 @@ public class ConsoleLogsHubTests [Fact] public void Hub_ExposesSubscriptionMethods() { - Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.SubscribeAsync))); - Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UpdateFilterAsync))); - Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UnsubscribeAsync))); + Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.SubscribeAsync))); + Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.UpdateFilterAsync))); + Assert.NotNull(typeof(ElsaConsoleLogsHub).GetMethod(nameof(ElsaConsoleLogsHub.UnsubscribeAsync))); } } diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs deleted file mode 100644 index 20ab44406..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Contracts; -using Elsa.Diagnostics.ConsoleLogs.Services; -using Microsoft.Extensions.Logging; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture; - -[Collection(ConsoleHostStateCollection.Name)] -public class ConsoleCaptureTeeTests : IDisposable -{ - private readonly TextWriter _originalOut = Console.Out; - private readonly TextWriter _originalError = Console.Error; - private readonly StringWriter _consoleOutput = new(); - private readonly CapturingProvider _provider = new(); - private readonly IOptions _options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()); - private readonly ConsoleLogScopeAccessor _scopeAccessor = new(); - - public ConsoleCaptureTeeTests() - { - // Tear down any process-wide host instance so its hook subscription doesn't double-publish. - ConsoleLogsHost.ShutdownAsync().AsTask().GetAwaiter().GetResult(); - ConsoleStreamHook.Uninstall(); - Console.SetOut(_consoleOutput); - } - - public void Dispose() - { - ConsoleStreamHook.Uninstall(); - ConsoleLogsHost.ShutdownAsync().AsTask().GetAwaiter().GetResult(); - Console.SetOut(_originalOut); - Console.SetError(_originalError); - _consoleOutput.Dispose(); - } - - private ConsoleCaptureTee CreateCapture() => new( - _provider, - new ConsoleLogSourceRegistry(_options), - new ConsoleLogRedactor(_options), - new ConsoleLineFormatter(_options), - _scopeAccessor, - _options); - - [Fact] - public async Task Capture_PreservesOriginalConsoleOutputAndPublishesLine() - { - await using var capture = CreateCapture(); - - Console.WriteLine("hello"); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal($"hello{Environment.NewLine}", _consoleOutput.ToString()); - var line = Assert.Single(_provider.Lines); - Assert.Equal("hello", line.Text); - Assert.Equal(ConsoleLogStream.Stdout, line.Stream); - } - - [Fact] - public async Task Capture_RedactsBeforePublishingToProvider() - { - await using var capture = CreateCapture(); - - Console.WriteLine("token=secret-value"); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal("[Redacted]", _provider.Lines[0].Text); - } - - [Fact] - public async Task Capture_AfterDispose_StopsPublishing() - { - var capture = CreateCapture(); - - Console.WriteLine("first"); - await WaitForLinesAsync(_provider, 1); - - await capture.DisposeAsync(); - - Console.WriteLine("second"); - await Task.Delay(50); - - Assert.Single(_provider.Lines); - Assert.Equal("first", _provider.Lines[0].Text); - } - - [Fact] - public async Task Capture_FansOutToMultipleConcurrentSubscribers() - { - var secondProvider = new CapturingProvider(); - await using var first = CreateCapture(); - await using var second = new ConsoleCaptureTee( - secondProvider, - new ConsoleLogSourceRegistry(_options), - new ConsoleLogRedactor(_options), - new ConsoleLineFormatter(_options), - _scopeAccessor, - _options); - - Console.WriteLine("broadcast"); - await WaitForLinesAsync(_provider, 1); - await WaitForLinesAsync(secondProvider, 1); - - Assert.Equal("broadcast", _provider.Lines[^1].Text); - Assert.Equal("broadcast", secondProvider.Lines[^1].Text); - } - - [Fact] - public async Task Capture_ReplaysStartupWritesOnlyOnce() - { - ConsoleStreamHook.Install(); - Console.WriteLine("startup"); - - var secondProvider = new CapturingProvider(); - await using var first = CreateCapture(); - await WaitForLinesAsync(_provider, 1); - - await using var second = new ConsoleCaptureTee( - secondProvider, - new ConsoleLogSourceRegistry(_options), - new ConsoleLogRedactor(_options), - new ConsoleLineFormatter(_options), - _scopeAccessor, - _options); - await Task.Delay(50); - - Assert.Equal("startup", _provider.Lines[0].Text); - Assert.Empty(secondProvider.Lines); - } - - [Fact] - public async Task Capture_SuppressesPublishPathReentrancy() - { - var loggingProvider = new ReentrantProvider(); - await using var capture = new ConsoleCaptureTee( - loggingProvider, - new ConsoleLogSourceRegistry(_options), - new ConsoleLogRedactor(_options), - new ConsoleLineFormatter(_options), - _scopeAccessor, - _options); - - Console.WriteLine("trigger"); - await WaitForLinesAsync(loggingProvider, 1); - await Task.Delay(50); // Give any reentrant publish a chance to misbehave. - - Assert.Single(loggingProvider.Lines); - Assert.Equal("trigger", loggingProvider.Lines[0].Text); - } - - [Fact] - public async Task Capture_ContinuesAfterProviderOperationCanceledException() - { - var provider = new OperationCanceledOnceProvider(); - await using var capture = new ConsoleCaptureTee( - provider, - new ConsoleLogSourceRegistry(_options), - new ConsoleLogRedactor(_options), - new ConsoleLineFormatter(_options), - _scopeAccessor, - _options); - - Console.WriteLine("first"); - Console.WriteLine("second"); - await WaitForLinesAsync(provider, 1); - - var line = Assert.Single(provider.Lines); - Assert.Equal("second", line.Text); - } - - [Fact] - public async Task Capture_AttachesCurrentWorkflowInstanceId() - { - using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - var logger = loggerFactory.CreateLogger("workflow"); - await using var capture = CreateCapture(); - using var scope = logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-a" - }); - - Console.WriteLine("scoped"); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId); - } - - [Fact] - public async Task Capture_AttachesInnermostWorkflowInstanceId() - { - using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - var logger = loggerFactory.CreateLogger("workflow"); - await using var capture = CreateCapture(); - using var outerScope = logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-a" - }); - using var innerScope = logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-b" - }); - - Console.WriteLine("scoped"); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal("workflow-instance-b", _provider.Lines[0].WorkflowInstanceId); - } - - [Fact] - public async Task Capture_PreservesScopeAcrossMultipleLoggerFactories() - { - using var firstLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - using var secondLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - var logger = firstLoggerFactory.CreateLogger("workflow"); - await using var capture = CreateCapture(); - using var scope = logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-a" - }); - - _ = secondLoggerFactory.CreateLogger("other"); - Console.WriteLine("scoped"); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId); - } - - [Fact] - public async Task Capture_AttachesWorkflowInstanceIdCapturedBeforeAsyncConsoleWrite() - { - using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - var logger = loggerFactory.CreateLogger("workflow"); - await using var capture = CreateCapture(); - - using (logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-a" - })) - { - logger.LogInformation("scoped"); - } - - await Task.Run(() => Console.WriteLine("scoped")); - await WaitForLinesAsync(_provider, 1); - - Assert.Equal("workflow-instance-a", _provider.Lines[0].WorkflowInstanceId); - } - - [Fact] - public async Task Capture_DoesNotAttachCapturedWorkflowInstanceIdToUnrelatedConsoleWrite() - { - using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(_scopeAccessor)); - var logger = loggerFactory.CreateLogger("workflow"); - await using var capture = CreateCapture(); - - using (logger.BeginScope(new Dictionary - { - ["WorkflowInstanceId"] = "workflow-instance-a" - })) - { - logger.LogInformation("scoped"); - } - - await Task.Run(() => Console.WriteLine("not scoped")); - await Task.Run(() => Console.WriteLine("scoped")); - await WaitForLinesAsync(_provider, 2); - - Assert.Null(_provider.Lines[0].WorkflowInstanceId); - Assert.Equal("workflow-instance-a", _provider.Lines[1].WorkflowInstanceId); - } - - private static async Task WaitForLinesAsync(CapturingProvider provider, int count) - { - var deadline = DateTimeOffset.UtcNow.AddSeconds(2); - while (provider.Lines.Count < count && DateTimeOffset.UtcNow < deadline) - await Task.Delay(10); - } - - private class CapturingProvider : IConsoleLogProvider - { - public List Lines { get; } = []; - - public virtual ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) - { - lock (Lines) - Lines.Add(line); - - return ValueTask.CompletedTask; - } - - public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => - ValueTask.FromResult(new RecentConsoleLogsResult(Lines)); - - public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - foreach (var line in Lines) - yield return ConsoleLogStreamItem.FromLine(line); - } - - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => - ValueTask.FromResult>([]); - } - - private sealed class ReentrantProvider : CapturingProvider - { - public override ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) - { - // Simulate a provider whose internals write to the console (e.g., SignalR diagnostics). - // With SuppressCapture set on this async context, this MUST NOT recurse. - Console.WriteLine("[provider-internal-log]"); - return base.PublishAsync(line, cancellationToken); - } - } - - private sealed class OperationCanceledOnceProvider : CapturingProvider - { - private int _throwCount; - - public override ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) - { - if (Interlocked.Increment(ref _throwCount) == 1) - throw new OperationCanceledException(); - - return base.PublishAsync(line, cancellationToken); - } - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineBufferTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineBufferTests.cs deleted file mode 100644 index deac0f6a2..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineBufferTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture; - -public class ConsoleLineBufferTests -{ - private readonly ConsoleLineBuffer _buffer = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 5, IdleFlushTimeout = TimeSpan.FromSeconds(1) })); - - [Fact] - public void Append_BuffersPartialWritesUntilNewline() - { - Assert.Empty(_buffer.Append("hel", DateTimeOffset.UtcNow)); - - var lines = _buffer.Append("lo\n", DateTimeOffset.UtcNow); - - Assert.Equal(["hello"], lines.Select(x => x.Text)); - } - - [Fact] - public void Append_CompletesLineAtMaximumLength() - { - var lines = _buffer.Append("hello!", DateTimeOffset.UtcNow); - - Assert.Equal(["hello"], lines.Select(x => x.Text)); - Assert.Equal("!", _buffer.Flush()?.Text); - } - - [Fact] - public void FlushIfIdle_CompletesBufferedLineAfterTimeout() - { - var now = DateTimeOffset.UtcNow; - - _buffer.Append("tail", now); - - Assert.Null(_buffer.FlushIfIdle(now.AddMilliseconds(500))); - Assert.Equal("tail", _buffer.FlushIfIdle(now.AddSeconds(2))?.Text); - } - - [Fact] - public void Append_PreservesEmptyLinesBetweenWrites() - { - var lines = _buffer.Append("a\n\nb\n", DateTimeOffset.UtcNow); - - Assert.Equal(["a", "", "b"], lines.Select(x => x.Text)); - } - - [Fact] - public void Append_PreservesWorkflowInstanceIdAcrossPartialWrites() - { - _buffer.Append("hel", DateTimeOffset.UtcNow, "workflow-instance-a"); - - var line = Assert.Single(_buffer.Append("lo\n", DateTimeOffset.UtcNow)); - - Assert.Equal("hello", line.Text); - Assert.Equal("workflow-instance-a", line.WorkflowInstanceId); - } - - [Fact] - public void Append_DoesNotAssignWorkflowInstanceIdAfterUnscopedPartialWrite() - { - _buffer.Append("hel", DateTimeOffset.UtcNow); - - var line = Assert.Single(_buffer.Append("lo\n", DateTimeOffset.UtcNow, "workflow-instance-a")); - - Assert.Equal("hello", line.Text); - Assert.Null(line.WorkflowInstanceId); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineFormatterTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineFormatterTests.cs deleted file mode 100644 index ef5645295..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineFormatterTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture; - -public class ConsoleLineFormatterTests -{ - [Fact] - public void Format_PreservesAnsiByDefault() - { - var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())); - - var result = formatter.Format("\u001b[31mred\u001b[0m"); - - Assert.Equal("\u001b[31mred\u001b[0m", result.Text); - Assert.False(result.Truncated); - } - - [Fact] - public void Format_StripsAnsiWhenConfigured() - { - var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { StripAnsiEscapeSequences = true })); - - var result = formatter.Format("\u001b[31mred\u001b[0m"); - - Assert.Equal("red", result.Text); - } - - [Fact] - public void Format_PreservesAnsiWhenConfigured() - { - var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { StripAnsiEscapeSequences = false })); - - var result = formatter.Format("\u001b[31mred\u001b[0m"); - - Assert.Equal("\u001b[31mred\u001b[0m", result.Text); - } - - [Fact] - public void Format_TruncatesOversizedLine() - { - var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 3 })); - - var result = formatter.Format("abcdef"); - - Assert.Equal("abc", result.Text); - Assert.True(result.Truncated); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs index 221316362..01db75930 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs @@ -1,15 +1,14 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; +using ConsoleLogStreaming.Core; namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; /// -/// xUnit collection used by every test class that mutates the process-wide -/// or . Members of this collection run sequentially, preventing parallel +/// xUnit collection used by every test class that mutates the process-wide console stream hook +/// or . Members of this collection run sequentially, preventing parallel /// tests from racing on the same static state. /// -[CollectionDefinition(Name)] +[CollectionDefinition(Name, DisableParallelization = true)] public sealed class ConsoleHostStateCollection { public const string Name = "ConsoleHostState"; } - diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs new file mode 100644 index 000000000..e85ba4dff --- /dev/null +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs @@ -0,0 +1,323 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Capture; +using ConsoleLogStreaming.Core.DependencyInjection; +using Elsa.Diagnostics.ConsoleLogs.Contracts; +using Elsa.Diagnostics.ConsoleLogs.Services; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Pipelines.ActivityExecution; +using Elsa.Workflows.Pipelines.WorkflowExecution; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; + +[Collection(ConsoleHostStateCollection.Name)] +public class ConsoleLogContextAccessorTests : IAsyncLifetime +{ + private readonly ConsoleLogContextAccessor _accessor = ConsoleLogContextAccessor.Instance; + + public Task InitializeAsync() + { + ConsoleStreamHook.Uninstall(); + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + ConsoleStreamHook.Uninstall(); + return Task.CompletedTask; + } + + [Fact] + public void PushMetadata_RestoresNestedCaseInsensitiveMetadata() + { + using (_accessor.PushMetadata("Tenant", "tenant-a")) + { + Assert.Equal("tenant-a", _accessor.GetMetadata()["tenant"]); + + using (_accessor.PushMetadata("tenant", "tenant-b")) + Assert.Equal("tenant-b", _accessor.GetMetadata()["TENANT"]); + + Assert.Equal("tenant-a", _accessor.GetMetadata()["tenant"]); + } + + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task PushWorkflowInstanceId_IsIsolatedAcrossConcurrentAsyncFlows() + { + var tasks = Enumerable.Range(0, 20).Select(index => Task.Run(async () => + { + var workflowInstanceId = $"workflow-{index}"; + + using (_accessor.PushWorkflowInstanceId(workflowInstanceId)) + { + await Task.Yield(); + await Task.Delay(1); + return _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId]; + } + })); + + var workflowInstanceIds = await Task.WhenAll(tasks); + + Assert.Equal(Enumerable.Range(0, 20).Select(index => $"workflow-{index}"), workflowInstanceIds); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task RecentQuery_FiltersDuplicateConsoleLinesByWorkflowMetadata() + { + await using var serviceProvider = new ServiceCollection() + .AddConsoleLogStreaming(options => options.SourceId = "test-source") + .BuildServiceProvider(); + + var provider = serviceProvider.GetRequiredService(); + await provider.PublishAsync(CreateLine("duplicate", "workflow-a", 1)); + await provider.PublishAsync(CreateLine("duplicate", "workflow-b", 2)); + + var result = await provider.GetRecentAsync(new ConsoleLogStreaming.Core.Models.ConsoleLogFilter + { + Metadata = new Dictionary + { + [ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-b" + } + }); + + var line = Assert.Single(result.Items); + Assert.Equal("duplicate", line.Text); + Assert.Equal("workflow-b", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]); + } + + [Fact] + public void ConsoleLineBuffer_PreservesWorkflowMetadataForMultilineOutput() + { + var buffer = new ConsoleLineBuffer(Microsoft.Extensions.Options.Options.Create(new ConsoleLogStreaming.Core.Options.ConsoleLogOptions())); + IReadOnlyCollection lines; + + using (_accessor.PushWorkflowInstanceId("workflow-multiline")) + lines = buffer.Append("first line\nsecond line\n", DateTimeOffset.UtcNow, _accessor.GetMetadata()); + + Assert.Collection( + lines, + line => Assert.Equal("workflow-multiline", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]), + line => Assert.Equal("workflow-multiline", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId])); + } + + [Fact] + public async Task ConsoleCapture_AttachesWorkflowMetadataToNonLoggerConsoleWrites() + { + await using var serviceProvider = new ServiceCollection() + .AddSingleton(_accessor) + .AddConsoleLogStreaming(options => options.SourceId = "test-source") + .BuildServiceProvider(); + + var capture = serviceProvider.GetRequiredService(); + await capture.StartAsync(); + + using (_accessor.PushWorkflowInstanceId("workflow-console")) + Console.WriteLine("plain console output"); + + await capture.StopAsync(); + + var result = await serviceProvider.GetRequiredService().GetRecentAsync(new ConsoleLogStreaming.Core.Models.ConsoleLogFilter + { + Metadata = new Dictionary + { + [ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-console" + } + }); + + var line = Assert.Single(result.Items, x => x.Text == "plain console output"); + Assert.Equal("workflow-console", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]); + } + + [Fact] + public async Task WorkflowExecutionMiddleware_PushesWorkflowInstanceMetadata() + { + await using var serviceProvider = new ServiceCollection() + .AddSingleton(_accessor) + .BuildServiceProvider(); + var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync(); + var context = activityExecutionContext.WorkflowExecutionContext; + string? workflowInstanceId = null; + var pipeline = new WorkflowExecutionPipeline(serviceProvider, builder => builder + .UseMiddleware() + .Use(_ => executionContext => + { + workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId]; + return ValueTask.CompletedTask; + })); + + await pipeline.ExecuteAsync(context); + + Assert.Equal(context.Id, workflowInstanceId); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task WorkflowExecutionContributor_PushesWorkflowInstanceMetadata() + { + await using var serviceProvider = new ServiceCollection() + .AddSingleton(_accessor) + .BuildServiceProvider(); + var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync(); + var context = activityExecutionContext.WorkflowExecutionContext; + string? workflowInstanceId = null; + var pipeline = new WorkflowExecutionPipeline(serviceProvider, builder => + { + new ConsoleLogWorkflowExecutionPipelineContributor().Configure(builder); + builder.Use(_ => executionContext => + { + workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId]; + return ValueTask.CompletedTask; + }); + }); + + await pipeline.ExecuteAsync(context); + + Assert.Equal(context.Id, workflowInstanceId); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task WorkflowFeature_AppliesWorkflowExecutionContributors() + { + var activityExecutionContext = await new ActivityTestFixture(new TestActivity()).BuildAsync(); + var context = activityExecutionContext.WorkflowExecutionContext; + string? workflowInstanceId = null; + var feature = new Elsa.Workflows.ShellFeatures.WorkflowsFeature + { + WorkflowExecutionPipeline = builder => builder.Use(_ => executionContext => + { + workflowInstanceId = _accessor.GetMetadata()[ConsoleLogMetadataKeys.WorkflowInstanceId]; + return ValueTask.CompletedTask; + }) + }; + var services = new ServiceCollection() + .AddSingleton(_accessor) + .AddScoped(); + feature.ConfigureServices(services); + await using var serviceProvider = services.BuildServiceProvider(); + var pipeline = serviceProvider.GetRequiredService(); + + await pipeline.ExecuteAsync(context); + + Assert.Equal(context.Id, workflowInstanceId); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task ActivityExecutionMiddleware_PushesWorkflowInstanceMetadata() + { + await using var serviceProvider = new ServiceCollection() + .AddSingleton(_accessor) + .BuildServiceProvider(); + var context = await CreateActivityContextAsync(); + IReadOnlyDictionary? metadata = null; + var pipeline = new ActivityExecutionPipeline(serviceProvider, builder => builder + .UseMiddleware() + .Use(_ => executionContext => + { + metadata = _accessor.GetMetadata(); + return ValueTask.CompletedTask; + })); + + await pipeline.ExecuteAsync(context); + + AssertActivityMetadata(context, metadata); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task ActivityExecutionContributor_PushesWorkflowInstanceMetadata() + { + await using var serviceProvider = new ServiceCollection() + .AddSingleton(_accessor) + .BuildServiceProvider(); + var context = await CreateActivityContextAsync(); + IReadOnlyDictionary? metadata = null; + var pipeline = new ActivityExecutionPipeline(serviceProvider, builder => + { + new ConsoleLogActivityExecutionPipelineContributor().Configure(builder); + builder.Use(_ => executionContext => + { + metadata = _accessor.GetMetadata(); + return ValueTask.CompletedTask; + }); + }); + + await pipeline.ExecuteAsync(context); + + AssertActivityMetadata(context, metadata); + Assert.Empty(_accessor.GetMetadata()); + } + + [Fact] + public async Task WorkflowFeature_AppliesActivityExecutionContributors() + { + var context = await CreateActivityContextAsync(); + IReadOnlyDictionary? metadata = null; + var feature = new Elsa.Workflows.ShellFeatures.WorkflowsFeature + { + ActivityExecutionPipeline = builder => builder.Use(_ => executionContext => + { + metadata = _accessor.GetMetadata(); + return ValueTask.CompletedTask; + }) + }; + var services = new ServiceCollection() + .AddSingleton(_accessor) + .AddScoped(); + feature.ConfigureServices(services); + await using var serviceProvider = services.BuildServiceProvider(); + var pipeline = serviceProvider.GetRequiredService(); + + await pipeline.ExecuteAsync(context); + + AssertActivityMetadata(context, metadata); + Assert.Empty(_accessor.GetMetadata()); + } + + private static void AssertActivityMetadata(ActivityExecutionContext context, IReadOnlyDictionary? metadata) + { + Assert.NotNull(metadata); + Assert.Equal(context.WorkflowExecutionContext.Id, metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]); + Assert.Equal(context.Id, metadata[ConsoleLogMetadataKeys.ActivityInstanceId]); + Assert.Equal(context.Activity.Id, metadata[ConsoleLogMetadataKeys.ActivityId]); + Assert.Equal(context.NodeId, metadata[ConsoleLogMetadataKeys.ActivityNodeId]); + } + + private static async Task CreateActivityContextAsync() + { + var context = await new ActivityTestFixture(new TestActivity()).BuildAsync(); + context.Id = "activity-instance-a"; + return context; + } + + private static ConsoleLogStreaming.Core.Models.ConsoleLogLine CreateLine(string text, string workflowInstanceId, long sequence) => new() + { + Text = text, + Sequence = sequence, + Stream = ConsoleLogStreaming.Core.Models.ConsoleStream.Stdout, + Source = new ConsoleLogStreaming.Core.Models.ConsoleLogSource { Id = "test-source" }, + Metadata = new Dictionary + { + [ConsoleLogMetadataKeys.WorkflowInstanceId] = workflowInstanceId + } + }; + + private class TestActivity : CodeActivity + { + public TestActivity() + { + Id = "activity-a"; + NodeId = "node-a"; + } + + protected override void Execute(ActivityExecutionContext context) + { + } + } +} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs index 76af179cd..b3a0ecced 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs @@ -1,10 +1,13 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Capture; +using ConsoleLogStreaming.Core.Hosting; +using ConsoleLogStreaming.Core.Providers; using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Extensions; -using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory; using Elsa.Diagnostics.ConsoleLogs.Services; +using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; @@ -14,13 +17,13 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime { public async Task InitializeAsync() { - await ConsoleLogsHost.ShutdownAsync(); + await ConsoleLogStreamingHost.ShutdownAsync(); ConsoleStreamHook.Uninstall(); } public async Task DisposeAsync() { - await ConsoleLogsHost.ShutdownAsync(); + await ConsoleLogStreamingHost.ShutdownAsync(); ConsoleStreamHook.Uninstall(); } @@ -33,85 +36,44 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime await using var serviceProvider = services.BuildServiceProvider(); Assert.IsType(serviceProvider.GetRequiredService()); - Assert.IsType(serviceProvider.GetRequiredService()); - Assert.IsType(serviceProvider.GetRequiredService()); - Assert.Same(ConsoleLogsHost.ScopeAccessor, serviceProvider.GetRequiredService()); - Assert.Contains(serviceProvider.GetServices(), x => ReferenceEquals(x, ConsoleLogsHost.ScopeAccessor)); - Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogsHostedService)); -#pragma warning disable CS0618 - Assert.IsType(serviceProvider.GetRequiredService()); -#pragma warning restore CS0618 + var contextAccessor = serviceProvider.GetRequiredService(); + Assert.Same(ConsoleLogStreamingHost.SourceRegistry, serviceProvider.GetRequiredService()); + Assert.Same(ConsoleLogStreamingHost.RedactionPipeline, serviceProvider.GetRequiredService()); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + AssertConsoleLogPipelineContributors(serviceProvider); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService)); + Assert.Same(ConsoleLogStreamingHost.Capture, serviceProvider.GetRequiredService()); - // Same instance must be visible across separate shell containers — console output is process-wide. var services2 = new ServiceCollection(); services2.AddConsoleLogsServices(); await using var serviceProvider2 = services2.BuildServiceProvider(); Assert.Same(serviceProvider.GetRequiredService(), serviceProvider2.GetRequiredService()); Assert.Same(serviceProvider.GetRequiredService(), serviceProvider2.GetRequiredService()); + Assert.Same(serviceProvider.GetRequiredService(), serviceProvider2.GetRequiredService()); + Assert.Same(contextAccessor, ConsoleLogStreamingHost.MetadataAccessor); } [Fact] - public async Task AddConsoleLogsServices_UsesCustomProviderForCapturePipeline() + public async Task AddConsoleLogsServices_RegistersHostOwnedDependencies() { - var provider = new CustomProvider(); - var services = new ServiceCollection(); - services.AddSingleton(provider); - services.AddConsoleLogsServices(); - - await using var serviceProvider = services.BuildServiceProvider(); - - _ = serviceProvider.GetRequiredService(); - - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StartAsync(CancellationToken.None); - - Assert.Same(provider, serviceProvider.GetRequiredService()); - Assert.Same(provider, ConsoleLogsHost.Provider); - - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StopAsync(CancellationToken.None); - } - - [Fact] - public async Task AddConsoleLogsServices_UsesCustomProviderRegisteredAfterConsoleLogsServices() - { - var provider = new CustomProvider(); var services = new ServiceCollection(); services.AddConsoleLogsServices(); - services.AddSingleton(provider); await using var serviceProvider = services.BuildServiceProvider(); foreach (var hostedService in serviceProvider.GetServices()) await hostedService.StartAsync(CancellationToken.None); - Assert.Same(provider, serviceProvider.GetRequiredService()); - Assert.Same(provider, ConsoleLogsHost.Provider); - - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StopAsync(CancellationToken.None); - } - - [Fact] - public async Task AddConsoleLogsServices_CreatesCustomProviderWithHostOwnedDependencies() - { - var services = new ServiceCollection(); - services.AddConsoleLogsServices(); - services.AddSingleton(); - - await using var serviceProvider = services.BuildServiceProvider(); - - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StartAsync(CancellationToken.None); - - var provider = Assert.IsType(ConsoleLogsHost.Provider); - Assert.Same(serviceProvider.GetRequiredService(), provider); - Assert.Same(ConsoleLogsHost.Options, provider.Options); - Assert.Same(ConsoleLogsHost.SourceRegistry, provider.SourceRegistry); - Assert.Same(ConsoleLogsHost.Redactor, provider.Redactor); - Assert.Same(ConsoleLogsHost.Formatter, provider.Formatter); - Assert.Same(ConsoleLogsHost.ScopeAccessor, provider.ScopeAccessor); + var contextAccessor = serviceProvider.GetRequiredService(); + Assert.Same(ConsoleLogStreamingHost.Provider, serviceProvider.GetRequiredService()); + Assert.Same(ConsoleLogStreamingHost.SourceRegistry, serviceProvider.GetRequiredService()); + Assert.Same(ConsoleLogStreamingHost.RedactionPipeline, serviceProvider.GetRequiredService()); + Assert.Same(ConsoleLogStreamingHost.Formatter, serviceProvider.GetRequiredService()); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + AssertConsoleLogPipelineContributors(serviceProvider); foreach (var hostedService in serviceProvider.GetServices()) await hostedService.StopAsync(CancellationToken.None); @@ -120,18 +82,18 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime [Fact] public async Task ShutdownAsync_ResetsReferenceLeases() { - ConsoleLogsHost.AddReference(); - ConsoleLogsHost.EnsureInitialized(); + ConsoleLogStreamingHost.AddReference(); + ConsoleLogStreamingHost.EnsureInitialized(); - await ConsoleLogsHost.ShutdownAsync(); + await ConsoleLogStreamingHost.ShutdownAsync(); - ConsoleLogsHost.AddReference(); - ConsoleLogsHost.EnsureInitialized(); - await ConsoleLogsHost.ReleaseReferenceAsync(); + ConsoleLogStreamingHost.AddReference(); + ConsoleLogStreamingHost.EnsureInitialized(); + await ConsoleLogStreamingHost.ReleaseReferenceAsync(); var provider = new CustomProvider(); - Assert.True(ConsoleLogsHost.ConfigureProvider((_, _) => provider)); - Assert.Same(provider, ConsoleLogsHost.Provider); + Assert.True(ConsoleLogStreamingHost.ConfigureProvider(_ => provider)); + Assert.Same(provider, ConsoleLogStreamingHost.Provider); } [Fact] @@ -142,17 +104,22 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime await using var serviceProvider = services.BuildServiceProvider(); - Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogsHostedService)); - Assert.Contains(serviceProvider.GetServices(), x => ReferenceEquals(x, ConsoleLogsHost.ScopeAccessor)); + var contextAccessor = serviceProvider.GetRequiredService(); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService)); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + AssertConsoleLogPipelineContributors(serviceProvider); } [Fact] public void AddConsoleLogsHost_AppliesConfigurationWhenCalledBeforeFirstAccess() { var services = new ServiceCollection(); - services.AddConsoleLogsHost(options => options.RecentLogCapacity = 17); + services.AddConsoleLogsHost(options => options.RecentCapacity = 17); - Assert.Equal(17, ConsoleLogsHost.Options.Value.RecentLogCapacity); + using var serviceProvider = services.BuildServiceProvider(); + + Assert.Equal(17, serviceProvider.GetRequiredService>().Value.RecentCapacity); } [Fact] @@ -161,52 +128,35 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime var services = new ServiceCollection(); services.AddConsoleLogsHost(); - Assert.True(ConsoleLogsHost.Configure(options => options.RecentLogCapacity = 23)); - Assert.Equal(23, ConsoleLogsHost.Options.Value.RecentLogCapacity); + Assert.True(ConsoleLogStreamingHost.Configure(options => options.RecentCapacity = 23)); + + using var serviceProvider = services.BuildServiceProvider(); + + Assert.Equal(23, serviceProvider.GetRequiredService>().Value.RecentCapacity); + } + + private static void AssertConsoleLogPipelineContributors(IServiceProvider serviceProvider) + { + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogWorkflowExecutionPipelineContributor)); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogActivityExecutionPipelineContributor)); } private sealed class CustomProvider : IConsoleLogProvider { - public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => - ValueTask.FromResult(new RecentConsoleLogsResult([])); + public ValueTask GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult()); - public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable SubscribeAsync( + ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.CompletedTask; yield break; } - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => - ValueTask.FromResult>([]); - } - - private sealed class ProviderWithHostDependencies( - IOptions options, - IConsoleLogSourceRegistry sourceRegistry, - IConsoleLogRedactor redactor, - ConsoleLineFormatter formatter, - ConsoleLogScopeAccessor scopeAccessor) : IConsoleLogProvider - { - public IOptions Options { get; } = options; - public IConsoleLogSourceRegistry SourceRegistry { get; } = sourceRegistry; - public IConsoleLogRedactor Redactor { get; } = redactor; - public ConsoleLineFormatter Formatter { get; } = formatter; - public ConsoleLogScopeAccessor ScopeAccessor { get; } = scopeAccessor; - - public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - - public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => - ValueTask.FromResult(new RecentConsoleLogsResult([])); - - public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield break; - } - - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => - ValueTask.FromResult>([]); + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => + ValueTask.FromResult>([]); } } diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj index 56303360e..17fe4f464 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj @@ -6,7 +6,9 @@ + + diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Filtering/ConsoleLogFilterTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Filtering/ConsoleLogFilterTests.cs deleted file mode 100644 index 2f44f05bf..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Filtering/ConsoleLogFilterTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Filtering; - -public class ConsoleLogFilterTests -{ - private readonly ConsoleLogLine _line = new() - { - Text = "workflow started", - Stream = ConsoleLogStream.Stdout, - Source = new ConsoleLogSource { Id = "source-a", DisplayName = "Source A", MachineName = "machine" }, - WorkflowInstanceId = "workflow-instance-a", - ReceivedAt = DateTimeOffset.Parse("2026-05-18T10:00:00Z") - }; - - [Fact] - public void Matches_FiltersBySource() - { - Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-a" })); - Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-b" })); - } - - [Fact] - public void Matches_FiltersByStream() - { - Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stdout })); - Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr })); - } - - [Fact] - public void Matches_FiltersByTextAndTime() - { - Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "started", From = _line.ReceivedAt.AddSeconds(-1), To = _line.ReceivedAt.AddSeconds(1) })); - Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "missing" })); - Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { From = _line.ReceivedAt.AddSeconds(1) })); - } - - [Fact] - public void Matches_FiltersByWorkflowInstanceId() - { - Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { WorkflowInstanceId = "workflow-instance-a" })); - Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { WorkflowInstanceId = "workflow-instance-b" })); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderDroppedLineTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderDroppedLineTests.cs deleted file mode 100644 index 4dfd86ebb..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderDroppedLineTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory; -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory; - -public class InMemoryConsoleLogProviderDroppedLineTests -{ - [Fact] - public async Task GetRecentAsync_IncludesDroppedSummaryWhenRecentBufferOverflows() - { - var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()))); - - await provider.PublishAsync(Line("one")); - await provider.PublishAsync(Line("two")); - - var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 }); - - Assert.Single(result.Dropped!); - Assert.Equal("RecentBufferFull", result.Dropped!.Single().Reason); - } - - [Fact] - public async Task GetRecentAsync_ConsumesDroppedSummaries() - { - var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()))); - - await provider.PublishAsync(Line("one")); - await provider.PublishAsync(Line("two")); - - Assert.NotEmpty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!); - Assert.Empty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!); - } - - [Fact] - public async Task GetRecentAsync_IncludesReportedCaptureDrops() - { - var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()))); - - provider.ReportDropped(new ConsoleLogDroppedSummary("source", ConsoleLogStream.Stdout, "CaptureChannelFull", 2)); - - var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 }); - - var dropped = Assert.Single(result.Dropped!); - Assert.Equal("CaptureChannelFull", dropped.Reason); - Assert.Equal(2, dropped.Count); - } - - private static ConsoleLogLine Line(string text) => new() - { - Text = text, - Source = new ConsoleLogSource { Id = "source", DisplayName = "source", MachineName = "machine" } - }; -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderSourceTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderSourceTests.cs deleted file mode 100644 index 7f1bce8e4..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderSourceTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory; -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory; - -public class InMemoryConsoleLogProviderSourceTests -{ - [Fact] - public async Task GetRecentAsync_OrdersOverlappingSourcesDeterministically() - { - var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()))); - var timestamp = DateTimeOffset.UtcNow; - - await provider.PublishAsync(Line("b", "b", timestamp, 1)); - await provider.PublishAsync(Line("a", "a", timestamp, 1)); - - var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 }); - - Assert.Equal(["a", "b"], result.Items.Select(x => x.Text)); - } - - private static ConsoleLogLine Line(string text, string sourceId, DateTimeOffset timestamp, long sequence) => new() - { - Text = text, - Source = new ConsoleLogSource { Id = sourceId, DisplayName = sourceId, MachineName = "machine" }, - Timestamp = timestamp, - ReceivedAt = timestamp, - Sequence = sequence - }; -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderTests.cs deleted file mode 100644 index e7faf4a5a..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory; -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory; - -public class InMemoryConsoleLogProviderTests -{ - private readonly ConsoleLogSource _source = new() { Id = "local", DisplayName = "local", MachineName = "machine", Health = ConsoleLogSourceHealth.Connected }; - private readonly InMemoryConsoleLogProvider _provider; - - public InMemoryConsoleLogProviderTests() - { - _provider = new( - Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 2, MaxRecentQuerySize = 1, SubscriberChannelCapacity = 10 }), - new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()))); - } - - [Fact] - public async Task GetRecentAsync_ClampsLimitAndOrdersByReceivedAt() - { - await _provider.PublishAsync(Line("one", 1)); - await _provider.PublishAsync(Line("two", 2)); - - var result = await _provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 }); - - var line = Assert.Single(result.Items); - Assert.Equal("two", line.Text); - } - - [Fact] - public async Task SubscribeAsync_StreamsMatchingLines() - { - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var readTask = ReadOneAsync(_provider.SubscribeAsync(new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr }, cancellation.Token), cancellation.Token); - - await _provider.PublishAsync(Line("ignored", 1)); - await _provider.PublishAsync(Line("error", 2, ConsoleLogStream.Stderr)); - - var item = await readTask; - Assert.Equal("error", item.Line?.Text); - } - - private ConsoleLogLine Line(string text, long sequence, ConsoleLogStream stream = ConsoleLogStream.Stdout) => new() - { - Text = text, - Sequence = sequence, - Stream = stream, - Source = _source, - Timestamp = DateTimeOffset.UnixEpoch.AddSeconds(sequence), - ReceivedAt = DateTimeOffset.UnixEpoch.AddSeconds(sequence) - }; - - private static async Task ReadOneAsync(IAsyncEnumerable items, CancellationToken cancellationToken) - { - await foreach (var item in items.WithCancellation(cancellationToken)) - return item; - - throw new InvalidOperationException("No item was streamed."); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogProviderRedactionTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogProviderRedactionTests.cs deleted file mode 100644 index 3e8721400..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogProviderRedactionTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Contracts; -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction; - -[Collection(ConsoleHostStateCollection.Name)] -public class ConsoleLogProviderRedactionTests : IAsyncLifetime -{ - public async Task InitializeAsync() - { - await ConsoleLogsHost.ShutdownAsync(); - ConsoleStreamHook.Uninstall(); - } - - public async Task DisposeAsync() - { - await ConsoleLogsHost.ShutdownAsync(); - ConsoleStreamHook.Uninstall(); - } - - [Fact] - public async Task CaptureTee_PublishesRedactedLinesToProvider() - { - var originalOut = Console.Out; - var provider = new CapturingProvider(); - var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()); - - try - { - ConsoleStreamHook.Uninstall(); - Console.SetOut(TextWriter.Null); - await using var capture = new ConsoleCaptureTee( - provider, - new ConsoleLogSourceRegistry(options), - new ConsoleLogRedactor(options), - new ConsoleLineFormatter(options), - new ConsoleLogScopeAccessor(), - options); - - Console.WriteLine(string.Concat("pass", "word", "=", "sample-value")); - await WaitForLineAsync(provider); - - Assert.Equal("[Redacted]", Assert.Single(provider.Lines).Text); - } - finally - { - ConsoleStreamHook.Uninstall(); - Console.SetOut(originalOut); - } - } - - private static async Task WaitForLineAsync(CapturingProvider provider) - { - var deadline = DateTimeOffset.UtcNow.AddSeconds(2); - while (provider.Lines.Count == 0 && DateTimeOffset.UtcNow < deadline) - await Task.Delay(10); - } - - private sealed class CapturingProvider : IConsoleLogProvider - { - public List Lines { get; } = []; - - public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) - { - lock (Lines) - Lines.Add(line); - - return ValueTask.CompletedTask; - } - - public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentConsoleLogsResult(Lines)); - - public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield break; - } - - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult>([]); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogRedactorTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogRedactorTests.cs deleted file mode 100644 index 083eb0677..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogRedactorTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction; - -public class ConsoleLogRedactorTests -{ - private readonly ConsoleLogRedactor _redactor = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())); - - [Fact] - public void Redact_MasksSensitiveLineText() - { - var line = CreateLine(string.Concat("Authorization: ", "Bearer ", "sample-token")); - - var redacted = _redactor.Redact(line); - - Assert.Equal("Authorization: [Redacted]", redacted.Text); - } - - [Fact] - public void Redact_MasksSensitiveLineTextSplitByAnsi() - { - var line = CreateLine("to\u001b[31mken=sample-token"); - - var redacted = _redactor.Redact(line); - - Assert.Equal("[Redacted]", redacted.Text); - } - - [Fact] - public void Redact_PreservesAnsiWhenLineTextIsNotSensitive() - { - var line = CreateLine("\u001b[31mhello\u001b[0m"); - - var redacted = _redactor.Redact(line); - - Assert.Equal("\u001b[31mhello\u001b[0m", redacted.Text); - } - - [Fact] - public void Redact_MasksSensitiveSourceMetadata() - { - var line = CreateLine("hello") with - { - Source = CreateSource() with - { - Metadata = new Dictionary { ["apiKey"] = "secret-value" } - } - }; - - var redacted = _redactor.Redact(line); - - Assert.Equal("[Redacted]", redacted.Source.Metadata["apiKey"]); - } - - private static ConsoleLogLine CreateLine(string text) => new() - { - Text = text, - Source = CreateSource() - }; - - private static ConsoleLogSource CreateSource() => new() - { - Id = "source", - DisplayName = "source", - MachineName = "machine" - }; -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Sources/ConsoleLogSourceRegistryTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Sources/ConsoleLogSourceRegistryTests.cs deleted file mode 100644 index fed67d345..000000000 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Sources/ConsoleLogSourceRegistryTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Elsa.Diagnostics.ConsoleLogs.Services; - -namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Sources; - -public class ConsoleLogSourceRegistryTests -{ - [Fact] - public void MarkSeen_AddsUnknownSourceAndRaisesChange() - { - var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())); - ConsoleLogSource? changed = null; - registry.SourceChanged += source => changed = source; - - registry.MarkSeen("remote", DateTimeOffset.UtcNow); - - Assert.NotNull(changed); - Assert.Contains(registry.List(), x => x.Id == "remote" && x.MachineName == "" && x.Health == ConsoleLogSourceHealth.Connected); - } - - [Fact] - public void List_MarksOldSourcesStale() - { - var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) })); - - registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1)); - - Assert.Contains(registry.List(), x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale); - } - - [Fact] - public void MarkSeen_AfterSourceBecameStale_RaisesConnectedChange() - { - var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) })); - var changes = new List(); - registry.SourceChanged += changes.Add; - - registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1)); - registry.List(); - registry.MarkSeen("remote", DateTimeOffset.UtcNow); - - Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale); - Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Connected); - } -} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs index f83bab28f..fd1941521 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs @@ -1,5 +1,4 @@ global using System.Runtime.CompilerServices; -global using Elsa.Diagnostics.ConsoleLogs.Models; -global using Elsa.Diagnostics.ConsoleLogs.Options; +global using ConsoleLogStreaming.Contracts; global using Microsoft.Extensions.Options; global using Xunit;