elsa-core/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs
Sipke Schoorstra 842cf7c162
[codex] Fix console log metadata and type resolution (#7542)
* Avoid null endpoint DTO metadata in tests

* Enforce console logs hub read permission

* Remove unused console logs hub import

* Support mapped endpoint metadata in auth tests

* Reduce console log capture throughput impact

* Address Copilot console logs review

* Refactor task scheduling to support tenant-level background work and enhance logging functionality.

* Introduce ConsoleStreamHook for stdout/stderr tee and enhance logging validation. Adjust test cases and startup warnings for distributed lock provider usage.

* Refactor console logging pipeline with capture optimization and new ConsoleLogsHost; update tests accordingly.

* Add Ansi SGR parser for console logs and associated unit tests

* Remove ANSI color renderings and parsers; integrate ConsoleLogScopeAccessor for improved logging context with workflow instance ID support.

* Address console logs code quality feedback

* Address PR review feedback

* Preserve console logs extension points

* Stabilize console logs host lifecycle

* Address final automated review comments

* Tighten console log capture shutdown

* Address console log review feedback

* Address follow-up review feedback

* Cover final review feedback

* Avoid recursive console provider initialization

* Guard console host lease shutdown

* Preserve console log scope and provider lifetime

* Correlate console log scope fallback

* Tighten console scope correlation

* Expose host services during provider construction

* Redact ANSI-normalized console lines

* Add OpenTelemetry diagnostics backend foundation

* Add OTLP HTTP ingestion parsing

* Document OpenTelemetry diagnostics setup

* Enforce OpenTelemetry hub permissions

* Remove `ConsoleCaptureTee` and related services and tests

* Add OpenTelemetry HTTP ingestion integration test

* Use pipeline contributors for console log context

* Update CShells package versions to 0.0.24-preview.132

* Add OpenTelemetry ingestion security tests

* Add OpenTelemetry API authorization tests

* Filter live console logs by workflow instance

* Add OpenTelemetry hub tests

* Add OpenTelemetry gRPC metadata hook

* Assert OpenTelemetry workflow tags survive ingestion

* Mark OpenTelemetry core build verified

* Enhance console logging with activity execution metadata and extend test coverage.

* Address console logs stream consumption comment

* Wire OpenTelemetry diagnostics into core sample

* Address Core diagnostics review feedback

* Address Core Copilot follow-up feedback

* Add OpenTelemetry metric instrument names

* Address Core Copilot provider feedback

* Address Core Copilot diagnostics follow-up

* Address Core Copilot live feed feedback

* Address Core Copilot store feedback

* Integrate OpenTelemetry for logging, tracing, and metrics in ModularServer and update launch settings and docker-compose configuration.

* Refactor to replace `ConsoleLogStream.Core` with `ConsoleLogStreaming.Core` across codebase and update `ConsoleStreamHook` installation.

* Add diagnostics OpenTelemetry backend

* Fix OpenTelemetry live hub subscription

* Fix modular OpenTelemetry exporter endpoints

* Add CShells logging configuration in appsettings.json

* Remove obsolete unit tests and helper classes

* Restore default activity exception handling

* Simplify type serialization and alias management

This commit refactors the internal type serialization and alias management system to reduce boilerplate, improve robustness, and simplify the developer experience:

-   Removed numerous explicit `ExpressionOptions` type alias registrations across various modules.
-   Updated `TypeJsonConverter` and polymorphic serialization to reliably handle types using assembly-qualified names when a short alias is not explicitly registered.
-   Streamlined `ExcludeFromHashConverter` to strictly adhere to `ExcludeFromHashAttribute` for hash calculations, removing complex `JsonIgnoreCondition` logic.
-   Eliminated several helper classes (`WorkflowJsonTypeResolver`, `WorkflowTypeValidator`, `IWorkflowTypeRegistry`, `WorkflowFactoryDictionary`, `JavaScriptExceptionTypeAliasRegistrar`, `WorkflowRuntimeTypeAliasRegistrar`) and their associated unit tests, simplifying the codebase.

Additionally, this commit introduces a comprehensive markdown document (`product-website-feature-source.md`) outlining Elsa's core features, Studio capabilities, extension ecosystem, and architectural selling points, intended as source material for the product website.

* Refine type serialization for improved robustness and alias handling

This commit further enhances the type serialization and deserialization mechanisms:

*   Centralizes type resolution and alias management through `IWellKnownTypeRegistry` and `WorkflowJsonTypeResolver`.
*   Prioritizes registered type aliases when serializing type metadata in `PolymorphicObjectConverter`, resulting in more concise JSON output.
*   Enhances deserialization in `PolymorphicObjectConverter` and `VariableMapper` to gracefully handle unknown or non-instantiable types, providing fallbacks and logging warnings.
*   Simplifies `TypeJsonConverter` by delegating complex type resolution logic to the `WorkflowJsonTypeResolver`.
*   Adds `JsonArray` to the well-known type aliases for direct recognition.

* Fix console logs packaging and workflow type resolution

* Fix console log metadata and type resolution

* Address Copilot review feedback

* Enhance type resolution, improve console log handling, and update tests

- Streamlined `WorkflowDictionaryExtensions` for better workflow registration validation.
- Refined `ConsoleLogsAuthorizationTests` with the new `SetJsonRequest` helper to improve test requests handling.
- Updated `OrderDefinition` to ignore JSON serialization for `KeySelector`.
- Enhanced `WorkflowRuntimeFeature` for improved workflow registration and type alias configuration.
- Added tests to ensure `ConsoleLogProvider` metadata filtration in various scenarios.
- Improved type serialization logic in `WorkflowJsonTypeResolver`.
- Updated README to fix references related to diagnostics.
- Optimized `ExcludeFromHashConverter` for property serialization conditions.
- Modified `TriggerIndexer` for streamlined trigger management.
- Tested payload checks in `PublishEventTests`.
- Adjusted `Endpoint` in `ConsoleLogs` for automatic JSON request handling.
- Ensured registration of workflow type aliases in `WorkflowsFeature`.

* Restore CLR workflow registration compatibility

* Align JSON island serialization fixtures

* Add Console Logs Services and Enhance Endpoint Handling

- Introduced `ActivityExecutionsEndpointTests` to validate route exposure.
- Added `ConsoleLogCaptureHostedService` for console log streaming.
- Implemented `ConsoleStreamJsonConverter` for JSON conversion of console streams.
- Developed `ElsaConsoleLogRecentBuffer` to handle recent log buffering.
- Updated `ConsoleLogsAuthorizationTests` with new test cases for stream filter mapping.
- Consolidated console log provider dependencies and registration, including recent buffering.
- Enhanced `ElsaConsoleLogProvider` to use recent buffer for filtering.
- Adjusted `Program.cs` for streamlined logging service setup.

* Enhance type resolution and test coverage; streamline console log integration

- Added `ConsoleStreamHook` for streamlined log streaming.
- Updated `WorkflowJsonTypeResolverTests` to improve type resolution and test new scenarios.
- Simplified type resolution by removing trusted assembly checks.

* Fix CI smoke and package restore failures

* Fix Docker smoke image project paths

* Fix Docker Python runtime packages

* Fix Docker CA smoke teardown

* Refresh Elsa roadmap

* Implement background processors and mediation coordination

- Added `BackgroundCommandProcessor`, `BackgroundJobProcessor`, and `BackgroundNotificationProcessor` classes for handling commands, jobs, and notifications, respectively.
- Introduced `MediatorBackgroundProcessingCoordinator` to coordinate the execution of all background processors.
- Implemented `MediatorBackgroundTask` for wrapping `MediatorBackgroundProcessingCoordinator` in `BackgroundTask`.
- Added unit tests for `MediatorBackgroundTask` to ensure proper start and stop behavior.
- Refactored `BackgroundCommandSenderHostedService` to utilize `BackgroundCommandProcessor`.
- Introduced 'elsa-roadmap-refresh' skill configuration for roadmap updates.

* Address workflow type resolution review feedback

* Address follow-up review feedback

* Restore recent console logs execute path

* Address Copilot follow-up review

* Decouple workflow JSON aliases from expressions

* Fix workflow management unit test setup

* Fix console logs recent endpoint handler shape

* Respect workflow JSON strict type aliases

* Remove unused console log contracts reference

* Address Copilot review feedback

* Address Copilot follow-up comments

* Synchronize ring buffer dropped count

* Address background processor strategy replay
2026-05-30 22:52:01 +02:00

419 lines
17 KiB
C#

using ConsoleLogStreaming.Core;
using ConsoleLogStreaming.Core.Capture;
using ConsoleLogStreaming.Core.Models;
using CShells.Lifecycle;
using Elsa.Diagnostics.ConsoleLogs.Contracts;
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using Elsa.Diagnostics.ConsoleLogs.Services;
using Elsa.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
[Collection(ConsoleHostStateCollection.Name)]
public class ConsoleLogsRegistrationTests : IAsyncLifetime
{
public async Task InitializeAsync()
{
await ConsoleLogStreamingHost.ShutdownAsync();
ConsoleStreamHook.Uninstall();
}
public async Task DisposeAsync()
{
await ConsoleLogStreamingHost.ShutdownAsync();
ConsoleStreamHook.Uninstall();
}
[Fact]
public async Task AddConsoleLogsServices_RegistersConsoleLogPipeline()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddConsoleLogsServices();
await using var serviceProvider = services.BuildServiceProvider();
Assert.NotNull(serviceProvider.GetRequiredService<IConsoleLogProvider>());
Assert.NotNull(serviceProvider.GetRequiredService<IConsoleLogCapture>());
Assert.NotNull(serviceProvider.GetRequiredService<IElsaConsoleLogHubAuthorizer>());
Assert.NotNull(serviceProvider.GetRequiredService<ElsaConsoleLogSubscriptionManager>());
Assert.Same(serviceProvider.GetRequiredService<ConsoleLogContextAccessor>(), serviceProvider.GetRequiredService<IConsoleLogContextAccessor>());
Assert.Same(serviceProvider.GetRequiredService<ConsoleLogContextAccessor>(), serviceProvider.GetRequiredService<IConsoleLogMetadataAccessor>());
Assert.Contains(serviceProvider.GetServices<IShellInitializer>(), x => x.GetType() == typeof(ConsoleLogCaptureShellInitializer));
Assert.Contains(serviceProvider.GetServices<IDrainHandler>(), x => x.GetType() == typeof(ConsoleLogCaptureShellDrainHandler));
AssertConsoleLogPipelineContributors(serviceProvider);
}
[Fact]
public async Task AddConsoleLogsServices_ShellInitializerStartsCapture()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddConsoleLogsServices();
await using var serviceProvider = services.BuildServiceProvider();
var initializer = serviceProvider.GetServices<IShellInitializer>().OfType<ConsoleLogCaptureShellInitializer>().Single();
await initializer.InitializeAsync(CancellationToken.None);
try
{
var line = $"console-shell-capture-{Guid.NewGuid():N}";
Console.WriteLine(line);
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
await AssertEventuallyAsync(async () =>
{
var result = await provider.GetRecentAsync(new()
{
Query = line,
Limit = 10
});
Assert.Contains(result.Items, x => x.Text.Contains(line, StringComparison.Ordinal));
});
}
finally
{
var drainHandler = serviceProvider.GetServices<IDrainHandler>().OfType<ConsoleLogCaptureShellDrainHandler>().Single();
await drainHandler.DrainAsync(new NoopDrainExtensionHandle(), CancellationToken.None);
}
}
[Fact]
public async Task AddConsoleLogsHost_RegistersHostedServicesAndCaptureDependencies()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddConsoleLogsHost();
await using var serviceProvider = services.BuildServiceProvider();
Assert.NotNull(serviceProvider.GetRequiredService<IConsoleLogCapture>());
Assert.NotNull(serviceProvider.GetRequiredService<IConsoleLogProvider>());
Assert.Same(serviceProvider.GetRequiredService<ConsoleLogContextAccessor>(), serviceProvider.GetRequiredService<IConsoleLogContextAccessor>());
Assert.Same(serviceProvider.GetRequiredService<ConsoleLogContextAccessor>(), serviceProvider.GetRequiredService<IConsoleLogMetadataAccessor>());
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType().Name == "ConsoleLogCaptureHostedService");
AssertConsoleLogPipelineContributors(serviceProvider);
}
[Fact]
public void AddConsoleLogsHost_AppliesConfiguration()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddConsoleLogsHost(options => options.RecentCapacity = 17);
using var serviceProvider = services.BuildServiceProvider();
Assert.Equal(17, serviceProvider.GetRequiredService<IOptions<ConsoleLogStreaming.Core.Options.ConsoleLogOptions>>().Value.RecentCapacity);
}
[Fact]
public async Task DecoratedProvider_AttachesAmbientElsaMetadata()
{
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddConsoleLogsServices(options => options.SourceId = "test-source")
.BuildServiceProvider();
var contextAccessor = serviceProvider.GetRequiredService<IConsoleLogContextAccessor>();
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
using (contextAccessor.PushWorkflowInstanceId("workflow-console"))
await provider.PublishAsync(new ConsoleLogLine { Text = "message", Source = new ConsoleLogSource { Id = "test-source" } });
var result = await provider.GetRecentAsync(new ConsoleLogFilter
{
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-console"
}
});
var line = Assert.Single(result.Items);
Assert.Equal("workflow-console", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
}
[Fact]
public async Task ConsoleCapture_AttachesAmbientElsaMetadataAtWriteTime()
{
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddConsoleLogsServices(options => options.SourceId = "test-source")
.BuildServiceProvider();
var contextAccessor = serviceProvider.GetRequiredService<IConsoleLogContextAccessor>();
var initializer = serviceProvider.GetServices<IShellInitializer>().OfType<ConsoleLogCaptureShellInitializer>().Single();
await initializer.InitializeAsync(CancellationToken.None);
try
{
var line = $"console-workflow-capture-{Guid.NewGuid():N}";
using (contextAccessor.PushWorkflowInstanceId("workflow-captured"))
Console.WriteLine(line);
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
await AssertEventuallyAsync(async () =>
{
var result = await provider.GetRecentAsync(new()
{
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-captured"
},
Query = line,
Limit = 10
});
var capturedLine = Assert.Single(result.Items);
Assert.Equal("workflow-captured", capturedLine.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
});
}
finally
{
var drainHandler = serviceProvider.GetServices<IDrainHandler>().OfType<ConsoleLogCaptureShellDrainHandler>().Single();
await drainHandler.DrainAsync(new NoopDrainExtensionHandle(), CancellationToken.None);
}
}
[Fact]
public async Task AddConsoleLogsServices_DecoratesExistingProvider()
{
var innerProvider = new RecordingConsoleLogProvider();
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddSingleton<IConsoleLogProvider>(innerProvider)
.AddConsoleLogsServices()
.BuildServiceProvider();
var contextAccessor = serviceProvider.GetRequiredService<IConsoleLogContextAccessor>();
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
using (contextAccessor.PushWorkflowInstanceId("workflow-console"))
await provider.PublishAsync(new ConsoleLogLine { Text = "message", Source = new ConsoleLogSource { Id = "test-source" } });
Assert.NotNull(innerProvider.PublishedLine);
Assert.Equal("workflow-console", innerProvider.PublishedLine.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
}
[Fact]
public async Task DecoratedProvider_FiltersRecentRowsByMetadataWhenInnerProviderDoesNot()
{
var innerProvider = new MetadataIgnoringConsoleLogProvider();
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddSingleton<IConsoleLogProvider>(innerProvider)
.AddConsoleLogsServices()
.BuildServiceProvider();
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
await provider.PublishAsync(CreateLine("workflow-a", "a"));
await provider.PublishAsync(CreateLine("workflow-b", "b"));
var result = await provider.GetRecentAsync(new()
{
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-b"
}
});
var line = Assert.Single(result.Items);
Assert.Equal("b", line.Text);
}
[Fact]
public async Task DecoratedProvider_FiltersRecentRowsByBufferedMetadataWhenInnerProviderDropsMetadata()
{
var innerProvider = new MetadataDroppingConsoleLogProvider();
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddSingleton<IConsoleLogProvider>(innerProvider)
.AddConsoleLogsServices()
.BuildServiceProvider();
var contextAccessor = serviceProvider.GetRequiredService<IConsoleLogContextAccessor>();
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
using (contextAccessor.PushWorkflowInstanceId("workflow-buffered"))
await provider.PublishAsync(new ConsoleLogLine { Text = "buffered", Source = new ConsoleLogSource { Id = "test-source" } });
Assert.Empty(Assert.Single(innerProvider.PublishedLines).Metadata);
var result = await provider.GetRecentAsync(new()
{
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-buffered"
}
});
var line = Assert.Single(result.Items);
Assert.Equal("buffered", line.Text);
Assert.Equal("workflow-buffered", line.Metadata[ConsoleLogMetadataKeys.WorkflowInstanceId]);
}
[Fact]
public async Task DecoratedProvider_FiltersLiveRowsByMetadataWhenInnerProviderDoesNot()
{
var innerProvider = new MetadataIgnoringConsoleLogProvider();
innerProvider.LiveItems.Add(CreateLine("workflow-a", "a"));
innerProvider.LiveItems.Add(CreateLine("workflow-b", "b"));
await using var serviceProvider = new ServiceCollection()
.AddLogging()
.AddSingleton<IConsoleLogProvider>(innerProvider)
.AddConsoleLogsServices()
.BuildServiceProvider();
var provider = serviceProvider.GetRequiredService<IConsoleLogProvider>();
var lines = await provider.SubscribeAsync(new()
{
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-b"
}
}).Where(x => x.Line != null).Select(x => x.Line!).ToListAsync();
var line = Assert.Single(lines);
Assert.Equal("b", line.Text);
}
private static void AssertConsoleLogPipelineContributors(IServiceProvider serviceProvider)
{
Assert.Contains(serviceProvider.GetServices<IWorkflowExecutionPipelineContributor>(), x => x.GetType() == typeof(ConsoleLogWorkflowExecutionPipelineContributor));
Assert.Contains(serviceProvider.GetServices<IActivityExecutionPipelineContributor>(), x => x.GetType() == typeof(ConsoleLogActivityExecutionPipelineContributor));
}
private static async Task AssertEventuallyAsync(Func<Task> assertion)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(3);
Exception? lastException = null;
while (DateTimeOffset.UtcNow < deadline)
{
try
{
await assertion();
return;
}
catch (Xunit.Sdk.XunitException e)
{
lastException = e;
await Task.Delay(25);
}
}
if (lastException != null)
throw lastException;
}
private static ConsoleLogLine CreateLine(string workflowInstanceId, string text) => new()
{
Text = text,
Source = new ConsoleLogSource { Id = "test-source" },
Metadata = new Dictionary<string, string>
{
[ConsoleLogMetadataKeys.WorkflowInstanceId] = workflowInstanceId
}
};
private sealed class RecordingConsoleLogProvider : IConsoleLogProvider
{
public ConsoleLogLine? PublishedLine { get; private set; }
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
PublishedLine = line;
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(new RecentConsoleLogsResult());
}
public IAsyncEnumerable<ConsoleLogStreamingItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return AsyncEnumerable.Empty<ConsoleLogStreamingItem>();
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}
private sealed class MetadataIgnoringConsoleLogProvider : IConsoleLogProvider
{
public List<ConsoleLogLine> RecentItems { get; } = [];
public List<ConsoleLogLine> LiveItems { get; } = [];
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
RecentItems.Add(line);
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(new RecentConsoleLogsResult { Items = RecentItems });
}
public async IAsyncEnumerable<ConsoleLogStreamingItem> SubscribeAsync(
ConsoleLogFilter filter,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var line in LiveItems)
{
cancellationToken.ThrowIfCancellationRequested();
yield return ConsoleLogStreamingItem.FromLine(line);
await Task.Yield();
}
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}
private sealed class MetadataDroppingConsoleLogProvider : IConsoleLogProvider
{
public List<ConsoleLogLine> PublishedLines { get; } = [];
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
PublishedLines.Add(line with { Metadata = new Dictionary<string, string>() });
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(new RecentConsoleLogsResult { Items = PublishedLines });
}
public IAsyncEnumerable<ConsoleLogStreamingItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return AsyncEnumerable.Empty<ConsoleLogStreamingItem>();
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}
private sealed class NoopDrainExtensionHandle : IDrainExtensionHandle
{
public bool TryExtend(TimeSpan requestedExtension, out TimeSpan grantedExtension)
{
grantedExtension = TimeSpan.Zero;
return false;
}
}
}