From 842cf7c16299da0886d79ae2e6de81a4d88e5cbf Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 30 May 2026 22:52:01 +0200 Subject: [PATCH] [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 --- .agents/skills/elsa-roadmap-refresh/SKILL.md | 126 ++++ .../elsa-roadmap-refresh/agents/openai.yaml | 4 + .specify/feature.json | 2 +- AGENTS.md | 2 +- Directory.Packages.props | 4 + Elsa.sln | 47 +- NuGet.Config | 5 + ROADMAP.md | 60 +- doc/website/product-website-feature-source.md | 438 +++++++++++ doc/wiki/opentelemetry-workflows.md | 58 +- docker/ElsaServer-Datadog.Dockerfile | 11 +- docker/ElsaServer.Dockerfile | 9 +- docker/ElsaServerAndStudio.Dockerfile | 20 +- docker/ElsaStudio.Dockerfile | 11 +- docker/docker-compose.yml | 10 + scripts/test-ca-trust.sh | 34 +- .../checklists/requirements.md | 31 + .../contracts/otlp-ingest.md | 50 ++ .../contracts/provider-contract.md | 48 ++ .../contracts/rest-api.md | 67 ++ .../contracts/signalr-hub.md | 39 + specs/008-diagnostics-otel/data-model.md | 104 +++ specs/008-diagnostics-otel/plan.md | 149 ++++ specs/008-diagnostics-otel/quickstart.md | 72 ++ specs/008-diagnostics-otel/research.md | 54 ++ specs/008-diagnostics-otel/spec.md | 158 ++++ specs/008-diagnostics-otel/tasks.md | 123 +++ .../Elsa.ModularServer.Web.csproj | 5 + src/apps/Elsa.ModularServer.Web/Program.cs | 72 +- .../Properties/launchSettings.json | 34 +- .../Elsa.ModularServer.Web/appsettings.json | 16 +- .../Contracts/IActivityExecutionsApi.cs | 4 +- .../CommandStrategies/BackgroundStrategy.cs | 13 +- .../DependencyInjectionExtensions.cs | 21 +- .../BackgroundCommandSenderHostedService.cs | 101 +-- .../BackgroundEventPublisherHostedService.cs | 109 +-- .../HostedServices/JobRunnerHostedService.cs | 68 +- .../BackgroundProcessingStrategy.cs | 11 +- .../Services/BackgroundCommandProcessor.cs | 101 +++ .../Services/BackgroundJobProcessor.cs | 71 ++ .../BackgroundNotificationProcessor.cs | 98 +++ .../Features/AlterationsFeature.cs | 4 +- .../ShellFeatures/AlterationsFeature.cs | 18 +- .../Elsa.Common/Entities/OrderDefinition.cs | 6 - .../ShellFeatures/MediatorFeature.cs | 11 +- ...MediatorBackgroundProcessingCoordinator.cs | 91 +++ .../ShellHandlers/MediatorBackgroundTask.cs | 10 + .../AssemblyInfo.cs | 3 + .../Contracts/ConsoleStreamJsonConverter.cs | 57 ++ .../Contracts/ElsaConsoleLogFilter.cs | 32 +- .../Elsa.Diagnostics.ConsoleLogs.csproj | 6 +- .../Endpoints/ConsoleLogs/Recent/Endpoint.cs | 51 +- .../Endpoints/ConsoleLogs/Sources/Endpoint.cs | 5 +- ...leLogContextServiceCollectionExtensions.cs | 5 +- ...eLogProviderServiceCollectionExtensions.cs | 40 + .../EndpointRouteBuilderExtensions.cs | 11 +- .../HostServiceCollectionExtensions.cs | 13 +- .../Extensions/ServiceCollectionExtensions.cs | 15 +- .../Elsa.Diagnostics.ConsoleLogs/README.md | 2 +- .../ElsaConsoleLogSubscriptionManager.cs | 76 +- .../RealTime/ElsaConsoleLogsHub.cs | 22 +- .../ConsoleLogCaptureHostedService.cs | 11 + .../Services/ConsoleLogCaptureShellLease.cs | 69 ++ .../Services/ConsoleLogContextAccessor.cs | 5 +- .../ConsoleLogContextAccessorExtensions.cs | 2 - .../Services/ConsoleLogFilterMapper.cs | 3 - .../Services/ElsaConsoleLogOptions.cs | 12 +- .../Services/ElsaConsoleLogProvider.cs | 110 +++ .../Services/ElsaConsoleLogRecentBuffer.cs | 69 ++ .../ElsaConsoleLogStreamHubAuthorizer.cs | 8 +- .../ShellFeatures/ConsoleLogsFeature.cs | 9 +- .../Elsa.Diagnostics.ConsoleLogs/Usings.cs | 2 +- .../AssemblyInfo.cs | 3 + .../ICollectorConfigurationProvider.cs | 8 + .../Contracts/IOpenTelemetryIngestor.cs | 8 + .../Contracts/IOpenTelemetryLiveFeed.cs | 9 + .../Contracts/IOpenTelemetryProvider.cs | 14 + .../Contracts/IOpenTelemetryRedactor.cs | 8 + .../Contracts/IOpenTelemetrySourceRegistry.cs | 10 + .../Contracts/IOpenTelemetryStore.cs | 14 + .../Elsa.Diagnostics.OpenTelemetry.csproj | 16 + .../CollectorConfiguration/Endpoint.cs | 22 + .../Endpoints/OpenTelemetry/Logs/Endpoint.cs | 22 + .../OpenTelemetry/Metrics/Endpoint.cs | 22 + .../OpenTelemetry/Resources/Endpoint.cs | 22 + .../OpenTelemetry/Storage/Endpoint.cs | 22 + .../Endpoints/OpenTelemetry/Trace/Endpoint.cs | 29 + .../OpenTelemetry/Traces/Endpoint.cs | 22 + .../EndpointRouteBuilderExtensions.cs | 114 +++ .../Extensions/ModuleExtensions.cs | 14 + .../Extensions/ServiceCollectionExtensions.cs | 33 + .../Features/OpenTelemetryFeature.cs | 23 + .../FodyWeavers.xml | 4 + .../HttpProtobuf/OtlpHttpProtobufParser.cs | 701 ++++++++++++++++++ .../Ingestion/OtlpIngestionSecurity.cs | 44 ++ .../Models/OpenTelemetryContracts.cs | 86 +++ .../Models/OpenTelemetryModels.cs | 127 ++++ .../OpenTelemetryDiagnosticsOptions.cs | 43 ++ .../Permissions/OpenTelemetryPermissions.cs | 7 + .../InMemory/InMemoryOpenTelemetryLiveFeed.cs | 166 +++++ .../InMemory/InMemoryOpenTelemetryStore.cs | 227 ++++++ .../Providers/InMemory/RingBuffer.cs | 46 ++ .../Elsa.Diagnostics.OpenTelemetry/README.md | 41 + .../RealTime/IOpenTelemetryClient.cs | 8 + .../RealTime/OpenTelemetryHub.cs | 50 ++ .../OpenTelemetrySubscriptionManager.cs | 82 ++ .../CollectorConfigurationProvider.cs | 34 + .../Services/DefaultOpenTelemetryProvider.cs | 21 + .../Services/OpenTelemetryIngestor.cs | 14 + .../Services/OpenTelemetryRedactor.cs | 84 +++ .../Services/OpenTelemetrySourceRegistry.cs | 59 ++ .../ShellFeatures/OpenTelemetryFeature.cs | 101 +++ .../Features/JavaScriptFeature.cs | 4 +- .../JavaScriptExceptionTypeAliasRegistrar.cs | 21 - .../ShellFeatures/JavaScriptFeature.cs | 2 - .../Options/ExpressionOptions.cs | 2 +- src/modules/Elsa.Http/Features/HttpFeature.cs | 4 +- .../Elsa.Http/ShellFeatures/HttpFeature.cs | 4 +- .../WorkflowDefinitionPersistenceFeature.cs | 4 +- .../Management/WorkflowDefinitionStore.cs | 4 +- .../Features/SchedulingFeature.cs | 4 +- .../ShellFeatures/SchedulingFeature.cs | 11 + .../ActivityExecutions/Get/Endpoint.cs | 14 +- .../Serialization/FlowchartJsonConverter.cs | 11 +- .../WorkflowJsonOptionsExtensions.cs | 66 ++ .../Features/FlowchartFeature.cs | 8 +- .../Features/WorkflowsFeature.cs | 43 +- .../Converters/ExcludeFromHashConverter.cs | 2 + .../Converters/OutputJsonConverter.cs | 15 +- .../PolymorphicDictionaryConverter.cs | 8 +- .../Converters/PolymorphicObjectConverter.cs | 26 +- .../PolymorphicObjectConverterFactory.cs | 24 +- .../Converters/TypeJsonConverter.cs | 35 +- .../Converters/VariableConverter.cs | 16 +- .../Converters/VariableConverterFactory.cs | 24 +- .../Helpers/WorkflowJsonTypeResolver.cs | 190 +++-- .../Options/WorkflowJsonOptions.cs | 91 +++ .../Serializers/BookmarkPayloadSerializer.cs | 15 +- .../JsonWorkflowStateSerializer.cs | 27 +- .../Serializers/SafeSerializer.cs | 4 +- .../Elsa.Workflows.Core/Services/Hasher.cs | 14 +- .../Services/VariableMapper.cs | 43 +- .../ShellFeatures/FlowchartFeature.cs | 5 +- .../ShellFeatures/WorkflowsFeature.cs | 12 +- .../ManagementServiceCollectionExtensions.cs | 7 +- .../Extensions/ModuleExtensions.cs | 10 +- .../WorkflowManagementFeatureExtensions.cs | 6 +- .../Features/WorkflowManagementFeature.cs | 4 +- .../Mappers/VariableDefinitionMapper.cs | 26 +- .../WorkflowManagementFeature.cs | 5 +- .../WorkflowTriggerEqualityComparer.cs | 21 +- .../Discovery/WorkflowTypeScanner.cs | 14 + .../DependencyInjectionExtensions.cs | 46 +- .../WorkflowDictionaryExtensions.cs | 20 +- .../Features/WorkflowRuntimeFeature.cs | 31 +- .../Helpers/IWorkflowTypeRegistry.cs | 8 - .../Helpers/WorkflowFactoryDictionary.cs | 16 - .../Helpers/WorkflowTypeValidator.cs | 17 - .../Services/TriggerIndexer.cs | 6 +- .../ShellFeatures/WorkflowRuntimeFeature.cs | 27 +- .../WorkflowRuntimeTypeAliasRegistrar.cs | 35 - test/TlsSmoke/Directory.Build.props | 5 + test/TlsSmoke/Program.cs | 16 + test/TlsSmoke/TlsSmoke.csproj | 10 + .../BulkDispatchWorkflowsTests.cs | 18 +- .../Primitives/Event/PublishEventTests.cs | 7 +- .../ConsoleLogsAuthorizationTests.cs | 195 +++-- .../ConsoleLogsHubSourceStatusTests.cs | 101 ++- .../ConsoleLogsModuleTests.cs | 1 + .../ConsoleLogsRecentEndpointTests.cs | 2 +- .../ConsoleLogsSourcesEndpointTests.cs | 2 +- ...ostics.ConsoleLogs.IntegrationTests.csproj | 1 + ...tics.OpenTelemetry.IntegrationTests.csproj | 14 + .../OpenTelemetryAuthorizationTests.cs | 102 +++ .../OpenTelemetryHubTests.cs | 238 ++++++ .../OtlpHttpIngestionTests.cs | 189 +++++ .../OtlpIngestionSecurityTests.cs | 101 +++ .../Usings.cs | 1 + .../JavaScriptFeatureTests.cs | 31 - .../Serialization/JsonSerialization/Tests.cs | 7 +- .../Serialization/Polymorphism/Tests.cs | 10 +- .../Serialization/Polymorphism/data.json | 2 +- .../Entities/OrderDefinitionTests.cs | 22 - .../MediatorBackgroundTaskTests.cs | 101 +++ .../ConsoleHostStateCollection.cs | 6 +- .../ConsoleLogContextAccessorTests.cs | 58 +- .../ConsoleLogsRegistrationTests.cs | 406 ++++++++-- ...a.Diagnostics.ConsoleLogs.UnitTests.csproj | 1 + .../ElsaConsoleLogSubscriptionManagerTests.cs | 156 ++++ .../RecentConsoleLogsEndpointTests.cs | 16 + .../Usings.cs | 2 +- ...Diagnostics.OpenTelemetry.UnitTests.csproj | 12 + .../InMemory/RingBufferTests.cs | 20 + .../Ingestion/OtlpHttpProtobufParserTests.cs | 142 ++++ .../Ingestion/OtlpIngestionSecurityTests.cs | 68 ++ .../InMemoryOpenTelemetryStoreTests.cs | 110 +++ .../OpenTelemetryMetricQueryTests.cs | 45 ++ .../OpenTelemetryStoreTestContext.cs | 59 ++ .../Providers/OpenTelemetryTraceQueryTests.cs | 38 + .../Providers/OtlpLogQueryTests.cs | 28 + .../Services/CollectorConfigurationTests.cs | 41 + .../Services/OpenTelemetryRedactorTests.cs | 39 + .../ShellFeatures/HttpFeatureTests.cs | 29 - .../BackgroundCommandProcessorTests.cs | 108 +++ .../BackgroundNotificationProcessorTests.cs | 145 ++++ .../ActivityExecutionsEndpointTests.cs | 27 + .../Converters/ActivityJsonConverterTests.cs | 6 +- .../ExcludeFromHashConverterTests.cs | 180 ----- .../WorkflowJsonTypeResolverTests.cs | 460 +++++------- .../Services/VariableMapperTests.cs | 44 +- .../Mappers/VariableDefinitionMapperTests.cs | 82 ++ ...rkflowDefinitionExporterRegressionTests.cs | 7 +- .../WorkflowTriggerEqualityComparerTests.cs | 93 +-- ...erviceCollectionWorkflowExtensionsTests.cs | 29 + .../Features/WorkflowRuntimeFeatureTests.cs | 213 ------ 215 files changed, 8517 insertions(+), 1999 deletions(-) create mode 100644 .agents/skills/elsa-roadmap-refresh/SKILL.md create mode 100644 .agents/skills/elsa-roadmap-refresh/agents/openai.yaml create mode 100644 doc/website/product-website-feature-source.md create mode 100644 specs/008-diagnostics-otel/checklists/requirements.md create mode 100644 specs/008-diagnostics-otel/contracts/otlp-ingest.md create mode 100644 specs/008-diagnostics-otel/contracts/provider-contract.md create mode 100644 specs/008-diagnostics-otel/contracts/rest-api.md create mode 100644 specs/008-diagnostics-otel/contracts/signalr-hub.md create mode 100644 specs/008-diagnostics-otel/data-model.md create mode 100644 specs/008-diagnostics-otel/plan.md create mode 100644 specs/008-diagnostics-otel/quickstart.md create mode 100644 specs/008-diagnostics-otel/research.md create mode 100644 specs/008-diagnostics-otel/spec.md create mode 100644 specs/008-diagnostics-otel/tasks.md create mode 100644 src/common/Elsa.Mediator/Services/BackgroundCommandProcessor.cs create mode 100644 src/common/Elsa.Mediator/Services/BackgroundJobProcessor.cs create mode 100644 src/common/Elsa.Mediator/Services/BackgroundNotificationProcessor.cs create mode 100644 src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundProcessingCoordinator.cs create mode 100644 src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundTask.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/AssemblyInfo.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ConsoleStreamJsonConverter.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogProviderServiceCollectionExtensions.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureShellLease.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogProvider.cs create mode 100644 src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogRecentBuffer.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/AssemblyInfo.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/ICollectorConfigurationProvider.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryIngestor.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryLiveFeed.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryProvider.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryRedactor.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetrySourceRegistry.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryStore.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Elsa.Diagnostics.OpenTelemetry.csproj create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/CollectorConfiguration/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Logs/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Metrics/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Resources/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Storage/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Trace/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Traces/Endpoint.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/EndpointRouteBuilderExtensions.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Features/OpenTelemetryFeature.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/FodyWeavers.xml create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/HttpProtobuf/OtlpHttpProtobufParser.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/OtlpIngestionSecurity.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryContracts.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryModels.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Options/OpenTelemetryDiagnosticsOptions.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Permissions/OpenTelemetryPermissions.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryLiveFeed.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/RingBuffer.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/README.md create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/IOpenTelemetryClient.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetryHub.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetrySubscriptionManager.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Services/CollectorConfigurationProvider.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Services/DefaultOpenTelemetryProvider.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryIngestor.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryRedactor.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetrySourceRegistry.cs create mode 100644 src/modules/Elsa.Diagnostics.OpenTelemetry/ShellFeatures/OpenTelemetryFeature.cs delete mode 100644 src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs create mode 100644 src/modules/Elsa.Workflows.Core/Extensions/WorkflowJsonOptionsExtensions.cs create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Options/WorkflowJsonOptions.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Discovery/WorkflowTypeScanner.cs delete mode 100644 src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs delete mode 100644 src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs delete mode 100644 src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs delete mode 100644 src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs create mode 100644 test/TlsSmoke/Directory.Build.props create mode 100644 test/TlsSmoke/Program.cs create mode 100644 test/TlsSmoke/TlsSmoke.csproj create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryAuthorizationTests.cs create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryHubTests.cs create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpHttpIngestionTests.cs create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpIngestionSecurityTests.cs create mode 100644 test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Usings.cs delete mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs delete mode 100644 test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs create mode 100644 test/unit/Elsa.Common.UnitTests/ShellHandlers/MediatorBackgroundTaskTests.cs create mode 100644 test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ElsaConsoleLogSubscriptionManagerTests.cs create mode 100644 test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/RecentConsoleLogsEndpointTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/InMemory/RingBufferTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpHttpProtobufParserTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpIngestionSecurityTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/InMemoryOpenTelemetryStoreTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryMetricQueryTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryStoreTestContext.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryTraceQueryTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OtlpLogQueryTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/CollectorConfigurationTests.cs create mode 100644 test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/OpenTelemetryRedactorTests.cs delete mode 100644 test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs create mode 100644 test/unit/Elsa.Mediator.UnitTests/BackgroundCommandProcessorTests.cs create mode 100644 test/unit/Elsa.Mediator.UnitTests/BackgroundNotificationProcessorTests.cs create mode 100644 test/unit/Elsa.Workflows.Api.UnitTests/ActivityExecutions/ActivityExecutionsEndpointTests.cs delete mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Mappers/VariableDefinitionMapperTests.cs create mode 100644 test/unit/Elsa.Workflows.Runtime.UnitTests/Extensions/ServiceCollectionWorkflowExtensionsTests.cs delete mode 100644 test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs diff --git a/.agents/skills/elsa-roadmap-refresh/SKILL.md b/.agents/skills/elsa-roadmap-refresh/SKILL.md new file mode 100644 index 000000000..412af9514 --- /dev/null +++ b/.agents/skills/elsa-roadmap-refresh/SKILL.md @@ -0,0 +1,126 @@ +--- +name: elsa-roadmap-refresh +description: Refresh the Elsa roadmap from current evidence. Use when Codex needs to update ROADMAP.md or the public roadmap issue by researching elsa-core, elsa-studio, and elsa-extensions source code, specs, releases, commits, issues, pull requests, and discussions; classify shipped foundations, partial/productization work, roadmap candidates, stale items, and recommendations; then update the roadmap checklist and GitHub issue coherently. +--- + +# Elsa Roadmap Refresh + +## Purpose + +Use this skill to refresh Elsa's roadmap from evidence across the Elsa product surface: + +- `elsa-workflows/elsa-core` +- `elsa-workflows/elsa-studio` +- `elsa-workflows/elsa-extensions` + +The output should be a coherent product roadmap, not a raw issue digest. Ground every substantial change in source code, specs, releases, issues, pull requests, or discussions. + +## Safety Rules + +- Before editing, run `git status --short` in `elsa-core`. +- Never revert unrelated user changes. +- If `ROADMAP.md` or `README.md` already has uncommitted user edits, stop and report the conflict unless the user explicitly asked to work through it. +- Edit only roadmap-related files unless the user asks for more. +- Do not commit, push, or publish unless the user or automation prompt explicitly asks for it. +- If updating GitHub issue `elsa-core#3232`, use the final `ROADMAP.md` body as the issue body. + +## Evidence Workflow + +1. Read the current roadmap. + - `ROADMAP.md` + - `README.md` roadmap link, if relevant + - GitHub issue `elsa-workflows/elsa-core#3232` + +2. Inspect `elsa-core`. + - Source modules under `src/modules` + - App hosts under `src/apps` + - Specs under `specs` + - Docs under `doc` and `design` + - Recent releases, commits, open issues, discussions, and open PRs + +3. Inspect `elsa-studio`. + - Source layout and feature modules + - Designer, diagnostics, auth, localization, embedding, workflow authoring, and instance UX + - Recent releases, commits, open issues, discussions, and open PRs + +4. Inspect `elsa-extensions`. + - Module families and integration foundations + - Connections, Secrets, Agents, OpenAPI, schedulers, messaging, persistence, data tooling, and package manifests + - Recent releases, commits, open issues, and open PRs + - Discussions if enabled; otherwise state that issue/PR signal is the available source + +Use `gh` for GitHub data when available. Prefer local clones when present; otherwise use temporary clones or `gh repo clone` into `/tmp`. + +## Classification Rules + +Use these categories consistently: + +- `[x]` shipped foundation: implemented in source or released enough to build on. +- `[~]` partially shipped or needs productization: code exists, but docs, Studio UX, integration, tests, release packaging, or operational readiness are incomplete. +- `[ ]` roadmap candidate: valuable capability not yet implemented or only represented by issue/PR/proposal. +- stale/obsolete: roadmap item is already done, superseded, or no longer matches repository direction. + +Be explicit when a conclusion is an inference from evidence rather than a direct source fact. + +## Roadmap Editing Rules + +- Keep `ROADMAP.md` product-oriented and exciting, but technically defensible. +- Preserve the main structure unless a better structure is clearly needed: + - North Star + - Capability Checklist + - Current Foundations + - Production Confidence + - Authoring Productivity + - Integrations And Ecosystem + - Observability And Operations + - Security, Identity, And Enterprise Readiness + - AI-Assisted Workflow Engineering + - Recommended Sequencing + - Maintainership Recommendations +- Update `Last refreshed` to the current date. +- Keep issue and PR links close to the claims they support. +- Prefer platform tracks over long lists of unrelated features. +- Separate what is already present from what still needs productization. +- Do not overpromise release dates. + +## High-Value Themes To Re-Evaluate Each Refresh + +- Runtime recovery, graceful shutdown, distributed execution, scheduler/message reliability +- Studio designer reliability, state machine authoring, debugging, progress/timeline, embedded components +- Studio diagnostics, structured logs, console logs, OpenTelemetry, workflow incident timelines +- Extension Platform, package manifests, Connections, Secrets, generated activities, connector SDK +- Agents, MCP/tooling, provider matrix, AI-assisted authoring +- OpenAPI activity provider, Azure Functions, Azure DevOps, Microsoft 365 and Google Workspace integrations +- Security, OIDC, tenant/role activity visibility, localization, white-label readiness +- Marketplace/plugin installation path and package maturity states + +## Recommended GitHub Commands + +```bash +gh issue view 3232 --repo elsa-workflows/elsa-core --json title,body,updatedAt,url +gh issue list --repo elsa-workflows/elsa-core --state open --limit 200 --json number,title,labels,updatedAt,url +gh issue list --repo elsa-workflows/elsa-studio --state open --limit 200 --json number,title,labels,updatedAt,url +gh issue list --repo elsa-workflows/elsa-extensions --state open --limit 200 --json number,title,labels,updatedAt,url +gh pr list --repo elsa-workflows/elsa-core --state open --limit 100 --json number,title,updatedAt,url +gh pr list --repo elsa-workflows/elsa-studio --state open --limit 100 --json number,title,updatedAt,url +gh pr list --repo elsa-workflows/elsa-extensions --state open --limit 100 --json number,title,updatedAt,url +gh release list --repo elsa-workflows/elsa-core --limit 10 +gh release list --repo elsa-workflows/elsa-studio --limit 10 +gh release list --repo elsa-workflows/elsa-extensions --limit 10 +``` + +## Publishing + +When asked to mirror the roadmap to GitHub: + +```bash +gh issue edit 3232 --repo elsa-workflows/elsa-core --body-file ROADMAP.md +``` + +After publishing, verify: + +```bash +gh issue view 3232 --repo elsa-workflows/elsa-core --json title,url,updatedAt +``` + +Final response should summarize changed themes, whether the GitHub issue was updated, and any files left modified. diff --git a/.agents/skills/elsa-roadmap-refresh/agents/openai.yaml b/.agents/skills/elsa-roadmap-refresh/agents/openai.yaml new file mode 100644 index 000000000..dd0db5ea5 --- /dev/null +++ b/.agents/skills/elsa-roadmap-refresh/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Elsa Roadmap Refresh" + short_description: "Refresh the Elsa roadmap from source and GitHub evidence" + default_prompt: "Use $elsa-roadmap-refresh to update ROADMAP.md and mirror it to the public roadmap issue." diff --git a/.specify/feature.json b/.specify/feature.json index 692144c11..d418d5e78 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/007-secrets-module" + "feature_directory": "specs/008-diagnostics-otel" } diff --git a/AGENTS.md b/AGENTS.md index 420dc3934..62610a8e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable: For additional context about technologies to be used, project structure, -shell commands, and other important information, read `specs/007-secrets-module/plan.md`. +shell commands, and other important information, read `specs/008-diagnostics-otel/plan.md`. ## Active Technologies diff --git a/Directory.Packages.props b/Directory.Packages.props index a20a362ba..b66561041 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -99,6 +99,10 @@ + + + + diff --git a/Elsa.sln b/Elsa.sln index 469ecff98..46a604192 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -347,6 +347,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.UnitTests", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Api.UnitTests", "test\unit\Elsa.Workflows.Api.UnitTests\Elsa.Workflows.Api.UnitTests.csproj", "{81CFD2E0-2E5E-4810-ADB8-A08301199166}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.OpenTelemetry", "src\modules\Elsa.Diagnostics.OpenTelemetry\Elsa.Diagnostics.OpenTelemetry.csproj", "{007E3396-34EE-40A8-B697-23D694DC9EBE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.OpenTelemetry.UnitTests", "test\unit\Elsa.Diagnostics.OpenTelemetry.UnitTests\Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj", "{D9D6C168-36CB-46EB-838A-B26C22FE9FDB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.OpenTelemetry.IntegrationTests", "test\integration\Elsa.Diagnostics.OpenTelemetry.IntegrationTests\Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj", "{B5075CE7-7332-4F78-AB3D-492D670789FB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1387,6 +1393,42 @@ Global {81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x64.Build.0 = Release|Any CPU {81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x86.ActiveCfg = Release|Any CPU {81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x86.Build.0 = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|x64.ActiveCfg = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|x64.Build.0 = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|x86.ActiveCfg = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Debug|x86.Build.0 = Debug|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|Any CPU.Build.0 = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|x64.ActiveCfg = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|x64.Build.0 = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|x86.ActiveCfg = Release|Any CPU + {007E3396-34EE-40A8-B697-23D694DC9EBE}.Release|x86.Build.0 = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|x64.Build.0 = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Debug|x86.Build.0 = Debug|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|Any CPU.Build.0 = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|x64.ActiveCfg = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|x64.Build.0 = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|x86.ActiveCfg = Release|Any CPU + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB}.Release|x86.Build.0 = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|x64.Build.0 = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Debug|x86.Build.0 = Debug|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|Any CPU.Build.0 = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|x64.ActiveCfg = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|x64.Build.0 = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|x86.ActiveCfg = Release|Any CPU + {B5075CE7-7332-4F78-AB3D-492D670789FB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1504,12 +1546,15 @@ Global {A0D906D7-9E4D-4C50-93B4-8720BB0AAFA7} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D} {695814F0-7E8F-469E-9A5A-E46759A4D67C} = {18453B51-25EB-4317-A4B3-B10518252E92} {FB7836E3-1D05-4123-B4FA-B9E722723603} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {195FD304-EC3F-4350-93C8-AFE80C4E6896} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D} {D8739449-22DC-42D4-85A4-4BA547B0B458} = {18453B51-25EB-4317-A4B3-B10518252E92} {93E9213A-694D-4AB4-870E-05E44F793133} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {09B4B78B-FE02-44E2-8667-E182AF921C54} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92} {81CFD2E0-2E5E-4810-ADB8-A08301199166} = {18453B51-25EB-4317-A4B3-B10518252E92} - {195FD304-EC3F-4350-93C8-AFE80C4E6896} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D} + {D9D6C168-36CB-46EB-838A-B26C22FE9FDB} = {18453B51-25EB-4317-A4B3-B10518252E92} + {B5075CE7-7332-4F78-AB3D-492D670789FB} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {007E3396-34EE-40A8-B697-23D694DC9EBE} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/NuGet.Config b/NuGet.Config index af5965e56..6a0629d8a 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -6,6 +6,7 @@ + @@ -19,5 +20,9 @@ + + + + diff --git a/ROADMAP.md b/ROADMAP.md index c27195307..4470b613d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Elsa Roadmap -Last refreshed: 2026-05-19 +Last refreshed: 2026-05-29 This roadmap is a product direction document, not a fixed release calendar. Elsa is developed through a mix of core maintainer work, customer-funded work, and community contributions, so sequencing can change when real-world demand changes. The intent is stable: make Elsa the most productive, dependable, and extensible workflow platform for the .NET ecosystem. @@ -38,13 +38,13 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio - [x] Distributed runtime package - [x] Structured logs - [x] Console logs -- [x] Studio structured-log and console-log foundations +- [x] Studio structured-log, console-log, and OpenTelemetry diagnostics foundations - [~] Durable structured log persistence - [~] Scheduler and message-bus foundations through Quartz, Hangfire, MassTransit, Kafka, and Azure Service Bus -- [ ] OpenTelemetry traces and default workflow metrics +- [~] OpenTelemetry diagnostics backend and default workflow metrics - [ ] Scheduler/message reliability hardening for clustered production workloads - [ ] Production security guide -- [ ] Enterprise deployment checklist +- [ ] Enterprise deployment platform and checklist ### Authoring And Studio @@ -56,8 +56,8 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio - [~] React Flow, sequence, and state-machine designer foundations - [~] Workflow organization with labels/categories/folders - [~] Workflow progress/timeline surface -- [~] Studio OIDC and identity modules -- [~] Studio diagnostics pages for structured and console logs +- [x] Studio OIDC and identity modules +- [~] Studio diagnostics pages for structured logs, console logs, and OpenTelemetry - [~] Studio alterations module - [ ] Designer reliability/regression hardening - [ ] Input validation and activity version visibility @@ -66,7 +66,7 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio - [ ] First-class workflow debugging - [ ] Designer extensibility, embedding, and white-label recipes - [ ] Tenant/role-based activity visibility -- [ ] AI-assisted workflow generation +- [~] AI-assisted workflow generation and copilot foundations ### Integrations @@ -77,11 +77,11 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio - [~] OpenAPI activity/provider foundations - [ ] Connector SDK - [ ] Marketplace/plugin installation -- [ ] Agent provider matrix and MCP/tool lifecycle +- [~] Agent provider matrix, with MCP/tool lifecycle still roadmap - [ ] Dynamic activity generation from OpenAPI, Azure Functions, registered methods, and schemas - [ ] Azure DevOps, Teams, OneDrive, SharePoint, Google Docs, and Google Sheets integration strategy - [ ] SQL authoring quality: Studio drag/drop reliability and IntelliSense -- [ ] Dapper package split and MongoDB secrets parity +- [~] Dapper package split and MongoDB secrets parity - [ ] MassTransit v9 strategy - [ ] Azure Functions / worker-service hosting guidance - [ ] WatchFileSystem and command-line automation activities @@ -93,21 +93,24 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio These are already present in the codebase and should be treated as foundations for the next roadmap slices: - Multi-targeting for `net8.0`, `net9.0`, and `net10.0` in [`src/Directory.Build.props`](src/Directory.Build.props). +- The `3.7.0` release train shipped across [Core](https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0), [Studio](https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.0), and [Extensions](https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.0) in May 2026, promoting shell integration, Studio authentication, workflow diagnostics, and extension package metadata into released foundations. - Modular core packages under [`src/modules`](src/modules), with code-first features and CShells shell features documented in [`doc/wiki/module-system.md`](doc/wiki/module-system.md). - A modular server host using CShells and Nuplane package loading in [`src/apps/Elsa.ModularServer.Web`](src/apps/Elsa.ModularServer.Web). - Runtime admin, quiescence, drain, and interrupted recovery infrastructure in [`Elsa.Workflows.Runtime`](src/modules/Elsa.Workflows.Runtime) and runtime admin endpoints in [`Elsa.Workflows.Api`](src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin). - Distributed runtime support in [`Elsa.Workflows.Runtime.Distributed`](src/modules/Elsa.Workflows.Runtime.Distributed). - Structured diagnostics with recent/live capture plus SQLite persistence in [`Elsa.Diagnostics.StructuredLogs`](src/modules/Elsa.Diagnostics.StructuredLogs) and [`Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite`](src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite). -- Raw stdout/stderr console diagnostics in [`Elsa.Diagnostics.ConsoleLogs`](src/modules/Elsa.Diagnostics.ConsoleLogs). +- Raw stdout/stderr console diagnostics in [`Elsa.Diagnostics.ConsoleLogs`](src/modules/Elsa.Diagnostics.ConsoleLogs), with the post-3.7 console pipeline now carrying workflow and activity execution context through [PR #7536](https://github.com/elsa-workflows/elsa-core/pull/7536). +- Core OpenTelemetry diagnostics are actively in productization through [PR #7537](https://github.com/elsa-workflows/elsa-core/pull/7537), which adds OTLP ingestion, bounded storage, REST APIs, SignalR live updates, collector configuration, security checks, and tests. - State machine core activity support in [`Elsa.Workflows.Core/Activities/StateMachine`](src/modules/Elsa.Workflows.Core/Activities/StateMachine). - ElsaScript DSL and blob storage integration in [`Elsa.Dsl.ElsaScript`](src/modules/Elsa.Dsl.ElsaScript) and [`Elsa.WorkflowProviders.BlobStorage.ElsaScript`](src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript). - Activity unit testing helpers and guidance in [`src/common/Elsa.Testing.Shared`](src/common/Elsa.Testing.Shared) and [`doc/qa/test-guidelines.md`](doc/qa/test-guidelines.md). - Label infrastructure in [`Elsa.Labels`](src/modules/Elsa.Labels), which is the likely backend foundation for workflow categories, tags, and folders. - Elsa Studio is already a modular Blazor product shell with workflow authoring, instance browsing, designer modules, diagnostics, authentication, localization, branding, custom elements, and early React wrapper work in [elsa-workflows/elsa-studio](https://github.com/elsa-workflows/elsa-studio). -- Studio `main` includes structured-log and console-log modules, newer React Flow/sequence/state-machine designer work, OIDC/identity infrastructure, custom elements, and an alterations module. +- Studio `3.7.0` shipped the modern authentication framework, Elsa Identity and OIDC modules, activity call-stack visualization, incident count badges, pending-instance filtering, and custom theme/DataPanel extensibility. +- Studio `main` includes structured-log, console-log, and OpenTelemetry diagnostics modules, newer React Flow/sequence/state-machine designer work, OIDC/identity infrastructure, custom elements, and an alterations module. The OpenTelemetry Studio module landed in [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834). - Elsa Extensions is an active modular integration repository with 70+ module projects in [elsa-workflows/elsa-extensions](https://github.com/elsa-workflows/elsa-extensions), targeting `net8.0`, `net9.0`, and `net10.0`. - Extensions already provide broad integration foundations: Connections, Secrets, Agents, OpenAPI, SQL/CSV/data tooling, messaging, schedulers, cloud storage, logging, webhooks, persistence providers, and external system activities. -- Recent extension work adds package manifest metadata, Kafka multitenancy/schema triggers, Quartz lifecycle/job cleanup fixes, Dapper bookmark cleanup, and other operational hardening. +- Extensions `3.7.0` adds package manifest metadata, infrastructure attributes, shell features for MassTransit/Quartz/Webhooks, Dapper and MongoDB activity execution-chain lookups, Dapper bookmark queue filtering, Kafka multitenancy/schema-trigger work, Quartz lifecycle/job cleanup fixes, and other operational hardening. The public roadmap issue remains useful history: [elsa-workflows/elsa-core#3232](https://github.com/elsa-workflows/elsa-core/issues/3232). Several items in that issue are now done in code but still open in the issue body, so this file should be considered the current working roadmap. @@ -123,12 +126,14 @@ High-value items: - Treat scheduler and messaging correctness as release-blocking infrastructure. Extensions issues around Quartz clustering and recovery ([elsa-extensions#109](https://github.com/elsa-workflows/elsa-extensions/issues/109), [elsa-extensions#101](https://github.com/elsa-workflows/elsa-extensions/issues/101)), Hangfire duplicate jobs ([elsa-extensions#121](https://github.com/elsa-workflows/elsa-extensions/issues/121)), MassTransit stimulus routing ([elsa-extensions#72](https://github.com/elsa-workflows/elsa-extensions/issues/72)), and Kafka extensibility ([elsa-extensions#134](https://github.com/elsa-workflows/elsa-extensions/issues/134)) all point to the same production theme: clustered workload behavior must be boring, observable, and customizable. - Turn the draft native background execution architecture into an implementation plan. [#7356](https://github.com/elsa-workflows/elsa-core/issues/7356) and [#7313](https://github.com/elsa-workflows/elsa-core/issues/7313) point toward an engine-owned, workflow-aware runtime that can evolve toward an actor-model abstraction without coupling Elsa to Orleans, Proto.Actor, or any single backend. - Treat persistence and migration reliability as a product feature: provider-specific migration validation, large-tenant performance tests, safer defaults, and upgrade notes that cover SQL Server, PostgreSQL, MySQL, SQLite, Oracle, and MongoDB scenarios. +- Promote the Elsa Deployment Platform PRD into scoped implementation work. [#7469](https://github.com/elsa-workflows/elsa-core/issues/7469) defines the right product boundary: declarative environment manifests, immutable deployment artifacts, dry-run validation, deployment history, and GitOps-compatible reconciliation should manage control-plane state without reconciling runtime execution state. - Maintain a security hardening track: document scripting trust boundaries, production-safe Docker posture, OIDC setup, default auth patterns, and secrets redaction. [#7096](https://github.com/elsa-workflows/elsa-core/issues/7096) is a reminder that optional code execution must be explained and guarded clearly. Recommended success measures: - rolling deploys do not leave workflows ambiguous or unrecoverable - every runtime admin action is observable and auditable +- deployments can be previewed, validated, promoted, rolled back, and audited without copying databases or reconciling runtime state - core distributed scenarios have component tests, not only unit coverage - upgrade guides call out migrations, package renames, and production security implications before users hit them @@ -142,7 +147,7 @@ High-value items: - Make designer reliability a visible workstream. Recent Studio issues show expression/input rendering regressions after 3.6 ([elsa-studio#791](https://github.com/elsa-workflows/elsa-studio/issues/791), [elsa-studio#781](https://github.com/elsa-workflows/elsa-studio/issues/781), [elsa-studio#795](https://github.com/elsa-workflows/elsa-studio/issues/795)); these should drive a regression harness for designer rendering, property editors, expression descriptors, drag/drop, and WASM/Server parity. - Make workflow progress visible to application users: a current-state/step API, timeline model, and embeddable progress component. This responds to [discussion #6012](https://github.com/elsa-workflows/elsa-core/discussions/6012) and should reuse execution logs, activity records, call-stack tracking, and real-time workflow updates. - Finish the state machine product surface. The core activity exists, but [#5085](https://github.com/elsa-workflows/elsa-core/issues/5085) should be closed only when JSON serialization, Studio authoring, docs, and examples make state machines approachable. -- Build first-class workflow testing and debugging: test runners for full workflows, breakpoint-like inspection, replay from execution logs where feasible, better failed-activity retry flows, child/descendant workflow instance navigation, and Studio affordances for fault investigation. This expands the activity test helper work that addressed [#3978](https://github.com/elsa-workflows/elsa-core/issues/3978) and matches Studio requests for child workflow visibility ([elsa-studio#152](https://github.com/elsa-workflows/elsa-studio/issues/152)) and breakpoint debugging ([elsa-studio discussion #662](https://github.com/elsa-workflows/elsa-studio/discussions/662)). +- Build first-class workflow testing and debugging: test runners for full workflows, breakpoint-like inspection, replay from execution logs where feasible, better failed-activity retry flows, child/descendant workflow instance navigation, and Studio affordances for fault investigation. The Studio `3.7.0` activity call-stack viewer is a useful foundation, but requests for child workflow visibility ([elsa-studio#152](https://github.com/elsa-workflows/elsa-studio/issues/152)) and breakpoint debugging ([elsa-studio discussion #662](https://github.com/elsa-workflows/elsa-studio/discussions/662)) still need a coherent debugging experience. - Improve Studio authoring fundamentals: input validation ([elsa-studio#15](https://github.com/elsa-workflows/elsa-studio/issues/15)), activity version indicators ([elsa-studio#284](https://github.com/elsa-workflows/elsa-studio/issues/284)), async `/dispatch` instead of blocking `/execute` where appropriate ([elsa-studio#811](https://github.com/elsa-workflows/elsa-studio/issues/811)), designer image export ([elsa-studio#585](https://github.com/elsa-workflows/elsa-studio/issues/585)), and expression evaluation controls ([elsa-studio#643](https://github.com/elsa-workflows/elsa-studio/issues/643)). - Improve designer extensibility, embedding, and white-labeling: custom activity property editors, custom list actions, embeddable designer/viewer recipes, custom elements, React wrapper direction, auth modes, base-path hosting, branding/theme, and clear Blazor/WASM/Server guidance. Community demand appears in [#4743](https://github.com/elsa-workflows/elsa-core/issues/4743), [#6685](https://github.com/elsa-workflows/elsa-core/issues/6685), [discussion #7246](https://github.com/elsa-workflows/elsa-core/discussions/7246), [elsa-studio#137](https://github.com/elsa-workflows/elsa-studio/issues/137), and [elsa-studio discussion #665](https://github.com/elsa-workflows/elsa-studio/discussions/665). - Ship user preference and UI state persistence as a Studio platform feature. [elsa-studio#703](https://github.com/elsa-workflows/elsa-studio/issues/703) already scopes theme, layout, table state, local/session storage, reset behavior, and future server profile storage. @@ -166,10 +171,10 @@ High-value items: - Create an OpenAPI activity provider that turns an OpenAPI document into typed designer activities. This is a recurring ask in [#2961](https://github.com/elsa-workflows/elsa-core/issues/2961) and [#6360](https://github.com/elsa-workflows/elsa-core/issues/6360), and it is the best foundation for a broad connector story. The existing Extensions OpenAPI work should be reconciled with this product goal. - Define a connector SDK before adding many one-off integrations. The SDK should cover authentication, secrets, generated activities, testing, metadata, versioning, packaging, Studio property editors, and manifest-driven installation. - Continue marketplace and plugin infrastructure. The Nuplane modular server, sample package, extension package manifest metadata, and [#7310](https://github.com/elsa-workflows/elsa-core/issues/7310) point to a compelling future where Elsa distributions can install safe custom modules without users maintaining a separate host app. -- Promote Agents to a strategic integration lane: provider matrix, OpenAI and Claude PR resolution, local/OpenRouter/custom endpoints, MCP/tool lifecycle, tool selection in Studio, and auditability. [elsa-extensions#58](https://github.com/elsa-workflows/elsa-extensions/issues/58), [elsa-extensions#98](https://github.com/elsa-workflows/elsa-extensions/pull/98), and [elsa-extensions#63](https://github.com/elsa-workflows/elsa-extensions/pull/63) show both demand and active work. +- Promote Agents to a strategic integration lane: provider matrix, OpenAI and Claude PR resolution, local/OpenRouter/custom endpoints, MCP/tool lifecycle, tool selection in Studio, and auditability. The Extensions source already contains Agents, OpenAI, Azure OpenAI, persistence, API, and Studio modules, while [elsa-extensions#58](https://github.com/elsa-workflows/elsa-extensions/issues/58), [elsa-extensions#98](https://github.com/elsa-workflows/elsa-extensions/pull/98), and [elsa-extensions#63](https://github.com/elsa-workflows/elsa-extensions/pull/63) show the provider matrix and Claude/OpenAI work are still actively moving. - Prioritize enterprise productivity integrations by leverage: Azure DevOps has a concrete implementation path ([elsa-extensions#124](https://github.com/elsa-workflows/elsa-extensions/issues/124), [elsa-extensions#125](https://github.com/elsa-workflows/elsa-extensions/pull/125)); Teams, OneDrive, SharePoint, Google Docs, and Google Sheets should follow a shared connector model rather than separate bespoke designs. - Treat dynamic activity generation as a platform primitive. Azure Functions ([elsa-extensions#39](https://github.com/elsa-workflows/elsa-extensions/issues/39)) and registered-method activities ([elsa-extensions#48](https://github.com/elsa-workflows/elsa-extensions/issues/48)) both point toward schema/method-driven activity generation that can also serve OpenAPI, SDK-generated connectors, and internal enterprise APIs. -- Improve data and automation authoring quality: SQL drag/drop reliability ([elsa-extensions#79](https://github.com/elsa-workflows/elsa-extensions/issues/79)), SQL IntelliSense ([elsa-extensions#88](https://github.com/elsa-workflows/elsa-extensions/issues/88)), Dapper package splitting ([elsa-extensions#131](https://github.com/elsa-workflows/elsa-extensions/issues/131), [elsa-extensions#132](https://github.com/elsa-workflows/elsa-extensions/pull/132)), MongoDB secrets parity ([elsa-extensions#126](https://github.com/elsa-workflows/elsa-extensions/issues/126)), WatchFileSystem ([elsa-extensions#90](https://github.com/elsa-workflows/elsa-extensions/issues/90)), and command-line activities ([elsa-extensions#36](https://github.com/elsa-workflows/elsa-extensions/issues/36)). +- Improve data and automation authoring quality: SQL result typing ([elsa-extensions#153](https://github.com/elsa-workflows/elsa-extensions/issues/153), [elsa-extensions#154](https://github.com/elsa-workflows/elsa-extensions/pull/154)), SQL drag/drop reliability ([elsa-extensions#79](https://github.com/elsa-workflows/elsa-extensions/issues/79)), SQL IntelliSense ([elsa-extensions#88](https://github.com/elsa-workflows/elsa-extensions/issues/88)), Dapper package splitting ([elsa-extensions#131](https://github.com/elsa-workflows/elsa-extensions/issues/131), [elsa-extensions#132](https://github.com/elsa-workflows/elsa-extensions/pull/132)), MongoDB secrets parity ([elsa-extensions#126](https://github.com/elsa-workflows/elsa-extensions/issues/126)), WatchFileSystem ([elsa-extensions#90](https://github.com/elsa-workflows/elsa-extensions/issues/90)), and command-line activities ([elsa-extensions#36](https://github.com/elsa-workflows/elsa-extensions/issues/36)). - Resolve the MassTransit strategy after the v9 licensing change. [discussion #6583](https://github.com/elsa-workflows/elsa-core/discussions/6583) raises a practical ecosystem risk; Elsa should either provide a clean split or reduce dependency weight through a smaller messaging abstraction. - Clarify Azure Functions and worker-service hosting patterns. [discussion #4707](https://github.com/elsa-workflows/elsa-core/discussions/4707) and [discussion #7420](https://github.com/elsa-workflows/elsa-core/discussions/7420) show demand for non-traditional hosts, Windows services, and serverless-adjacent deployments. - Add data movement and streaming workflow primitives. [#4809](https://github.com/elsa-workflows/elsa-core/issues/4809) frames this as datasets, linked services, transforms, and stream-oriented processing inspired by Azure Data Factory and stream analytics. @@ -188,9 +193,9 @@ Recommended success measures: High-value items: -- Finish the diagnostics trilogy: structured logs, console logs, and an explicit OpenTelemetry boundary. Structured and console logs now exist; OpenTelemetry traces/metrics need a current module story because [PR #5810](https://github.com/elsa-workflows/elsa-core/pull/5810) previously introduced a module, [#5988](https://github.com/elsa-workflows/elsa-core/issues/5988) asks for default metrics, and the current repo no longer contains an `Elsa.OpenTelemetry` module. -- Add default workflow semantic metrics: started, resumed, suspended, faulted, completed, active, activity executed/faulted, queue depth, recovery count, drain count, and dispatch latency. Align with OpenTelemetry semantic convention work where possible. -- Build Studio diagnostics pages that are useful under pressure: live console, structured logs, trace/metric links, workflow incident timelines, source health, dropped-event counters, source selection, filters, URL state, export/copy affordances, and direct deep links to workflow instances. +- Finish the diagnostics trilogy: structured logs, console logs, and OpenTelemetry. Structured and console logs now exist; Studio `main` has an OpenTelemetry diagnostics page from [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834), and Core has an active backend PR for OTLP ingestion, bounded stores, REST endpoints, SignalR live updates, collector configuration, and tests in [#7537](https://github.com/elsa-workflows/elsa-core/pull/7537). The remaining product work is to merge, release, document, and correlate this with workflow incidents. +- Add default workflow semantic metrics: started, resumed, suspended, faulted, completed, active, activity executed/faulted, queue depth, recovery count, drain count, and dispatch latency. [#5988](https://github.com/elsa-workflows/elsa-core/issues/5988) remains the durable demand signal, while [#7537](https://github.com/elsa-workflows/elsa-core/pull/7537) supplies the first current module boundary. +- Build Studio diagnostics pages that are useful under pressure: live console, structured logs, OpenTelemetry traces/metrics/logs, workflow incident timelines, source health, dropped-event counters, source selection, filters, URL state, export/copy affordances, and direct deep links to workflow instances. - Make execution history easier to reason about: distinguish faulted, interrupted, cancelled, crash-recovered, retried, and operator-modified workflows consistently across API, Studio, logs, and metrics. - Connect diagnostics to workflow navigation: trace/span IDs, log source IDs, child workflow chains, alterations, runtime admin actions, and recovery actions should be correlated instead of presented as isolated tables. @@ -206,7 +211,7 @@ Recommended success measures: High-value items: -- Publish canonical OIDC recipes for Blazor Server, WASM, separate server/studio, and all-in-one hosts. [#7181](https://github.com/elsa-workflows/elsa-core/issues/7181) shows both implementation and documentation demand. +- Publish canonical OIDC recipes for Blazor Server, WASM, separate server/studio, all-in-one hosts, and reverse-proxy sub-path deployments. Studio `3.7.0` shipped the modern authentication modules, [#7181](https://github.com/elsa-workflows/elsa-core/issues/7181) shows Core-side implementation and documentation demand, and [elsa-studio#809](https://github.com/elsa-workflows/elsa-studio/pull/809) shows sub-path redirect URI handling is still being hardened. - Provide a production security guide: API keys, JWT/OIDC, default admin bootstrap, scripting trust levels, C# expression risks, Docker demo boundaries, secret masking, tenant isolation, and permission design. - Expand authorization coverage tests around workflow instances, runtime admin, diagnostics, labels, tenants, and HTTP endpoint activities. - Add Studio governance controls: tenant/role-based activity visibility, permission-aware menus/routes, feature-gated modules, and clear behavior for hidden activities in existing workflow definitions. [elsa-studio#584](https://github.com/elsa-workflows/elsa-studio/issues/584) captures the authoring side of this enterprise need. @@ -226,10 +231,10 @@ Recommended success measures: High-value items: -- Build AI-assisted workflow generation that produces multiple visible activities from intent rather than hiding logic in one script activity. This direction is proposed in [discussion #7367](https://github.com/elsa-workflows/elsa-core/discussions/7367). +- Build AI-assisted workflow generation that produces multiple visible activities from intent rather than hiding logic in one script activity. This direction is proposed in [discussion #7367](https://github.com/elsa-workflows/elsa-core/discussions/7367), and [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) now provides an active Weaver AI Copilot foundation with AI abstractions, provider/session contracts, chat/tool endpoints, audit events, proposal persistence, EF Core storage, and integration/unit tests. - Provide an Elsa MCP/tooling surface for reading, validating, editing, and explaining workflow JSON/ElsaScript. This would make Elsa a strong fit for AI-enabled .NET development environments. - Align AI authoring with the Extensions Agents work: provider abstractions, MCP tools, OpenAI/Claude/local model support, tool approval, secrets handling, and Studio UX should share contracts instead of creating parallel AI stacks. -- Build a Studio copilot only after the authoring contracts are stable: validation, generated activity metadata, designer APIs, diagnostics links, and test scaffolding should be available before AI generation becomes prominent. [elsa-studio#553](https://github.com/elsa-workflows/elsa-studio/issues/553) has clear community signal and maintainer interest, but it should be an accelerator on top of reliable authoring. +- Build a Studio copilot only after the authoring contracts are stable: validation, generated activity metadata, designer APIs, diagnostics links, and test scaffolding should be available before AI generation becomes prominent. [elsa-studio#553](https://github.com/elsa-workflows/elsa-studio/issues/553) has clear community signal and maintainer interest, while [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) is still Core/backend-oriented and should not be treated as a complete Studio product surface. - Add "explain this workflow", "find risky activities", "suggest tests", and "generate migration notes" capabilities backed by workflow graph metadata. - Pair AI generation with validation: generated workflows should include test scaffolds, required input/output definitions, secrets handling, and clear review diffs. @@ -245,16 +250,17 @@ Near term: 1. Finish runtime confidence work: graceful shutdown remaining tasks, recovery clarity, distributed runtime regressions, security documentation, and OIDC recipes. 2. Stabilize Studio authoring: designer regression harness, input/property-editor fixes, async dispatch/run UX, state machine Studio/docs completion, and a clear UI framework direction. -3. Make workflow authoring easier to manage at scale: organization, search, progress/timeline APIs, testing docs, and user preference/table-state persistence. -4. Reconcile shipped extension foundations with roadmap status: package manifests, Connections/Secrets, OpenAPI, Agents, schedulers, messaging, and integration maturity labels. +3. Complete the diagnostics trilogy: merge/release the Core OpenTelemetry backend, connect it to the Studio OpenTelemetry page, document collector setup, and correlate traces/logs/metrics with workflow incidents. +4. Make workflow authoring easier to manage at scale: organization, search, progress/timeline APIs, testing docs, and user preference/table-state persistence. +5. Reconcile shipped extension foundations with roadmap status: package manifests, Connections/Secrets, OpenAPI, Agents, schedulers, messaging, and integration maturity labels. Mid term: -1. OpenAPI activity provider plus connector SDK. -2. Extension Platform: generated activities, contribution harnesses, Studio extension recipes, package manifest maturity, and marketplace/plugin installation path built on Nuplane and shell features. -3. OpenTelemetry module boundary, default workflow metrics, and Studio trace/log/incident correlation. +1. Promote [#7469](https://github.com/elsa-workflows/elsa-core/issues/7469) from deployment-platform PRD into an implementation spec covering manifests, immutable artifacts, dry-run validation, deployment history, and API/CLI surfaces. +2. OpenAPI activity provider plus connector SDK. +3. Extension Platform: generated activities, contribution harnesses, Studio extension recipes, package manifest maturity, and marketplace/plugin installation path built on Nuplane and shell features. 4. Workflow debugging, replay-oriented incident analysis, child workflow navigation, and operator recovery UX. -5. Agents provider matrix and MCP/tool lifecycle, with Studio UX that keeps generated workflows inspectable. +5. Weaver AI Copilot productization and Agents provider matrix/MCP lifecycle, with Studio UX that keeps generated workflows inspectable. Longer term: diff --git a/doc/website/product-website-feature-source.md b/doc/website/product-website-feature-source.md new file mode 100644 index 000000000..aa6ffea8e --- /dev/null +++ b/doc/website/product-website-feature-source.md @@ -0,0 +1,438 @@ +# Elsa Product Website Feature Source + +This document is source material for a product website aimed at developers, architects, and CTOs evaluating workflow engines. It was compiled from: + +- `elsa-core`: `/Users/sipke/Projects/Elsa/elsa-core` +- `elsa-studio`: `/Users/sipke/Projects/Elsa/elsa-studio` +- `elsa-extensions`: `/Users/sipke/Projects/Elsa/elsa-extensions-investigation` (`Elsa.Extensions.sln`) + +Use this as the comprehensive feature inventory. The website should not show every item with equal weight; the final section contains instructions for Lovable AI to select and present the strongest public-facing set. + +## Core Workflow Engine + +### Embeddable .NET Workflow Engine + +Elsa runs inside any .NET application and gives teams a workflow runtime they can embed directly into their own products, services, portals, back-office systems, and integration platforms. Workflows can be hosted in ASP.NET Core, run from worker services, or exposed through Elsa's server-style API surface. This matters to engineering teams that need workflow automation without handing orchestration to a black-box SaaS product. + +### Code-First, Designer-First, and JSON Workflows + +Workflows can be authored in C#, visually in Elsa Studio, or represented as JSON. Developers can keep critical workflows close to source control, product teams can iterate visually, and platforms can import/export workflow definitions through APIs or storage providers. This gives organizations a practical path from developer-owned automation to collaborative workflow operations. + +### Long-Running and Short-Running Workflows + +Elsa supports short-running automations as well as durable, long-running business processes that wait for external events, approvals, timers, or callbacks. The runtime persists workflow state, bookmarks, triggers, execution records, and incidents, allowing processes to pause and resume across application restarts and infrastructure events. + +### Rich Control Flow + +Elsa includes core control-flow activities such as `Sequence`, `If`, `Switch`, `For`, `ForEach`, `While`, `Parallel`, `Fork`, `Break`, `End`, `Finish`, `Complete`, and `Fault`. Teams can model simple linear automations, branching business rules, parallel work, loops, and failure paths without building custom orchestration logic for every process. + +### Flowcharts, Sequences, and State Machines + +Elsa's engine includes flowchart, sequence, and state machine execution models. Flowcharts support visual business-process modeling with joins, forks, switches, and token-based execution semantics. Sequences provide straightforward procedural automation. State machines model named states and trigger-driven transitions for processes that are naturally lifecycle-oriented. + +### Triggers, Bookmarks, and Event-Driven Resumption + +Workflows can start from triggers and resume from bookmarks. This is the foundation for event-driven automation: HTTP requests, timers, messages, webhooks, user actions, or external systems can start or continue the right workflow instance. Bookmarks are indexed by the runtime so waiting workflows can be found and resumed efficiently. + +### Background Dispatch and Runtime Workers + +The workflow runtime includes background dispatch for workflows, stimuli, tasks, and activities. This allows requests to hand off work and return while Elsa continues processing asynchronously. It also supports patterns where one workflow dispatches another workflow without coupling the caller to immediate execution. + +### Transactional Dispatch Outbox + +Elsa can use a transactional outbox for workflow dispatches created from inside a running workflow. The outbox prevents silent loss when a process crashes between persisting workflow state and enqueueing a dispatch command. Delivery is at-least-once, with idempotency support for child workflow dispatches. + +### Graceful Shutdown and Recovery + +The runtime includes node-local quiescence, drain orchestration, ingress-source pause/resume behavior, and interrupted workflow recovery. This gives operators a controlled way to stop servers without accepting new work mid-drain, and it helps recover work that was interrupted by shutdowns or crashes. + +### Runtime Administration APIs + +Elsa exposes runtime administration endpoints for status, pause, resume, and force-drain operations. Operators can inspect and control workflow runtime state through API-driven automation rather than relying only on process-level controls. + +### Dead-Lettered Bookmark Queue Management + +Bookmark queue items that expire or exceed delivery attempts can be moved to a dead-letter store. APIs allow operators to inspect, replay, and delete dead-lettered items. This is valuable for production workflows where failed event delivery should be visible and recoverable. + +### Workflow Versioning and Migration + +Elsa supports workflow definition versioning and API-driven migration. Teams can evolve workflows over time, publish new versions, and manage compatibility between definitions and running instances. + +### Workflow Management APIs + +Elsa has API surfaces for definitions, instances, execution journals, activity executions, runtime administration, triggers, bookmarks, and related workflow management concerns. This allows workflow capabilities to be embedded into custom platforms and integrated with existing developer or operator tooling. + +### Custom Activities + +Developers can add first-party or product-specific activities with strongly typed inputs and outputs, descriptors, UI hints, and designer integration. This is the extension point that turns Elsa from a generic workflow engine into a domain automation platform for a specific business. + +### Activities as Composable Building Blocks + +Elsa models workflow work as activities. Activities can be composed into higher-level workflows, and workflows themselves can be exposed as callable units. This supports reuse and standardization across teams. + +### Dynamic Activity Handling + +Elsa includes dynamic and missing-activity handling, allowing workflow definitions and designer/API surfaces to evolve while maintaining controlled behavior when activity types are not available. + +### Expressions and Scripting + +Elsa supports dynamic expressions through C#, JavaScript, Python, and Liquid expression modules. Activities can evaluate runtime data, inputs, variables, outputs, workflow context, and external values without forcing every transformation into compiled code. + +### Workflow Inputs, Outputs, Variables, and Correlation + +Elsa includes typed input/output models, variables, workflow input, activity outputs, correlation, and runtime state capture. This gives workflows structured data flow and allows external systems to correlate events with the right workflow instance. + +### Persistence-Agnostic Architecture + +Elsa is persistence-agnostic and includes persistence packages for EF Core providers such as SQLite, SQL Server, PostgreSQL, MySQL, and Oracle. Extension packages also add Dapper and MongoDB persistence options. This lets teams choose infrastructure that fits existing operational standards. + +### Multi-Tenancy and Identity + +Elsa includes identity, authorization, tenant support, and tenant-aware runtime behavior. This is important for SaaS platforms and internal platforms where different teams, customers, or environments need isolated workflow definitions, instances, and permissions. + +### Secrets Management + +Elsa includes a secrets module design with in-memory, configuration-backed, and encrypted Elsa-managed stores. Secrets are intended for use by activities and integrations that need credentials without exposing sensitive values in workflow definitions. + +### Labels, Key Values, SAS Tokens, and Supporting Infrastructure + +Elsa includes supporting modules such as labels, key-value storage, SAS tokens, caching, mediator infrastructure, and feature/module composition. These are less visible as standalone marketing features but help developers build complete workflow-enabled products. + +## Operations and Observability + +### Workflow Execution Journal + +Elsa stores workflow and activity execution records that can be exposed through APIs and Studio. Operators and developers can inspect what ran, what failed, and how a workflow reached its current state. + +### Structured Log Capture + +`Elsa.Diagnostics.StructuredLogs` captures semantic `ILogger` events from Elsa hosts, redacts sensitive data, stores recent records, exposes REST query endpoints, and streams live events to Studio over SignalR. Records include rendered messages, templates, properties, scopes, exceptions, source metadata, trace/span IDs, workflow context, tenant, and correlation fields. + +### Durable Structured Log Persistence + +Structured logs default to a bounded in-memory store, with relational/SQLite persistence available for durable storage. The design includes bounded write queues, batch writes, retention services, storage diagnostics, and dropped-write reporting. + +### Console Log Streaming + +`Elsa.Diagnostics.ConsoleLogs` captures raw stdout and stderr, preserves the original console output destination, redacts content before buffering or streaming, and exposes recent/live console lines through REST and SignalR. This helps operators diagnose container or process-level output without leaving the workflow management experience. + +### OpenTelemetry Workflow Diagnostics + +The extensions repository includes `Elsa.OpenTelemetry`, and Studio includes an OpenTelemetry diagnostics module. The intent is workflow-aware trace, metric, and log exploration where Core remains responsible for ingestion, redaction, storage, permissions, and API contracts while Studio presents normalized diagnostics views. + +### Configurable Workflow Logging Framework + +The extensions repository includes a logging framework with configurable sinks such as console and Serilog, appsettings-based configuration, custom sink factories, and a `Log` activity for emitting structured log entries from workflows. + +### Source and Storage Diagnostics + +Diagnostics modules track source metadata, source health, storage pressure, dropped durable writes, dropped console lines, and live subscription state. These details are useful in clustered or high-volume environments where observability itself needs operational guardrails. + +## Elsa Studio + +### Modular Workflow Management UI + +Elsa Studio is a modular Blazor application framework built with MudBlazor for managing workflows and related entities. It can run as Blazor Server, Blazor WebAssembly, hosted WebAssembly, or custom elements depending on host requirements. + +### Visual Workflow Designer + +Studio provides a drag-and-drop workflow designer for authoring workflows visually. It integrates with Elsa activity descriptors and UI hints so custom activities can appear in the designer alongside built-in activities. + +### Workflow Definition and Instance Management + +Studio includes workflow modules for managing definitions, viewing workflow state, and working with execution/runtime data exposed by the backend. This turns the engine from a library into an operator-facing workflow platform. + +### Real-Time Workflow and Diagnostics Updates + +Studio uses SignalR-backed clients for live updates such as structured logs and console logs. Authentication providers can configure SignalR connections consistently, allowing live operational views to work with JWT, Elsa Identity, or OpenID Connect setups. + +### Structured Logs Viewer + +Studio's structured log viewer loads recent semantic logs, subscribes to live updates, supports source selection, filtering, pause/reconnect/clear behaviors, and exposes detailed log inspection with properties, scopes, exceptions, trace/span fields, tenant, workflow, correlation, and raw JSON. + +### Console Logs Viewer + +Studio includes a diagnostics console page for raw stdout/stderr streaming. Navigation is feature-gated by backend capability and permission, with unavailable and unauthorized states handled in the UI. + +### OpenTelemetry Diagnostics Viewer + +Studio includes a diagnostics OpenTelemetry shell at `/diagnostics/opentelemetry`, with expected views for resource search, trace search/detail, metrics, OTLP logs, storage diagnostics, and collector configuration. + +### Authentication Options + +Studio supports Elsa Identity and OpenID Connect authentication modules across Blazor Server and WebAssembly hosting models. The architecture includes token providers, automatic refresh, backend API token acquisition, SignalR authentication configuration, and custom unauthorized UI rendering. + +### Localization + +Studio includes localization modules for Blazor Server and WebAssembly. Applications can register localization providers, configure supported cultures, and translate the Studio UI for international teams. + +### Extensible Studio Modules + +Studio modules include dashboard, workflows, designer, labels, environments, security, UI hints, diagnostics, localization, authentication, secrets, workflow contexts, and agents. The modular architecture lets product teams compose the Studio experience that fits their platform. + +### Performance-Oriented Designer + +Recent designer work includes batched activity size calculation, activity size caching, and conditional size updates to improve large workflow loading. This matters for teams building complex workflows with many activities. + +### React Wrapper and Custom Embedding + +Studio includes wrapper projects and host variants that support embedding Elsa workflow design experiences into different frontends, including React-oriented integration samples. + +## Built-In Core Capabilities and Activities + +### HTTP Workflows + +Elsa includes HTTP activities for exposing endpoints, sending HTTP requests, downloading HTTP files, and writing HTTP responses or file responses. This makes it natural to build API-triggered workflows, webhook receivers, integration endpoints, and workflow-backed HTTP services. + +### Scheduling and Timers + +Scheduling activities include cron, delay, start-at, and timer behavior. Workflows can run on schedules, pause until a specific time, or wait for durations without custom background job code. + +### Alterations + +Elsa includes alteration modules for applying operational changes to workflow instances. Extensions add a MassTransit-backed alteration background runner for broker-backed, more resilient background processing. + +### Resilience + +Elsa includes resilience modules for workflow execution and activity behavior. These support more robust process automation in failure-prone distributed systems. + +### Workflow Providers + +Elsa includes workflow providers for loading definitions from blob storage and ElsaScript sources. This allows teams to source workflows from external storage rather than only from the management database. + +### ElsaScript DSL + +The ElsaScript DSL module provides a workflow definition scripting format, useful for teams that want text-based workflow authoring and source-controlled definitions. + +## Extensions Ecosystem + +### AI Agents and Multi-Agent Workflows + +The extensions repository includes an Elsa Agents module built on Microsoft's Agent Framework/Semantic Kernel Agents. It supports configuration-based agents, code-first agents, multi-agent workflows, tool calling, persistent conversations/state, REST APIs, persistence packages, OpenAI and Azure OpenAI providers, workflow activities, and Studio UI. + +### Connections Framework + +The extensions repository includes connection modules for registering, describing, and applying connection information to activities. This is a foundation for reusable, centrally managed integration credentials and connection settings. + +### SQL Automation + +The SQL extension provides `SqlQuery`, `SqlCommand`, and `SqlSingleValue` activities, with providers for MySQL, PostgreSQL, SQLite, and SQL Server. It supports parameterization of workflow inputs, outputs, variables, activity context, execution context, workflow properties, POCOs, dictionaries, arrays, lists, and JSON objects, plus SQL syntax highlighting in Studio. + +### CSV Processing + +The CSV extension provides a `ReadCsv` activity that can read byte arrays, streams, strings, URLs, and uploaded files. It supports delimiters, header detection, dictionary output, and optional strongly typed mapping through CsvHelper. + +### Email Automation + +The extensions repository includes an email module with a `SendEmail` activity. This is a common workflow primitive for notifications, approvals, and operational alerts. + +### Slack Automation + +The Slack extension includes activities for channels, messages, reminders, reactions, stars, files, users, search, and event triggers. Workflows can create/update/delete messages, pin/unpin messages, upload files, manage reminders, list/search users and channels, and react to Slack events. + +### GitHub DevOps Automation + +The GitHub extension includes activities for issues, comments, labels, pull requests, repositories, users, organizations, milestones, releases, gists, branch search, GraphQL queries, and webhook-style triggers for issues, pull requests, and branches. Tokens should be stored securely through Elsa secrets. + +### Service Bus and Messaging + +Extensions include Azure Service Bus, Kafka, and MassTransit packages. Activities can send/publish/produce messages and react to received messages, enabling event-driven workflow automation across common messaging infrastructure. + +### Distributed Runtime with Proto.Actor + +The extensions repository includes actor-based runtime packages, including Proto.Actor-backed workflow runtime and actor modules. This extends Elsa's throughput and distributed execution story beyond the base runtime. + +### Distributed Caching + +Extensions include distributed caching modules over MassTransit and Proto.Actor. These support clustered scenarios where local in-memory assumptions are not enough. + +### Scheduling Backends + +Extensions include Hangfire and Quartz scheduling packages, with Quartz EF Core providers for MySQL, PostgreSQL, SQL Server, and SQLite. This lets teams use mature scheduling infrastructure behind Elsa's workflow scheduling concepts. + +### Persistence Extensions + +Extensions add persistence options such as Dapper, MongoDB, and Elasticsearch. These broaden deployment choices for organizations that standardize on specific storage technologies. + +### File and Blob Storage + +Extensions include Azure Storage and local file storage activities such as upload blob, save file, and open file. Workflows can move files through storage systems as part of business processes. + +### HTTP OpenAPI and Webhooks + +Extensions include OpenAPI and webhook modules for HTTP workflows. These help expose, document, and consume HTTP-driven workflow integration surfaces. + +### Compression and I/O + +Extensions include I/O and compression modules, including ZIP archive creation. These are useful in document, file transfer, and batch processing workflows. + +### Telnyx Telephony Automation + +The Telnyx extension includes call-control activities and webhook triggers such as answer call, hang up, dial, bridge calls, speak text, gather DTMF input, get call status, and react to call hangup or general webhook events. + +### Orchard Core Integration + +The extensions repository includes Orchard Core integration modules, useful for teams building CMS-backed or modular application platforms that also need workflow automation. + +### Retention + +The retention extension includes cleanup strategies and collectors for workflow-related records. This supports operational lifecycle management for workflow data. + +### Workflow Contexts + +Workflow context modules let activities set and use contextual parameters associated with workflow context providers. Studio modules add UI support for workflow context activity settings. + +### Drop-Ins + +Drop-in modules suggest a packaging and extension mechanism for pluggable capabilities. These are relevant to teams building modular workflow platforms. + +### Planned and Roadmap Extensions + +The extensions README lists planned or in-development integrations for Telegram, Discord, Microsoft Teams, Gmail, Outlook, Google Calendar, Microsoft Calendar, Google Drive, OneDrive, Azure Storage, Dropbox, Azure DevOps, GitLab, Jenkins, Datadog, cloud functions, CRM systems, payment providers, AI providers, video platforms, and industrial protocols such as OPC UA, Modbus, and MQTT Sparkplug. These should be presented carefully as ecosystem direction unless confirmed released. + +## Architectural Selling Points + +### Modular Feature System + +Elsa uses explicit feature/module registration, dependency declarations, and extension methods. Teams can add only the capabilities they need, keep hosts lean, and build their own modules using the same conventions as first-party packages. + +### Provider-Neutral Boundaries + +Many Elsa capabilities are expressed behind provider contracts: persistence, structured logs, scheduling, workflow providers, SQL clients, authentication, storage, logging sinks, and diagnostics. This gives architects flexibility to standardize on their own infrastructure. + +### API-First and Studio-Ready + +Core capabilities are exposed through APIs and then surfaced in Studio. This means teams can automate through APIs, use Studio as an operator interface, or build their own UI on top of the same contracts. + +### Production Operations Mindset + +The runtime includes operational concepts such as graceful shutdown, recovery scanning, dead-letter queues, storage diagnostics, redaction, bounded buffers, authorization, tenant-aware behavior, and runtime admin endpoints. These are the details architects and CTOs look for when evaluating production fit. + +### Developer Extensibility + +Elsa is designed for developers to extend: custom activities, expression providers, API endpoints, stores, workflow providers, runtime ingress sources, Studio modules, authentication providers, log sinks, SQL clients, agent providers, and integration modules all have clear extension paths. + +### Open Source and Ecosystem Friendly + +Elsa is distributed through NuGet packages, Docker images, GitHub repositories, and community channels. The ecosystem includes Core, Studio, Extensions, Templates, package catalogs, and professional support options. + +## Recommended Website Feature Selection + +For the public product website, do not display all features as a flat checklist. Select the most compelling features for evaluators: + +1. Visual workflow designer plus embeddable .NET runtime. +2. Code-first, visual, and JSON authoring. +3. Long-running durable workflows with triggers and bookmarks. +4. Rich control flow, including flowcharts, sequences, state machines, and parallel execution. +5. API-first workflow management and runtime administration. +6. Persistence choices and production-ready storage boundaries. +7. Observability: execution journal, structured logs, console logs, OpenTelemetry direction. +8. Security and platform fit: identity, authorization, multi-tenancy, secrets. +9. Extensibility: custom activities, modules, expression providers, Studio modules. +10. Integration ecosystem: HTTP, scheduling, SQL, Slack, GitHub, messaging, files, AI agents. +11. Operational readiness: graceful shutdown, recovery, dead-letter handling, durable dispatch outbox. +12. Studio as a modular operations console, not only a designer. + +## Suggested Public Website Copy + +### Hero + +**Title:** Workflow automation for .NET teams + +**Description:** Elsa is an open-source workflow engine and visual workflow platform for building long-running, event-driven, and integration-heavy processes inside your own .NET applications. + +### Feature: Build Workflows Your Way + +**Title:** Code-first, visual, or JSON workflows + +**Description:** Define workflows in C#, design them visually in Elsa Studio, or store them as JSON for API-driven platforms. Developers can keep critical automation close to source control while business and operations teams collaborate through a visual designer. + +### Feature: Durable Event-Driven Runtime + +**Title:** Long-running workflows that wait, resume, and recover + +**Description:** Model processes that pause for timers, webhooks, approvals, messages, and external events. Elsa persists workflow state, indexes triggers and bookmarks, and resumes the right instance when the next signal arrives. + +### Feature: Visual Studio Experience + +**Title:** A modular Studio for designers and operators + +**Description:** Elsa Studio provides a Blazor-based workflow designer, management UI, diagnostics views, authentication modules, localization, and extensible modules for custom platform experiences. + +### Feature: Integration-Ready + +**Title:** Connect workflows to real systems + +**Description:** Use built-in and extension activities for HTTP, scheduling, SQL, CSV, email, Slack, GitHub, Azure Service Bus, Kafka, files, storage, telephony, and AI agents. Add custom activities when your domain needs first-class workflow building blocks. + +### Feature: Production Operations + +**Title:** Built for production workflow operations + +**Description:** Elsa includes runtime administration, execution journals, graceful shutdown, recovery scanning, dead-letter handling, structured log capture, console streaming, redaction, permissions, and storage diagnostics. + +### Feature: Architecture Fit + +**Title:** Modular, provider-neutral, and extensible + +**Description:** Compose only the features you need, plug in your persistence and infrastructure choices, and extend the platform with custom modules, activities, expression providers, APIs, authentication providers, and Studio screens. + +### Feature: Observability + +**Title:** See what workflows are doing + +**Description:** Inspect workflow execution history, activity records, structured logs, raw console output, source metadata, trace and span correlation, workflow context, tenant, and correlation fields from API and Studio diagnostics views. + +### Feature: Security and Multi-Tenancy + +**Title:** Designed for secure platforms + +**Description:** Elsa includes identity, authorization, tenant-aware behavior, secrets management, authenticated SignalR diagnostics, and permission-gated APIs for teams building SaaS, internal platforms, and regulated automation systems. + +### Feature: AI Workflow Orchestration + +**Title:** Orchestrate agents inside workflows + +**Description:** Elsa Agents brings configuration-based and code-first AI agents, multi-agent workflows, tool calling, stateful conversations, OpenAI and Azure OpenAI providers, APIs, persistence, and Studio integration into the workflow model. + +## Lovable AI Instructions + +Use the source inventory above to design a product website section for Elsa Workflows. The target audience is developers, software architects, engineering managers, and CTOs evaluating workflow engines for .NET platforms. + +Prioritize clarity, technical credibility, and enterprise-readiness over hype. Select the most interesting features rather than displaying the complete inventory. The site should make Elsa feel like a serious embeddable workflow platform with a strong visual designer, production runtime, and integration ecosystem. + +Recommended structure: + +1. Hero: "Workflow automation for .NET teams" with a concise description about open-source, embeddable, event-driven workflow automation. +2. Primary feature grid: 6-8 cards covering authoring flexibility, durable runtime, visual Studio, integrations, operations, observability, security, and extensibility. +3. Architecture band: explain modular/provider-neutral design, custom activities, APIs, and persistence choices. +4. Integrations band: show categories rather than a huge checklist. Use "HTTP & APIs", "Scheduling", "Databases", "Messaging", "DevOps", "Communication", "Files & Storage", and "AI Agents". +5. Operations band: highlight graceful shutdown, recovery, dead-letter queues, runtime admin, logs, and diagnostics. +6. CTA: invite visitors to try the Docker image, explore docs, or view GitHub. + +Use these exact card titles unless the layout requires shorter labels: + +- Build Workflows Your Way +- Durable Event-Driven Runtime +- Visual Workflow Studio +- Integration-Ready Automation +- Production Workflow Operations +- Observable by Design +- Secure Multi-Tenant Platform +- Extensible by Developers + +Use concise card descriptions derived from the suggested public copy. Avoid listing unreleased/planned extensions as if they are shipped. If planned ecosystem items are mentioned, label them as roadmap or ecosystem direction. + +Visual direction: + +- Use product screenshots or workflow/designer imagery prominently. +- Keep the design technical, polished, and readable. +- Avoid vague abstract SaaS imagery. +- Favor architecture diagrams, workflow nodes, log/detail panels, integration icons, and code snippets. +- Make the first viewport communicate that Elsa is both a workflow engine and a visual workflow platform. + +Tone: + +- Direct, practical, developer-first. +- Avoid exaggerated claims such as "effortless", "revolutionary", or "no-code magic". +- Emphasize control, extensibility, operational readiness, and .NET-native integration. diff --git a/doc/wiki/opentelemetry-workflows.md b/doc/wiki/opentelemetry-workflows.md index f41449514..3896934cf 100644 --- a/doc/wiki/opentelemetry-workflows.md +++ b/doc/wiki/opentelemetry-workflows.md @@ -6,8 +6,12 @@ To collect workflow traces, configure OpenTelemetry to listen to the `Elsa.Workf ```csharp services.AddOpenTelemetry() - .WithTracing(builder => builder.AddSource("Elsa.Workflows")) - .WithMetrics(builder => builder.AddMeter("Elsa.Workflows")); + .WithTracing(builder => builder + .AddSource("Elsa.Workflows") + .AddOtlpExporter()) + .WithMetrics(builder => builder + .AddMeter("Elsa.Workflows") + .AddOtlpExporter()); ``` If you previously enabled workflow tracing through the `Elsa.OpenTelemetry` extension package, avoid enabling both the extension tracing middleware and the first-party workflow spans for the same host unless duplicate workflow and activity spans are acceptable. Both integrations publish to the `Elsa.Workflows` activity source so existing collectors can keep the same source configuration. @@ -30,3 +34,53 @@ The `Elsa.Workflows` meter emits: - `elsa.activity.duration` in seconds Metric tags use the same low-cardinality workflow and activity metadata as the spans where practical. The started counter omits execution status tags because it records the pre-execution boundary; completed and faulted counters include terminal workflow status tags. + +## Elsa Diagnostics Collector + +The `Elsa.Diagnostics.OpenTelemetry` module can collect OTLP HTTP/protobuf telemetry for local diagnostics and Studio visualization. The default ingestion routes are: + +- `/elsa/otlp/v1/traces` +- `/elsa/otlp/v1/metrics` +- `/elsa/otlp/v1/logs` + +Use the collector for development and focused troubleshooting. Production deployments should usually export to an external OpenTelemetry Collector or observability backend unless Elsa collector capacity, retention, and ingress security are deliberately configured for that environment. + +### .NET Setup + +Configure the .NET OpenTelemetry SDK to collect Elsa workflow instrumentation and export via OTLP: + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddSource("Elsa.Workflows") + .AddOtlpExporter()) + .WithMetrics(builder => builder + .AddMeter("Elsa.Workflows") + .AddOtlpExporter()); +``` + +Then configure standard OTEL environment variables for the process: + +```bash +OTEL_SERVICE_NAME=elsa-server +OTEL_RESOURCE_ATTRIBUTES=service.instance.id=local-dev +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5000/elsa/otlp/v1 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_BSP_SCHEDULE_DELAY=1000 +OTEL_METRIC_EXPORT_INTERVAL=1000 +``` + +If the collector is exposed beyond loopback, also configure the required API key header. The header name is returned by the collector configuration API; do not expose the secret value through Studio or documentation. + +### Polyglot Setup + +Non-.NET services can use the same standard OTEL variables: + +```bash +OTEL_SERVICE_NAME=worker +OTEL_RESOURCE_ATTRIBUTES=service.instance.id=worker-1 +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5000/elsa/otlp/v1 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +``` + +Use each language SDK's OTLP exporter package. Studio identifies resources by `service.name` and `service.instance.id` when those attributes are present. diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index 9305f71e8..13c6f966d 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -13,7 +13,7 @@ COPY *.props ./ RUN dotnet restore "./src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj" # Build and publish (UseAppHost=false creates platform independent binaries). -WORKDIR /source/src/bundles/Elsa.Server.Web +WORKDIR /source/src/apps/Elsa.Server.Web RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 @@ -27,20 +27,21 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ - libpython3.11 \ - python3.11 \ - python3.11-dev \ + libpython3-dev \ + python3 \ + python3-dev \ python3-pip \ unzip \ wget \ && update-ca-certificates \ + && ln -s "$(find /usr/lib -name 'libpython3*.so' -print -quit)" /usr/local/lib/libpython3.so \ && rm -rf /var/lib/apt/lists/* COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable -ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so +ENV PYTHONNET_PYDLL=/usr/local/lib/libpython3.so # Set environment variables for OpenTelemetry Auto-Instrumentation ENV OTEL_DOTNET_AUTO_HOME=/otel \ diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile index c194e4253..30d51c454 100644 --- a/docker/ElsaServer.Dockerfile +++ b/docker/ElsaServer.Dockerfile @@ -23,18 +23,19 @@ COPY --from=build /app/publish ./ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ - libpython3.11 \ - python3.11 \ - python3.11-dev \ + libpython3-dev \ + python3 \ + python3-dev \ python3-pip \ && update-ca-certificates \ + && ln -s "$(find /usr/lib -name 'libpython3*.so' -print -quit)" /usr/local/lib/libpython3.so \ && rm -rf /var/lib/apt/lists/* COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable -ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so +ENV PYTHONNET_PYDLL=/usr/local/lib/libpython3.so EXPOSE 8080/tcp EXPOSE 443/tcp diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile index 726904cd0..95c1c7bfb 100644 --- a/docker/ElsaServerAndStudio.Dockerfile +++ b/docker/ElsaServerAndStudio.Dockerfile @@ -7,13 +7,12 @@ COPY ./NuGet.Config ./ COPY *.props ./ # restore packages. -RUN dotnet restore "./src/apps/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj" -RUN dotnet restore "./src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj" +RUN dotnet restore "./src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj" # build and publish (UseAppHost=false creates platform independent binaries). -WORKDIR /source/src/apps/Elsa.ServerAndStudio.Web -RUN dotnet build "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 +WORKDIR /source/src/apps/Elsa.ModularServer.Web +RUN dotnet build "Elsa.ModularServer.Web.csproj" -c Release -o /app/build +RUN dotnet publish "Elsa.ModularServer.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base @@ -24,20 +23,21 @@ COPY --from=build /app/publish ./ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ - libpython3.11 \ - python3.11 \ - python3.11-dev \ + libpython3-dev \ + python3 \ + python3-dev \ python3-pip \ && update-ca-certificates \ + && ln -s "$(find /usr/lib -name 'libpython3*.so' -print -quit)" /usr/local/lib/libpython3.so \ && rm -rf /var/lib/apt/lists/* COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable -ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so +ENV PYTHONNET_PYDLL=/usr/local/lib/libpython3.so EXPOSE 8080/tcp EXPOSE 443/tcp ENTRYPOINT ["/entrypoint.sh"] -CMD ["dotnet", "Elsa.ServerAndStudio.Web.dll"] +CMD ["dotnet", "Elsa.ModularServer.Web.dll"] diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile index 34b69c0c9..61b6032f3 100644 --- a/docker/ElsaStudio.Dockerfile +++ b/docker/ElsaStudio.Dockerfile @@ -7,13 +7,12 @@ COPY ./NuGet.Config ./ COPY *.props ./ # restore packages. -RUN dotnet restore "./src/apps/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj" -RUN dotnet restore "./src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj" +RUN dotnet restore "./src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj" # build and publish (UseAppHost=false creates platform independent binaries). -WORKDIR /source/src/apps/Elsa.Studio.Web -RUN dotnet build "Elsa.Studio.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.Studio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 +WORKDIR /source/src/apps/Elsa.ModularServer.Web +RUN dotnet build "Elsa.ModularServer.Web.csproj" -c Release -o /app/build +RUN dotnet publish "Elsa.ModularServer.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base @@ -32,4 +31,4 @@ RUN chmod +x /entrypoint.sh EXPOSE 8080/tcp EXPOSE 443/tcp ENTRYPOINT ["/entrypoint.sh"] -CMD ["dotnet", "Elsa.Studio.Web.dll"] +CMD ["dotnet", "Elsa.ModularServer.Web.dll"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 356daf467..2df206496 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -75,6 +75,16 @@ services: - ASPNETCORE_URLS=http://+:80 - Logging__LogLevel__Default=Information + aspire-dashboard: + image: mcr.microsoft.com/dotnet/aspire-dashboard:latest + container_name: aspire-dashboard + ports: + - "18888:18888" # Dashboard UI + - "4317:18889" # OTLP/gRPC + - "4318:18890" # OTLP/HTTP + environment: + ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" + volumes: sqlserver_data: postgres-data: diff --git a/scripts/test-ca-trust.sh b/scripts/test-ca-trust.sh index 7c078606b..42f0bcdf7 100755 --- a/scripts/test-ca-trust.sh +++ b/scripts/test-ca-trust.sh @@ -54,40 +54,48 @@ CERTEXT fi sleep 0.2 done +} - echo "$CERT_WORKDIR" +cleanup_local_ca_server() { + if [ -n "${SERVER_PID:-}" ]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID= + fi + + if [ -n "${CERT_WORKDIR:-}" ]; then + rm -rf "$CERT_WORKDIR" + CERT_WORKDIR= + fi } run_extra_ca_test() { - local cert_dir - cert_dir=$(start_local_ca_server) - trap "kill ${SERVER_PID:-0} >/dev/null 2>&1 || true; rm -rf '$cert_dir'" EXIT + start_local_ca_server + trap cleanup_local_ca_server EXIT echo "[CA] Validating EXTRA_CA_CERT flow against local CA" >&2 run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ --add-host host.docker.internal:host-gateway \ - -v "${cert_dir}:/certs:ro" \ + -v "${CERT_WORKDIR}:/certs:ro" \ -e EXTRA_CA_CERT=/certs/ca.crt echo "[CA] Validating SSL_CERT_FILE fallback" >&2 run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ --add-host host.docker.internal:host-gateway \ - -v "${cert_dir}:/certs:ro" \ + -v "${CERT_WORKDIR}:/certs:ro" \ -e SSL_CERT_FILE=/certs/ca.crt - mkdir -p "$cert_dir/dir" - cp "$cert_dir/ca.crt" "$cert_dir/dir/custom-ca.crt" - openssl rehash "$cert_dir/dir" >/dev/null 2>&1 + mkdir -p "$CERT_WORKDIR/dir" + cp "$CERT_WORKDIR/ca.crt" "$CERT_WORKDIR/dir/custom-ca.crt" + openssl rehash "$CERT_WORKDIR/dir" >/dev/null 2>&1 echo "[CA] Validating SSL_CERT_DIR fallback" >&2 run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ --add-host host.docker.internal:host-gateway \ - -v "${cert_dir}:/certs:ro" \ + -v "${CERT_WORKDIR}:/certs:ro" \ -e SSL_CERT_DIR=/certs/dir - kill "${SERVER_PID:-0}" >/dev/null 2>&1 || true - wait "${SERVER_PID:-0}" 2>/dev/null || true - rm -rf "$cert_dir" + cleanup_local_ca_server trap - EXIT } diff --git a/specs/008-diagnostics-otel/checklists/requirements.md b/specs/008-diagnostics-otel/checklists/requirements.md new file mode 100644 index 000000000..de04485bb --- /dev/null +++ b/specs/008-diagnostics-otel/checklists/requirements.md @@ -0,0 +1,31 @@ +# Specification Quality Checklist: Diagnostics OpenTelemetry + +**Purpose**: Validate Core specification completeness before implementation +**Created**: 2026-05-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details in product requirements beyond Core system boundaries +- [x] Focused on user value and backend diagnostics needs +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] Functional requirements have clear acceptance criteria +- [x] User scenarios cover primary Core flows +- [x] Technical plan identifies Core module boundaries + +## Notes + +- Studio UI work is intentionally tracked in `elsa-studio/specs/008-diagnostics-otel`. diff --git a/specs/008-diagnostics-otel/contracts/otlp-ingest.md b/specs/008-diagnostics-otel/contracts/otlp-ingest.md new file mode 100644 index 000000000..e6b3913ec --- /dev/null +++ b/specs/008-diagnostics-otel/contracts/otlp-ingest.md @@ -0,0 +1,50 @@ +# Contract: OTLP Ingestion + +## HTTP/protobuf + +Default base path: + +```text +/elsa/otlp/v1 +``` + +Supported paths: + +```text +POST /elsa/otlp/v1/traces +POST /elsa/otlp/v1/metrics +POST /elsa/otlp/v1/logs +``` + +Requests use OTLP protobuf bodies. Accepted payloads return `200 OK`; unauthorized requests return `401 Unauthorized`; malformed protobuf payloads return `400 Bad Request`; payloads over the configured request body limit return `413 Payload Too Large`. The current collector does not emit OTLP partial-success response bodies. + +## gRPC + +When enabled, Core exposes OTLP gRPC services: + +```text +opentelemetry.proto.collector.trace.v1.TraceService/Export +opentelemetry.proto.collector.metrics.v1.MetricsService/Export +opentelemetry.proto.collector.logs.v1.LogsService/Export +``` + +When gRPC is disabled, collector configuration must return `GrpcEnabled = false` and no endpoint. + +## Security + +- Loopback-only development ingestion may run without an API key only when the request remote address is loopback and the advertised collector binding is loopback. +- Non-loopback requests and non-loopback collector bindings must require a configured header such as `x-otlp-api-key`. +- Secret header values are configured server-side, compared without exposing the configured value, and omitted from collector configuration responses. +- Accepted telemetry is redacted before storage and live streaming. +- OTLP sender auth is separate from diagnostics API user auth. + +## Internal Contract + +```csharp +public interface IOpenTelemetryIngestor +{ + ValueTask IngestAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default); +} +``` + +Transport-specific code parses payloads; shared ingestion normalizes, redacts, stores, and publishes live updates. diff --git a/specs/008-diagnostics-otel/contracts/provider-contract.md b/specs/008-diagnostics-otel/contracts/provider-contract.md new file mode 100644 index 000000000..7b0f974ff --- /dev/null +++ b/specs/008-diagnostics-otel/contracts/provider-contract.md @@ -0,0 +1,48 @@ +# Contract: Provider and Store + +## Provider Facade + +```csharp +public interface IOpenTelemetryProvider +{ + ValueTask SearchResourcesAsync(TelemetryResourceFilter filter, CancellationToken cancellationToken = default); + ValueTask SearchTracesAsync(TelemetryTraceFilter filter, CancellationToken cancellationToken = default); + ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default); + ValueTask SearchMetricsAsync(TelemetryMetricFilter filter, CancellationToken cancellationToken = default); + ValueTask SearchLogsAsync(OtlpLogFilter filter, CancellationToken cancellationToken = default); + ValueTask GetStorageDiagnosticsAsync(CancellationToken cancellationToken = default); +} +``` + +## Store + +```csharp +public interface IOpenTelemetryStore +{ + ValueTask WriteTracesAsync(IReadOnlyCollection spans, CancellationToken cancellationToken = default); + ValueTask WriteMetricsAsync(IReadOnlyCollection metrics, CancellationToken cancellationToken = default); + ValueTask WriteLogsAsync(IReadOnlyCollection logs, CancellationToken cancellationToken = default); +} +``` + +The concrete in-memory implementation also supports provider queries. A later durable provider may split write/query responsibilities if needed. + +Search methods return bounded results using server-capped limits. Search ordering is newest receive time first. Trace detail ordering is parent/child chronological so Studio can render a waterfall without reassembling hierarchy from arbitrary span order. + +## Redaction + +```csharp +public interface IOpenTelemetryRedactor +{ + TelemetryResource Redact(TelemetryResource resource); + TelemetrySpan Redact(TelemetrySpan span); + MetricPoint Redact(MetricPoint point); + OtlpLogRecord Redact(OtlpLogRecord logRecord); +} +``` + +Redaction occurs before store writes and live publishing. + +## Capacity + +The default in-memory provider uses configurable per-signal limits with drop-oldest overflow. Defaults must be at least 500 resources, 2,000 traces, 10,000 spans, 20,000 metric points, 10,000 OTLP log records, and 1,000 queued live updates per subscriber. diff --git a/specs/008-diagnostics-otel/contracts/rest-api.md b/specs/008-diagnostics-otel/contracts/rest-api.md new file mode 100644 index 000000000..66c128a48 --- /dev/null +++ b/specs/008-diagnostics-otel/contracts/rest-api.md @@ -0,0 +1,67 @@ +# Contract: REST API + +Base path: + +```text +/diagnostics/opentelemetry +``` + +All endpoints require the OpenTelemetry diagnostics view permission. + +Search endpoints accept a caller-provided limit capped by server options. Trace, metric, and log search results return the latest matching window in ascending chronological order. Resource search results are ordered by most recently seen first. Trace detail spans are returned in parent/child order with chronological ordering inside each sibling set. + +## Resources + +```text +POST /diagnostics/opentelemetry/resources/search +``` + +Filters by service name, status, and text. + +## Trace Search + +```text +POST /diagnostics/opentelemetry/traces/search +``` + +Filters by resource key, service name, trace ID, workflow instance ID, workflow definition ID, status, text, and time range. + +## Trace Detail + +```text +GET /diagnostics/opentelemetry/traces/{traceId} +``` + +Returns trace summary, ordered spans, related resources, and OTLP logs for the trace when available. + +## Metrics + +```text +POST /diagnostics/opentelemetry/metrics/search +``` + +Filters by resource, instrument name, attribute text, and time range. + +## OTLP Logs + +```text +POST /diagnostics/opentelemetry/logs/search +``` + +Filters by resource, trace ID, span ID, severity, text, and time range. + +## Collector Configuration + +```text +GET /diagnostics/opentelemetry/collector-configuration +``` + +Returns HTTP endpoint metadata, nullable/disabled gRPC metadata, required header names, loopback status, and recommended non-secret OTEL environment variables. + +## Storage Diagnostics + +```text +GET /diagnostics/opentelemetry/storage +``` + +Returns capacity, dropped counts, and current resource/trace/series counts. diff --git a/specs/008-diagnostics-otel/contracts/signalr-hub.md b/specs/008-diagnostics-otel/contracts/signalr-hub.md new file mode 100644 index 000000000..f788b4d96 --- /dev/null +++ b/specs/008-diagnostics-otel/contracts/signalr-hub.md @@ -0,0 +1,39 @@ +# Contract: SignalR Hub + +Hub path: + +```text +/elsa/hubs/diagnostics/opentelemetry +``` + +The hub requires the OpenTelemetry diagnostics view permission. + +## Client-to-Server Methods + +```text +SubscribeAsync(OpenTelemetryTraceFilter filter) +``` + +Filters support resource ID, service name, trace ID, workflow instance ID, workflow definition ID, status, text, and time range. Metric clients subscribe through the trace-filter-compatible live stream and apply metric-specific filtering client-side. + +## Server-to-Client Events + +```text +ReceiveAsync(OpenTelemetryStreamItem item) +``` + +`OpenTelemetryStreamItem` variants: + +- `Resource` +- `Trace` +- `MetricPoint` +- `Log` +- `DroppedItems` + +## Lifecycle + +- Subscriptions are bounded by server-side per-connection subscriber capacity. +- Filter changes are represented by starting a new subscription. +- Disconnected clients release subscriber resources. +- When a subscriber queue is full, live updates are dropped and reported through `DroppedItems` summaries. +- Subscriber overflow must not disconnect the client unless the connection itself is unhealthy or unauthorized. diff --git a/specs/008-diagnostics-otel/data-model.md b/specs/008-diagnostics-otel/data-model.md new file mode 100644 index 000000000..f1c72d262 --- /dev/null +++ b/specs/008-diagnostics-otel/data-model.md @@ -0,0 +1,104 @@ +# Data Model: Diagnostics OpenTelemetry + +## Telemetry Resource + +| Field | Notes | +|-------|-------| +| `ResourceKey` | Stable key derived from service name, instance ID, and resource attributes. | +| `ServiceName` | From `service.name` or `OTEL_SERVICE_NAME`; fallback is `unknown_service`. | +| `ServiceInstanceId` | From `service.instance.id` when present. | +| `ServiceVersion` | From `service.version` when present. | +| `DeploymentEnvironment` | From `deployment.environment` when present. | +| `Attributes` | Redacted resource attributes. | +| `FirstSeen`, `LastSeen` | Backend receive timestamps. | +| `Status` | Active, stale, or disconnected. | + +## Telemetry Trace + +| Field | Notes | +|-------|-------| +| `TraceId` | Required trace identifier. | +| `RootSpanId` | Root span when known. | +| `Name` | Root span name or synthesized trace label. | +| `ResourceKeys` | Participating resources. | +| `StartTime`, `EndTime`, `Duration` | Derived from spans. | +| `Status` | Ok, error, unset, or mixed. | +| `SpanCount`, `ErrorCount` | Derived counters. | +| `WorkflowInstanceId`, `WorkflowDefinitionId` | Elsa span attributes when present. | +| `LastReceivedAt` | Backend receive timestamp. | + +## Telemetry Span + +| Field | Notes | +|-------|-------| +| `TraceId`, `SpanId`, `ParentSpanId` | Trace hierarchy. | +| `ResourceKey` | Emitting resource. | +| `Name`, `Kind` | OTEL span metadata. | +| `StartTime`, `EndTime`, `Duration` | Timing. | +| `StatusCode`, `StatusDescription` | OTEL status. | +| `Attributes` | Redacted span attributes. | +| `Events` | Redacted events. | +| `Links` | Span links. | +| `Workflow*`, `Activity*`, `TenantId`, `CorrelationId` | Existing `Elsa.Workflows` semantic attributes when present: `workflow.*`, `workflow.activity.*`, and `elsa.tenant.id`. | +| `ReceivedAt` | Backend receive timestamp. | + +## Metric Instrument and Point + +| Field | Notes | +|-------|-------| +| `Name`, `Description`, `Unit`, `Type` | Instrument metadata. | +| `ResourceKey` | Emitting resource. | +| `Series` | Bounded collection by attribute set. | +| `Timestamp`, `StartTimestamp` | Point timestamps. | +| `Attributes` | Redacted point attributes. | +| `Value` | Sum/gauge value. | +| `Count`, `Sum`, `Min`, `Max`, `Buckets` | Histogram fields when present. | +| `Temporality` | Cumulative or delta. | +| `DroppedPointCount` | Capacity drop count. | + +## OTLP Log Record + +| Field | Notes | +|-------|-------| +| `Id` | Backend assigned ID. | +| `Timestamp`, `ObservedTimestamp`, `ReceivedAt` | OTLP and backend timing. | +| `ResourceKey` | Emitting resource. | +| `SeverityText`, `SeverityNumber` | OTEL severity. | +| `Body` | Redacted body. | +| `TraceId`, `SpanId` | Correlation fields. | +| `Attributes` | Redacted log attributes. | + +## Collector Configuration + +| Field | Notes | +|-------|-------| +| `HttpEndpoint` | Base endpoint suitable for `OTEL_EXPORTER_OTLP_ENDPOINT` with HTTP/protobuf. | +| `GrpcEndpoint` | Endpoint suitable for gRPC when enabled; null when unavailable. | +| `GrpcEnabled` | Whether gRPC ingestion is enabled. | +| `GrpcDisabledReason` | Optional non-secret explanation when disabled. | +| `RequiredHeaders` | Required ingestion header names without secret values. | +| `RecommendedEnvironment` | Copyable non-secret OTEL environment variable values. | +| `IsLoopbackOnly` | Whether ingestion is restricted to loopback. | +| `RequiresApiKey` | Whether configured ingestion protection is required for advertised endpoints. | + +## Storage Diagnostics + +| Field | Notes | +|-------|-------| +| `DroppedSpanCount` | Spans dropped by store capacity. | +| `DroppedMetricPointCount` | Metric points dropped by capacity. | +| `DroppedLogRecordCount` | OTLP logs dropped by capacity. | +| `DroppedLiveUpdateCount` | Live updates dropped by subscriber capacity. | +| `ResourceCount`, `TraceCount`, `MetricSeriesCount` | Current counts. | + +## Storage Capacity Policy + +| Field | Notes | +|-------|-------| +| `MaxResources` | Default at least 500. | +| `MaxTraces` | Default at least 2,000. | +| `MaxSpans` | Default at least 10,000. | +| `MaxMetricPoints` | Default at least 20,000 across all metric series. | +| `MaxLogRecords` | Default at least 10,000. | +| `MaxLiveUpdatesPerSubscriber` | Default at least 1,000 queued updates per SignalR subscriber. | +| `OverflowPolicy` | Drop oldest item in the signal-specific buffer and increment the matching dropped counter. | diff --git a/specs/008-diagnostics-otel/plan.md b/specs/008-diagnostics-otel/plan.md new file mode 100644 index 000000000..0fd66f7df --- /dev/null +++ b/specs/008-diagnostics-otel/plan.md @@ -0,0 +1,149 @@ +# Implementation Plan: Diagnostics OpenTelemetry + +**Branch**: `008-diagnostics-otel` | **Date**: 2026-05-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/008-diagnostics-otel/spec.md` + +## Summary + +Add an opt-in `Elsa.Diagnostics.OpenTelemetry` Core diagnostics module that receives OTLP telemetry, normalizes it into Elsa diagnostics read models, redacts sensitive values before provider boundaries, stores recent telemetry in bounded memory, exposes authenticated REST APIs and SignalR live updates for Studio, and publishes collector configuration metadata for standard OTEL senders. + +The module is a collector/API backend, not producer-side workflow tracing middleware. Existing `Elsa.Workflows.Core` `ActivitySource` and `Meter` instrumentation remains the workflow telemetry producer. The historical `Elsa.OpenTelemetry` module from `elsa-extensions` is not ported in v1. + +## Technical Context + +**Language/Version**: C# latest, nullable reference types enabled, implicit usings enabled. +**Primary Dependencies**: OpenTelemetry SDK/proto contracts, ASP.NET Core endpoint patterns, optional gRPC services, SignalR, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, Elsa shell feature infrastructure, existing Elsa identity/authorization patterns, current `Elsa.Workflows.Core` instrumentation. +**Storage**: Bounded in-memory telemetry repository for resources, traces, spans, metric instruments/points, OTLP log records, live subscribers, and dropped-count diagnostics. Defaults are configurable, drop oldest per signal on overflow, and start with at least 500 resources, 2,000 traces, 10,000 spans, 20,000 metric points, 10,000 OTLP log records, and 1,000 queued live updates per subscriber. No durable database schema in v1. +**Testing**: xUnit unit tests for normalization, redaction, resource identity, filtering, bounded storage, dropped counts, and collector configuration; integration tests for HTTP/protobuf OTLP ingestion, optional gRPC ingestion when enabled, permissions, SignalR, API contracts, and workflow export-to-collector timing. +**Target Platform**: ASP.NET Core Elsa Server on supported repository target frameworks. +**Project Type**: Modular .NET library inside the existing Elsa solution. +**Performance Goals**: Local workflow telemetry appears through diagnostics APIs within 2 seconds; trace detail handles 100 spans; metrics handle 20 instruments and 1,000 recent points; backend buffers remain bounded under overload. +**Constraints**: Do not merge with Structured Logs or Console Logs. Do not port historical producer middleware. Do not start workflow/activity spans in the diagnostics module or mutate `Activity.Current`. Do not add durable OTEL persistence, vendor exporters, Kubernetes/Docker APIs, or Studio UI in Core. Non-loopback OTLP ingestion requires explicit protection. +**Scale/Scope**: One Core module, unit/integration tests, docs/wiki quickstart updates, API/hub contracts, and sample host wiring if needed for verification. + +## Constitution Check + +Evaluated against `.specify/memory/constitution.md` v1.1.0: + +| Principle | Verdict | Evidence | +|-----------|---------|----------| +| I. Modular Architecture | PASS | Work is a focused module under `src/modules/Elsa.Diagnostics.OpenTelemetry` with contracts, services, endpoints, hub, options, permissions, and shell feature. | +| II. Composition & Extensibility | PASS | Ingestion, redaction, store, live feed, source registry, and collector configuration are explicit contracts/services. | +| III. Convention-Driven Design | PASS | Names follow Elsa module, endpoint, feature, permission, and test conventions with American English. | +| IV. Async & Pipeline Execution | PASS | Ingestion, provider queries, endpoints, live subscriptions, and shutdown paths are async/cancellation-aware. | +| V. Testing Discipline | PASS | Plan requires unit/integration coverage for ingestion, security, storage, API, SignalR, and workflow telemetry timing. | +| VI. Trunk-Based Development | PASS | Core backend module can land independently from Studio after API contracts stabilize. | +| VII. Simplicity, SRP, DRY & KISS | PASS | V1 uses bounded in-memory storage and excludes durable storage, vendor exporters, app launcher behavior, and producer middleware changes. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-diagnostics-otel/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── rest-api.md +│ ├── otlp-ingest.md +│ ├── signalr-hub.md +│ └── provider-contract.md +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/modules/ +└── Elsa.Diagnostics.OpenTelemetry/ + ├── Contracts/ + │ ├── IOpenTelemetryIngestor.cs + │ ├── IOpenTelemetryProvider.cs + │ ├── IOpenTelemetryRedactor.cs + │ ├── IOpenTelemetrySourceRegistry.cs + │ ├── IOpenTelemetryStore.cs + │ └── IOpenTelemetryLiveFeed.cs + ├── Endpoints/OpenTelemetry/ + │ ├── CollectorConfiguration/Endpoint.cs + │ ├── Logs/Endpoint.cs + │ ├── Metrics/Endpoint.cs + │ ├── Resources/Endpoint.cs + │ ├── Storage/Endpoint.cs + │ ├── Trace/Endpoint.cs + │ └── Traces/Endpoint.cs + ├── Extensions/ + ├── Features/OpenTelemetryFeature.cs + ├── Ingestion/ + │ ├── Grpc/ + │ └── HttpProtobuf/ + ├── Models/ + ├── Options/OpenTelemetryDiagnosticsOptions.cs + ├── Permissions/OpenTelemetryPermissions.cs + ├── Providers/InMemory/ + ├── RealTime/OpenTelemetryHub.cs + ├── Services/ + └── ShellFeatures/OpenTelemetryFeature.cs + +test/unit/ +└── Elsa.Diagnostics.OpenTelemetry.UnitTests/ + +test/integration/ +└── Elsa.Diagnostics.OpenTelemetry.IntegrationTests/ +``` + +**Structure Decision**: Add a new diagnostics module parallel to `Elsa.Diagnostics.StructuredLogs` and `Elsa.Diagnostics.ConsoleLogs`. The module owns collector, normalization, storage, APIs, and live contracts only. + +## Phase 0 Output + +See [research.md](./research.md). + +Resolved decisions: + +- Core module is the OTLP collector and diagnostics API boundary. +- Existing `Elsa.Workflows.Core` instrumentation remains the producer. +- Do not port historical `Elsa.OpenTelemetry` producer middleware from `elsa-extensions`. +- Support HTTP/protobuf as required; support gRPC when host/dependencies enable it and advertise disabled metadata otherwise. +- Use bounded in-memory storage in v1. +- Keep OTLP logs separate from `Elsa.Diagnostics.StructuredLogs`. +- Require protection for non-loopback ingestion. + +## Phase 1 Output + +- [data-model.md](./data-model.md) +- [contracts/rest-api.md](./contracts/rest-api.md) +- [contracts/otlp-ingest.md](./contracts/otlp-ingest.md) +- [contracts/signalr-hub.md](./contracts/signalr-hub.md) +- [contracts/provider-contract.md](./contracts/provider-contract.md) +- [quickstart.md](./quickstart.md) + +## Post-Design Constitution Re-Check + +| Principle | Verdict | Post-design evidence | +|-----------|---------|----------------------| +| I. Modular Architecture | PASS | Contracts and source tree keep all Core OpenTelemetry diagnostics behavior in one focused module. | +| II. Composition & Extensibility | PASS | Provider/store/live-feed/redactor/ingestor contracts isolate real variability points. | +| III. Convention-Driven Design | PASS | Endpoint and type names follow diagnostics module conventions. | +| IV. Async & Pipeline Execution | PASS | Provider, store, endpoint, and hub contracts are async/cancellation-aware. | +| V. Testing Discipline | PASS | Tasks include unit and integration tests for each buildable slice. | +| VI. Trunk-Based Development | PASS | Core slice remains independently reviewable from Studio. | +| VII. Simplicity, SRP, DRY & KISS | PASS | V1 avoids persistence and producer instrumentation rewrites. | + +## Phase 2 Handoff + +Use [tasks.md](./tasks.md) as the implementation backlog. Suggested order: + +1. Create module/test projects, solution entries, options, permissions, feature, and shell feature. +2. Add models and shared provider contracts. +3. Add HTTP/protobuf ingestion, normalization, redaction, resource registry, bounded store, and provider facade. +4. Add REST APIs, SignalR hub, collector configuration, and security checks. +5. Add optional gRPC wrappers and disabled metadata handling. +6. Add docs, sample host wiring if needed, and run targeted tests/builds. + +## Complexity Tracking + +No constitution violations identified. diff --git a/specs/008-diagnostics-otel/quickstart.md b/specs/008-diagnostics-otel/quickstart.md new file mode 100644 index 000000000..11ab69d79 --- /dev/null +++ b/specs/008-diagnostics-otel/quickstart.md @@ -0,0 +1,72 @@ +# Quickstart: Diagnostics OpenTelemetry + +## Enable Core Diagnostics + +```csharp +services.AddElsa(elsa => +{ + elsa.UseOpenTelemetryDiagnostics(options => + { + options.HttpEndpointPath = "/elsa/otlp/v1"; + options.ApiKey = builder.Configuration["Diagnostics:OpenTelemetry:ApiKey"]; + options.TraceCapacity = 2_000; + options.SpanCapacity = 10_000; + options.MetricPointCapacity = 20_000; + options.LogRecordCapacity = 10_000; + options.ResourceCapacity = 500; + options.SubscriberChannelCapacity = 1_000; + options.MaxHttpRequestBodySize = 10 * 1024 * 1024; + }); +}); +``` + +## Configure Elsa Workflow Telemetry Export + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddSource("Elsa.Workflows") + .AddOtlpExporter()) + .WithMetrics(builder => builder + .AddMeter("Elsa.Workflows") + .AddOtlpExporter()); +``` + +Development HTTP/protobuf environment: + +```text +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5000/elsa/otlp/v1 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SERVICE_NAME=elsa-api +OTEL_RESOURCE_ATTRIBUTES=service.instance.id=local-elsa-api,deployment.environment=development +OTEL_BSP_SCHEDULE_DELAY=1000 +OTEL_BLRP_SCHEDULE_DELAY=1000 +OTEL_METRIC_EXPORT_INTERVAL=1000 +``` + +When the collector is reachable from non-loopback addresses, configure the server-side API key and send only the configured header name/value through OTLP exporter headers. Collector configuration must advertise required header names but never secret values. + +gRPC senders should only use gRPC when collector configuration advertises a gRPC endpoint: + +```text +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_EXPORTER_OTLP_PROTOCOL=grpc +``` + +## Verify + +```bash +dotnet test test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj +dotnet test test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj +``` + +Manual checks: + +- Run a workflow and confirm a workflow trace appears through `/diagnostics/opentelemetry/traces/search` within 2 seconds. +- Query `/diagnostics/opentelemetry/traces/{traceId}` and confirm workflow/activity spans are parented and ordered. +- Query collector configuration and confirm HTTP metadata, nullable/disabled gRPC metadata, and required header names. +- Attempt non-loopback ingestion without the configured API key and confirm rejection. + +## Scope Boundaries + +This feature does not add durable OTEL persistence, vendor exporters, Studio UI, alerting, Kubernetes/Docker APIs, or historical `Elsa.OpenTelemetry` producer middleware. Production deployments should usually export to a standard OpenTelemetry Collector or observability platform unless Elsa collector capacity and security are deliberately configured. diff --git a/specs/008-diagnostics-otel/research.md b/specs/008-diagnostics-otel/research.md new file mode 100644 index 000000000..40d3ebb0e --- /dev/null +++ b/specs/008-diagnostics-otel/research.md @@ -0,0 +1,54 @@ +# Research: Diagnostics OpenTelemetry + +## Decision: Core owns the collector/API boundary + +**Rationale**: OTLP ingestion must happen in an ASP.NET Core backend, not in Studio. Core can authenticate diagnostics APIs, protect ingestion, normalize protobuf payloads, and stream live updates. + +**Alternatives considered**: + +- Studio collector: rejected because browser/Studio hosts cannot reliably expose OTLP endpoints. +- External collector only: rejected because this feature needs a first-party local diagnostics story. + +## Decision: Preserve existing workflow instrumentation + +**Rationale**: Current `Elsa.Workflows.Core` already emits `Elsa.Workflows` spans and metrics through `System.Diagnostics`. This module should consume/export that telemetry through OTLP rather than re-instrument workflow execution. + +**Alternatives considered**: + +- Port `elsa-extensions` `Elsa.OpenTelemetry`: rejected because it is producer-side middleware, overlaps current Core instrumentation, and includes trace-boundary behavior that mutates ambient `Activity.Current`. +- Move instrumentation into diagnostics: rejected because workflow instrumentation is valuable even when diagnostics collector is not installed. + +## Decision: HTTP/protobuf required, gRPC optional + +**Rationale**: HTTP/protobuf fits existing ASP.NET Core endpoint patterns and common deployment routes. gRPC is useful for SDK defaults but depends on host configuration and package choices, so it should be enabled when available and represented honestly in collector metadata. + +**Alternatives considered**: + +- gRPC only: rejected because it complicates proxying and local HTTP testing. +- Always return a gRPC endpoint: rejected because it misleads clients when the host does not expose gRPC. + +## Decision: Bounded in-memory v1 storage + +**Rationale**: Traces, metrics, and logs can be high volume. A bounded in-memory default matches the existing diagnostics modules and keeps v1 focused. + +**Alternatives considered**: + +- Durable storage now: rejected because trace and metric schemas need a deliberate follow-up provider design. +- Raw OTLP payload storage only: rejected because diagnostics clients need filtered, assembled trace and metric models. + +## Decision: OTLP logs stay separate from Structured Logs + +**Rationale**: OTLP logs can come from arbitrary resources and use OTEL schemas. `Elsa.Diagnostics.StructuredLogs` owns first-party `ILogger` capture. Correlation should use trace/span IDs, not shared storage. + +**Alternatives considered**: + +- Merge OTLP logs into Structured Logs: rejected because schemas, retention, permissions, and source semantics differ. + +## Decision: Explicit non-loopback protection + +**Rationale**: OTLP ingestion can expose sensitive application metadata and create high traffic. Loopback development can be low friction, but remote ingestion needs an API key header or equivalent configured protection. + +**Alternatives considered**: + +- No OTLP sender auth: rejected as unsafe. +- Require interactive user auth for OTLP senders: rejected because SDKs usually support headers, not browser auth. diff --git a/specs/008-diagnostics-otel/spec.md b/specs/008-diagnostics-otel/spec.md new file mode 100644 index 000000000..2c662a5ce --- /dev/null +++ b/specs/008-diagnostics-otel/spec.md @@ -0,0 +1,158 @@ +# Feature Specification: Diagnostics OpenTelemetry + +**Feature Branch**: `008-diagnostics-otel` +**Created**: 2026-05-25 +**Status**: Draft +**Input**: Core-owned specification for the backend side of the OpenTelemetry diagnostics PRD coordinated with `elsa-studio/specs/008-diagnostics-otel`. + +## Clarifications + +### Session 2026-05-25 + +- Q: What default capacity and overflow policy should the Core in-memory OTEL store use? -> A: Configurable bounded defaults with drop-oldest per signal. +- Q: How should Core decide when OTLP ingestion requires an API key? -> A: Require the configured header for any non-loopback request or non-loopback collector binding. +- Q: How should live SignalR subscriptions behave under backpressure? -> A: Use bounded per-connection queues, drop oldest updates, and report dropped counts. +- Q: Which workflow attributes are canonical for Elsa trace correlation? -> A: Preserve existing `Elsa.Workflows` semantic tags; do not add producer middleware or mutate ambient activity context. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Collect OpenTelemetry from Elsa services (Priority: P1) + +An operator enables OpenTelemetry diagnostics on an Elsa backend and receives workflow traces, activity spans, metrics, resources, and correlated OTLP logs through a first-party diagnostics backend. + +**Why this priority**: Studio cannot visualize telemetry until Core can collect, normalize, secure, and expose it. + +**Independent Test**: Enable the Core OpenTelemetry diagnostics feature, run a workflow with multiple activities, export telemetry to the local diagnostics collector, and verify recent resources, traces, spans, metrics, and logs through backend diagnostics APIs. + +**Acceptance Scenarios**: + +1. **Given** the diagnostics OpenTelemetry feature is enabled, **When** workflow execution emits spans and metrics from `Elsa.Workflows`, **Then** Core stores recent telemetry with resource identity, trace/span IDs, timing, status, and workflow metadata. +2. **Given** a standard OpenTelemetry SDK posts OTLP traces, metrics, or logs to the HTTP/protobuf collector endpoint, **When** the payload is accepted, **Then** Core normalizes the telemetry into queryable diagnostics models. +3. **Given** telemetry contains sensitive attributes matching configured redaction rules, **When** Core stores or streams it, **Then** sensitive values are redacted before provider boundaries. + +--- + +### User Story 2 - Serve Trace Investigation APIs (Priority: P1) + +Studio and other authenticated diagnostics clients can query recent traces and retrieve a trace detail model with ordered spans, resource metadata, OTLP logs, and storage diagnostics. + +**Why this priority**: Trace investigation is the main workflow troubleshooting path and must be backend-filtered before Studio renders a waterfall. + +**Independent Test**: Seed or ingest telemetry for a workflow trace and call the trace search/detail APIs with filters for service, resource, trace ID, workflow instance ID, status, text, and time range. + +**Acceptance Scenarios**: + +1. **Given** a trace contains workflow and activity spans, **When** a diagnostics client requests trace detail, **Then** Core returns parent/child span data ordered for a waterfall view. +2. **Given** filters include workflow instance ID or trace ID, **When** trace search is executed, **Then** only matching trace summaries are returned. +3. **Given** the caller lacks the OpenTelemetry diagnostics view permission, **When** the caller requests trace APIs or live updates, **Then** Core denies the request without exposing telemetry. + +--- + +### User Story 3 - Serve Metrics and OTLP Logs (Priority: P2) + +An authenticated diagnostics client can inspect recent OpenTelemetry metric instruments, bounded metric points, OTLP log records, and overflow diagnostics. + +**Why this priority**: Metrics and OTLP logs complement traces and validate that all OpenTelemetry signals are flowing. + +**Independent Test**: Ingest metrics and OTLP logs for multiple resources, query them by resource, instrument, severity, trace/span ID, text, and time range, and verify capacity diagnostics. + +**Acceptance Scenarios**: + +1. **Given** metrics are emitted by multiple services, **When** a client filters by resource or instrument, **Then** Core returns only matching bounded series. +2. **Given** a metric has high-cardinality attributes, **When** capacity is exceeded, **Then** Core drops according to configured policy and reports dropped point counts. +3. **Given** OTLP logs include trace/span IDs, **When** logs are queried by trace/span, **Then** Core returns correlated OTLP log records without merging them into `Elsa.Diagnostics.StructuredLogs`. + +--- + +### User Story 4 - Expose Collector Configuration and Secure Ingestion (Priority: P3) + +A developer can discover active collector endpoints and configure .NET or non-.NET senders using standard OTEL environment variables, while Core protects non-loopback ingestion. + +**Why this priority**: Elsa does not own an Aspire-style launcher, so Core must make active collector configuration discoverable and secure. + +**Independent Test**: Request collector configuration, verify HTTP metadata and nullable/disabled gRPC metadata, configure a sample sender, and verify non-loopback ingestion requires the configured API key header. + +**Acceptance Scenarios**: + +1. **Given** the collector is enabled, **When** a diagnostics client requests collector configuration, **Then** Core returns HTTP endpoint metadata, any enabled gRPC endpoint metadata, required header names, and recommended non-secret environment variables. +2. **Given** gRPC ingestion is unavailable, **When** configuration is requested, **Then** Core marks gRPC disabled instead of returning a misleading endpoint. +3. **Given** OTLP ingestion is exposed beyond loopback, **When** a sender omits the required API key header, **Then** Core rejects the request. + +### Edge Cases + +- OTLP payloads include unsupported future fields or incomplete resource attributes. +- Multiple services share `service.name` but have different `service.instance.id` values. +- A trace arrives out of span order. +- Metrics create more series or points than configured capacity allows. +- Backend restarts and loses in-memory telemetry. +- OTLP logs reference trace/span IDs for traces that have expired. +- gRPC support is not available in the hosting configuration. +- The caller has Studio access but not the OpenTelemetry diagnostics permission. +- A sender posts from loopback while the collector is configured for loopback-only development. +- A sender posts from a non-loopback address without the configured ingestion API key header. +- A live client subscribes with filters that receive more updates than its per-connection queue allows. +- The historical `Elsa.OpenTelemetry` extension package is present in another repository but must not be ported into this module. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Core MUST introduce an opt-in module under `src/modules/Elsa.Diagnostics.OpenTelemetry`. +- **FR-002**: The module MUST be separate from `Elsa.Diagnostics.StructuredLogs` and `Elsa.Diagnostics.ConsoleLogs`. +- **FR-003**: The module MUST use existing `Elsa.Workflows` `ActivitySource` and `Meter` instrumentation as the producer of Elsa workflow telemetry. +- **FR-004**: The module MUST NOT port the historical `Elsa.OpenTelemetry` producer-side middleware from `elsa-extensions` in v1. +- **FR-005**: The module MUST accept OTLP HTTP/protobuf traces, metrics, and logs. +- **FR-006**: The module SHOULD support OTLP gRPC ingestion when the host has gRPC support enabled; collector metadata MUST represent gRPC as disabled or null when unavailable. +- **FR-007**: HTTP and gRPC ingestion, when both are enabled, MUST feed one shared ingestion contract for normalization, redaction, storage, and live publishing. +- **FR-008**: The module MUST normalize telemetry into bounded queryable models for resources, traces, spans, metrics, and OTLP log records. +- **FR-009**: The default store MUST be bounded in memory and MUST report dropped telemetry counts. +- **FR-010**: Redaction MUST run before telemetry reaches provider storage or live subscribers. +- **FR-011**: Resource identity MUST use OpenTelemetry resource attributes, including service name and service instance ID when present. +- **FR-012**: Workflow and activity telemetry MUST preserve the existing `Elsa.Workflows` semantic attributes when present, including `workflow.instance.id`, `workflow.definition.id`, `workflow.definition.version`, `workflow.definition.version.id`, `workflow.status`, `workflow.substatus`, `workflow.faulted`, `workflow.parent.instance.id`, `workflow.correlation.id`, `workflow.activity.id`, `workflow.activity.name`, `workflow.activity.type`, `workflow.activity.version`, `workflow.activity.execution.id`, `workflow.activity.status`, `workflow.activity.outcome`, `workflow.activity.parent.execution.id`, `workflow.activity.scheduled.by.execution.id`, `workflow.activity.faulted`, `elsa.tenant.id`, and `exception.type`. +- **FR-013**: The module MUST expose authenticated diagnostics APIs for resources, trace search, trace detail, metrics, OTLP logs, storage diagnostics, and collector configuration. +- **FR-014**: The module MUST expose live updates through an authenticated SignalR hub. +- **FR-015**: The module MUST enforce an OpenTelemetry diagnostics view permission for all diagnostics APIs and live connections. +- **FR-016**: Loopback-only development ingestion MAY run without an API key only for loopback requests while the collector is bound to loopback; any non-loopback request or non-loopback collector binding MUST require an explicit API key header or equivalent configured protection. +- **FR-017**: Collector configuration MUST expose standard OTEL environment variable metadata without exposing secret values. +- **FR-018**: The module MUST document that production deployments should generally export to an external OpenTelemetry Collector or observability backend unless Elsa collector capacity and security are deliberately configured. +- **FR-019**: OTLP logs MUST remain separate from `Elsa.Diagnostics.StructuredLogs`; correlation uses trace/span IDs rather than shared storage. +- **FR-020**: The module MUST include tests for ingestion, normalization, redaction, bounded storage, permissions, live updates, collector configuration, and end-to-end workflow export-to-collector timing. +- **FR-021**: In-memory storage defaults MUST be configurable and MUST start with bounded development defaults of at least 500 resources, 2,000 traces, 10,000 spans, 20,000 metric points, 10,000 OTLP log records, and 1,000 queued live updates per subscriber; when a capacity is exceeded, the oldest item in that signal-specific buffer is dropped and the relevant dropped count is incremented. +- **FR-022**: Search APIs MUST return deterministic, bounded result sets with caller-specified limits capped by server options; default ordering is newest receive time first for searches and parent/child chronological ordering for trace detail spans. +- **FR-023**: Live SignalR subscriptions MUST use bounded per-connection queues, update filters in place, drop oldest queued updates on overflow, and publish dropped-update diagnostics without disconnecting healthy subscribers. +- **FR-024**: The diagnostics module MUST NOT start new workflow or activity spans itself and MUST NOT mutate `Activity.Current`; trace production remains owned by existing `Elsa.Workflows.Core` instrumentation. + +### Key Entities + +- **Telemetry Resource**: Resource/service identity derived from OTEL resource attributes. +- **Telemetry Trace**: Trace summary grouped by trace ID. +- **Telemetry Span**: Timed operation inside a trace with attributes, events, links, and status. +- **Metric Instrument**: Metric name, unit, description, type, resource, and bounded series. +- **Metric Point**: Recent data point for a metric series. +- **OTLP Log Record**: Log record received through OTLP and correlated by trace/span IDs. +- **Collector Configuration**: Active endpoint, protocol, security, and environment-variable metadata. +- **Storage Diagnostics**: Capacity and dropped telemetry counters. +- **Telemetry Subscription**: Live SignalR subscription with filters and connection state. +- **Storage Capacity Policy**: Configurable per-signal limits and drop-oldest overflow behavior for in-memory telemetry. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A local Core host with OpenTelemetry diagnostics enabled receives workflow trace spans and exposes them through diagnostics APIs within 2 seconds of workflow execution. +- **SC-002**: Core accepts representative OTLP HTTP/protobuf traces, metrics, and logs from standard SDK payloads and returns normalized diagnostics models. +- **SC-003**: Trace detail APIs return at least 100 ordered spans for one trace without unbounded memory growth. +- **SC-004**: Metric APIs return at least 20 instruments and 1,000 recent points while honoring configured capacity. +- **SC-005**: Unauthorized API and hub calls are denied without exposing telemetry. +- **SC-006**: Sensitive configured attribute names and text patterns are redacted before stored telemetry is returned. +- **SC-007**: Collector configuration reports HTTP metadata, nullable/disabled gRPC metadata, and required header names accurately. +- **SC-008**: Capacity tests prove each signal-specific buffer drops oldest telemetry, increments dropped counts, and keeps queries bounded when defaults are exceeded. +- **SC-009**: SignalR tests prove subscriber overflow drops oldest queued live updates, reports dropped-update counts, and keeps the connection usable. + +## Assumptions + +- `Elsa.Workflows.Core` keeps first-party workflow tracing and metrics instrumentation. +- Durable OpenTelemetry persistence is a later provider feature. +- The Studio feature has its own spec in `elsa-studio/specs/008-diagnostics-otel`. +- Core and Studio contracts should remain aligned, but Core is the source of truth for ingestion, permissions, normalization, and API behavior. +- Default in-memory capacities are development-friendly starting points, not production sizing guidance. diff --git a/specs/008-diagnostics-otel/tasks.md b/specs/008-diagnostics-otel/tasks.md new file mode 100644 index 000000000..ca6f798c0 --- /dev/null +++ b/specs/008-diagnostics-otel/tasks.md @@ -0,0 +1,123 @@ +# Tasks: Diagnostics OpenTelemetry + +**Input**: Design documents from `/specs/008-diagnostics-otel/` +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/), [quickstart.md](./quickstart.md) + +**Tests**: Required for ingestion, normalization, redaction, bounded storage, permissions, live updates, collector configuration, and workflow export-to-collector timing. + +## Phase 1: Setup + +- [X] T001 Create Core project skeleton at `src/modules/Elsa.Diagnostics.OpenTelemetry/Elsa.Diagnostics.OpenTelemetry.csproj` +- [X] T002 Create unit test project at `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj` +- [X] T003 Create integration test project at `test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj` +- [X] T004 Add project and test projects to `Elsa.sln` +- [X] T005 [P] Add initial module README at `src/modules/Elsa.Diagnostics.OpenTelemetry/README.md` + +--- + +## Phase 2: Foundational + +- [X] T006 Create telemetry resource/span/trace/metric/log/storage models in `src/modules/Elsa.Diagnostics.OpenTelemetry/Models` +- [X] T007 Create filter/result DTOs for resources, traces, metrics, logs, and collector configuration in `src/modules/Elsa.Diagnostics.OpenTelemetry/Models` +- [X] T008 Create provider, store, live feed, ingestor, redactor, and source registry contracts in `src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts` +- [X] T009 Create options in `src/modules/Elsa.Diagnostics.OpenTelemetry/Options/OpenTelemetryDiagnosticsOptions.cs` with configurable capacity defaults, ingestion security, and live subscriber queue settings +- [X] T010 Create permission constants in `src/modules/Elsa.Diagnostics.OpenTelemetry/Permissions/OpenTelemetryPermissions.cs` +- [X] T011 Create feature and shell feature in `src/modules/Elsa.Diagnostics.OpenTelemetry/Features/OpenTelemetryFeature.cs` and `src/modules/Elsa.Diagnostics.OpenTelemetry/ShellFeatures/OpenTelemetryFeature.cs` +- [X] T012 Create DI and endpoint mapping extensions in `src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions` +- [X] T013 Add README note that historical `Elsa.OpenTelemetry` producer middleware is not ported in v1 in `src/modules/Elsa.Diagnostics.OpenTelemetry/README.md` + +--- + +## Phase 3: User Story 1 - Collect OpenTelemetry from Elsa services (Priority: P1) MVP + +**Goal**: Receive OTLP payloads, normalize, redact, store bounded telemetry, and expose basic diagnostics queries. + +**Independent Test**: Ingest OTLP traces/metrics/logs and run an Elsa workflow export-to-collector integration test. + +- [X] T014 [P] [US1] Add OTLP trace normalization tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpTraceNormalizerTests.cs` +- [X] T015 [P] [US1] Add OTLP metric normalization tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpMetricNormalizerTests.cs` +- [X] T016 [P] [US1] Add OTLP log normalization tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpLogNormalizerTests.cs` +- [X] T017 [P] [US1] Add redaction tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/OpenTelemetryRedactorTests.cs` +- [X] T018 [P] [US1] Add bounded store/drop-count tests for default capacities, drop-oldest overflow, and deterministic query limits in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/InMemoryOpenTelemetryStoreTests.cs` +- [X] T019 [P] [US1] Add HTTP/protobuf ingestion and workflow export-to-collector timing tests that assert existing `Elsa.Workflows` tags are preserved in `test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpHttpIngestionTests.cs` +- [X] T020 [US1] Implement HTTP/protobuf request parsing in `src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/HttpProtobuf` +- [X] T021 [US1] Implement shared OTLP normalization services in `src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion` +- [X] T022 [US1] Implement redactor in `src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryRedactor.cs` +- [X] T023 [US1] Implement source registry in `src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetrySourceRegistry.cs` +- [X] T024 [US1] Implement bounded in-memory store in `src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs` +- [X] T025 [US1] Implement provider facade in `src/modules/Elsa.Diagnostics.OpenTelemetry/Services/DefaultOpenTelemetryProvider.cs` +- [X] T026 [US1] Implement HTTP/protobuf endpoint mapping in `src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/EndpointRouteBuilderExtensions.cs` + +--- + +## Phase 4: User Story 2 - Serve Trace Investigation APIs (Priority: P1) + +**Goal**: Provide authenticated trace search/detail APIs and live updates. + +**Independent Test**: Query traces by service/resource/trace/workflow/status/time and retrieve ordered span detail. + +- [X] T027 [P] [US2] Add trace search/detail provider tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryTraceQueryTests.cs` +- [X] T028 [P] [US2] Add API authorization tests in `test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryAuthorizationTests.cs` +- [X] T029 [P] [US2] Add SignalR hub tests for authorization, filter updates, per-subscriber queue overflow, drop-oldest behavior, and dropped-update diagnostics in `test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryHubTests.cs` +- [X] T030 [US2] Implement trace search and trace detail query logic in `src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs` +- [X] T031 [US2] Implement resource search endpoint in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Resources/Endpoint.cs` +- [X] T032 [US2] Implement trace search and detail endpoints in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Traces/Endpoint.cs` and `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Trace/Endpoint.cs` +- [X] T033 [US2] Implement live feed in `src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryLiveFeed.cs` +- [X] T034 [US2] Implement SignalR hub in `src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetryHub.cs` + +--- + +## Phase 5: User Story 3 - Serve Metrics and OTLP Logs (Priority: P2) + +**Goal**: Provide bounded metric and OTLP log search APIs with overflow diagnostics. + +**Independent Test**: Ingest metrics/logs, query by filters, and verify dropped counts. + +- [X] T035 [P] [US3] Add metric query tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryMetricQueryTests.cs` +- [X] T036 [P] [US3] Add OTLP log query tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OtlpLogQueryTests.cs` +- [X] T037 [US3] Implement metric instrument and point query support in `src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs` +- [X] T038 [US3] Implement OTLP log query support in `src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs` +- [X] T039 [US3] Implement metrics endpoint in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Metrics/Endpoint.cs` +- [X] T040 [US3] Implement OTLP logs endpoint in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Logs/Endpoint.cs` +- [X] T041 [US3] Implement storage diagnostics endpoint in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Storage/Endpoint.cs` + +--- + +## Phase 6: User Story 4 - Expose Collector Configuration and Secure Ingestion (Priority: P3) + +**Goal**: Provide collector configuration metadata and protect non-loopback ingestion. + +**Independent Test**: Query configuration and verify API key enforcement. + +- [X] T042 [P] [US4] Add collector configuration tests in `test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/CollectorConfigurationTests.cs` +- [X] T043 [P] [US4] Add ingestion security tests for loopback-only no-key development, non-loopback request rejection without API key, and non-secret collector metadata in `test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpIngestionSecurityTests.cs` +- [X] T044 [US4] Implement collector configuration provider in `src/modules/Elsa.Diagnostics.OpenTelemetry/Services/CollectorConfigurationProvider.cs` +- [X] T045 [US4] Implement collector configuration endpoint in `src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/CollectorConfiguration/Endpoint.cs` +- [X] T046 [US4] Implement API key header validation in `src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion` +- [X] T047 [US4] Add option-gated gRPC wrappers and disabled metadata handling in `src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/Grpc` +- [X] T048 [US4] Update workflow OpenTelemetry docs in `doc/wiki/opentelemetry-workflows.md` + +--- + +## Phase 7: Polish and Verification + +- [X] T049 [P] Update module README with routes, permissions, security, scope boundaries, and historical extension decision in `src/modules/Elsa.Diagnostics.OpenTelemetry/README.md` +- [X] T050 [P] Verify no production code references historical producer middleware by running `rg "Elsa.OpenTelemetry|UseWorkflowExecutionTracing|UseActivityExecutionTracing" src test` +- [X] T051 Run `dotnet test test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj` +- [X] T052 Run `dotnet test test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj` +- [X] T053 Run `dotnet build Elsa.sln` + +## Dependencies + +- Phase 1 precedes all work. +- Phase 2 blocks all user stories. +- US1 is the MVP backend collector slice. +- US2 can begin after Phase 2 with seeded store data; full validation depends on US1. +- US3 depends on US1 storage and normalization. +- US4 depends on endpoint mapping from US1. + +## Notes + +- Keep Studio UI tasks in `elsa-studio/specs/008-diagnostics-otel/tasks.md`. +- Do not port historical `Elsa.OpenTelemetry` middleware in this feature. +- Durable OTEL persistence and vendor exporters are out of scope. diff --git a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj index ae9991352..cbb44d3ba 100644 --- a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj +++ b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj @@ -5,9 +5,14 @@ + + + + + diff --git a/src/apps/Elsa.ModularServer.Web/Program.cs b/src/apps/Elsa.ModularServer.Web/Program.cs index 981f34116..3a5624e39 100644 --- a/src/apps/Elsa.ModularServer.Web/Program.cs +++ b/src/apps/Elsa.ModularServer.Web/Program.cs @@ -2,8 +2,6 @@ using ConsoleLogStreaming.Core.Capture; using CShells.AspNetCore.Configuration; using CShells.AspNetCore.Extensions; using CShells.DependencyInjection; -using Elsa.Diagnostics.ConsoleLogs.Extensions; -using Elsa.Diagnostics.ConsoleLogs.Services; using Elsa.ModularServer.Web; using Elsa.ModularServer.Web.Catalog; using Elsa.ShellFeatures; @@ -12,22 +10,44 @@ using Elsa.Workflows.Management.ShellFeatures; using Elsa.Workflows.Runtime.Distributed.ShellFeatures; using Elsa.Workflows.Runtime.ShellFeatures; using Elsa.Workflows.ShellFeatures; +using Elsa.Workflows.Telemetry; using Nuplane; using Nuplane.Loading.Hosting.Builder; using Nuplane.Sources.Directory.Configuration; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; -// Install the console stream tee as early as possible — before WebApplication.CreateBuilder and -// any logger provider construction — so that downstream loggers (Microsoft.Extensions.Logging.Console, -// Kestrel/Hosting loggers, etc.) capture our tee writer instead of the raw Console.Out / Console.Error. ConsoleStreamHook.Install(); var builder = WebApplication.CreateBuilder(args); var services = builder.Services; var configuration = builder.Configuration; +var serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString(); -// Console output is a single process-wide resource; the capture pipeline is owned by the root host so -// every shell's diagnostics endpoints (REST + SignalR) read from the same in-memory ring buffer. -services.AddConsoleLogsHost(); +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.ParseStateValues = true; + logging.SetResourceBuilder(CreateOpenTelemetryResource(builder.Environment.ApplicationName, serviceVersion)); + logging.AddOtlpExporter(options => ConfigureDiagnosticsOtlpExporter(options, configuration, "logs")); +}); + +services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName, serviceVersion: serviceVersion)) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSource(WorkflowInstrumentation.ActivitySourceName) + .AddOtlpExporter(options => ConfigureDiagnosticsOtlpExporter(options, configuration, "traces"))) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddMeter(WorkflowInstrumentation.MeterName) + .AddOtlpExporter(options => ConfigureDiagnosticsOtlpExporter(options, configuration, "metrics"))); var nuplaneConfiguration = configuration.GetSection("Nuplane"); @@ -71,3 +91,39 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapSampleCatalog(); app.Run(); + +static ResourceBuilder CreateOpenTelemetryResource(string serviceName, string? serviceVersion) +{ + return ResourceBuilder.CreateDefault().AddService(serviceName, serviceVersion: serviceVersion); +} + +static void ConfigureDiagnosticsOtlpExporter(OtlpExporterOptions options, IConfiguration configuration, string signal) +{ + var protocol = configuration["Diagnostics:OpenTelemetry:Exporter:Protocol"]; + options.Protocol = protocol?.Trim().ToLowerInvariant() switch + { + "grpc" => OtlpExportProtocol.Grpc, + "http/protobuf" or "httpprotobuf" => OtlpExportProtocol.HttpProtobuf, + _ => options.Protocol + }; + + var endpoint = configuration["Diagnostics:OpenTelemetry:Exporter:Endpoint"]; + if (!string.IsNullOrWhiteSpace(endpoint)) + options.Endpoint = new Uri(GetSignalEndpoint(endpoint, signal, options.Protocol), UriKind.Absolute); +} + +static string GetSignalEndpoint(string endpoint, string signal, OtlpExportProtocol protocol) +{ + if (protocol != OtlpExportProtocol.HttpProtobuf) + return endpoint; + + var trimmed = endpoint.TrimEnd('/'); + + if (trimmed.EndsWith($"/v1/{signal}", StringComparison.OrdinalIgnoreCase)) + return trimmed; + + if (trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + return $"{trimmed}/{signal}"; + + return $"{trimmed}/v1/{signal}"; +} diff --git a/src/apps/Elsa.ModularServer.Web/Properties/launchSettings.json b/src/apps/Elsa.ModularServer.Web/Properties/launchSettings.json index 2d8d243e8..cb36e8242 100644 --- a/src/apps/Elsa.ModularServer.Web/Properties/launchSettings.json +++ b/src/apps/Elsa.ModularServer.Web/Properties/launchSettings.json @@ -7,7 +7,10 @@ "launchBrowser": true, "applicationUrl": "http://localhost:5002", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "Elsa.ModularServer.Web", + "Diagnostics__OpenTelemetry__Exporter__Endpoint": "http://localhost:5002/elsa/otlp", + "Diagnostics__OpenTelemetry__Exporter__Protocol": "http/protobuf" } }, "https": { @@ -16,7 +19,34 @@ "launchBrowser": true, "applicationUrl": "https://localhost:7294;http://localhost:5002", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "Elsa.ModularServer.Web", + "Diagnostics__OpenTelemetry__Exporter__Endpoint": "https://localhost:7294/elsa/otlp", + "Diagnostics__OpenTelemetry__Exporter__Protocol": "http/protobuf" + } + }, + "http-aspire": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5002", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "Elsa.ModularServer.Web", + "Diagnostics__OpenTelemetry__Exporter__Endpoint": "http://localhost:4317", + "Diagnostics__OpenTelemetry__Exporter__Protocol": "grpc" + } + }, + "https-aspire": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7294;http://localhost:5002", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "Elsa.ModularServer.Web", + "Diagnostics__OpenTelemetry__Exporter__Endpoint": "http://localhost:4317", + "Diagnostics__OpenTelemetry__Exporter__Protocol": "grpc" } } } diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json index 3b0d7ad3e..d7e3f4a0f 100644 --- a/src/apps/Elsa.ModularServer.Web/appsettings.json +++ b/src/apps/Elsa.ModularServer.Web/appsettings.json @@ -4,7 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning", - "Nuplane": "Warning" + "Nuplane": "Warning", + "CShells": "Warning" }, "Console": { "FormatterOptions": { @@ -12,6 +13,14 @@ } } }, + "Diagnostics": { + "OpenTelemetry": { + "Exporter": { + "Endpoint": "http://localhost:5002/elsa/otlp", + "Protocol": "http/protobuf" + } + } + }, "AllowedHosts": "*", "CShells": { "Shells": { @@ -44,6 +53,11 @@ }, "StructuredLogs": {}, "ConsoleLogs": {}, + "OpenTelemetry": {}, + "Alterations": {}, + "SqliteAlterationsPersistence": { + "ConnectionString": "Data Source=elsa_workflows.db;Cache=Shared" + }, "SqliteStructuredLogPersistence": { "ConnectionString": "Data Source=elsa_logs.db;Cache=Shared" }, diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs index 65266fb05..4535cebda 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs @@ -43,8 +43,8 @@ public interface IActivityExecutionsApi /// The ID of the activity execution. /// An optional cancellation token. /// The activity execution. - [Get("/activity-executions/{id}")] - Task GetAsync(string id, CancellationToken cancellationToken = default); + [Get("/activity-executions/by-id")] + Task GetAsync([Query] string id, CancellationToken cancellationToken = default); /// /// Gets the call stack (execution chain) for a given activity execution. diff --git a/src/common/Elsa.Mediator/CommandStrategies/BackgroundStrategy.cs b/src/common/Elsa.Mediator/CommandStrategies/BackgroundStrategy.cs index 8d4210e05..242f6d772 100644 --- a/src/common/Elsa.Mediator/CommandStrategies/BackgroundStrategy.cs +++ b/src/common/Elsa.Mediator/CommandStrategies/BackgroundStrategy.cs @@ -1,5 +1,6 @@ using Elsa.Mediator.Contexts; using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Command; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Mediator.CommandStrategies; @@ -13,7 +14,15 @@ public class BackgroundStrategy : ICommandStrategy public async Task ExecuteAsync(CommandStrategyContext context) { var commandsChannel = context.ServiceProvider.GetRequiredService(); - await commandsChannel.Writer.WriteAsync(context.CommandContext, context.CancellationToken); + var commandContext = context.CommandContext; + var queuedContext = new CommandContext( + commandContext.Command, + CommandStrategy.Default, + commandContext.ResultType, + commandContext.Headers, + commandContext.ServiceProvider, + commandContext.CancellationToken); + await commandsChannel.Writer.WriteAsync(queuedContext, context.CancellationToken); return default!; } -} \ No newline at end of file +} diff --git a/src/common/Elsa.Mediator/Extensions/DependencyInjectionExtensions.cs b/src/common/Elsa.Mediator/Extensions/DependencyInjectionExtensions.cs index 68ea123d8..04f4f0ba9 100644 --- a/src/common/Elsa.Mediator/Extensions/DependencyInjectionExtensions.cs +++ b/src/common/Elsa.Mediator/Extensions/DependencyInjectionExtensions.cs @@ -48,15 +48,28 @@ public static class DependencyInjectionExtensions /// /// public static IServiceCollection AddMediatorHostedServices(this IServiceCollection services) + { + return services + .AddMediatorBackgroundChannels() + .AddHostedService() + .AddHostedService() + .AddHostedService(); + } + + /// + /// Adds mediator background processing channels to the . + /// + public static IServiceCollection AddMediatorBackgroundChannels(this IServiceCollection services) { return services .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddHostedService() - .AddHostedService() - .AddHostedService(); + .AddSingleton() + .AddSingleton() + .AddSingleton() + ; } /// @@ -225,4 +238,4 @@ public static class DependencyInjectionExtensions private static Channel CreateChannel() => Channel.CreateUnbounded(new UnboundedChannelOptions()); private static ChannelReader CreateChannelReader(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService>().Reader; private static ChannelWriter CreateChannelWriter(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService>().Writer; -} \ No newline at end of file +} diff --git a/src/common/Elsa.Mediator/HostedServices/BackgroundCommandSenderHostedService.cs b/src/common/Elsa.Mediator/HostedServices/BackgroundCommandSenderHostedService.cs index d8aa66be5..f75125a1c 100644 --- a/src/common/Elsa.Mediator/HostedServices/BackgroundCommandSenderHostedService.cs +++ b/src/common/Elsa.Mediator/HostedServices/BackgroundCommandSenderHostedService.cs @@ -1,103 +1,20 @@ -using System.Threading.Channels; -using Elsa.Mediator.Contracts; -using Elsa.Mediator.Middleware.Command; -using Elsa.Mediator.Options; -using Microsoft.Extensions.DependencyInjection; +using Elsa.Mediator.Services; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Elsa.Mediator.HostedServices; /// -/// Continuously reads from a channel to which commands can be sent, executing each received command. +/// Hosted service adapter for . /// -public class BackgroundCommandSenderHostedService : BackgroundService +public class BackgroundCommandSenderHostedService(BackgroundCommandProcessor processor) : BackgroundService { - private readonly int _workerCount; - private readonly ICommandsChannel _commandsChannel; - private readonly IServiceScopeFactory _scopeFactory; - private readonly List> _outputs; - private readonly ILogger _logger; - - /// - public BackgroundCommandSenderHostedService(IOptions options, ICommandsChannel commandsChannel, IServiceScopeFactory scopeFactory, ILogger logger) + /// + /// Initializes a new instance of the class. + /// + public BackgroundCommandSenderHostedService(BackgroundCommandProcessor processor, int workerCount) : this(processor) { - _workerCount = options.Value.CommandWorkerCount; - _commandsChannel = commandsChannel; // The shared input channel for all commands - _scopeFactory = scopeFactory; - _logger = logger; - _outputs = new(_workerCount); // Prepare a list to hold worker-specific channels } /// - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - var index = 0; // Used for round-robin distribution of work - - // Set up worker channels and start background tasks for each worker - for (var i = 0; i < _workerCount; i++) - { - var output = Channel.CreateUnbounded(); - _outputs.Add(output); - // Start a background task that processes commands from this worker's channel - _ = ReadOutputAsync(output, cancellationToken); - } - - // Main dispatcher loop: read from the input channel and distribute to worker channels - try - { - await foreach (var commandContext in _commandsChannel.Reader.ReadAllAsync(cancellationToken)) - { - var output = _outputs[index]; - await output.Writer.WriteAsync(commandContext, cancellationToken); - // Round-robin distribution - move to next worker - index = (index + 1) % _workerCount; - } - } - catch (OperationCanceledException ex) - { - _logger.LogDebug(ex, "An operation was cancelled while processing the queue"); - } - - // If the input channel is completed, complete all worker channels - foreach (var output in _outputs) - output.Writer.Complete(); - } - - private async Task ReadOutputAsync(Channel output, CancellationToken cancellationToken) - { - // Worker task: process commands from the worker's channel - try - { - await foreach (var commandContext in output.Reader.ReadAllAsync(cancellationToken)) - { - try - { - // Create a fresh scope for each command to ensure proper service lifetime - using var scope = _scopeFactory.CreateScope(); - var commandSender = scope.ServiceProvider.GetRequiredService(); - - // Decouple from the caller's CancellationToken. - // We use only the service's cancellationToken (the background worker's lifetime) - // to ensure that dispatched workflows are processed even if the original - // HTTP request or triggering context has timed out. - await commandSender.SendAsync( - commandContext.Command, - CommandStrategy.Default, - commandContext.Headers, - cancellationToken); - } - catch (Exception e) - { - // Log errors but continue processing other commands - _logger.LogError(e, "An unhandled exception occurred while processing the queue"); - } - } - } - catch (OperationCanceledException ex) - { - _logger.LogDebug(ex, "An operation was cancelled while processing the queue"); - } - } -} \ No newline at end of file + protected override Task ExecuteAsync(CancellationToken stoppingToken) => processor.ExecuteAsync(stoppingToken); +} diff --git a/src/common/Elsa.Mediator/HostedServices/BackgroundEventPublisherHostedService.cs b/src/common/Elsa.Mediator/HostedServices/BackgroundEventPublisherHostedService.cs index 9bb516bdf..411039b69 100644 --- a/src/common/Elsa.Mediator/HostedServices/BackgroundEventPublisherHostedService.cs +++ b/src/common/Elsa.Mediator/HostedServices/BackgroundEventPublisherHostedService.cs @@ -1,111 +1,20 @@ -using System.Threading.Channels; -using Elsa.Mediator.Contracts; -using Elsa.Mediator.Middleware.Notification; -using Elsa.Mediator.Options; -using Microsoft.Extensions.DependencyInjection; +using Elsa.Mediator.Services; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Elsa.Mediator.HostedServices; /// -/// Continuously reads from a channel to which notifications can be sent, publishing each received notification. +/// Hosted service adapter for . /// -public class BackgroundEventPublisherHostedService : BackgroundService +public class BackgroundEventPublisherHostedService(BackgroundNotificationProcessor processor) : BackgroundService { - private readonly int _workerCount; - private readonly INotificationsChannel _notificationsChannel; - private readonly IServiceScopeFactory _scopeFactory; - private readonly List> _outputs; - private readonly ILogger _logger; - - /// - public BackgroundEventPublisherHostedService(IOptions options, INotificationsChannel notificationsChannel, IServiceScopeFactory scopeFactory, ILogger logger) - { - _workerCount = options.Value.NotificationWorkerCount; - _notificationsChannel = notificationsChannel; - _scopeFactory = scopeFactory; - _logger = logger; - _outputs = new(_workerCount); - } - - /// - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - // Index to round-robin distribute notifications across worker channels - var index = 0; - - using var scope = _scopeFactory.CreateScope(); - var notificationSender = scope.ServiceProvider.GetRequiredService(); - - // Create multiple output channels and start worker tasks for parallel processing - for (var i = 0; i < _workerCount; i++) - { - var output = Channel.CreateUnbounded(); - _outputs.Add(output); - // Start a background task to process notifications from this output channel - _ = ReadOutputAsync(output, notificationSender, cancellationToken); - } - - var channelReader = _notificationsChannel.Reader; - - // Continuously read notifications from the input channel and distribute them to worker channels - // using round-robin distribution for load balancing - try - { - await foreach (var notification in channelReader.ReadAllAsync(cancellationToken)) - { - var output = _outputs[index]; - await output.Writer.WriteAsync(notification, cancellationToken); - // Move to the next worker in a circular fashion - index = (index + 1) % _workerCount; - } - } - catch (OperationCanceledException ex) - { - _logger.LogDebug(ex, "An operation was cancelled while processing the queue"); - } - - // When the input channel is completed, complete all output channels - foreach (var output in _outputs) - { - output.Writer.Complete(); - } - } - /// - /// Processes notifications from an output channel asynchronously. + /// Initializes a new instance of the class. /// - /// The channel to read notifications from - /// The service used to send notifications - /// Cancellation token from the hosted service - private async Task ReadOutputAsync(Channel output, INotificationSender notificationSender, CancellationToken cancellationToken) + public BackgroundEventPublisherHostedService(BackgroundNotificationProcessor processor, int workerCount) : this(processor) { - try - { - await foreach (var notificationContext in output.Reader.ReadAllAsync(cancellationToken)) - { - try - { - var notification = notificationContext.Notification; - // Link the cancellation tokens so that cancellation can happen from either source - using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notificationContext.CancellationToken); - await notificationSender.SendAsync(notification, NotificationStrategy.Sequential, linkedTokenSource.Token); - } - catch (OperationCanceledException e) - { - _logger.LogDebug(e, "An operation was cancelled while processing the queue"); - } - catch (Exception e) - { - _logger.LogError(e, "An unhandled exception occurred while processing the queue"); - } - } - } - catch (OperationCanceledException ex) - { - _logger.LogDebug(ex, "An operation was cancelled while processing the queue"); - } } -} \ No newline at end of file + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) => processor.ExecuteAsync(stoppingToken); +} diff --git a/src/common/Elsa.Mediator/HostedServices/JobRunnerHostedService.cs b/src/common/Elsa.Mediator/HostedServices/JobRunnerHostedService.cs index 14af18b4b..603f83f89 100644 --- a/src/common/Elsa.Mediator/HostedServices/JobRunnerHostedService.cs +++ b/src/common/Elsa.Mediator/HostedServices/JobRunnerHostedService.cs @@ -1,71 +1,13 @@ -using Elsa.Mediator.Contracts; -using Elsa.Mediator.Options; +using Elsa.Mediator.Services; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Elsa.Mediator.HostedServices; /// -/// A hosted service that runs jobs. +/// Hosted service adapter for . /// -public class JobRunnerHostedService : BackgroundService +public class JobRunnerHostedService(BackgroundJobProcessor processor) : BackgroundService { - private readonly int _workerCount; - private readonly IJobsChannel _jobsChannel; - private readonly ILogger _logger; - /// - public JobRunnerHostedService(IOptions options, IJobsChannel jobsChannel, ILogger logger) - { - _workerCount = options.Value.JobWorkerCount; - _jobsChannel = jobsChannel; - _logger = logger; - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Create an array of worker tasks to process jobs in parallel - var workers = new Task[_workerCount]; - - // Start multiple workers (tasks) to process jobs concurrently - for (var i = 0; i < _workerCount; i++) - workers[i] = ProcessJobsAsync(stoppingToken); - - // Wait for all worker tasks to complete (typically when the application is shutting down) - await Task.WhenAll(workers); - } - - /// - /// Continuously processes jobs from the job channel until cancellation is requested. - /// - /// Cancellation token from the hosted service - private async Task ProcessJobsAsync(CancellationToken stoppingToken) - { - // Process all jobs from the channel until it's completed or cancellation is requested - await foreach (var jobItem in _jobsChannel.Reader.ReadAllAsync(stoppingToken)) - { - try - { - // Link the cancellation tokens so that cancellation can happen from either source - using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, jobItem.CancellationTokenSource.Token); - await jobItem.Action(linkedTokenSource.Token); - _logger.LogInformation("Worker {CurrentTaskId} processed job {JobId}", Task.CurrentId, jobItem.JobId); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Job {JobId} was canceled", jobItem.JobId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Job {JobId} failed", jobItem.JobId); - } - finally - { - // Notify that the job has completed (whether successfully or not) - jobItem.OnJobCompleted(jobItem.JobId); - } - } - } -} \ No newline at end of file + protected override Task ExecuteAsync(CancellationToken stoppingToken) => processor.ExecuteAsync(stoppingToken); +} diff --git a/src/common/Elsa.Mediator/PublishingStrategies/BackgroundProcessingStrategy.cs b/src/common/Elsa.Mediator/PublishingStrategies/BackgroundProcessingStrategy.cs index e960050f5..c611415e5 100644 --- a/src/common/Elsa.Mediator/PublishingStrategies/BackgroundProcessingStrategy.cs +++ b/src/common/Elsa.Mediator/PublishingStrategies/BackgroundProcessingStrategy.cs @@ -1,5 +1,6 @@ using Elsa.Mediator.Contexts; using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Notification; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Mediator.PublishingStrategies; @@ -13,7 +14,13 @@ public class BackgroundProcessingStrategy : IEventPublishingStrategy public async Task PublishAsync(NotificationStrategyContext context) { var notificationsChannel = context.ServiceProvider.GetRequiredService(); + var notificationContext = context.NotificationContext; + var queuedContext = new NotificationContext( + notificationContext.Notification, + NotificationStrategy.Sequential, + notificationContext.ServiceProvider, + notificationContext.CancellationToken); - await notificationsChannel.Writer.WriteAsync(context.NotificationContext, context.CancellationToken); + await notificationsChannel.Writer.WriteAsync(queuedContext, context.CancellationToken); } -} \ No newline at end of file +} diff --git a/src/common/Elsa.Mediator/Services/BackgroundCommandProcessor.cs b/src/common/Elsa.Mediator/Services/BackgroundCommandProcessor.cs new file mode 100644 index 000000000..876bbd59a --- /dev/null +++ b/src/common/Elsa.Mediator/Services/BackgroundCommandProcessor.cs @@ -0,0 +1,101 @@ +using System.Threading.Channels; +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Command; +using Elsa.Mediator.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Mediator.Services; + +/// +/// Continuously reads from a channel to which commands can be sent, executing each received command. +/// +public class BackgroundCommandProcessor +{ + private readonly int _workerCount; + private readonly ICommandsChannel _commandsChannel; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public BackgroundCommandProcessor(IOptions options, ICommandsChannel commandsChannel, IServiceScopeFactory scopeFactory, ILogger logger) + { + _workerCount = options.Value.CommandWorkerCount; + _commandsChannel = commandsChannel; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Runs the processor until cancellation is requested. + /// + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var index = 0; + var outputs = new List>(_workerCount); + var workers = new List(_workerCount); + + for (var i = 0; i < _workerCount; i++) + { + var output = Channel.CreateUnbounded(); + outputs.Add(output); + workers.Add(ReadOutputAsync(output, cancellationToken)); + } + + try + { + await foreach (var commandContext in _commandsChannel.Reader.ReadAllAsync(cancellationToken)) + { + var output = outputs[index]; + await output.Writer.WriteAsync(commandContext, cancellationToken); + index = (index + 1) % _workerCount; + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "An operation was cancelled while processing the command queue"); + } + + foreach (var output in outputs) + output.Writer.Complete(); + + await Task.WhenAll(workers); + } + + private async Task ReadOutputAsync(Channel output, CancellationToken cancellationToken) + { + try + { + await foreach (var commandContext in output.Reader.ReadAllAsync(cancellationToken)) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var commandSender = scope.ServiceProvider.GetRequiredService(); + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, commandContext.CancellationToken); + + await commandSender.SendAsync( + commandContext.Command, + commandContext.CommandStrategy, + commandContext.Headers, + linkedTokenSource.Token); + } + catch (OperationCanceledException e) + { + _logger.LogDebug(e, "An operation was cancelled while processing the command queue"); + } + catch (Exception e) + { + _logger.LogError(e, "An unhandled exception occurred while processing the command queue"); + } + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "An operation was cancelled while processing the command queue"); + } + } +} diff --git a/src/common/Elsa.Mediator/Services/BackgroundJobProcessor.cs b/src/common/Elsa.Mediator/Services/BackgroundJobProcessor.cs new file mode 100644 index 000000000..dee6867f3 --- /dev/null +++ b/src/common/Elsa.Mediator/Services/BackgroundJobProcessor.cs @@ -0,0 +1,71 @@ +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Mediator.Services; + +/// +/// Continuously reads from a channel to which jobs can be sent, executing each received job. +/// +public class BackgroundJobProcessor +{ + private readonly int _workerCount; + private readonly IJobsChannel _jobsChannel; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public BackgroundJobProcessor(IOptions options, IJobsChannel jobsChannel, ILogger logger) + { + _workerCount = options.Value.JobWorkerCount; + _jobsChannel = jobsChannel; + _logger = logger; + } + + /// + /// Runs the processor until cancellation is requested. + /// + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var workers = new Task[_workerCount]; + + for (var i = 0; i < _workerCount; i++) + workers[i] = ProcessJobsAsync(cancellationToken); + + await Task.WhenAll(workers); + } + + private async Task ProcessJobsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var jobItem in _jobsChannel.Reader.ReadAllAsync(cancellationToken)) + { + try + { + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, jobItem.CancellationTokenSource.Token); + await jobItem.Action(linkedTokenSource.Token); + _logger.LogInformation("Worker {CurrentTaskId} processed job {JobId}", Task.CurrentId, jobItem.JobId); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Job {JobId} was canceled", jobItem.JobId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Job {JobId} failed", jobItem.JobId); + } + finally + { + jobItem.OnJobCompleted(jobItem.JobId); + } + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "An operation was cancelled while processing the job queue"); + } + } +} diff --git a/src/common/Elsa.Mediator/Services/BackgroundNotificationProcessor.cs b/src/common/Elsa.Mediator/Services/BackgroundNotificationProcessor.cs new file mode 100644 index 000000000..8e062287a --- /dev/null +++ b/src/common/Elsa.Mediator/Services/BackgroundNotificationProcessor.cs @@ -0,0 +1,98 @@ +using System.Threading.Channels; +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Notification; +using Elsa.Mediator.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Mediator.Services; + +/// +/// Continuously reads from a channel to which notifications can be sent, publishing each received notification. +/// +public class BackgroundNotificationProcessor +{ + private readonly int _workerCount; + private readonly INotificationsChannel _notificationsChannel; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public BackgroundNotificationProcessor(IOptions options, INotificationsChannel notificationsChannel, IServiceScopeFactory scopeFactory, ILogger logger) + { + _workerCount = options.Value.NotificationWorkerCount; + _notificationsChannel = notificationsChannel; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Runs the processor until cancellation is requested. + /// + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var index = 0; + var outputs = new List>(_workerCount); + var workers = new List(_workerCount); + + for (var i = 0; i < _workerCount; i++) + { + var output = Channel.CreateUnbounded(); + outputs.Add(output); + workers.Add(ReadOutputAsync(output, cancellationToken)); + } + + try + { + await foreach (var notification in _notificationsChannel.Reader.ReadAllAsync(cancellationToken)) + { + var output = outputs[index]; + await output.Writer.WriteAsync(notification, cancellationToken); + index = (index + 1) % _workerCount; + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "An operation was cancelled while processing the notification queue"); + } + + foreach (var output in outputs) + output.Writer.Complete(); + + await Task.WhenAll(workers); + } + + private async Task ReadOutputAsync(Channel output, CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var notificationSender = scope.ServiceProvider.GetRequiredService(); + + await foreach (var notificationContext in output.Reader.ReadAllAsync(cancellationToken)) + { + try + { + var notification = notificationContext.Notification; + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notificationContext.CancellationToken); + await notificationSender.SendAsync(notification, notificationContext.NotificationStrategy, linkedTokenSource.Token); + } + catch (OperationCanceledException e) + { + _logger.LogDebug(e, "An operation was cancelled while processing the notification queue"); + } + catch (Exception e) + { + _logger.LogError(e, "An unhandled exception occurred while processing the notification queue"); + } + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "An operation was cancelled while processing the notification queue"); + } + } +} diff --git a/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs b/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs index 947221322..2b608d392 100644 --- a/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs +++ b/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs @@ -6,7 +6,7 @@ using Elsa.Alterations.Core.Stores; using Elsa.Alterations.Extensions; using Elsa.Alterations.Services; using Elsa.Alterations.Workflows; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Services; @@ -60,7 +60,7 @@ public class AlterationsFeature : FeatureBase /// public override void Apply() { - Services.Configure(options => + Services.Configure(options => { options.RegisterTypeAlias(typeof(AlterationPlanParams), typeof(AlterationPlanParams).GetSimpleAssemblyQualifiedName()); options.RegisterTypeAlias(typeof(AlterationPlanParams), nameof(AlterationPlanParams)); diff --git a/src/modules/Elsa.Alterations/ShellFeatures/AlterationsFeature.cs b/src/modules/Elsa.Alterations/ShellFeatures/AlterationsFeature.cs index cb8674b42..aa6962a91 100644 --- a/src/modules/Elsa.Alterations/ShellFeatures/AlterationsFeature.cs +++ b/src/modules/Elsa.Alterations/ShellFeatures/AlterationsFeature.cs @@ -1,10 +1,14 @@ +using CShells.FastEndpoints.Features; using CShells.Features; using Elsa.Alterations.Core.Contracts; using Elsa.Alterations.Core.Entities; using Elsa.Alterations.Core.Extensions; +using Elsa.Alterations.Core.Models; using Elsa.Alterations.Core.Stores; using Elsa.Alterations.Extensions; using Elsa.Alterations.Services; +using Elsa.Alterations.Workflows; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -16,9 +20,10 @@ namespace Elsa.Alterations.ShellFeatures; /// [ShellFeature( DisplayName = "Alterations", - Description = "Provides workflow alteration capabilities for modifying running workflow instances")] + Description = "Provides workflow alteration capabilities for modifying running workflow instances", + DependsOn = ["ElsaFastEndpoints", "WorkflowRuntime"])] [UsedImplicitly] -public class AlterationsFeature : IShellFeature +public class AlterationsFeature : IFastEndpointsShellFeature { /// /// Gets or sets the factory for the alteration plan store. @@ -37,6 +42,12 @@ public class AlterationsFeature : IShellFeature public void ConfigureServices(IServiceCollection services) { + services.Configure(options => + { + options.RegisterTypeAlias(typeof(AlterationPlanParams), typeof(AlterationPlanParams).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(AlterationPlanParams), nameof(AlterationPlanParams)); + }); + services.AddScoped(); services.AddAlterations(); services.AddAlterationsCore(); @@ -51,6 +62,7 @@ public class AlterationsFeature : IShellFeature services.AddScoped(AlterationPlanStoreFactory); services.AddScoped(AlterationJobStoreFactory); services.AddScoped(AlterationJobDispatcherFactory); + + services.AddWorkflow(); } } - diff --git a/src/modules/Elsa.Common/Entities/OrderDefinition.cs b/src/modules/Elsa.Common/Entities/OrderDefinition.cs index 8a50560f5..18c1fa090 100644 --- a/src/modules/Elsa.Common/Entities/OrderDefinition.cs +++ b/src/modules/Elsa.Common/Entities/OrderDefinition.cs @@ -34,10 +34,4 @@ public class OrderDefinition /// [JsonIgnore] public Expression> KeySelector { get; set; } = null!; - - /// - /// A best-effort textual representation of the key selector for cache-key generation. - /// - [JsonIgnore] - public string? KeySelectorText => KeySelector?.ToString(); } diff --git a/src/modules/Elsa.Common/ShellFeatures/MediatorFeature.cs b/src/modules/Elsa.Common/ShellFeatures/MediatorFeature.cs index e162e8d10..6808838c4 100644 --- a/src/modules/Elsa.Common/ShellFeatures/MediatorFeature.cs +++ b/src/modules/Elsa.Common/ShellFeatures/MediatorFeature.cs @@ -1,5 +1,8 @@ using CShells.Features; +using Elsa.Common.ShellHandlers; +using Elsa.Extensions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Elsa.Common.ShellFeatures; @@ -9,13 +12,17 @@ namespace Elsa.Common.ShellFeatures; [ShellFeature( "Mediator", DisplayName = "Mediator", - Description = "Registers mediator services for in-process notifications and requests")] + Description = "Registers mediator services for in-process notifications and requests", + DependsOn = [typeof(MultitenancyFeature)])] public class MediatorFeature : IShellFeature { public void ConfigureServices(IServiceCollection services) { services .AddMediator() - .AddMediatorHostedServices(); + .AddMediatorBackgroundChannels(); + + services.TryAddSingleton(); + services.AddBackgroundTask(); } } diff --git a/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundProcessingCoordinator.cs b/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundProcessingCoordinator.cs new file mode 100644 index 000000000..702e438c7 --- /dev/null +++ b/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundProcessingCoordinator.cs @@ -0,0 +1,91 @@ +using Elsa.Mediator.Services; +using Microsoft.Extensions.Logging; + +namespace Elsa.Common.ShellHandlers; + +public sealed class MediatorBackgroundProcessingCoordinator( + BackgroundCommandProcessor commandProcessor, + BackgroundNotificationProcessor notificationProcessor, + BackgroundJobProcessor jobProcessor, + ILogger logger) : IAsyncDisposable +{ + private readonly SemaphoreSlim _lock = new(1, 1); + private CancellationTokenSource? _cancellationTokenSource; + private Task? _processingTask; + private int _referenceCount; + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_referenceCount++ > 0) + return; + + _cancellationTokenSource = new(); + _processingTask = Task.WhenAll( + commandProcessor.ExecuteAsync(_cancellationTokenSource.Token), + notificationProcessor.ExecuteAsync(_cancellationTokenSource.Token), + jobProcessor.ExecuteAsync(_cancellationTokenSource.Token)); + } + finally + { + _lock.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + Task? processingTask; + CancellationTokenSource? cancellationTokenSource; + + await _lock.WaitAsync(cancellationToken); + try + { + if (_referenceCount == 0) + return; + + if (--_referenceCount > 0) + return; + + processingTask = _processingTask; + cancellationTokenSource = _cancellationTokenSource; + _processingTask = null; + _cancellationTokenSource = null; + } + finally + { + _lock.Release(); + } + + if (cancellationTokenSource is null) + return; + + await cancellationTokenSource.CancelAsync(); + + if (processingTask is not null) + { + try + { + await processingTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + } + catch (Exception e) + { + logger.LogError(e, "An error occurred while stopping mediator background processing"); + throw; + } + } + + cancellationTokenSource.Dispose(); + } + + public async ValueTask DisposeAsync() + { + _referenceCount = 1; + await StopAsync(CancellationToken.None); + _lock.Dispose(); + } +} diff --git a/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundTask.cs b/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundTask.cs new file mode 100644 index 000000000..62756b83c --- /dev/null +++ b/src/modules/Elsa.Common/ShellHandlers/MediatorBackgroundTask.cs @@ -0,0 +1,10 @@ +using Elsa.Common; + +namespace Elsa.Common.ShellHandlers; + +public sealed class MediatorBackgroundTask(MediatorBackgroundProcessingCoordinator coordinator) : BackgroundTask +{ + public override Task StartAsync(CancellationToken cancellationToken) => coordinator.StartAsync(cancellationToken); + + public override Task StopAsync(CancellationToken cancellationToken) => coordinator.StopAsync(cancellationToken); +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/AssemblyInfo.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/AssemblyInfo.cs new file mode 100644 index 000000000..5b9f0622d --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Elsa.Diagnostics.ConsoleLogs.UnitTests")] diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ConsoleStreamJsonConverter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ConsoleStreamJsonConverter.cs new file mode 100644 index 000000000..d3274d5bb --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ConsoleStreamJsonConverter.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ConsoleLogStreaming.Core.Models; + +namespace Elsa.Diagnostics.ConsoleLogs.Contracts; + +/// +/// Converts the public console stream filter contract values. +/// +public sealed class ConsoleStreamJsonConverter : JsonConverter +{ + public override ConsoleStream? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Null => null, + JsonTokenType.String => ReadString(reader.GetString()), + JsonTokenType.Number => ReadNumber(ref reader), + _ => throw new JsonException("The console log stream filter must be 'stdout', 'stderr', or null.") + }; + } + + public override void Write(Utf8JsonWriter writer, ConsoleStream? value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.Value switch + { + ConsoleStream.Stdout => "stdout", + ConsoleStream.Stderr => "stderr", + _ => throw new JsonException($"Unsupported console stream value '{value}'.") + }); + } + + private static ConsoleStream? ReadString(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + null or "" or "all" => null, + "stdout" => ConsoleStream.Stdout, + "stderr" => ConsoleStream.Stderr, + _ => throw new JsonException("The console log stream filter must be 'stdout', 'stderr', or null.") + }; + } + + private static ConsoleStream ReadNumber(ref Utf8JsonReader reader) + { + if (!reader.TryGetInt32(out var value) || !Enum.IsDefined(typeof(ConsoleStream), value)) + throw new JsonException("The console log stream filter has an unsupported numeric value."); + + return (ConsoleStream)value; + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs index 8571ce119..b4b0e42fb 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/ElsaConsoleLogFilter.cs @@ -1,21 +1,25 @@ +using System.Text.Json.Serialization; + namespace Elsa.Diagnostics.ConsoleLogs.Contracts; /// /// Console log filter accepted by Elsa REST and SignalR endpoints. /// -public sealed record ElsaConsoleLogFilter +public sealed class 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; } + public string? SourceId { get; set; } + + [JsonConverter(typeof(ConsoleStreamJsonConverter))] + public global::ConsoleLogStreaming.Core.Models.ConsoleStream? Stream { get; set; } + public string? Query { get; set; } + public string? WorkflowInstanceId { get; set; } + public string? WorkflowDefinitionId { get; set; } + public string? WorkflowDefinitionVersionId { get; set; } + public string? ActivityInstanceId { get; set; } + public string? ActivityId { get; set; } + public string? ActivityNodeId { get; set; } + public IDictionary? Metadata { get; set; } + public DateTimeOffset? From { get; set; } + public DateTimeOffset? To { get; set; } + public int? Limit { get; set; } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj index d1b95cc5b..208c80ab4 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj @@ -8,13 +8,13 @@ + + + - - - 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 906218c13..6f51c3436 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs @@ -1,15 +1,18 @@ +using System.Text.Json; using ConsoleLogStreaming.Core; using Elsa.Abstractions; -using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Permissions; using Elsa.Diagnostics.ConsoleLogs.Services; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent; [PublicAPI] -internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpoint +internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutRequest { + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web); + public override void Configure() { Verbs(FastEndpoints.Http.POST); @@ -17,9 +20,47 @@ internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMap ConfigurePermissions(ConsoleLogsPermissions.Read); } - public override async Task ExecuteAsync(ElsaConsoleLogFilter request, CancellationToken cancellationToken) + public override async Task ExecuteAsync(CancellationToken cancellationToken) { - var result = await provider.GetRecentAsync(mapper.ToCore(ConsoleLogFilterMapper.ToStreamingFilter(request)), cancellationToken); - return mapper.ToApi(result); + var request = await ReadJsonBodyAsync(cancellationToken); + if (ValidationFailed) + { + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return new(); + } + + var result = await provider.GetRecentAsync(ConsoleLogFilterMapper.ToStreamingFilter(request ?? new()), cancellationToken); + return result; + } + + private async Task ReadJsonBodyAsync(CancellationToken cancellationToken) + { + if (HttpContext.Request.ContentLength is 0) + return null; + + if (!HttpContext.Request.HasJsonContentType()) + return null; + + using var body = new MemoryStream(); + await HttpContext.Request.Body.CopyToAsync(body, cancellationToken); + + if (body.Length == 0) + return null; + + body.Position = 0; + try + { + return await DeserializeJsonBodyAsync(body, cancellationToken); + } + catch (JsonException) + { + AddError("Invalid console log filter JSON."); + return null; + } + } + + internal static async ValueTask DeserializeJsonBodyAsync(Stream body, CancellationToken cancellationToken) + { + return await JsonSerializer.DeserializeAsync(body, JsonSerializerOptions, cancellationToken); } } 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 98e1ed557..81858b72b 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs @@ -6,7 +6,7 @@ using JetBrains.Annotations; namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources; [PublicAPI] -internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMapper mapper) : ElsaEndpointWithoutRequest> +internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutRequest> { public override void Configure() { @@ -16,7 +16,6 @@ internal class Endpoint(IConsoleLogProvider provider, IConsoleLogStreamingApiMap public override async Task> ExecuteAsync(CancellationToken cancellationToken) { - var sources = await provider.ListSourcesAsync(cancellationToken); - return sources.Select(mapper.ToApi).ToList(); + return await provider.ListSourcesAsync(cancellationToken); } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs index 0463ef364..ac4bb466d 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogContextServiceCollectionExtensions.cs @@ -1,7 +1,4 @@ -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; @@ -13,7 +10,7 @@ internal static class ConsoleLogContextServiceCollectionExtensions { services.TryAddSingleton(_ => ConsoleLogContextAccessor.Instance); services.TryAddSingleton(sp => sp.GetRequiredService()); - 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/ConsoleLogProviderServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogProviderServiceCollectionExtensions.cs new file mode 100644 index 000000000..b0efb30d7 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ConsoleLogProviderServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using ConsoleLogStreaming.Core; +using Elsa.Diagnostics.ConsoleLogs.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Elsa.Diagnostics.ConsoleLogs.Extensions; + +internal static class ConsoleLogProviderServiceCollectionExtensions +{ + public static void DecorateConsoleLogProvider(this IServiceCollection services) + { + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(IConsoleLogProvider)); + if (descriptor == null) + return; + + services.TryAddSingleton(); + services.Remove(descriptor); + services.Add(ServiceDescriptor.Describe( + typeof(IConsoleLogProvider), + sp => new ElsaConsoleLogProvider( + CreateProvider(sp, descriptor), + sp.GetRequiredService(), + sp.GetRequiredService()), + descriptor.Lifetime)); + } + + private static IConsoleLogProvider CreateProvider(IServiceProvider serviceProvider, ServiceDescriptor descriptor) + { + if (descriptor.ImplementationInstance is IConsoleLogProvider instance) + return instance; + + if (descriptor.ImplementationFactory != null) + return (IConsoleLogProvider)descriptor.ImplementationFactory(serviceProvider)!; + + if (descriptor.ImplementationType != null) + return (IConsoleLogProvider)ActivatorUtilities.CreateInstance(serviceProvider, descriptor.ImplementationType); + + throw new InvalidOperationException("The console log provider registration is invalid."); + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs index 6401544fb..666ee32a6 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/EndpointRouteBuilderExtensions.cs @@ -1,9 +1,6 @@ -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; @@ -13,12 +10,6 @@ public static class EndpointRouteBuilderExtensions public static void MapConsoleLogsHub(this IEndpointRouteBuilder endpoints) { - 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); + endpoints.MapHub(HubRoute); } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs index 93d996cc1..f6152bbfb 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/HostServiceCollectionExtensions.cs @@ -1,13 +1,10 @@ -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; namespace Elsa.Diagnostics.ConsoleLogs.Extensions; @@ -19,14 +16,16 @@ public static class HostServiceCollectionExtensions public static IServiceCollection AddConsoleLogsHost(this IServiceCollection services, Action? configure = null) { services.AddConsoleLogContextServices(); - services.AddConsoleLogStreamingHost(options => + services.AddConsoleLogStreaming(options => { ElsaConsoleLogOptions.ConfigureDefaults(options); configure?.Invoke(options); }); - services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute); - services.TryAddSingleton(); + services.AddSignalR(); + services.DecorateConsoleLogProvider(); + services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs index 4cf635467..c0dde3cac 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,6 @@ -using ConsoleLogStreaming.Contracts; -using ConsoleLogStreaming.Core; using ConsoleLogStreaming.Core.DependencyInjection; using ConsoleLogStreaming.Core.Options; -using ConsoleLogStreaming.SignalR; -using ConsoleLogStreaming.SignalR.DependencyInjection; +using CShells.Lifecycle; using Elsa.Diagnostics.ConsoleLogs.RealTime; using Elsa.Diagnostics.ConsoleLogs.Services; using Microsoft.Extensions.DependencyInjection; @@ -19,14 +16,18 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action? configureOptions = null) { services.AddConsoleLogContextServices(); - services.AddConsoleLogStreamingHost(options => + services.AddConsoleLogStreaming(options => { ElsaConsoleLogOptions.ConfigureDefaults(options); configureOptions?.Invoke(options); }); - services.AddConsoleLogStreamingSignalR(options => options.HubPath = EndpointRouteBuilderExtensions.HubRoute); - services.TryAddSingleton(); + services.AddSignalR(); + services.DecorateConsoleLogProvider(); + services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); return services; } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md index 96c3c6c13..dd8058740 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/README.md @@ -69,7 +69,7 @@ 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. +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.Core.Models` DTOs at the REST and SignalR boundaries. ## Safety Boundaries diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs index ecc1237f2..0b25cc2a6 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogSubscriptionManager.cs @@ -1,13 +1,8 @@ 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; /// @@ -17,9 +12,7 @@ public sealed class ElsaConsoleLogSubscriptionManager : IDisposable { private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal); private readonly IConsoleLogProvider _provider; - private readonly IConsoleLogSourceRegistry _sourceRegistry; - private readonly IConsoleLogStreamingApiMapper _mapper; - private readonly IHubContext _hubContext; + private readonly IHubContext _hubContext; private readonly ILogger _logger; /// @@ -27,17 +20,12 @@ public sealed class ElsaConsoleLogSubscriptionManager : IDisposable /// public ElsaConsoleLogSubscriptionManager( IConsoleLogProvider provider, - IConsoleLogSourceRegistry sourceRegistry, - IConsoleLogStreamingApiMapper mapper, - IHubContext hubContext, + IHubContext hubContext, ILogger logger) { _provider = provider; - _sourceRegistry = sourceRegistry; - _mapper = mapper; _hubContext = hubContext; _logger = logger; - _sourceRegistry.SourceChanged += OnSourceChanged; } /// @@ -74,8 +62,6 @@ public sealed class ElsaConsoleLogSubscriptionManager : IDisposable /// public void Dispose() { - _sourceRegistry.SourceChanged -= OnSourceChanged; - foreach (var subscription in _subscriptions.Values) { subscription.CancellationTokenSource.Cancel(); @@ -89,13 +75,24 @@ public sealed class ElsaConsoleLogSubscriptionManager : IDisposable { try { - await foreach (var item in _provider.SubscribeAsync(_mapper.ToCore(ConsoleLogFilterMapper.ToStreamingFilter(filter)), cancellationToken).ConfigureAwait(false)) + string? lastSourceSignature = null; + + await foreach (var item in _provider.SubscribeAsync(ConsoleLogFilterMapper.ToStreamingFilter(filter), cancellationToken).ConfigureAwait(false)) { if (item.Line != null) - await _hubContext.Clients.Client(connectionId).ReceiveConsoleLogLineAsync(_mapper.ToApi(item.Line), cancellationToken).ConfigureAwait(false); + { + await _hubContext.Clients.Client(connectionId).ReceiveConsoleLogLineAsync(item.Line, cancellationToken).ConfigureAwait(false); + + var sourceSignature = GetSourceSignature(item.Line.Source); + if (!string.Equals(sourceSignature, lastSourceSignature, StringComparison.Ordinal)) + { + lastSourceSignature = sourceSignature; + await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(item.Line.Source, cancellationToken).ConfigureAwait(false); + } + } if (item.Dropped != null) - await _hubContext.Clients.Client(connectionId).ReceiveDroppedLinesAsync(_mapper.ToApi(item.Dropped), cancellationToken).ConfigureAwait(false); + await _hubContext.Clients.Client(connectionId).ReceiveDroppedLinesAsync(item.Dropped, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException e) @@ -128,36 +125,21 @@ public sealed class ElsaConsoleLogSubscriptionManager : IDisposable subscription.CancellationTokenSource.Dispose(); } - private void OnSourceChanged(CoreConsoleLogSource source) + private static string GetSourceSignature(ConsoleLogSource source) { - _ = BroadcastSourceChangedAsync(source, _subscriptions.ToArray()); - } + var metadata = source.Metadata + .OrderBy(x => x.Key, StringComparer.Ordinal) + .ThenBy(x => x.Value, StringComparer.Ordinal) + .Select(x => $"{x.Key}={x.Value}"); - private async Task BroadcastSourceChangedAsync(CoreConsoleLogSource source, IReadOnlyCollection> subscriptions) - { - try - { - foreach (var (connectionId, subscription) in subscriptions) - { - if (!MatchesSource(source, subscription.Filter)) - continue; - - await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(_mapper.ToApi(source), subscription.CancellationTokenSource.Token).ConfigureAwait(false); - } - } - catch (OperationCanceledException e) - { - _logger.LogDebug(e, "Console log source change broadcast for source {SourceId} was canceled", source.Id); - } - catch (Exception e) when (e is not OperationCanceledException) - { - _logger.LogDebug(e, "Failed to broadcast console log source change for source {SourceId}", source.Id); - } - } - - private static bool MatchesSource(CoreConsoleLogSource source, ElsaConsoleLogFilter filter) - { - return string.IsNullOrWhiteSpace(filter.SourceId) || string.Equals(source.Id, filter.SourceId, StringComparison.OrdinalIgnoreCase); + return string.Join('\u001f', + source.Id, + source.DisplayName, + source.ServiceName, + source.ProcessId?.ToString(), + source.MachineName, + source.Health.ToString(), + string.Join('\u001e', metadata)); } 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 index 592e619d1..a0fd23346 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogsHub.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ElsaConsoleLogsHub.cs @@ -1,9 +1,6 @@ -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.Authorization; using Microsoft.AspNetCore.SignalR; namespace Elsa.Diagnostics.ConsoleLogs.RealTime; @@ -11,11 +8,11 @@ namespace Elsa.Diagnostics.ConsoleLogs.RealTime; /// /// SignalR hub for live Elsa console log streaming. /// +[Authorize] public sealed class ElsaConsoleLogsHub( IConsoleLogProvider provider, - IConsoleLogStreamingApiMapper mapper, - IConsoleLogStreamingHubAuthorizer authorizer, - ElsaConsoleLogSubscriptionManager subscriptionManager) : Hub + IElsaConsoleLogHubAuthorizer authorizer, + ElsaConsoleLogSubscriptionManager subscriptionManager) : Hub { /// /// Streams matching console log items as a SignalR streaming method. @@ -27,8 +24,8 @@ public sealed class ElsaConsoleLogsHub( 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); + await foreach (var item in provider.SubscribeAsync(ConsoleLogFilterMapper.ToStreamingFilter(filter), cancellationToken).ConfigureAwait(false)) + yield return item; } /// @@ -80,3 +77,10 @@ public sealed class ElsaConsoleLogsHub( throw new HubException("Access denied."); } } + +public interface IElsaConsoleLogsClient +{ + 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/ConsoleLogCaptureHostedService.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs new file mode 100644 index 000000000..35d55f251 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs @@ -0,0 +1,11 @@ +using ConsoleLogStreaming.Core; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +internal sealed class ConsoleLogCaptureHostedService(IConsoleLogCapture capture) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => capture.StartAsync(cancellationToken).AsTask(); + + public Task StopAsync(CancellationToken cancellationToken) => capture.StopAsync(cancellationToken).AsTask(); +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureShellLease.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureShellLease.cs new file mode 100644 index 000000000..e1464f6b1 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureShellLease.cs @@ -0,0 +1,69 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Capture; +using CShells.Lifecycle; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +public class ConsoleLogCaptureShellLease(IServiceProvider serviceProvider) +{ + private readonly SemaphoreSlim _lock = new(1, 1); + private bool _started; + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_started) + return; + + ConsoleLogStreamingHost.AddReference(); + var releaseReference = true; + + try + { + var capture = serviceProvider.GetRequiredService(); + await capture.StartAsync(cancellationToken); + _started = true; + releaseReference = false; + } + finally + { + if (releaseReference) + await ConsoleLogStreamingHost.ReleaseReferenceAsync(CancellationToken.None); + } + } + finally + { + _lock.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_started) + return; + + _started = false; + await ConsoleLogStreamingHost.ReleaseReferenceAsync(cancellationToken); + } + finally + { + _lock.Release(); + } + } +} + +public class ConsoleLogCaptureShellInitializer(ConsoleLogCaptureShellLease lease) : IShellInitializer +{ + public Task InitializeAsync(CancellationToken cancellationToken = default) => lease.StartAsync(cancellationToken); +} + +public class ConsoleLogCaptureShellDrainHandler(ConsoleLogCaptureShellLease lease) : IDrainHandler +{ + public Task DrainAsync(IDrainExtensionHandle extensionHandle, CancellationToken cancellationToken) => lease.StopAsync(cancellationToken); +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs index dbe4b7fb8..c78ce367d 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessor.cs @@ -1,12 +1,9 @@ -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 +public sealed class ConsoleLogContextAccessor : IConsoleLogContextAccessor, ConsoleLogStreaming.Core.IConsoleLogMetadataAccessor { private static readonly AsyncLocal CurrentFrame = new(); private static readonly IReadOnlyDictionary Empty = new Dictionary(); diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs index 7344aa62e..030f64b09 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogContextAccessorExtensions.cs @@ -1,5 +1,3 @@ -using Elsa.Diagnostics.ConsoleLogs.Contracts; - namespace Elsa.Diagnostics.ConsoleLogs.Services; internal static class ConsoleLogContextAccessorExtensions diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs index b07cbe4ac..562022783 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterMapper.cs @@ -1,6 +1,3 @@ -using ConsoleLogStreaming.Contracts; -using Elsa.Diagnostics.ConsoleLogs.Contracts; - namespace Elsa.Diagnostics.ConsoleLogs.Services; internal static class ConsoleLogFilterMapper diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs index e9ac0fa91..7c9b7519b 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogOptions.cs @@ -13,12 +13,14 @@ internal static class ElsaConsoleLogOptions options.SourceDisplayName = !string.IsNullOrWhiteSpace(podName) ? podName : sourceId; options.ServiceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? AppDomain.CurrentDomain.FriendlyName; options.PreserveAnsi = true; + options.RecentCapacity = 2_000; + options.MaxRecentQuerySize = 250; - 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")); + SetMetadata(options, "kubernetes.pod.name", podName); + SetMetadata(options, "kubernetes.namespace.name", Environment.GetEnvironmentVariable("POD_NAMESPACE")); + SetMetadata(options, "container.name", Environment.GetEnvironmentVariable("CONTAINER_NAME")); + SetMetadata(options, "kubernetes.node.name", Environment.GetEnvironmentVariable("NODE_NAME")); + SetMetadata(options, "process.started_at", DateTimeOffset.UtcNow.ToString("O")); } private static void SetMetadata(ConsoleLogOptions options, string key, string? value) diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogProvider.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogProvider.cs new file mode 100644 index 000000000..f36e3b1a8 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogProvider.cs @@ -0,0 +1,110 @@ +using ConsoleLogStreaming.Core; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +internal sealed class ElsaConsoleLogProvider( + IConsoleLogProvider inner, + IConsoleLogContextAccessor contextAccessor, + ElsaConsoleLogRecentBuffer recentBuffer) : IConsoleLogProvider, IDisposable, IAsyncDisposable +{ + private static readonly IReadOnlyDictionary EmptyMetadataFilter = new Dictionary(); + + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + var enrichedLine = Enrich(line); + recentBuffer.Add(enrichedLine); + return inner.PublishAsync(enrichedLine, cancellationToken); + } + + public async ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + if (!HasMetadataFilter(filter)) + return await inner.GetRecentAsync(filter, cancellationToken); + + var result = await inner.GetRecentAsync(filter with + { + Metadata = EmptyMetadataFilter + }, cancellationToken); + var items = recentBuffer.Query(filter); + + return result with { Items = items }; + } + + public async IAsyncEnumerable SubscribeAsync( + ConsoleLogFilter filter, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (!HasMetadataFilter(filter)) + { + await foreach (var item in inner.SubscribeAsync(filter, cancellationToken).ConfigureAwait(false)) + yield return item; + + yield break; + } + + await foreach (var item in inner.SubscribeAsync(filter with { Metadata = EmptyMetadataFilter }, cancellationToken).ConfigureAwait(false)) + { + if (item.Line != null && !MatchesMetadata(item.Line, filter.Metadata)) + continue; + + yield return item; + } + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return inner.ListSourcesAsync(cancellationToken); + } + + public void Dispose() + { + switch (inner) + { + case IDisposable disposable: + disposable.Dispose(); + break; + case IAsyncDisposable asyncDisposable: + asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); + break; + } + } + + public async ValueTask DisposeAsync() + { + switch (inner) + { + case IAsyncDisposable asyncDisposable: + await asyncDisposable.DisposeAsync(); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } + + private ConsoleLogLine Enrich(ConsoleLogLine line) + { + var ambientMetadata = contextAccessor.GetMetadata(); + if (ambientMetadata.Count == 0) + return line; + + var metadata = new Dictionary(line.Metadata, StringComparer.OrdinalIgnoreCase); + foreach (var item in ambientMetadata) + metadata[item.Key] = item.Value; + + return line with { Metadata = metadata }; + } + + private static bool HasMetadataFilter(ConsoleLogFilter filter) => filter.Metadata.Count > 0; + + private static bool MatchesMetadata(ConsoleLogLine line, IReadOnlyDictionary metadata) + { + foreach (var (key, value) in metadata) + { + if (!line.Metadata.TryGetValue(key, out var candidate) || !string.Equals(candidate, value, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogRecentBuffer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogRecentBuffer.cs new file mode 100644 index 000000000..38f050765 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogRecentBuffer.cs @@ -0,0 +1,69 @@ +using ConsoleLogStreaming.Core.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.ConsoleLogs.Services; + +internal sealed class ElsaConsoleLogRecentBuffer +{ + private readonly object _lock = new(); + private readonly Queue _lines = new(); + private readonly int _capacity; + private readonly int _maxQuerySize; + + public ElsaConsoleLogRecentBuffer(IOptions options) + { + _capacity = Math.Max(1, options.Value.RecentCapacity); + _maxQuerySize = Math.Max(1, options.Value.MaxRecentQuerySize); + } + + public void Add(ConsoleLogLine line) + { + lock (_lock) + { + _lines.Enqueue(line); + + while (_lines.Count > _capacity) + _lines.Dequeue(); + } + } + + public IReadOnlyList Query(ConsoleLogFilter filter) + { + ConsoleLogLine[] snapshot; + + lock (_lock) + snapshot = _lines.ToArray(); + + var limit = filter.Limit is > 0 ? Math.Min(filter.Limit.Value, _maxQuerySize) : _maxQuerySize; + return snapshot + .Where(line => Matches(line, filter)) + .TakeLast(limit) + .ToArray(); + } + + private static bool Matches(ConsoleLogLine line, ConsoleLogFilter filter) + { + if (!string.IsNullOrWhiteSpace(filter.SourceId) && !string.Equals(line.Source.Id, filter.SourceId, StringComparison.OrdinalIgnoreCase)) + return false; + + if (filter.Stream.HasValue && line.Stream != filter.Stream.Value) + return false; + + if (!string.IsNullOrWhiteSpace(filter.Query) && !line.Text.Contains(filter.Query, StringComparison.OrdinalIgnoreCase)) + return false; + + if (filter.From.HasValue && line.ReceivedAt < filter.From.Value) + return false; + + if (filter.To.HasValue && line.ReceivedAt > filter.To.Value) + return false; + + foreach (var (key, value) in filter.Metadata) + { + if (!line.Metadata.TryGetValue(key, out var candidate) || !string.Equals(candidate, value, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs index 2961c06a4..f08da4b89 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ElsaConsoleLogStreamHubAuthorizer.cs @@ -1,11 +1,15 @@ -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 +public interface IElsaConsoleLogHubAuthorizer +{ + ValueTask CanReadAsync(HubCallerContext context, CancellationToken cancellationToken = default); +} + +public sealed class ElsaConsoleLogStreamHubAuthorizer : IElsaConsoleLogHubAuthorizer { private const string ReadAllPermission = "read:*"; private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ConsoleLogsPermissions.Read]; diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs index ada74e6f4..955c8ec42 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs @@ -24,14 +24,11 @@ public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature 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.PreserveAnsi; - public TimeSpan SourceHeartbeatTimeout { get; set; } = DefaultOptions.SourceHeartbeatTimeout; - public string RedactionReplacement { get; set; } = DefaultOptions.RedactionReplacement; - public ICollection SensitiveNames { get; set; } = [..DefaultOptions.SensitiveNames]; + public string RedactionReplacement { get; set; } = "[Redacted]"; public ICollection SensitiveTextPatterns { get; set; } = [..DefaultOptions.RedactionRules.Select(x => x.Pattern)]; public void ConfigureServices(IServiceCollection services) @@ -48,14 +45,10 @@ public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature { options.RecentCapacity = RecentLogCapacity; options.SubscriberCapacity = SubscriberChannelCapacity; - options.CaptureChannelCapacity = CaptureChannelCapacity; options.MaxRecentQuerySize = MaxRecentQuerySize; options.MaxLineLength = MaxLineLength; options.IdleFlushTimeout = IdleFlushTimeout; options.PreserveAnsi = !StripAnsiEscapeSequences; - options.SourceHeartbeatTimeout = SourceHeartbeatTimeout; - options.RedactionReplacement = RedactionReplacement; - options.SensitiveNames = [..SensitiveNames]; options.RedactionRules.Clear(); foreach (var pattern in SensitiveTextPatterns) { diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs index 0fd2c0638..42fc132f3 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs @@ -1,5 +1,5 @@ global using System.Runtime.CompilerServices; -global using ConsoleLogStreaming.Contracts; +global using ConsoleLogStreaming.Core.Models; global using Elsa.Diagnostics.ConsoleLogs.Contracts; global using Elsa.Workflows; global using Elsa.Workflows.Pipelines.ActivityExecution; diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/AssemblyInfo.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/AssemblyInfo.cs new file mode 100644 index 000000000..479bbcf0f --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Elsa.Diagnostics.OpenTelemetry.UnitTests")] diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/ICollectorConfigurationProvider.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/ICollectorConfigurationProvider.cs new file mode 100644 index 000000000..82d810f19 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/ICollectorConfigurationProvider.cs @@ -0,0 +1,8 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface ICollectorConfigurationProvider +{ + ValueTask GetAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryIngestor.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryIngestor.cs new file mode 100644 index 000000000..ebe0b2037 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryIngestor.cs @@ -0,0 +1,8 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetryIngestor +{ + ValueTask IngestAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryLiveFeed.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryLiveFeed.cs new file mode 100644 index 000000000..24148cdb1 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryLiveFeed.cs @@ -0,0 +1,9 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetryLiveFeed +{ + ValueTask PublishAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default); + IAsyncEnumerable SubscribeAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryProvider.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryProvider.cs new file mode 100644 index 000000000..7a2e18b00 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryProvider.cs @@ -0,0 +1,14 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetryProvider +{ + ValueTask GetResourcesAsync(OpenTelemetryResourceFilter filter, CancellationToken cancellationToken = default); + ValueTask GetTracesAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default); + ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default); + ValueTask GetMetricsAsync(OpenTelemetryMetricFilter filter, CancellationToken cancellationToken = default); + ValueTask GetLogsAsync(OpenTelemetryLogFilter filter, CancellationToken cancellationToken = default); + ValueTask GetStorageDiagnosticsAsync(CancellationToken cancellationToken = default); + ValueTask GetCollectorConfigurationAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryRedactor.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryRedactor.cs new file mode 100644 index 000000000..a97f6a04e --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryRedactor.cs @@ -0,0 +1,8 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetryRedactor +{ + OpenTelemetryBatch Redact(OpenTelemetryBatch batch); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetrySourceRegistry.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetrySourceRegistry.cs new file mode 100644 index 000000000..9e314c138 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetrySourceRegistry.cs @@ -0,0 +1,10 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetrySourceRegistry +{ + long DroppedCount { get; } + void MarkSeen(TelemetryResource resource); + IReadOnlyCollection List(); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryStore.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryStore.cs new file mode 100644 index 000000000..2fac67706 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Contracts/IOpenTelemetryStore.cs @@ -0,0 +1,14 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Contracts; + +public interface IOpenTelemetryStore +{ + ValueTask WriteAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default); + ValueTask QueryResourcesAsync(OpenTelemetryResourceFilter filter, CancellationToken cancellationToken = default); + ValueTask QueryTracesAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default); + ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default); + ValueTask QueryMetricsAsync(OpenTelemetryMetricFilter filter, CancellationToken cancellationToken = default); + ValueTask QueryLogsAsync(OpenTelemetryLogFilter filter, CancellationToken cancellationToken = default); + ValueTask GetDiagnosticsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Elsa.Diagnostics.OpenTelemetry.csproj b/src/modules/Elsa.Diagnostics.OpenTelemetry/Elsa.Diagnostics.OpenTelemetry.csproj new file mode 100644 index 000000000..5341fb4cf --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Elsa.Diagnostics.OpenTelemetry.csproj @@ -0,0 +1,16 @@ + + + + Provides OpenTelemetry diagnostics collection and query services for Elsa hosts. + elsa module diagnostics opentelemetry otel otlp signalr operations + + + + + + + + + + + diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/CollectorConfiguration/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/CollectorConfiguration/Endpoint.cs new file mode 100644 index 000000000..18816c5fa --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/CollectorConfiguration/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.CollectorConfiguration; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/diagnostics/opentelemetry/collector-configuration"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + return await provider.GetCollectorConfigurationAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Logs/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Logs/Endpoint.cs new file mode 100644 index 000000000..edb27a692 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Logs/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Logs; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/diagnostics/opentelemetry/logs/search"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(OpenTelemetryLogFilter request, CancellationToken cancellationToken) + { + return await provider.GetLogsAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Metrics/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Metrics/Endpoint.cs new file mode 100644 index 000000000..ddf444163 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Metrics/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Metrics; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/diagnostics/opentelemetry/metrics/search"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(OpenTelemetryMetricFilter request, CancellationToken cancellationToken) + { + return await provider.GetMetricsAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Resources/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Resources/Endpoint.cs new file mode 100644 index 000000000..a38052b04 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Resources/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Resources; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/diagnostics/opentelemetry/resources/search"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(OpenTelemetryResourceFilter request, CancellationToken cancellationToken) + { + return await provider.GetResourcesAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Storage/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Storage/Endpoint.cs new file mode 100644 index 000000000..e946aebcf --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Storage/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Storage; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/diagnostics/opentelemetry/storage"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + return await provider.GetStorageDiagnosticsAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Trace/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Trace/Endpoint.cs new file mode 100644 index 000000000..c919503a6 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Trace/Endpoint.cs @@ -0,0 +1,29 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Trace; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/diagnostics/opentelemetry/traces/{traceId}"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var trace = await provider.GetTraceAsync(Route("traceId")!, cancellationToken); + if (trace == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + + await Send.OkAsync(trace, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Traces/Endpoint.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Traces/Endpoint.cs new file mode 100644 index 000000000..63056b4f0 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Endpoints/OpenTelemetry/Traces/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Traces; + +[PublicAPI] +internal class Endpoint(IOpenTelemetryProvider provider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/diagnostics/opentelemetry/traces/search"); + ConfigurePermissions(OpenTelemetryPermissions.Read); + } + + public override async Task ExecuteAsync(OpenTelemetryTraceFilter request, CancellationToken cancellationToken) + { + return await provider.GetTracesAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/EndpointRouteBuilderExtensions.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..ba497335c --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Ingestion; +using Elsa.Diagnostics.OpenTelemetry.Ingestion.HttpProtobuf; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.RealTime; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Extensions; + +public static class EndpointRouteBuilderExtensions +{ + public const string DefaultHubRoute = "/elsa/hubs/diagnostics/opentelemetry"; + + public static void MapOpenTelemetryHub(this IEndpointRouteBuilder endpoints) + { + var options = endpoints.ServiceProvider.GetRequiredService>().Value; + endpoints.MapHub(string.IsNullOrWhiteSpace(options.HubRoute) ? DefaultHubRoute : options.HubRoute); + } + + public static void MapOpenTelemetryHttpProtobufCollector(this IEndpointRouteBuilder endpoints) + { + var options = endpoints.ServiceProvider.GetRequiredService>().Value; + var basePath = (string.IsNullOrWhiteSpace(options.HttpEndpointPath) ? "/elsa/otlp/v1" : options.HttpEndpointPath).TrimEnd('/'); + + endpoints.MapPost($"{basePath}/traces", static async (HttpContext httpContext, IOpenTelemetryIngestor ingestor, IOptions options, CancellationToken cancellationToken) => + { + return await IngestAsync(httpContext, ingestor, options, payload => OtlpHttpProtobufParser.ParseTraces(payload.Span), cancellationToken); + }); + + endpoints.MapPost($"{basePath}/metrics", static async (HttpContext httpContext, IOpenTelemetryIngestor ingestor, IOptions options, CancellationToken cancellationToken) => + { + return await IngestAsync(httpContext, ingestor, options, payload => OtlpHttpProtobufParser.ParseMetrics(payload.Span), cancellationToken); + }); + + endpoints.MapPost($"{basePath}/logs", static async (HttpContext httpContext, IOpenTelemetryIngestor ingestor, IOptions options, CancellationToken cancellationToken) => + { + return await IngestAsync(httpContext, ingestor, options, payload => OtlpHttpProtobufParser.ParseLogs(payload.Span), cancellationToken); + }); + } + + public static void MapOpenTelemetryGrpcCollector(this IEndpointRouteBuilder endpoints) + { + var options = endpoints.ServiceProvider.GetRequiredService>().Value; + + if (!options.EnableGrpc) + return; + + if (string.IsNullOrWhiteSpace(options.GrpcEndpointPath)) + throw new InvalidOperationException("OpenTelemetry gRPC ingestion is enabled, but no gRPC endpoint path was configured."); + + // The actual gRPC service binding is host-specific. This module exposes shared ingestion + // contracts and accurate collector metadata without forcing every host to reference gRPC. + } + + private static async Task IngestAsync( + HttpContext httpContext, + IOpenTelemetryIngestor ingestor, + IOptions options, + Func, OpenTelemetryBatch> parse, + CancellationToken cancellationToken) + { + if (!OtlpIngestionSecurity.IsAuthorized(httpContext, options.Value)) + return Results.Unauthorized(); + + try + { + var payload = await ReadBodyAsync(httpContext, options.Value.MaxHttpRequestBodySize, cancellationToken); + await ingestor.IngestAsync(parse(payload), cancellationToken); + return Results.Ok(); + } + catch (RequestBodyTooLargeException) + { + return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); + } + catch (InvalidDataException) + { + return Results.BadRequest(); + } + } + + private static async Task> ReadBodyAsync(HttpContext httpContext, long maxBodySize, CancellationToken cancellationToken) + { + using var stream = new MemoryStream(); + var buffer = ArrayPool.Shared.Rent(81920); + var totalBytes = 0L; + + try + { + int read; + while ((read = await httpContext.Request.Body.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0) + { + totalBytes += read; + if (totalBytes > maxBodySize) + throw new RequestBodyTooLargeException(); + + stream.Write(buffer, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return stream.ToArray(); + } + + private sealed class RequestBodyTooLargeException : Exception; +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..d36a113c2 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ModuleExtensions.cs @@ -0,0 +1,14 @@ +using Elsa.Diagnostics.OpenTelemetry.Features; +using Elsa.Features.Services; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class OpenTelemetryDiagnosticsModuleExtensions +{ + public static IModule UseOpenTelemetryDiagnostics(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..e2580549b --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; +using Elsa.Diagnostics.OpenTelemetry.RealTime; +using Elsa.Diagnostics.OpenTelemetry.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Elsa.Diagnostics.OpenTelemetry.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddOpenTelemetryDiagnosticsServices(this IServiceCollection services, Action? configureOptions = null) + { + if (configureOptions != null) + services.Configure(configureOptions); + + services.AddSignalR(); + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Features/OpenTelemetryFeature.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Features/OpenTelemetryFeature.cs new file mode 100644 index 000000000..2356e7331 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Features/OpenTelemetryFeature.cs @@ -0,0 +1,23 @@ +using Elsa.Diagnostics.OpenTelemetry.Extensions; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; + +namespace Elsa.Diagnostics.OpenTelemetry.Features; + +public class OpenTelemetryFeature(IModule module) : FeatureBase(module) +{ + public Action? ConfigureOptions { get; set; } + + public override void Configure() + { + Module.AddFastEndpointsAssembly(); + } + + public override void Apply() + { + Services.AddOpenTelemetryDiagnosticsServices(ConfigureOptions); + Module.AddFastEndpointsFromModule(); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/FodyWeavers.xml b/src/modules/Elsa.Diagnostics.OpenTelemetry/FodyWeavers.xml new file mode 100644 index 000000000..535dce765 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/FodyWeavers.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/HttpProtobuf/OtlpHttpProtobufParser.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/HttpProtobuf/OtlpHttpProtobufParser.cs new file mode 100644 index 000000000..7cffcebe4 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/HttpProtobuf/OtlpHttpProtobufParser.cs @@ -0,0 +1,701 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Ingestion.HttpProtobuf; + +internal static class OtlpHttpProtobufParser +{ + public static OpenTelemetryBatch ParseTraces(ReadOnlySpan payload) + { + var resources = new List(); + var spans = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number != 1 || field.WireType != ProtobufWireType.LengthDelimited) + continue; + + var resourceSpans = ParseResourceSpans(field.Bytes); + resources.Add(resourceSpans.Resource); + spans.AddRange(resourceSpans.Spans); + } + + return CreateBatch(resources, spans, [], [], []); + } + + public static OpenTelemetryBatch ParseMetrics(ReadOnlySpan payload) + { + var resources = new List(); + var instruments = new Dictionary(StringComparer.OrdinalIgnoreCase); + var points = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number != 1 || field.WireType != ProtobufWireType.LengthDelimited) + continue; + + var resourceMetrics = ParseResourceMetrics(field.Bytes); + resources.Add(resourceMetrics.Resource); + + foreach (var instrument in resourceMetrics.Instruments) + instruments[instrument.Id] = instrument; + + points.AddRange(resourceMetrics.Points); + } + + return CreateBatch(resources, [], instruments.Values.ToList(), points, []); + } + + public static OpenTelemetryBatch ParseLogs(ReadOnlySpan payload) + { + var resources = new List(); + var logs = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number != 1 || field.WireType != ProtobufWireType.LengthDelimited) + continue; + + var resourceLogs = ParseResourceLogs(field.Bytes); + resources.Add(resourceLogs.Resource); + logs.AddRange(resourceLogs.Logs); + } + + return CreateBatch(resources, [], [], [], logs); + } + + private static (TelemetryResource Resource, List Spans) ParseResourceSpans(ReadOnlySpan payload) + { + var resource = CreateResource(new Dictionary()); + var spans = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.WireType != ProtobufWireType.LengthDelimited) + continue; + + if (field.Number == 1) + resource = CreateResource(ParseResourceAttributes(field.Bytes)); + else if (field.Number == 2) + spans.AddRange(ParseScopeSpans(field.Bytes, resource.Id)); + } + + return (resource, spans); + } + + private static List ParseScopeSpans(ReadOnlySpan payload, string resourceId) + { + var spans = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + spans.Add(ParseSpan(field.Bytes, resourceId)); + } + + return spans; + } + + private static TelemetrySpan ParseSpan(ReadOnlySpan payload, string resourceId) + { + var traceId = ""; + var spanId = ""; + string? parentSpanId = null; + var name = ""; + var kind = "unspecified"; + var start = DateTimeOffset.UnixEpoch; + var end = DateTimeOffset.UnixEpoch; + var status = SpanStatus.Unset; + string? statusDescription = null; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var events = new List(); + var links = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + switch (field.Number) + { + case 1 when field.WireType == ProtobufWireType.LengthDelimited: + traceId = ToHex(field.Bytes); + break; + case 2 when field.WireType == ProtobufWireType.LengthDelimited: + spanId = ToHex(field.Bytes); + break; + case 4 when field.WireType == ProtobufWireType.LengthDelimited: + parentSpanId = ToHex(field.Bytes); + break; + case 5 when field.WireType == ProtobufWireType.LengthDelimited: + name = field.StringValue(); + break; + case 6: + kind = SpanKindName(field.Varint); + break; + case 7: + start = FromUnixNanos(field.Varint); + break; + case 8: + end = FromUnixNanos(field.Varint); + break; + case 9 when field.WireType == ProtobufWireType.LengthDelimited: + AddAttribute(attributes, field.Bytes); + break; + case 11 when field.WireType == ProtobufWireType.LengthDelimited: + events.Add(ParseSpanEvent(field.Bytes)); + break; + case 12 when field.WireType == ProtobufWireType.LengthDelimited: + links.Add(ParseSpanLink(field.Bytes)); + break; + case 15 when field.WireType == ProtobufWireType.LengthDelimited: + (status, statusDescription) = ParseSpanStatus(field.Bytes); + break; + } + } + + if (end < start) + end = start; + + return new TelemetrySpan($"{traceId}:{spanId}", traceId, spanId, parentSpanId, resourceId, name, kind, start, end, status, statusDescription, attributes, events, links); + } + + private static TelemetrySpanEvent ParseSpanEvent(ReadOnlySpan payload) + { + var timestamp = DateTimeOffset.UnixEpoch; + var name = ""; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1) + timestamp = FromUnixNanos(field.Varint); + else if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + name = field.StringValue(); + else if (field.Number == 3 && field.WireType == ProtobufWireType.LengthDelimited) + AddAttribute(attributes, field.Bytes); + } + + return new TelemetrySpanEvent(name, timestamp, attributes); + } + + private static TelemetrySpanLink ParseSpanLink(ReadOnlySpan payload) + { + var traceId = ""; + var spanId = ""; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1 && field.WireType == ProtobufWireType.LengthDelimited) + traceId = ToHex(field.Bytes); + else if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + spanId = ToHex(field.Bytes); + else if (field.Number == 3 && field.WireType == ProtobufWireType.LengthDelimited) + AddAttribute(attributes, field.Bytes); + } + + return new TelemetrySpanLink(traceId, spanId, attributes); + } + + private static (SpanStatus Status, string? Description) ParseSpanStatus(ReadOnlySpan payload) + { + var status = SpanStatus.Unset; + string? description = null; + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + description = field.StringValue(); + else if (field.Number == 3) + status = field.Varint switch + { + 1 => SpanStatus.Ok, + 2 => SpanStatus.Error, + _ => SpanStatus.Unset + }; + } + + return (status, description); + } + + private static (TelemetryResource Resource, List Instruments, List Points) ParseResourceMetrics(ReadOnlySpan payload) + { + var resource = CreateResource(new Dictionary()); + var instruments = new List(); + var points = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.WireType != ProtobufWireType.LengthDelimited) + continue; + + if (field.Number == 1) + resource = CreateResource(ParseResourceAttributes(field.Bytes)); + else if (field.Number == 2) + ParseScopeMetrics(field.Bytes, resource.Id, instruments, points); + } + + return (resource, instruments, points); + } + + private static void ParseScopeMetrics(ReadOnlySpan payload, string resourceId, ICollection instruments, ICollection points) + { + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number != 2 || field.WireType != ProtobufWireType.LengthDelimited) + continue; + + var metric = ParseMetric(field.Bytes, resourceId); + instruments.Add(metric.Instrument); + + foreach (var point in metric.Points) + points.Add(point); + } + } + + private static (MetricInstrument Instrument, List Points) ParseMetric(ReadOnlySpan payload, string resourceId) + { + var name = ""; + string? description = null; + string? unit = null; + var kind = MetricKind.Gauge; + var points = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + switch (field.Number) + { + case 1 when field.WireType == ProtobufWireType.LengthDelimited: + name = field.StringValue(); + break; + case 2 when field.WireType == ProtobufWireType.LengthDelimited: + description = field.StringValue(); + break; + case 3 when field.WireType == ProtobufWireType.LengthDelimited: + unit = field.StringValue(); + break; + case 5 when field.WireType == ProtobufWireType.LengthDelimited: + kind = MetricKind.Gauge; + points.AddRange(ParseNumberDataPoints(field.Bytes, resourceId, () => $"{resourceId}:{name}:gauge")); + break; + case 7 when field.WireType == ProtobufWireType.LengthDelimited: + kind = MetricKind.Sum; + points.AddRange(ParseNumberDataPoints(field.Bytes, resourceId, () => $"{resourceId}:{name}:sum")); + break; + case 9 when field.WireType == ProtobufWireType.LengthDelimited: + kind = MetricKind.Histogram; + points.AddRange(ParseHistogramDataPoints(field.Bytes, resourceId, () => $"{resourceId}:{name}:histogram")); + break; + } + } + + var instrumentId = $"{resourceId}:{name}:{kind.ToString().ToLowerInvariant()}"; + var instrument = new MetricInstrument(instrumentId, resourceId, name, unit, description, kind, new Dictionary()); + points = points.Select(x => x with { InstrumentId = instrumentId, InstrumentName = name }).ToList(); + return (instrument, points); + } + + private static List ParseNumberDataPoints(ReadOnlySpan payload, string resourceId, Func instrumentIdFactory) + { + var points = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1 && field.WireType == ProtobufWireType.LengthDelimited) + points.Add(ParseNumberDataPoint(field.Bytes, resourceId, instrumentIdFactory())); + } + + return points; + } + + private static MetricPoint ParseNumberDataPoint(ReadOnlySpan payload, string resourceId, string instrumentId) + { + var timestamp = DateTimeOffset.UnixEpoch; + double? value = null; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 3) + timestamp = FromUnixNanos(field.Varint); + else if (field.Number == 4 && field.WireType == ProtobufWireType.Fixed64) + value = field.DoubleValue; + else if (field.Number == 6) + value = (long)field.Varint; + else if (field.Number == 7 && field.WireType == ProtobufWireType.LengthDelimited) + AddAttribute(attributes, field.Bytes); + } + + return new MetricPoint(Guid.NewGuid().ToString("N"), instrumentId, "", resourceId, timestamp, value, null, null, attributes, null, null); + } + + private static List ParseHistogramDataPoints(ReadOnlySpan payload, string resourceId, Func instrumentIdFactory) + { + var points = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1 && field.WireType == ProtobufWireType.LengthDelimited) + points.Add(ParseHistogramDataPoint(field.Bytes, resourceId, instrumentIdFactory())); + } + + return points; + } + + private static MetricPoint ParseHistogramDataPoint(ReadOnlySpan payload, string resourceId, string instrumentId) + { + var timestamp = DateTimeOffset.UnixEpoch; + long? count = null; + double? sum = null; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 3) + timestamp = FromUnixNanos(field.Varint); + else if (field.Number == 4) + count = (long)field.Varint; + else if (field.Number == 5 && field.WireType == ProtobufWireType.Fixed64) + sum = field.DoubleValue; + else if (field.Number == 9 && field.WireType == ProtobufWireType.LengthDelimited) + AddAttribute(attributes, field.Bytes); + } + + return new MetricPoint(Guid.NewGuid().ToString("N"), instrumentId, "", resourceId, timestamp, null, sum, count, attributes, null, null); + } + + private static (TelemetryResource Resource, List Logs) ParseResourceLogs(ReadOnlySpan payload) + { + var resource = CreateResource(new Dictionary()); + var logs = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.WireType != ProtobufWireType.LengthDelimited) + continue; + + if (field.Number == 1) + resource = CreateResource(ParseResourceAttributes(field.Bytes)); + else if (field.Number == 2) + logs.AddRange(ParseScopeLogs(field.Bytes, resource.Id)); + } + + return (resource, logs); + } + + private static List ParseScopeLogs(ReadOnlySpan payload, string resourceId) + { + var logs = new List(); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + logs.Add(ParseLogRecord(field.Bytes, resourceId)); + } + + return logs; + } + + private static OtlpLogRecord ParseLogRecord(ReadOnlySpan payload, string resourceId) + { + var timestamp = DateTimeOffset.UnixEpoch; + var severityText = ""; + int? severityNumber = null; + var body = ""; + string? traceId = null; + string? spanId = null; + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + switch (field.Number) + { + case 1: + timestamp = FromUnixNanos(field.Varint); + break; + case 2: + severityNumber = (int)field.Varint; + break; + case 3 when field.WireType == ProtobufWireType.LengthDelimited: + severityText = field.StringValue(); + break; + case 5 when field.WireType == ProtobufWireType.LengthDelimited: + body = ParseAnyValue(field.Bytes) ?? ""; + break; + case 6 when field.WireType == ProtobufWireType.LengthDelimited: + AddAttribute(attributes, field.Bytes); + break; + case 9 when field.WireType == ProtobufWireType.LengthDelimited: + traceId = ToHex(field.Bytes); + break; + case 10 when field.WireType == ProtobufWireType.LengthDelimited: + spanId = ToHex(field.Bytes); + break; + } + } + + if (string.IsNullOrWhiteSpace(severityText) && severityNumber != null) + severityText = SeverityName(severityNumber.Value); + + return new OtlpLogRecord(Guid.NewGuid().ToString("N"), resourceId, timestamp, severityText, severityNumber, body, traceId, spanId, attributes); + } + + private static Dictionary ParseResourceAttributes(ReadOnlySpan payload) + { + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1 && field.WireType == ProtobufWireType.LengthDelimited) + AddAttribute(attributes, field.Bytes); + } + + return attributes; + } + + private static void AddAttribute(IDictionary attributes, ReadOnlySpan payload) + { + string? key = null; + string? value = null; + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + if (field.Number == 1 && field.WireType == ProtobufWireType.LengthDelimited) + key = field.StringValue(); + else if (field.Number == 2 && field.WireType == ProtobufWireType.LengthDelimited) + value = ParseAnyValue(field.Bytes); + } + + if (!string.IsNullOrWhiteSpace(key)) + attributes[key] = value; + } + + private static string? ParseAnyValue(ReadOnlySpan payload) + { + var reader = new ProtobufReader(payload); + + while (reader.TryReadField(out var field)) + { + return field.Number switch + { + 1 when field.WireType == ProtobufWireType.LengthDelimited => field.StringValue(), + 2 => field.Varint != 0 ? "true" : "false", + 3 => ((long)field.Varint).ToString(CultureInfo.InvariantCulture), + 4 when field.WireType == ProtobufWireType.Fixed64 => field.DoubleValue.ToString(CultureInfo.InvariantCulture), + 7 when field.WireType == ProtobufWireType.LengthDelimited => ToHex(field.Bytes), + _ => null + }; + } + + return null; + } + + private static TelemetryResource CreateResource(IDictionary attributes) + { + var serviceName = GetAttribute(attributes, "service.name") ?? "unknown_service"; + var instanceId = GetAttribute(attributes, "service.instance.id"); + var resourceId = string.IsNullOrWhiteSpace(instanceId) ? serviceName : $"{serviceName}:{instanceId}"; + return new TelemetryResource(resourceId, serviceName, instanceId, GetAttribute(attributes, "telemetry.sdk.language"), new Dictionary(attributes, StringComparer.OrdinalIgnoreCase), DateTimeOffset.UtcNow, TelemetryResourceStatus.Active); + } + + private static OpenTelemetryBatch CreateBatch( + IReadOnlyCollection resources, + IReadOnlyCollection spans, + IReadOnlyCollection instruments, + IReadOnlyCollection points, + IReadOnlyCollection logs) + { + var traces = spans + .GroupBy(x => x.TraceId, StringComparer.OrdinalIgnoreCase) + .Select(CreateTrace) + .ToList(); + + return new OpenTelemetryBatch(resources.DistinctBy(x => x.Id).ToList(), traces, spans.ToList(), instruments.ToList(), points.ToList(), logs.ToList()); + } + + private static TelemetryTrace CreateTrace(IGrouping spans) + { + var orderedSpans = spans.OrderBy(x => x.StartTime).ToList(); + var root = orderedSpans.FirstOrDefault(x => string.IsNullOrWhiteSpace(x.ParentSpanId)) ?? orderedSpans[0]; + var start = orderedSpans.Min(x => x.StartTime); + var end = orderedSpans.Max(x => x.EndTime); + var status = orderedSpans.Any(x => x.Status == SpanStatus.Error) ? SpanStatus.Error : orderedSpans.Any(x => x.Status == SpanStatus.Ok) ? SpanStatus.Ok : SpanStatus.Unset; + var workflowInstanceIds = orderedSpans + .Select(x => GetAttribute(x.Attributes, "workflow.instance.id")) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new TelemetryTrace(spans.Key, root.SpanId, root.Name, start, end, end - start, status, orderedSpans.Select(x => x.ResourceId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), workflowInstanceIds, orderedSpans.Count); + } + + private static string? GetAttribute(IDictionary attributes, string key) => attributes.TryGetValue(key, out var value) ? value : null; + + private static DateTimeOffset FromUnixNanos(ulong value) + { + try + { + var ticks = checked((long)(value / 100)); + return DateTimeOffset.UnixEpoch.AddTicks(ticks); + } + catch (Exception e) when (e is OverflowException or ArgumentOutOfRangeException) + { + throw new InvalidDataException("The OTLP timestamp is outside the supported range.", e); + } + } + + private static string ToHex(ReadOnlySpan bytes) + { + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string SpanKindName(ulong value) => value switch + { + 1 => "internal", + 2 => "server", + 3 => "client", + 4 => "producer", + 5 => "consumer", + _ => "unspecified" + }; + + private static string SeverityName(int value) => value switch + { + >= 21 => "Fatal", + >= 17 => "Error", + >= 13 => "Warning", + >= 9 => "Information", + >= 5 => "Debug", + >= 1 => "Trace", + _ => "Unspecified" + }; + + private enum ProtobufWireType + { + Varint = 0, + Fixed64 = 1, + LengthDelimited = 2, + Fixed32 = 5 + } + + private readonly ref struct ProtobufField + { + public ProtobufField(int number, ProtobufWireType wireType, ulong varint, ReadOnlySpan bytes, double doubleValue) + { + Number = number; + WireType = wireType; + Varint = varint; + Bytes = bytes; + DoubleValue = doubleValue; + } + + public int Number { get; } + public ProtobufWireType WireType { get; } + public ulong Varint { get; } + public ReadOnlySpan Bytes { get; } + public double DoubleValue { get; } + + public string StringValue() => Encoding.UTF8.GetString(Bytes); + } + + private ref struct ProtobufReader + { + private ReadOnlySpan _remaining; + + public ProtobufReader(ReadOnlySpan payload) + { + _remaining = payload; + } + + public bool TryReadField(out ProtobufField field) + { + field = default; + + if (_remaining.IsEmpty) + return false; + + var tag = ReadVarint(); + var number = (int)(tag >> 3); + var wireType = (ProtobufWireType)(tag & 7); + + switch (wireType) + { + case ProtobufWireType.Varint: + field = new ProtobufField(number, wireType, ReadVarint(), default, default); + return true; + case ProtobufWireType.Fixed64: + EnsureAvailable(8); + var fixed64 = BinaryPrimitives.ReadUInt64LittleEndian(_remaining[..8]); + _remaining = _remaining[8..]; + field = new ProtobufField(number, wireType, fixed64, default, BitConverter.Int64BitsToDouble((long)fixed64)); + return true; + case ProtobufWireType.LengthDelimited: + var length = checked((int)ReadVarint()); + EnsureAvailable(length); + var bytes = _remaining[..length]; + _remaining = _remaining[length..]; + field = new ProtobufField(number, wireType, default, bytes, default); + return true; + case ProtobufWireType.Fixed32: + EnsureAvailable(4); + _remaining = _remaining[4..]; + field = new ProtobufField(number, wireType, default, default, default); + return true; + default: + throw new InvalidDataException($"Unsupported protobuf wire type '{wireType}'."); + } + } + + private ulong ReadVarint() + { + ulong value = 0; + var shift = 0; + + for (var i = 0; i < 10; i++) + { + if (_remaining.IsEmpty) + throw new InvalidDataException("Unexpected end of protobuf payload."); + + var b = _remaining[0]; + _remaining = _remaining[1..]; + value |= (ulong)(b & 0x7f) << shift; + + if ((b & 0x80) == 0) + return value; + + shift += 7; + } + + throw new InvalidDataException("Invalid protobuf varint."); + } + + private readonly void EnsureAvailable(int byteCount) + { + if (_remaining.Length < byteCount) + throw new InvalidDataException("Unexpected end of protobuf payload."); + } + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/OtlpIngestionSecurity.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/OtlpIngestionSecurity.cs new file mode 100644 index 000000000..b8d13ddac --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Ingestion/OtlpIngestionSecurity.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Diagnostics.OpenTelemetry.Ingestion; + +public static class OtlpIngestionSecurity +{ + public static bool IsAuthorized(HttpContext httpContext, OpenTelemetryDiagnosticsOptions options) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + return options.AllowUnauthenticatedLoopback && IsLoopback(httpContext); + + return httpContext.Request.Headers.TryGetValue(options.ApiKeyHeaderName, out var value) && ApiKeysMatch(value.ToString(), options.ApiKey); + } + + private static bool IsLoopback(HttpContext httpContext) + { + var remoteAddress = httpContext.Connection.RemoteIpAddress; + return remoteAddress != null && IPAddress.IsLoopback(remoteAddress); + } + + private static bool ApiKeysMatch(string providedApiKey, string expectedApiKey) + { + var providedBytes = Encoding.UTF8.GetBytes(providedApiKey); + var expectedBytes = Encoding.UTF8.GetBytes(expectedApiKey); + var providedHash = SHA256.HashData(providedBytes); + var expectedHash = SHA256.HashData(expectedBytes); + + try + { + return CryptographicOperations.FixedTimeEquals(providedHash, expectedHash); + } + finally + { + CryptographicOperations.ZeroMemory(providedBytes); + CryptographicOperations.ZeroMemory(expectedBytes); + CryptographicOperations.ZeroMemory(providedHash); + CryptographicOperations.ZeroMemory(expectedHash); + } + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryContracts.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryContracts.cs new file mode 100644 index 000000000..8f2d3dc77 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryContracts.cs @@ -0,0 +1,86 @@ +namespace Elsa.Diagnostics.OpenTelemetry.Models; + +public record OpenTelemetryResourceFilter +{ + public string? Search { get; init; } + public string? ServiceName { get; init; } + public TelemetryResourceStatus? Status { get; init; } + public int? Take { get; init; } +} + +public record OpenTelemetryTraceFilter +{ + public string? ResourceId { get; init; } + public string? ServiceName { get; init; } + public string? TraceId { get; init; } + public string? WorkflowInstanceId { get; init; } + public SpanStatus? Status { get; init; } + public DateTimeOffset? From { get; init; } + public DateTimeOffset? To { get; init; } + public string? Search { get; init; } + public int? Take { get; init; } +} + +public record OpenTelemetryMetricFilter +{ + public string? ResourceId { get; init; } + public string? ServiceName { get; init; } + public string? InstrumentName { get; init; } + public DateTimeOffset? From { get; init; } + public DateTimeOffset? To { get; init; } + public int? Take { get; init; } +} + +public record OpenTelemetryLogFilter +{ + public string? ResourceId { get; init; } + public string? ServiceName { get; init; } + public string? TraceId { get; init; } + public string? SpanId { get; init; } + public string? Severity { get; init; } + public DateTimeOffset? From { get; init; } + public DateTimeOffset? To { get; init; } + public string? Search { get; init; } + public int? Take { get; init; } +} + +public record OpenTelemetryResourceResult(IReadOnlyCollection Items, long DroppedCount); + +public record OpenTelemetryTraceResult(IReadOnlyCollection Items, long DroppedCount); + +public record OpenTelemetryTraceDetail( + TelemetryTrace Trace, + IReadOnlyCollection Spans, + IReadOnlyCollection Resources, + IReadOnlyCollection Logs); + +public record OpenTelemetryMetricResult(IReadOnlyCollection Instruments, IReadOnlyCollection Points, long DroppedCount); + +public record OpenTelemetryLogResult(IReadOnlyCollection Items, long DroppedCount); + +public record CollectorEndpointInfo(string Protocol, string? Endpoint, bool Enabled, string? DisabledReason); + +public record CollectorConfiguration( + CollectorEndpointInfo Http, + CollectorEndpointInfo Grpc, + string ServiceNameEnvironmentVariable, + string EndpointEnvironmentVariable, + string ProtocolEnvironmentVariable, + IReadOnlyDictionary RequiredHeaders); + +public record OpenTelemetryBatch( + IReadOnlyCollection Resources, + IReadOnlyCollection Traces, + IReadOnlyCollection Spans, + IReadOnlyCollection Instruments, + IReadOnlyCollection MetricPoints, + IReadOnlyCollection Logs); + +public record OpenTelemetryStreamItem +{ + public TelemetryResource? Resource { get; init; } + public TelemetryTrace? Trace { get; init; } + public MetricPoint? MetricPoint { get; init; } + public OtlpLogRecord? Log { get; init; } + public OpenTelemetryDroppedItemSummary? DroppedItems { get; init; } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryModels.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryModels.cs new file mode 100644 index 000000000..4e201fce8 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Models/OpenTelemetryModels.cs @@ -0,0 +1,127 @@ +namespace Elsa.Diagnostics.OpenTelemetry.Models; + +public enum OpenTelemetrySignalType +{ + Trace, + Metric, + Log +} + +public enum TelemetryResourceStatus +{ + Unknown, + Active, + Stale +} + +public enum SpanStatus +{ + Unset, + Ok, + Error +} + +public enum MetricKind +{ + Gauge, + Sum, + Histogram +} + +public record TelemetryResource( + string Id, + string ServiceName, + string? ServiceInstanceId, + string? TelemetrySdkLanguage, + IDictionary Attributes, + DateTimeOffset LastSeen, + TelemetryResourceStatus Status); + +public record TelemetrySpan( + string Id, + string TraceId, + string SpanId, + string? ParentSpanId, + string ResourceId, + string Name, + string Kind, + DateTimeOffset StartTime, + DateTimeOffset EndTime, + SpanStatus Status, + string? StatusDescription, + IDictionary Attributes, + IReadOnlyCollection Events, + IReadOnlyCollection Links); + +public record TelemetrySpanEvent( + string Name, + DateTimeOffset Timestamp, + IDictionary Attributes); + +public record TelemetrySpanLink( + string TraceId, + string SpanId, + IDictionary Attributes); + +public record TelemetryTrace( + string TraceId, + string? RootSpanId, + string? Name, + DateTimeOffset StartTime, + DateTimeOffset EndTime, + TimeSpan Duration, + SpanStatus Status, + IReadOnlyCollection ResourceIds, + IReadOnlyCollection WorkflowInstanceIds, + int SpanCount); + +public record MetricInstrument( + string Id, + string ResourceId, + string Name, + string? Unit, + string? Description, + MetricKind Kind, + IDictionary Attributes); + +public record MetricPoint( + string Id, + string InstrumentId, + string InstrumentName, + string ResourceId, + DateTimeOffset Timestamp, + double? Value, + double? Sum, + long? Count, + IDictionary Attributes, + string? TraceId, + string? SpanId); + +public record OtlpLogRecord( + string Id, + string ResourceId, + DateTimeOffset Timestamp, + string SeverityText, + int? SeverityNumber, + string Body, + string? TraceId, + string? SpanId, + IDictionary Attributes); + +public record OpenTelemetryDroppedItemSummary(OpenTelemetrySignalType SignalType, long Count, string Reason); + +public record OpenTelemetryStorageDiagnostics( + int TraceCapacity, + int SpanCapacity, + int MetricPointCapacity, + int LogRecordCapacity, + int ResourceCount, + int TraceCount, + int SpanCount, + int MetricInstrumentCount, + int MetricPointCount, + int LogRecordCount, + long DroppedTraceCount, + long DroppedSpanCount, + long DroppedMetricPointCount, + long DroppedLogRecordCount); diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Options/OpenTelemetryDiagnosticsOptions.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Options/OpenTelemetryDiagnosticsOptions.cs new file mode 100644 index 000000000..5d2d4b11a --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Options/OpenTelemetryDiagnosticsOptions.cs @@ -0,0 +1,43 @@ +namespace Elsa.Diagnostics.OpenTelemetry.Options; + +public class OpenTelemetryDiagnosticsOptions +{ + public int TraceCapacity { get; set; } = 5_000; + public int SpanCapacity { get; set; } = 25_000; + public int MetricPointCapacity { get; set; } = 25_000; + public int LogRecordCapacity { get; set; } = 10_000; + public int ResourceCapacity { get; set; } = 500; + public int SubscriberChannelCapacity { get; set; } = 1_000; + public int MaxQuerySize { get; set; } = 1_000; + public long MaxHttpRequestBodySize { get; set; } = 10 * 1024 * 1024; + public string HttpEndpointPath { get; set; } = "/elsa/otlp/v1"; + public string? GrpcEndpointPath { get; set; } + public string GrpcDisabledReason { get; set; } = "gRPC ingestion is not enabled for this host."; + public string HubRoute { get; set; } = "/elsa/hubs/diagnostics/opentelemetry"; + public bool EnableGrpc { get; set; } + public string? ApiKey { get; set; } + public string ApiKeyHeaderName { get; set; } = "x-otlp-api-key"; + public bool AllowUnauthenticatedLoopback { get; set; } = true; + + 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]+)" + ]; + + public TimeSpan SensitiveTextPatternTimeout { get; set; } = TimeSpan.FromMilliseconds(100); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Permissions/OpenTelemetryPermissions.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Permissions/OpenTelemetryPermissions.cs new file mode 100644 index 000000000..47bb415b5 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Permissions/OpenTelemetryPermissions.cs @@ -0,0 +1,7 @@ +namespace Elsa.Diagnostics.OpenTelemetry.Permissions; + +public static class OpenTelemetryPermissions +{ + public const string Read = "read:diagnostics:opentelemetry"; + public const string Ingest = "ingest:diagnostics:opentelemetry"; +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryLiveFeed.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryLiveFeed.cs new file mode 100644 index 000000000..b84776c44 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryLiveFeed.cs @@ -0,0 +1,166 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; + +public class InMemoryOpenTelemetryLiveFeed(IOptions options) : IOpenTelemetryLiveFeed +{ + private readonly OpenTelemetryDiagnosticsOptions _options = options.Value; + private readonly object _subscribersLock = new(); + private readonly List _subscribers = []; + + public ValueTask PublishAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default) + { + List subscribers; + lock (_subscribersLock) + subscribers = _subscribers.ToList(); + + foreach (var subscriber in subscribers) + subscriber.TryWrite(batch); + + return ValueTask.CompletedTask; + } + + public async IAsyncEnumerable SubscribeAsync(OpenTelemetryTraceFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var subscriber = new OpenTelemetrySubscriber(filter, _options.SubscriberChannelCapacity); + + lock (_subscribersLock) + _subscribers.Add(subscriber); + + try + { + await foreach (var item in subscriber.Channel.Reader.ReadAllAsync(cancellationToken)) + { + subscriber.MarkRead(item); + yield return item; + } + } + finally + { + lock (_subscribersLock) + _subscribers.Remove(subscriber); + } + } + + private sealed class OpenTelemetrySubscriber(OpenTelemetryTraceFilter filter, int channelCapacity) + { + private readonly object _lock = new(); + private readonly int _capacity = Math.Max(1, channelCapacity); + private int _pendingItemCount; + private readonly Dictionary _droppedSinceLastSummary = []; + + public Channel Channel { get; } = System.Threading.Channels.Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, channelCapacity)) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }); + + public void TryWrite(OpenTelemetryBatch batch) + { + foreach (var resource in batch.Resources) + TryWrite(new OpenTelemetryStreamItem { Resource = resource }); + + foreach (var trace in batch.Traces.Where(Matches)) + TryWrite(new OpenTelemetryStreamItem { Trace = trace }); + + foreach (var log in batch.Logs.Where(x => string.IsNullOrWhiteSpace(filter.TraceId) || string.Equals(x.TraceId, filter.TraceId, StringComparison.OrdinalIgnoreCase))) + TryWrite(new OpenTelemetryStreamItem { Log = log }); + + foreach (var point in batch.MetricPoints) + TryWrite(new OpenTelemetryStreamItem { MetricPoint = point }); + } + + private void TryWrite(OpenTelemetryStreamItem item) + { + lock (_lock) + { + EnsureCapacityForWrite(); + if (Channel.Writer.TryWrite(item)) + _pendingItemCount++; + else + TrackDrop(item); + + TryWriteDroppedSummary(); + } + } + + public void MarkRead(OpenTelemetryStreamItem item) + { + lock (_lock) + { + _pendingItemCount = Math.Max(0, _pendingItemCount - 1); + + if (item.DroppedItems != null) + _droppedSinceLastSummary.Remove(item.DroppedItems.SignalType); + + TryWriteDroppedSummary(); + } + } + + private void EnsureCapacityForWrite() + { + if (_pendingItemCount < _capacity) + return; + + if (!Channel.Reader.TryRead(out var dropped)) + return; + + _pendingItemCount = Math.Max(0, _pendingItemCount - 1); + TrackDrop(dropped); + } + + private void TrackDrop(OpenTelemetryStreamItem item) + { + var signalType = GetSignalType(item); + if (signalType == null) + return; + + _droppedSinceLastSummary.TryGetValue(signalType.Value, out var count); + _droppedSinceLastSummary[signalType.Value] = count + (item.DroppedItems?.Count ?? 1); + } + + private void TryWriteDroppedSummary() + { + if (_droppedSinceLastSummary.Count == 0) + return; + + if (_pendingItemCount >= _capacity) + return; + + var dropped = _droppedSinceLastSummary.OrderBy(x => x.Key).First(); + if (Channel.Writer.TryWrite(new OpenTelemetryStreamItem { DroppedItems = new(dropped.Key, dropped.Value, "SubscriberQueueFull") })) + _pendingItemCount++; + } + + private static OpenTelemetrySignalType? GetSignalType(OpenTelemetryStreamItem item) + { + if (item.Trace != null) + return OpenTelemetrySignalType.Trace; + + if (item.MetricPoint != null) + return OpenTelemetrySignalType.Metric; + + if (item.Log != null) + return OpenTelemetrySignalType.Log; + + return item.DroppedItems?.SignalType; + } + + private bool Matches(TelemetryTrace trace) + { + if (!string.IsNullOrWhiteSpace(filter.TraceId) && !string.Equals(trace.TraceId, filter.TraceId, StringComparison.OrdinalIgnoreCase)) + return false; + + if (filter.Status != null && trace.Status != filter.Status) + return false; + + return true; + } + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs new file mode 100644 index 000000000..93ba58d27 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/InMemoryOpenTelemetryStore.cs @@ -0,0 +1,227 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; + +public class InMemoryOpenTelemetryStore : IOpenTelemetryStore +{ + private readonly IOpenTelemetrySourceRegistry _sourceRegistry; + private readonly OpenTelemetryDiagnosticsOptions _options; + private readonly int _traceCapacity; + private readonly int _spanCapacity; + private readonly int _metricPointCapacity; + private readonly int _logRecordCapacity; + private readonly RingBuffer _traces; + private readonly RingBuffer _spans; + private readonly RingBuffer _metricPoints; + private readonly RingBuffer _logs; + private readonly object _instrumentLock = new(); + private readonly Dictionary _instruments = new(StringComparer.OrdinalIgnoreCase); + + public InMemoryOpenTelemetryStore(IOptions options, IOpenTelemetrySourceRegistry sourceRegistry) + { + _options = options.Value; + _sourceRegistry = sourceRegistry; + _traceCapacity = ClampCapacity(_options.TraceCapacity); + _spanCapacity = ClampCapacity(_options.SpanCapacity); + _metricPointCapacity = ClampCapacity(_options.MetricPointCapacity); + _logRecordCapacity = ClampCapacity(_options.LogRecordCapacity); + _traces = new(_traceCapacity); + _spans = new(_spanCapacity); + _metricPoints = new(_metricPointCapacity); + _logs = new(_logRecordCapacity); + } + + public ValueTask WriteAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default) + { + foreach (var resource in batch.Resources) + _sourceRegistry.MarkSeen(resource); + + foreach (var trace in batch.Traces) + _traces.Add(trace); + + foreach (var span in batch.Spans) + _spans.Add(span); + + lock (_instrumentLock) + { + foreach (var instrument in batch.Instruments) + _instruments[instrument.Id] = instrument; + } + + foreach (var point in batch.MetricPoints) + _metricPoints.Add(point); + + foreach (var log in batch.Logs) + _logs.Add(log); + + return ValueTask.CompletedTask; + } + + public ValueTask QueryResourcesAsync(OpenTelemetryResourceFilter filter, CancellationToken cancellationToken = default) + { + var take = ClampTake(filter.Take); + var items = _sourceRegistry.List() + .Where(x => string.IsNullOrWhiteSpace(filter.ServiceName) || string.Equals(x.ServiceName, filter.ServiceName, StringComparison.OrdinalIgnoreCase)) + .Where(x => filter.Status == null || x.Status == filter.Status) + .Where(x => string.IsNullOrWhiteSpace(filter.Search) || Matches(x.ServiceName, filter.Search) || Matches(x.Id, filter.Search)) + .OrderByDescending(x => x.LastSeen) + .ThenBy(x => x.ServiceName, StringComparer.OrdinalIgnoreCase) + .Take(take) + .ToList(); + + return ValueTask.FromResult(new OpenTelemetryResourceResult(items, _sourceRegistry.DroppedCount)); + } + + public ValueTask QueryTracesAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default) + { + var take = ClampTake(filter.Take); + var serviceResourceIds = ResolveResourceIds(filter.ServiceName); + var items = _traces.Snapshot() + .Where(x => string.IsNullOrWhiteSpace(filter.TraceId) || Matches(x.TraceId, filter.TraceId)) + .Where(x => filter.Status == null || x.Status == filter.Status) + .Where(x => filter.From == null || x.StartTime >= filter.From) + .Where(x => filter.To == null || x.StartTime <= filter.To) + .Where(x => string.IsNullOrWhiteSpace(filter.WorkflowInstanceId) || x.WorkflowInstanceIds.Any(id => Matches(id, filter.WorkflowInstanceId))) + .Where(x => string.IsNullOrWhiteSpace(filter.ResourceId) || x.ResourceIds.Contains(filter.ResourceId, StringComparer.OrdinalIgnoreCase)) + .Where(x => serviceResourceIds == null || x.ResourceIds.Any(serviceResourceIds.Contains)) + .Where(x => string.IsNullOrWhiteSpace(filter.Search) || Matches(x.TraceId, filter.Search) || Matches(x.Name, filter.Search)) + .OrderBy(x => x.StartTime) + .GroupBy(x => x.TraceId, StringComparer.OrdinalIgnoreCase) + .Select(x => x.Last()) + .TakeLast(take) + .ToList(); + + return ValueTask.FromResult(new OpenTelemetryTraceResult(items, _traces.DroppedCount)); + } + + public ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default) + { + var trace = _traces.Snapshot().LastOrDefault(x => string.Equals(x.TraceId, traceId, StringComparison.OrdinalIgnoreCase)); + if (trace == null) + return ValueTask.FromResult(null); + + var spans = _spans.Snapshot() + .Where(x => string.Equals(x.TraceId, traceId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.StartTime) + .ThenBy(x => x.SpanId, StringComparer.OrdinalIgnoreCase) + .ToList(); + var resourceIds = trace.ResourceIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + var resources = _sourceRegistry.List() + .Where(x => resourceIds.Contains(x.Id)) + .OrderBy(x => x.ServiceName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + var logs = _logs.Snapshot() + .Where(x => string.Equals(x.TraceId, traceId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Timestamp) + .ThenBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return ValueTask.FromResult(new OpenTelemetryTraceDetail(trace, spans, resources, logs)); + } + + public ValueTask QueryMetricsAsync(OpenTelemetryMetricFilter filter, CancellationToken cancellationToken = default) + { + var take = ClampTake(filter.Take); + var serviceResourceIds = ResolveResourceIds(filter.ServiceName); + + Dictionary instruments; + lock (_instrumentLock) + instruments = new(_instruments, StringComparer.OrdinalIgnoreCase); + + var instrumentFilterIds = string.IsNullOrWhiteSpace(filter.InstrumentName) + ? null + : instruments.Values + .Where(x => Matches(x.Name, filter.InstrumentName)) + .Select(x => x.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var points = _metricPoints.Snapshot() + .Where(x => string.IsNullOrWhiteSpace(filter.ResourceId) || string.Equals(x.ResourceId, filter.ResourceId, StringComparison.OrdinalIgnoreCase)) + .Where(x => serviceResourceIds == null || serviceResourceIds.Contains(x.ResourceId)) + .Where(x => filter.From == null || x.Timestamp >= filter.From) + .Where(x => filter.To == null || x.Timestamp <= filter.To) + .Where(x => instrumentFilterIds == null || instrumentFilterIds.Contains(x.InstrumentId)) + .OrderBy(x => x.Timestamp) + .TakeLast(take) + .ToList(); + + var selectedInstruments = points + .Select(x => x.InstrumentId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(instruments.ContainsKey) + .Select(x => instruments[x]) + .ToList(); + + return ValueTask.FromResult(new OpenTelemetryMetricResult(selectedInstruments, points, _metricPoints.DroppedCount)); + } + + public ValueTask QueryLogsAsync(OpenTelemetryLogFilter filter, CancellationToken cancellationToken = default) + { + var take = ClampTake(filter.Take); + var serviceResourceIds = ResolveResourceIds(filter.ServiceName); + var logs = _logs.Snapshot() + .Where(x => string.IsNullOrWhiteSpace(filter.ResourceId) || string.Equals(x.ResourceId, filter.ResourceId, StringComparison.OrdinalIgnoreCase)) + .Where(x => serviceResourceIds == null || serviceResourceIds.Contains(x.ResourceId)) + .Where(x => string.IsNullOrWhiteSpace(filter.TraceId) || Matches(x.TraceId, filter.TraceId)) + .Where(x => string.IsNullOrWhiteSpace(filter.SpanId) || Matches(x.SpanId, filter.SpanId)) + .Where(x => string.IsNullOrWhiteSpace(filter.Severity) || Matches(x.SeverityText, filter.Severity)) + .Where(x => filter.From == null || x.Timestamp >= filter.From) + .Where(x => filter.To == null || x.Timestamp <= filter.To) + .Where(x => string.IsNullOrWhiteSpace(filter.Search) || Matches(x.Body, filter.Search)) + .OrderBy(x => x.Timestamp) + .TakeLast(take) + .ToList(); + + return ValueTask.FromResult(new OpenTelemetryLogResult(logs, _logs.DroppedCount)); + } + + public ValueTask GetDiagnosticsAsync(CancellationToken cancellationToken = default) + { + Dictionary instruments; + lock (_instrumentLock) + instruments = new(_instruments, StringComparer.OrdinalIgnoreCase); + + var traceSnapshot = _traces.Snapshot(); + var spanSnapshot = _spans.Snapshot(); + var metricPointSnapshot = _metricPoints.Snapshot(); + var logSnapshot = _logs.Snapshot(); + var diagnostics = new OpenTelemetryStorageDiagnostics( + _traceCapacity, + _spanCapacity, + _metricPointCapacity, + _logRecordCapacity, + _sourceRegistry.List().Count, + traceSnapshot.Count, + spanSnapshot.Count, + instruments.Count, + metricPointSnapshot.Count, + logSnapshot.Count, + _traces.DroppedCount, + _spans.DroppedCount, + _metricPoints.DroppedCount, + _logs.DroppedCount); + + return ValueTask.FromResult(diagnostics); + } + + private int ClampTake(int? take) => Math.Clamp(take ?? _options.MaxQuerySize, 0, _options.MaxQuerySize); + + private static int ClampCapacity(int capacity) => Math.Max(1, capacity); + + private HashSet? ResolveResourceIds(string? serviceName) + { + if (string.IsNullOrWhiteSpace(serviceName)) + return null; + + return _sourceRegistry.List() + .Where(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static bool Matches(string? candidate, string? search) => !string.IsNullOrEmpty(candidate) && !string.IsNullOrEmpty(search) && candidate.Contains(search, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/RingBuffer.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/RingBuffer.cs new file mode 100644 index 000000000..cd9dc9cd9 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Providers/InMemory/RingBuffer.cs @@ -0,0 +1,46 @@ +namespace Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; + +public class RingBuffer +{ + private readonly Queue _items = new(); + private readonly object _lock = new(); + private readonly int _capacity; + private long _droppedCount; + + public RingBuffer(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero."); + + _capacity = capacity; + } + + public long DroppedCount + { + get + { + lock (_lock) + return _droppedCount; + } + } + + 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(); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/README.md b/src/modules/Elsa.Diagnostics.OpenTelemetry/README.md new file mode 100644 index 000000000..cf204accb --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/README.md @@ -0,0 +1,41 @@ +# Elsa Diagnostics OpenTelemetry + +Provides the OpenTelemetry diagnostics collector surface for Elsa hosts. + +This module owns the Core-side OTEL contracts, HTTP/protobuf ingestion, bounded in-memory diagnostics store, feature registration, permissions, read APIs, live update hub, and collector metadata that Studio consumes. + +## Scope + +`Elsa.Diagnostics.OpenTelemetry` is collector/read-side diagnostics infrastructure. It does not create workflow spans, mutate `Activity.Current`, export telemetry to vendors, or provide durable OpenTelemetry persistence. Workflow telemetry production remains owned by existing `Elsa.Workflows` `ActivitySource` and `Meter` instrumentation. + +The historical `Elsa.OpenTelemetry` module from `elsa-extensions` is intentionally not ported into this feature. It is producer-side workflow tracing middleware, duplicates current `Elsa.Workflows` instrumentation, and mutates `Activity.Current`; this module is collector/read-side diagnostics infrastructure. + +## Routes + +OTLP HTTP/protobuf ingestion is exposed under `OpenTelemetryDiagnosticsOptions.HttpEndpointPath`, which defaults to `/elsa/otlp/v1`: + +- `POST /elsa/otlp/v1/traces` +- `POST /elsa/otlp/v1/metrics` +- `POST /elsa/otlp/v1/logs` + +Diagnostics read APIs are exposed through Elsa API endpoints: + +- `POST /diagnostics/opentelemetry/resources/search` +- `POST /diagnostics/opentelemetry/traces/search` +- `GET /diagnostics/opentelemetry/traces/{traceId}` +- `POST /diagnostics/opentelemetry/metrics/search` +- `POST /diagnostics/opentelemetry/logs/search` +- `GET /diagnostics/opentelemetry/storage` +- `GET /diagnostics/opentelemetry/collector-configuration` + +Live updates are exposed through SignalR at `OpenTelemetryDiagnosticsOptions.HubRoute`, which defaults to `/elsa/hubs/diagnostics/opentelemetry`. + +## Security + +All diagnostics read APIs require the OpenTelemetry diagnostics read permission. OTLP ingestion allows unauthenticated loopback traffic by default for local development only when no API key is configured. When `OpenTelemetryDiagnosticsOptions.ApiKey` is set, every sender must provide the configured API key header, including loopback senders. + +Collector configuration returns endpoint and required-header names only. It never returns secret header values. + +## Storage + +The default provider is bounded in-memory storage. Capacity options cover traces, spans, metric points, OTLP log records, and live subscriber queues. When a buffer exceeds capacity, the oldest item for that signal is dropped and diagnostics counters are incremented. diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/IOpenTelemetryClient.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/IOpenTelemetryClient.cs new file mode 100644 index 000000000..e7242dbed --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/IOpenTelemetryClient.cs @@ -0,0 +1,8 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.RealTime; + +public interface IOpenTelemetryClient +{ + Task ReceiveAsync(OpenTelemetryStreamItem item); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetryHub.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetryHub.cs new file mode 100644 index 000000000..eea15d662 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetryHub.cs @@ -0,0 +1,50 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using FastEndpoints.Security; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Elsa.Diagnostics.OpenTelemetry.RealTime; + +[Authorize] +public class OpenTelemetryHub(OpenTelemetrySubscriptionManager subscriptionManager) : Hub +{ + private const string ReadAllPermission = "read:*"; + private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, OpenTelemetryPermissions.Read]; + + public Task SubscribeAsync(OpenTelemetryTraceFilter? filter) + { + EnsureCanReadOpenTelemetry(); + return subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted); + } + + 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 OpenTelemetryTraceFilter ValidateFilter(OpenTelemetryTraceFilter? filter) + { + filter ??= new(); + + if (filter.From is { } from && filter.To is { } to && from > to) + throw new HubException("The OpenTelemetry filter 'from' timestamp must be earlier than or equal to 'to'."); + + return filter; + } + + private void EnsureCanReadOpenTelemetry() + { + 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.OpenTelemetry/RealTime/OpenTelemetrySubscriptionManager.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetrySubscriptionManager.cs new file mode 100644 index 000000000..c676e1ac3 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/RealTime/OpenTelemetrySubscriptionManager.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace Elsa.Diagnostics.OpenTelemetry.RealTime; + +public sealed class OpenTelemetrySubscriptionManager( + IOpenTelemetryLiveFeed liveFeed, + IHubContext hubContext, + ILogger logger) : IDisposable +{ + private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal); + + public Task SubscribeAsync(string connectionId, OpenTelemetryTraceFilter filter, CancellationToken cancellationToken) + { + Unsubscribe(connectionId); + var subscriptionCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var subscription = new OpenTelemetrySubscription(filter, subscriptionCancellation); + _subscriptions[connectionId] = subscription; + + _ = StreamAsync(connectionId, filter, subscription, subscriptionCancellation.Token); + return Task.CompletedTask; + } + + public Task UnsubscribeAsync(string connectionId) + { + Unsubscribe(connectionId); + return Task.CompletedTask; + } + + public void Dispose() + { + foreach (var subscription in _subscriptions.Values) + { + subscription.CancellationTokenSource.Cancel(); + subscription.CancellationTokenSource.Dispose(); + } + + _subscriptions.Clear(); + } + + private async Task StreamAsync(string connectionId, OpenTelemetryTraceFilter filter, OpenTelemetrySubscription subscription, CancellationToken cancellationToken) + { + try + { + await foreach (var item in liveFeed.SubscribeAsync(filter, cancellationToken).ConfigureAwait(false)) + await hubContext.Clients.Client(connectionId).ReceiveAsync(item).ConfigureAwait(false); + } + catch (OperationCanceledException e) + { + logger.LogDebug(e, "OpenTelemetry subscription for connection {ConnectionId} was canceled", connectionId); + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.LogWarning(e, "OpenTelemetry subscription for connection {ConnectionId} stopped unexpectedly", connectionId); + } + finally + { + Remove(connectionId, subscription); + } + } + + private void Unsubscribe(string connectionId) + { + if (!_subscriptions.TryRemove(connectionId, out var subscription)) + return; + + subscription.CancellationTokenSource.Cancel(); + subscription.CancellationTokenSource.Dispose(); + } + + private void Remove(string connectionId, OpenTelemetrySubscription subscription) + { + var entry = new KeyValuePair(connectionId, subscription); + if (((ICollection>)_subscriptions).Remove(entry)) + subscription.CancellationTokenSource.Dispose(); + } + + private sealed record OpenTelemetrySubscription(OpenTelemetryTraceFilter Filter, CancellationTokenSource CancellationTokenSource); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/CollectorConfigurationProvider.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/CollectorConfigurationProvider.cs new file mode 100644 index 000000000..8001aadda --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/CollectorConfigurationProvider.cs @@ -0,0 +1,34 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Services; + +public class CollectorConfigurationProvider(IOptions options) : ICollectorConfigurationProvider +{ + private readonly OpenTelemetryDiagnosticsOptions _options = options.Value; + + public ValueTask GetAsync(CancellationToken cancellationToken = default) + { + var headers = string.IsNullOrWhiteSpace(_options.ApiKey) + ? new Dictionary() + : new Dictionary { [_options.ApiKeyHeaderName] = "" }; + var grpcEnabled = _options.EnableGrpc && !string.IsNullOrWhiteSpace(_options.GrpcEndpointPath); + var grpcDisabledReason = grpcEnabled + ? null + : _options.EnableGrpc + ? "gRPC endpoint path is not configured." + : _options.GrpcDisabledReason; + + var configuration = new CollectorConfiguration( + new CollectorEndpointInfo("http/protobuf", _options.HttpEndpointPath, true, null), + new CollectorEndpointInfo("grpc", grpcEnabled ? _options.GrpcEndpointPath : null, grpcEnabled, grpcDisabledReason), + "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + headers); + + return ValueTask.FromResult(configuration); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/DefaultOpenTelemetryProvider.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/DefaultOpenTelemetryProvider.cs new file mode 100644 index 000000000..5dddb9d66 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/DefaultOpenTelemetryProvider.cs @@ -0,0 +1,21 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Services; + +public class DefaultOpenTelemetryProvider(IOpenTelemetryStore store, ICollectorConfigurationProvider collectorConfigurationProvider) : IOpenTelemetryProvider +{ + public ValueTask GetResourcesAsync(OpenTelemetryResourceFilter filter, CancellationToken cancellationToken = default) => store.QueryResourcesAsync(filter, cancellationToken); + + public ValueTask GetTracesAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default) => store.QueryTracesAsync(filter, cancellationToken); + + public ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default) => store.GetTraceAsync(traceId, cancellationToken); + + public ValueTask GetMetricsAsync(OpenTelemetryMetricFilter filter, CancellationToken cancellationToken = default) => store.QueryMetricsAsync(filter, cancellationToken); + + public ValueTask GetLogsAsync(OpenTelemetryLogFilter filter, CancellationToken cancellationToken = default) => store.QueryLogsAsync(filter, cancellationToken); + + public ValueTask GetStorageDiagnosticsAsync(CancellationToken cancellationToken = default) => store.GetDiagnosticsAsync(cancellationToken); + + public ValueTask GetCollectorConfigurationAsync(CancellationToken cancellationToken = default) => collectorConfigurationProvider.GetAsync(cancellationToken); +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryIngestor.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryIngestor.cs new file mode 100644 index 000000000..472562990 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryIngestor.cs @@ -0,0 +1,14 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.Services; + +public class OpenTelemetryIngestor(IOpenTelemetryRedactor redactor, IOpenTelemetryStore store, IOpenTelemetryLiveFeed liveFeed) : IOpenTelemetryIngestor +{ + public async ValueTask IngestAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default) + { + var redactedBatch = redactor.Redact(batch); + await store.WriteAsync(redactedBatch, cancellationToken); + await liveFeed.PublishAsync(redactedBatch, cancellationToken); + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryRedactor.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryRedactor.cs new file mode 100644 index 000000000..d1630f465 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetryRedactor.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Services; + +public class OpenTelemetryRedactor(IOptions options) : IOpenTelemetryRedactor +{ + private const string Redacted = "[Redacted]"; + 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, options.Value.SensitiveTextPatternTimeout)) + .ToList(); + + public OpenTelemetryBatch Redact(OpenTelemetryBatch batch) + { + return batch with + { + Resources = batch.Resources.Select(RedactResource).ToList(), + Spans = batch.Spans.Select(RedactSpan).ToList(), + Instruments = batch.Instruments.Select(x => x with { Attributes = RedactDictionary(x.Attributes) }).ToList(), + MetricPoints = batch.MetricPoints.Select(x => x with { Attributes = RedactDictionary(x.Attributes) }).ToList(), + Logs = batch.Logs.Select(RedactLog).ToList() + }; + } + + private TelemetryResource RedactResource(TelemetryResource resource) => resource with { Attributes = RedactDictionary(resource.Attributes) }; + + private TelemetrySpan RedactSpan(TelemetrySpan span) + { + return span with + { + Name = RedactValue("span.name", span.Name) ?? string.Empty, + StatusDescription = RedactValue("span.status.description", span.StatusDescription), + Attributes = RedactDictionary(span.Attributes), + Events = span.Events.Select(x => x with { Attributes = RedactDictionary(x.Attributes) }).ToList(), + Links = span.Links.Select(x => x with { Attributes = RedactDictionary(x.Attributes) }).ToList() + }; + } + + private OtlpLogRecord RedactLog(OtlpLogRecord log) + { + return log with + { + Body = RedactValue("log.body", log.Body) ?? string.Empty, + Attributes = RedactDictionary(log.Attributes) + }; + } + + private Dictionary RedactDictionary(IDictionary values) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (key, value) in values) + result[key] = RedactValue(key, value); + + return result; + } + + private string? RedactValue(string name, string? value) + { + if (value == null) + return null; + + if (_sensitiveNames.Any(sensitiveName => name.Contains(sensitiveName, StringComparison.OrdinalIgnoreCase))) + return Redacted; + + return _sensitiveTextPatterns.Aggregate(value, RedactPattern); + } + + private static string RedactPattern(string value, Regex pattern) + { + try + { + return pattern.Replace(value, Redacted); + } + catch (RegexMatchTimeoutException) + { + return value; + } + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetrySourceRegistry.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetrySourceRegistry.cs new file mode 100644 index 000000000..07413cb7a --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/Services/OpenTelemetrySourceRegistry.cs @@ -0,0 +1,59 @@ +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.Services; + +public class OpenTelemetrySourceRegistry : IOpenTelemetrySourceRegistry +{ + private readonly object _lock = new(); + private readonly Dictionary _resources = new(StringComparer.OrdinalIgnoreCase); + private readonly int _capacity; + private long _droppedCount; + + public OpenTelemetrySourceRegistry(IOptions options) + { + _capacity = Math.Max(1, options.Value.ResourceCapacity); + } + + public OpenTelemetrySourceRegistry() : this(Microsoft.Extensions.Options.Options.Create(new OpenTelemetryDiagnosticsOptions())) + { + } + + public long DroppedCount + { + get + { + lock (_lock) + return _droppedCount; + } + } + + public void MarkSeen(TelemetryResource resource) + { + lock (_lock) + { + if (!_resources.ContainsKey(resource.Id) && _resources.Count >= _capacity) + RemoveOldestResource(); + + _resources[resource.Id] = resource; + } + } + + public IReadOnlyCollection List() + { + lock (_lock) + return _resources.Values.OrderBy(x => x.ServiceName, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Id, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private void RemoveOldestResource() + { + var oldest = _resources.Values.OrderBy(x => x.LastSeen).ThenBy(x => x.Id, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + if (oldest == null) + return; + + _resources.Remove(oldest.Id); + _droppedCount++; + } +} diff --git a/src/modules/Elsa.Diagnostics.OpenTelemetry/ShellFeatures/OpenTelemetryFeature.cs b/src/modules/Elsa.Diagnostics.OpenTelemetry/ShellFeatures/OpenTelemetryFeature.cs new file mode 100644 index 000000000..d154398af --- /dev/null +++ b/src/modules/Elsa.Diagnostics.OpenTelemetry/ShellFeatures/OpenTelemetryFeature.cs @@ -0,0 +1,101 @@ +using CShells.AspNetCore.Features; +using CShells.FastEndpoints.Features; +using CShells.Features; +using Elsa.Diagnostics.OpenTelemetry.Extensions; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.PackageManifest.Generator.Hints; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Diagnostics.OpenTelemetry.ShellFeatures; + +[ShellFeature( + DisplayName = "OpenTelemetry Diagnostics", + Description = "Provides OpenTelemetry diagnostics collection, query services, and live updates", + DependsOn = ["ElsaFastEndpoints"])] +[UsedImplicitly] +public class OpenTelemetryFeature : IFastEndpointsShellFeature, IWebShellFeature +{ + private static readonly OpenTelemetryDiagnosticsOptions DefaultOptions = new(); + + [ManifestSetting( + DisplayName = "Trace Capacity", + Description = "Maximum number of recent traces retained in memory.", + Category = "Diagnostics", + DefaultValue = "5000", + RestartRequired = true)] + public int TraceCapacity { get; set; } = DefaultOptions.TraceCapacity; + + [ManifestSetting( + DisplayName = "Span Capacity", + Description = "Maximum number of recent spans retained in memory.", + Category = "Diagnostics", + DefaultValue = "25000", + RestartRequired = true)] + public int SpanCapacity { get; set; } = DefaultOptions.SpanCapacity; + + [ManifestSetting( + DisplayName = "Metric Point Capacity", + Description = "Maximum number of recent metric points retained in memory.", + Category = "Diagnostics", + DefaultValue = "25000", + RestartRequired = true)] + public int MetricPointCapacity { get; set; } = DefaultOptions.MetricPointCapacity; + + [ManifestSetting( + DisplayName = "Log Record Capacity", + Description = "Maximum number of recent OTLP log records retained in memory.", + Category = "Diagnostics", + DefaultValue = "10000", + RestartRequired = true)] + public int LogRecordCapacity { get; set; } = DefaultOptions.LogRecordCapacity; + + [ManifestSetting( + DisplayName = "Resource Capacity", + Description = "Maximum number of recent telemetry resources retained in memory.", + Category = "Diagnostics", + DefaultValue = "500", + RestartRequired = true)] + public int ResourceCapacity { get; set; } = DefaultOptions.ResourceCapacity; + + [ManifestSetting( + DisplayName = "Subscriber Channel Capacity", + Description = "Maximum queued live updates per subscriber before updates are dropped.", + Category = "Diagnostics", + DefaultValue = "1000", + RestartRequired = true)] + public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberChannelCapacity; + + [ManifestSetting( + DisplayName = "Max HTTP Request Body Size", + Description = "Maximum OTLP HTTP/protobuf request body size in bytes.", + Category = "Diagnostics", + DefaultValue = "10485760", + RestartRequired = true)] + public long MaxHttpRequestBodySize { get; set; } = DefaultOptions.MaxHttpRequestBodySize; + + public void ConfigureServices(IServiceCollection services) + { + services.AddOpenTelemetryDiagnosticsServices(ConfigureOptions); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, IHostEnvironment? environment) + { + endpoints.MapOpenTelemetryHttpProtobufCollector(); + endpoints.MapOpenTelemetryGrpcCollector(); + endpoints.MapOpenTelemetryHub(); + } + + private void ConfigureOptions(OpenTelemetryDiagnosticsOptions options) + { + options.TraceCapacity = TraceCapacity; + options.SpanCapacity = SpanCapacity; + options.MetricPointCapacity = MetricPointCapacity; + options.LogRecordCapacity = LogRecordCapacity; + options.ResourceCapacity = ResourceCapacity; + options.SubscriberChannelCapacity = SubscriberChannelCapacity; + options.MaxHttpRequestBodySize = MaxHttpRequestBodySize; + } +} diff --git a/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs b/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs index b41b8bec2..043008a60 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs @@ -1,5 +1,4 @@ using Elsa.Caching.Features; -using Elsa.Expressions.Options; using Elsa.Common.Features; using Elsa.Expressions.Features; using Elsa.Extensions; @@ -60,7 +59,6 @@ public class JavaScriptFeature : FeatureBase public override void Apply() { Services.Configure(JintOptions); - Services.Configure(JavaScriptExceptionTypeAliasRegistrar.Register); // JavaScript services. Services @@ -96,4 +94,4 @@ public class JavaScriptFeature : FeatureBase // UI property handlers. Services.AddScoped(); } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs b/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs deleted file mode 100644 index dea8c76a7..000000000 --- a/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using Acornima; -using Elsa.Expressions.Options; -using Jint; -using Jint.Runtime; - -namespace Elsa.Expressions.JavaScript; - -internal static class JavaScriptExceptionTypeAliasRegistrar -{ - public static void Register(ExpressionOptions options) - { - options.RegisterTypeAlias(typeof(ScriptPreparationException), nameof(ScriptPreparationException)); - options.RegisterTypeAlias(typeof(JavaScriptException), nameof(JavaScriptException)); - options.RegisterTypeAlias(typeof(SyntaxErrorException), nameof(SyntaxErrorException)); - - var wrapperExceptionType = typeof(JavaScriptException).GetNestedType("JavaScriptErrorWrapperException", BindingFlags.Public | BindingFlags.NonPublic); - if (wrapperExceptionType != null) - options.RegisterTypeAlias(wrapperExceptionType, "Jint.JavaScriptErrorWrapperException"); - } -} diff --git a/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs b/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs index 1b5f26760..8111b335b 100644 --- a/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs +++ b/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs @@ -8,7 +8,6 @@ using Elsa.Expressions.JavaScript.Providers; using Elsa.Expressions.JavaScript.Services; using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts; using Elsa.Expressions.JavaScript.TypeDefinitions.Services; -using Elsa.Expressions.Options; using Elsa.Extensions; using Elsa.PackageManifest.Generator.Hints; using Elsa.Workflows; @@ -105,7 +104,6 @@ public class JavaScriptFeature : IShellFeature options.DisableWrappers = DisableWrappers; options.DisableVariableCopying = DisableVariableCopying; }); - services.Configure(JavaScriptExceptionTypeAliasRegistrar.Register); // JavaScript services. services diff --git a/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs b/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs index 5e448a8a2..866344e0b 100644 --- a/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs +++ b/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs @@ -51,7 +51,7 @@ public class ExpressionOptions /// Gets the type alias dictionary. /// public IDictionary AliasTypeDictionary { get; set; } - + /// /// Registers a well-known type alias. /// diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 441789ee4..6df56020f 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -1,5 +1,5 @@ using System.Net; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -247,7 +247,7 @@ public class HttpFeature(IModule module) : FeatureBase(module) foreach (var httpWorkflowInstanceIdSelectorType in HttpWorkflowInstanceIdSelectorTypes) Services.AddScoped(typeof(IHttpWorkflowInstanceIdSelector), httpWorkflowInstanceIdSelectorType); - Services.Configure(options => + Services.Configure(options => { options.AddTypeAlias("HttpRequest"); options.AddTypeAlias("HttpResponse"); diff --git a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs index 956460357..b59c0874b 100644 --- a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs +++ b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs @@ -1,7 +1,7 @@ using System.Net; using CShells.AspNetCore.Features; using CShells.Features; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Http.Bookmarks; using Elsa.Http.ContentWriters; @@ -218,7 +218,7 @@ public class HttpFeature : IMiddlewareShellFeature foreach (var httpWorkflowInstanceIdSelectorType in HttpWorkflowInstanceIdSelectorTypes) services.AddScoped(typeof(IHttpWorkflowInstanceIdSelector), httpWorkflowInstanceIdSelectorType); - services.Configure(options => + services.Configure(options => { options.AddTypeAlias("HttpRequest"); options.AddTypeAlias("HttpResponse"); diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs index 104a90694..0c8e27392 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs @@ -1,4 +1,3 @@ -using Elsa.Expressions.Options; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Workflows.Management.Entities; @@ -27,7 +26,6 @@ public class EFCoreWorkflowDefinitionPersistenceFeature(IModule module) : Persis public override void Apply() { base.Apply(); - Services.Configure(options => options.RegisterTypeAlias(EFCoreWorkflowDefinitionStore.WorkflowDefinitionStateType, "EFCoreWorkflowDefinitionState")); AddEntityStore(); } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs index 451792359..e805caeaa 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs @@ -22,8 +22,6 @@ namespace Elsa.Persistence.EFCore.Modules.Management; public class EFCoreWorkflowDefinitionStore(EntityStore store, IPayloadSerializer payloadSerializer, ILogger logger) : IWorkflowDefinitionStore { - internal static Type WorkflowDefinitionStateType => typeof(WorkflowDefinitionState); - /// public async Task FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { @@ -269,4 +267,4 @@ public class EFCoreWorkflowDefinitionStore(EntityStore Outcomes { get; set; } = new List(); public IDictionary CustomProperties { get; set; } = new Dictionary(); } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs index 7bac060de..df06db78e 100644 --- a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs +++ b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs @@ -1,6 +1,6 @@ using Elsa.Common.Features; using Elsa.Common.Multitenancy; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -58,7 +58,7 @@ public class SchedulingFeature : FeatureBase // Graceful shutdown: register scheduled-trigger ingress for diagnostic visibility (FR-006). .AddSingleton(); - Services.Configure(options => + Services.Configure(options => { options.AddTypeAlias(); options.AddTypeAlias(); diff --git a/src/modules/Elsa.Scheduling/ShellFeatures/SchedulingFeature.cs b/src/modules/Elsa.Scheduling/ShellFeatures/SchedulingFeature.cs index 0c960b2cc..1459795d7 100644 --- a/src/modules/Elsa.Scheduling/ShellFeatures/SchedulingFeature.cs +++ b/src/modules/Elsa.Scheduling/ShellFeatures/SchedulingFeature.cs @@ -1,6 +1,7 @@ using CShells.Features; using Elsa.Common.Multitenancy; using Elsa.Common.ShellFeatures; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Scheduling.Bookmarks; using Elsa.Scheduling.Handlers; @@ -49,5 +50,15 @@ public class SchedulingFeature : IShellFeature .AddHandlersFrom() .AddTriggerPayloadValidator() .AddActivitiesFrom(); + + services.Configure(options => + { + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + }); } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs index 829720b25..819d95a64 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs @@ -15,14 +15,22 @@ internal class Endpoint(IActivityExecutionStore store) : ElsaEndpointWithoutRequ /// public override void Configure() { - Get("/activity-executions/{id}"); + Get("/activity-executions/{id}", "/activity-executions/{*id}", "/activity-executions/by-id"); ConfigurePermissions("read:activity-execution"); } /// public override async Task HandleAsync(CancellationToken cancellationToken) { - var id = Route("id"); + var id = Query("id", false) ?? Route("id", false); + + if (string.IsNullOrWhiteSpace(id)) + { + AddError("The activity execution ID is required."); + await Send.ErrorsAsync(cancellation: cancellationToken); + return; + } + var filter = new ActivityExecutionRecordFilter { Id = id @@ -38,4 +46,4 @@ internal class Endpoint(IActivityExecutionStore store) : ElsaEndpointWithoutRequ await Send.OkAsync(record, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs index b65421cd1..71bfbb4a6 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs @@ -1,12 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Memory; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Activities.Flowchart.Serialization; @@ -14,7 +15,7 @@ namespace Elsa.Workflows.Activities.Flowchart.Serialization; /// A JSON converter for . /// [UsedImplicitly] -public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellKnownTypeRegistry wellKnownTypeRegistry, ILoggerFactory loggerFactory) : JsonConverter +public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IOptions workflowJsonOptions, ILoggerFactory loggerFactory) : JsonConverter { private const string AllActivitiesKey = "allActivities"; private const string AllConnectionsKey = "allConnections"; @@ -47,7 +48,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK var variables = variablesElement.ValueKind != JsonValueKind.Undefined ? variablesElement.Deserialize>(options) ?? new List() : new List(); var polymorphicOptions = options.Clone(); - polymorphicOptions.Converters.Add(new PolymorphicDictionaryConverter(options, wellKnownTypeRegistry)); + polymorphicOptions.Converters.Add(new PolymorphicDictionaryConverter(options, workflowJsonOptions.Value)); var metadataElement = doc.RootElement.TryGetProperty("metadata", out var metadataEl) ? metadataEl : default; var metadata = metadataElement.ValueKind != JsonValueKind.Undefined ? metadataElement.Deserialize>(polymorphicOptions) ?? new Dictionary() : new Dictionary(); @@ -105,7 +106,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK var flowchartSerializerOptions = new JsonSerializerOptions(options); flowchartSerializerOptions.Converters.Add(new ConnectionJsonConverter(activityDictionary, loggerFactory)); - flowchartSerializerOptions.Converters.Add(new PolymorphicDictionaryConverter(options, wellKnownTypeRegistry)); + flowchartSerializerOptions.Converters.Add(new PolymorphicDictionaryConverter(options, workflowJsonOptions.Value)); JsonSerializer.Serialize(writer, model, flowchartSerializerOptions); } @@ -218,4 +219,4 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK // drop any half‑baked entries return raw.Where(c => c != null && c.Source.Activity != null! && c.Target.Activity != null!).Cast().ToList(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowJsonOptionsExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowJsonOptionsExtensions.cs new file mode 100644 index 000000000..851cc8214 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowJsonOptionsExtensions.cs @@ -0,0 +1,66 @@ +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Exceptions; +using Elsa.Workflows.IncidentStrategies; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Options; +using Elsa.Workflows.Services; +using Elsa.Workflows.State; +using Newtonsoft.Json.Linq; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Extends . +/// +public static class WorkflowJsonOptionsExtensions +{ + /// + /// Registers using its CLR type name. + /// + public static void AddTypeAlias(this WorkflowJsonOptions options) => options.RegisterTypeAlias(typeof(T), typeof(T).Name); + + /// + /// Registers using the specified alias. + /// + public static void AddTypeAlias(this WorkflowJsonOptions options, string alias) => options.RegisterTypeAlias(typeof(T), alias); + + /// + /// Registers the built-in workflow JSON aliases. + /// + public static void RegisterWorkflowTypeAliases(this WorkflowJsonOptions options) + { + options.RegisterTypeAlias(typeof(ExceptionState), nameof(ExceptionState)); + options.RegisterTypeAlias(typeof(FaultException), nameof(FaultException)); + options.RegisterTypeAlias(typeof(VariablesDictionary), nameof(VariablesDictionary)); + options.RegisterTypeAlias(typeof(Token), nameof(Token)); + options.RegisterTypeAlias(typeof(FlowJoinMode), "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core"); + options.RegisterTypeAlias(typeof(FlowJoinMode), typeof(FlowJoinMode).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(FlowJoinMode), nameof(FlowJoinMode)); + options.RegisterTypeAlias(typeof(WorkflowStorageDriver), typeof(WorkflowStorageDriver).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver)); + options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), nameof(WorkflowInstanceStorageDriver)); + options.RegisterTypeAlias(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(MemoryStorageDriver), nameof(MemoryStorageDriver)); + options.RegisterTypeAlias(typeof(FaultStrategy), typeof(FaultStrategy).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(FaultStrategy), nameof(FaultStrategy)); + options.RegisterTypeAlias(typeof(ContinueWithIncidentsStrategy), typeof(ContinueWithIncidentsStrategy).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(ContinueWithIncidentsStrategy), nameof(ContinueWithIncidentsStrategy)); + options.RegisterTypeAlias(typeof(Exception), nameof(Exception)); + options.RegisterTypeAlias(typeof(ArgumentException), nameof(ArgumentException)); + options.RegisterTypeAlias(typeof(ArgumentNullException), nameof(ArgumentNullException)); + options.RegisterTypeAlias(typeof(InvalidOperationException), nameof(InvalidOperationException)); + options.RegisterTypeAlias(typeof(NullReferenceException), nameof(NullReferenceException)); + options.RegisterTypeAlias(typeof(OperationCanceledException), nameof(OperationCanceledException)); + options.RegisterTypeAlias(typeof(TaskCanceledException), nameof(TaskCanceledException)); + options.RegisterTypeAlias(typeof(TimeoutException), nameof(TimeoutException)); + options.RegisterTypeAlias(typeof(NotSupportedException), nameof(NotSupportedException)); + options.RegisterTypeAlias(typeof(JObject), nameof(JObject)); + options.RegisterTypeAlias(typeof(JArray), nameof(JArray)); + } +} diff --git a/src/modules/Elsa.Workflows.Core/Features/FlowchartFeature.cs b/src/modules/Elsa.Workflows.Core/Features/FlowchartFeature.cs index ab3a000a2..5414125df 100644 --- a/src/modules/Elsa.Workflows.Core/Features/FlowchartFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/FlowchartFeature.cs @@ -4,6 +4,7 @@ using Elsa.Features.Services; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Activities.Flowchart.Options; using Elsa.Workflows.Activities.Flowchart.Serialization; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.Features; @@ -33,10 +34,7 @@ public class FlowchartFeature : FeatureBase if (FlowchartOptionsConfigurator != null) Services.Configure(FlowchartOptionsConfigurator); - } - public override void Configure() - { - Module.AddTypeAlias("FlowScope"); + Services.Configure(options => options.AddTypeAlias("FlowScope")); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 4822214f9..005b87d09 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -2,16 +2,14 @@ using Elsa.Common; using Elsa.Common.Features; using Elsa.Common.Serialization; using Elsa.Expressions.Features; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Workflows.ActivationValidators; -using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Builders; using Elsa.Workflows.CommitStates; -using Elsa.Workflows.Exceptions; using Elsa.Workflows.IncidentStrategies; using Elsa.Workflows.LogPersistence; using Elsa.Workflows.LogPersistence.Strategies; @@ -24,14 +22,12 @@ using Elsa.Workflows.Serialization.Configurators; using Elsa.Workflows.Serialization.Helpers; using Elsa.Workflows.Serialization.Serializers; using Elsa.Workflows.Services; -using Elsa.Workflows.State; using Elsa.Workflows.UIHints.CheckList; using Elsa.Workflows.UIHints.Dictionary; using Elsa.Workflows.UIHints.Dropdown; using Elsa.Workflows.UIHints.JsonEditor; using Elsa.Workflows.UIHints.RadioList; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json.Linq; namespace Elsa.Workflows.Features; @@ -91,7 +87,12 @@ public class WorkflowsFeature : FeatureBase /// /// A delegate to configure the . /// - public Action ActivityExecutionPipeline { get; set; } = builder => builder.UseDefaultActivityInvoker(); + public Action ActivityExecutionPipeline { get; set; } = builder => builder + .UseLogging() + .UseExceptionHandling() + .UseExecutionLogging() + .UseNotifications() + .UseDefaultActivityInvoker(); /// /// Fluent method to set . @@ -164,35 +165,7 @@ public class WorkflowsFeature : FeatureBase private void AddElsaCore(IServiceCollection services) { - services.Configure(options => - { - options.RegisterTypeAlias(typeof(ExceptionState), nameof(ExceptionState)); - options.RegisterTypeAlias(typeof(FaultException), nameof(FaultException)); - options.RegisterTypeAlias(typeof(VariablesDictionary), nameof(VariablesDictionary)); - options.RegisterTypeAlias(typeof(Token), nameof(Token)); - options.RegisterTypeAlias(typeof(FlowJoinMode), "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core"); - options.RegisterTypeAlias(typeof(FlowJoinMode), typeof(FlowJoinMode).GetSimpleAssemblyQualifiedName()); - options.RegisterTypeAlias(typeof(FlowJoinMode), nameof(FlowJoinMode)); - options.RegisterTypeAlias(typeof(WorkflowStorageDriver), typeof(WorkflowStorageDriver).GetSimpleAssemblyQualifiedName()); - options.RegisterTypeAlias(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver)); - options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName()); - options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), nameof(WorkflowInstanceStorageDriver)); - options.RegisterTypeAlias(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()); - options.RegisterTypeAlias(typeof(MemoryStorageDriver), nameof(MemoryStorageDriver)); - options.RegisterTypeAlias(typeof(FaultStrategy), typeof(FaultStrategy).GetSimpleAssemblyQualifiedName()); - options.RegisterTypeAlias(typeof(FaultStrategy), nameof(FaultStrategy)); - options.RegisterTypeAlias(typeof(Exception), nameof(Exception)); - options.RegisterTypeAlias(typeof(ArgumentException), nameof(ArgumentException)); - options.RegisterTypeAlias(typeof(ArgumentNullException), nameof(ArgumentNullException)); - options.RegisterTypeAlias(typeof(InvalidOperationException), nameof(InvalidOperationException)); - options.RegisterTypeAlias(typeof(NullReferenceException), nameof(NullReferenceException)); - options.RegisterTypeAlias(typeof(OperationCanceledException), nameof(OperationCanceledException)); - options.RegisterTypeAlias(typeof(TaskCanceledException), nameof(TaskCanceledException)); - options.RegisterTypeAlias(typeof(TimeoutException), nameof(TimeoutException)); - options.RegisterTypeAlias(typeof(NotSupportedException), nameof(NotSupportedException)); - options.RegisterTypeAlias(typeof(JObject), nameof(JObject)); - options.RegisterTypeAlias(typeof(JArray), nameof(JArray)); - }); + services.Configure(options => options.RegisterWorkflowTypeAliases()); services diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs index b425eb865..4c84b5de9 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs @@ -35,7 +35,9 @@ public class ExcludeFromHashConverter : JsonConverter var propertyValue = property.GetValue(value); if (ShouldIgnoreProperty(metadata.JsonIgnoreCondition, property.PropertyType, propertyValue)) + { continue; + } writer.WritePropertyName(property.Name); JsonSerializer.Serialize(writer, propertyValue, newOptions); diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/OutputJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/OutputJsonConverter.cs index ecefa40b6..888c732e9 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/OutputJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/OutputJsonConverter.cs @@ -1,9 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Extensions; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Converters; @@ -12,12 +13,12 @@ namespace Elsa.Workflows.Serialization.Converters; /// public class OutputJsonConverter : JsonConverter?> { - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + private readonly WorkflowJsonOptions _workflowJsonOptions; /// - public OutputJsonConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) + public OutputJsonConverter(IOptions workflowJsonOptions) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; + _workflowJsonOptions = workflowJsonOptions.Value; } /// @@ -50,7 +51,7 @@ public class OutputJsonConverter : JsonConverter?> public override void Write(Utf8JsonWriter writer, Output? value, JsonSerializerOptions options) { var valueType = typeof(T); - var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(valueType); + var valueTypeAlias = WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, valueType); var model = new { @@ -63,4 +64,4 @@ public class OutputJsonConverter : JsonConverter?> JsonSerializer.Serialize(writer, model, options); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicDictionaryConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicDictionaryConverter.cs index 3e0060aa8..12653413a 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicDictionaryConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicDictionaryConverter.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; +using Elsa.Workflows.Serialization.Options; namespace Elsa.Workflows.Serialization.Converters; @@ -12,9 +12,9 @@ public class PolymorphicDictionaryConverter : JsonConverter _objectConverter; /// - public PolymorphicDictionaryConverter(JsonSerializerOptions options, IWellKnownTypeRegistry wellKnownTypeRegistry) + public PolymorphicDictionaryConverter(JsonSerializerOptions options, WorkflowJsonOptions workflowJsonOptions) { - var factory = (JsonConverterFactory)(options.Converters.FirstOrDefault(x => x is PolymorphicObjectConverterFactory) ?? new PolymorphicObjectConverterFactory(wellKnownTypeRegistry)); + var factory = (JsonConverterFactory)(options.Converters.FirstOrDefault(x => x is PolymorphicObjectConverterFactory) ?? new PolymorphicObjectConverterFactory(workflowJsonOptions)); _objectConverter = (JsonConverter)factory.CreateConverter(typeof(object), options)!; } @@ -53,4 +53,4 @@ public class PolymorphicDictionaryConverter : JsonConverter private const string IdPropertyName = "$id"; private const string RefPropertyName = "$ref"; private const string ValuesPropertyName = "$values"; - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + private readonly WorkflowJsonOptions _workflowJsonOptions; /// /// Initializes a new instance of the class. /// - public PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) + public PolymorphicObjectConverter(IOptions workflowJsonOptions) + : this(workflowJsonOptions.Value) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; + } + + /// + /// Initializes a new instance of the class. + /// + public PolymorphicObjectConverter(WorkflowJsonOptions workflowJsonOptions) + { + _workflowJsonOptions = workflowJsonOptions; } /// /// Initializes a new instance of the class. /// public PolymorphicObjectConverter() + : this(new WorkflowJsonOptions()) { - _wellKnownTypeRegistry = WellKnownTypeRegistry.CreateDefault(); } /// @@ -350,14 +358,12 @@ public class PolymorphicObjectConverter : JsonConverter } // If we found the _type property, attempt to resolve the type. - return typeName != null ? WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeName) : default; + return typeName != null ? WorkflowJsonTypeResolver.ResolveType(_workflowJsonOptions, typeName, _workflowJsonOptions.AllowLegacyClrTypeNames) : default; } private void WriteTypeMetadata(Utf8JsonWriter writer, Type type) { - if (!WorkflowJsonTypeResolver.TryGetAlias(_wellKnownTypeRegistry, type, out var typeAlias)) - return; - + var typeAlias = WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, type); writer.WritePropertyName(TypePropertyName); writer.WriteStringValue(typeAlias); } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs index 9bf659435..d1994e820 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs @@ -1,8 +1,8 @@ using System.Dynamic; using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Services; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Converters; @@ -11,22 +11,30 @@ namespace Elsa.Workflows.Serialization.Converters; /// public class PolymorphicObjectConverterFactory : JsonConverterFactory { - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + private readonly WorkflowJsonOptions _workflowJsonOptions; /// /// A JSON converter factory that creates instances. /// - public PolymorphicObjectConverterFactory(IWellKnownTypeRegistry wellKnownTypeRegistry) + public PolymorphicObjectConverterFactory(IOptions workflowJsonOptions) + : this(workflowJsonOptions.Value) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; + } + + /// + /// A JSON converter factory that creates instances. + /// + public PolymorphicObjectConverterFactory(WorkflowJsonOptions workflowJsonOptions) + { + _workflowJsonOptions = workflowJsonOptions; } /// /// Default constructor for use with attributes. /// public PolymorphicObjectConverterFactory() + : this(new WorkflowJsonOptions()) { - _wellKnownTypeRegistry = WellKnownTypeRegistry.CreateDefault(); } /// @@ -49,8 +57,8 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { if (typeof(IDictionary).IsAssignableFrom(typeToConvert)) - return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry); + return new PolymorphicDictionaryConverter(options, _workflowJsonOptions); - return new PolymorphicObjectConverter(_wellKnownTypeRegistry); + return new PolymorphicObjectConverter(_workflowJsonOptions); } } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs index c74191e41..34165b414 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs @@ -1,29 +1,36 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; -using Elsa.Extensions; using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; using JetBrains.Annotations; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Converters; /// /// Serializes objects to a simple alias representing the type. -/// Unregistered types are written as metadata-only aliases and intentionally deserialize to instead of loading the original CLR type. /// [UsedImplicitly] public class TypeJsonConverter : JsonConverter { - /// - /// Prefix for unregistered type metadata that is not used for CLR type loading during deserialization. - /// - private const string UnregisteredTypeAliasPrefix = "UnregisteredClrType:"; - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + private readonly WorkflowJsonOptions _workflowJsonOptions; /// - public TypeJsonConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) + public TypeJsonConverter() + : this(new WorkflowJsonOptions()) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; + } + + /// + public TypeJsonConverter(IOptions workflowJsonOptions) + : this(workflowJsonOptions.Value) + { + } + + /// + public TypeJsonConverter(WorkflowJsonOptions workflowJsonOptions) + { + _workflowJsonOptions = workflowJsonOptions; } /// @@ -36,18 +43,14 @@ public class TypeJsonConverter : JsonConverter public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var typeAlias = reader.GetString(); - if (typeAlias?.StartsWith(UnregisteredTypeAliasPrefix, StringComparison.Ordinal) == true) - return typeof(Exception); - return WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeAlias); + return WorkflowJsonTypeResolver.ResolveType(_workflowJsonOptions, typeAlias, _workflowJsonOptions.AllowLegacyClrTypeNames); } /// public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options) { - if (!WorkflowJsonTypeResolver.TryGetAlias(_wellKnownTypeRegistry, value, out var typeAlias)) - typeAlias = $"{UnregisteredTypeAliasPrefix}{value.GetSimpleAssemblyQualifiedName()}"; - + var typeAlias = WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, value); writer.WriteStringValue(typeAlias); } } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverter.cs index 6dcc60b4e..b5f7bf522 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverter.cs @@ -1,9 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Converters; @@ -17,9 +18,16 @@ public class VariableConverter : JsonConverter /// // ReSharper disable once ContextualLoggerProblem - public VariableConverter(IWellKnownTypeRegistry wellKnownTypeRegistry, ILogger logger) + public VariableConverter(ILogger logger) { - _mapper = new VariableMapper(wellKnownTypeRegistry, logger); + _mapper = new VariableMapper(logger); + } + + /// + // ReSharper disable once ContextualLoggerProblem + public VariableConverter(ILogger logger, IOptions workflowJsonOptions) + { + _mapper = new VariableMapper(logger, workflowJsonOptions); } /// @@ -49,4 +57,4 @@ public class VariableConverter : JsonConverter _options = newOptions; return newOptions; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverterFactory.cs index 293ce4e35..56971c510 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverterFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/VariableConverterFactory.cs @@ -1,8 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Memory; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Converters; @@ -11,20 +12,31 @@ namespace Elsa.Workflows.Serialization.Converters; /// public class VariableConverterFactory : JsonConverterFactory { - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; private readonly ILoggerFactory _loggerFactory; + private readonly IOptions? _workflowJsonOptions; /// // ReSharper disable once ContextualLoggerProblem - public VariableConverterFactory(IWellKnownTypeRegistry wellKnownTypeRegistry, ILoggerFactory loggerFactory) + public VariableConverterFactory(ILoggerFactory loggerFactory) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; _loggerFactory = loggerFactory; } + /// + // ReSharper disable once ContextualLoggerProblem + public VariableConverterFactory(ILoggerFactory loggerFactory, IOptions workflowJsonOptions) + { + _loggerFactory = loggerFactory; + _workflowJsonOptions = workflowJsonOptions; + } + /// public override bool CanConvert(Type typeToConvert) => typeof(Variable).IsAssignableFrom(typeToConvert); /// - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new VariableConverter(_wellKnownTypeRegistry, _loggerFactory.CreateLogger()); -} \ No newline at end of file + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var logger = _loggerFactory.CreateLogger(); + return _workflowJsonOptions != null ? new VariableConverter(logger, _workflowJsonOptions) : new VariableConverter(logger); + } +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs b/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs index 86771b0e8..d5258b9ae 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs @@ -2,13 +2,13 @@ using System.Collections; using System.Collections.ObjectModel; using System.Reflection; using System.Text.Json; -using Elsa.Expressions.Contracts; using Elsa.Extensions; +using Elsa.Workflows.Serialization.Options; namespace Elsa.Workflows.Serialization.Helpers; /// -/// Resolves workflow JSON type aliases without loading arbitrary CLR type names. +/// Resolves workflow JSON type aliases and CLR type names. /// public static class WorkflowJsonTypeResolver { @@ -52,85 +52,47 @@ public static class WorkflowJsonTypeResolver [typeof(IDictionary)] = typeof(Dictionary) }; - /// - /// Resolves the specified workflow JSON type alias. - /// - public static Type ResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string? typeAlias) + public static Type ResolveType(WorkflowJsonOptions workflowJsonOptions, string? typeAlias, bool allowLegacyClrTypeNames = true) { if (string.IsNullOrWhiteSpace(typeAlias)) throw new JsonException("The workflow JSON type alias is missing."); - if (TryResolveType(wellKnownTypeRegistry, typeAlias, out var type)) + if (TryResolveType(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, out var type)) return type; - throw new JsonException( - $"Unknown workflow JSON type alias '{typeAlias}'. Only registered aliases and supported compound aliases can be deserialized."); + var supportedTypeNames = allowLegacyClrTypeNames + ? "Registered aliases, supported compound aliases, and CLR type names" + : "Registered aliases, supported compound aliases, and registered legacy CLR type names"; + throw new JsonException($"Unknown workflow JSON type alias '{typeAlias}'. {supportedTypeNames} can be deserialized."); } - /// - /// Attempts to resolve the specified workflow JSON type alias. - /// - public static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, out Type type) + public static bool TryResolveType(WorkflowJsonOptions workflowJsonOptions, string typeAlias, out Type type) + { + return TryResolveType(workflowJsonOptions, typeAlias, true, out type); + } + + public static bool TryResolveType(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, out Type type) { IReadOnlyList? registeredTypes = null; - return TryResolveType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type); + return TryResolveType(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, ref registeredTypes, out type); } - private static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? registeredTypes, out Type type) + public static bool TryGetAlias(WorkflowJsonOptions workflowJsonOptions, Type type, out string alias) { - if (wellKnownTypeRegistry.TryGetType(typeAlias, out var registeredType)) - { - type = registeredType; - return true; - } - - if (TryResolveArrayType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var arrayType)) - { - type = arrayType; - return true; - } - - if (TryResolveGenericCollectionType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var genericCollectionType)) - { - type = genericCollectionType; - return true; - } - - if (TryResolveRegisteredLegacyTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var legacyType)) - { - type = legacyType; - return true; - } - - type = null!; - return false; - } - - /// - /// Attempts to return a workflow JSON type alias that this resolver can read back. - /// - public static bool TryGetAlias(IWellKnownTypeRegistry wellKnownTypeRegistry, Type type, out string alias) - { - if (wellKnownTypeRegistry.TryGetAlias(type, out alias!)) + if (workflowJsonOptions.TryGetAlias(type, out alias!)) return true; - if (type.IsArray) + if (type.IsArray && TryGetAlias(workflowJsonOptions, type.GetElementType()!, out var elementTypeAlias)) { - var elementType = type.GetElementType()!; - - if (TryGetAlias(wellKnownTypeRegistry, elementType, out var elementTypeAlias)) - { - alias = $"{elementTypeAlias}[]"; - return true; - } + alias = $"{elementTypeAlias}[]"; + return true; } if (type is { IsGenericType: true, GenericTypeArguments.Length: 1 }) { var genericTypeDefinition = type.GetGenericTypeDefinition(); - if (TryGetWritableGenericCollectionAlias(genericTypeDefinition, out var genericTypeAlias) && - TryGetAlias(wellKnownTypeRegistry, type.GenericTypeArguments[0], out var elementTypeAlias)) + TryGetAlias(workflowJsonOptions, type.GenericTypeArguments[0], out elementTypeAlias)) { alias = $"{genericTypeAlias}<{elementTypeAlias}>"; return true; @@ -141,9 +103,17 @@ public static class WorkflowJsonTypeResolver return false; } - /// - /// Attempts to map a supported collection interface type to an instantiable concrete type. - /// + public static string GetAliasOrLegacyClrTypeName(WorkflowJsonOptions workflowJsonOptions, Type type) + { + if (TryGetAlias(workflowJsonOptions, type, out var alias)) + return alias; + + if (!workflowJsonOptions.AllowLegacyClrTypeNames) + throw new JsonException($"Type '{type}' is not registered as a workflow JSON type alias."); + + return type.GetSimpleAssemblyQualifiedName(); + } + public static bool TryGetInstantiableCollectionType(Type type, out Type instantiableType) { if (type.IsGenericType) @@ -163,22 +133,41 @@ public static class WorkflowJsonTypeResolver return false; } - private static bool TryResolveArrayType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? registeredTypes, out Type type) + private static bool TryResolveType(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, ref IReadOnlyList? registeredTypes, out Type type) + { + if (workflowJsonOptions.TryGetType(typeAlias, out type!)) + return true; + + if (TryResolveArrayType(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, ref registeredTypes, out type)) + return true; + + if (TryResolveGenericCollectionType(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, ref registeredTypes, out type)) + return true; + + if (TryResolveRegisteredLegacyTypeName(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, ref registeredTypes, out type)) + return true; + + if (allowLegacyClrTypeNames && TryResolveClrTypeName(typeAlias, out type)) + return true; + + type = null!; + return false; + } + + private static bool TryResolveArrayType(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, ref IReadOnlyList? registeredTypes, out Type type) { type = null!; - if (!typeAlias.EndsWith("[]", StringComparison.Ordinal)) return false; - var elementTypeAlias = typeAlias[..^2]; - if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType)) + if (!TryResolveType(workflowJsonOptions, typeAlias[..^2], allowLegacyClrTypeNames, ref registeredTypes, out var elementType)) return false; type = elementType.MakeArrayType(); return true; } - private static bool TryResolveGenericCollectionType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? registeredTypes, out Type type) + private static bool TryResolveGenericCollectionType(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, ref IReadOnlyList? registeredTypes, out Type type) { type = null!; var genericStart = typeAlias.IndexOf('<', StringComparison.Ordinal); @@ -186,12 +175,10 @@ public static class WorkflowJsonTypeResolver if (genericStart <= 0 || !typeAlias.EndsWith(">", StringComparison.Ordinal)) return false; - var genericTypeAlias = typeAlias[..genericStart]; - if (!GenericCollectionTypes.TryGetValue(genericTypeAlias, out var genericTypeDefinition)) + if (!GenericCollectionTypes.TryGetValue(typeAlias[..genericStart], out var genericTypeDefinition)) return false; - var elementTypeAlias = typeAlias[(genericStart + 1)..^1]; - if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType)) + if (!TryResolveType(workflowJsonOptions, typeAlias[(genericStart + 1)..^1], allowLegacyClrTypeNames, ref registeredTypes, out var elementType)) return false; type = genericTypeDefinition.MakeGenericType(elementType); @@ -205,59 +192,60 @@ public static class WorkflowJsonTypeResolver if (GenericCollectionInterfaceMappings.TryGetValue(genericTypeDefinition, out var instantiableGenericTypeDefinition) && GenericCollectionAliases.TryGetValue(instantiableGenericTypeDefinition, out alias!)) - { return true; - } alias = null!; return false; } - private static bool TryResolveRegisteredLegacyTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? registeredTypes, out Type type) + private static bool TryResolveClrTypeName(string typeAlias, out Type type) { - registeredTypes ??= GetRegisteredTypes(wellKnownTypeRegistry); + try + { + type = Type.GetType(typeAlias, false)!; + return type != null; + } + catch (Exception e) when (e is ArgumentException or FileLoadException or FileNotFoundException or TypeLoadException or BadImageFormatException) + { + type = null!; + return false; + } + } + + private static bool TryResolveRegisteredLegacyTypeName(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, ref IReadOnlyList? registeredTypes, out Type type) + { + registeredTypes ??= workflowJsonOptions.ListTypes().ToArray(); var registeredTypeSnapshot = registeredTypes; - if (TryResolveRegisteredSimpleAssemblyQualifiedName(registeredTypeSnapshot, typeAlias, out type)) + type = registeredTypeSnapshot.FirstOrDefault(x => + string.Equals(x.GetSimpleAssemblyQualifiedName(), typeAlias, StringComparison.Ordinal) || + string.Equals(x.AssemblyQualifiedName, typeAlias, StringComparison.Ordinal))!; + + if (type != null) return true; - if (TryResolveLegacyGenericCollectionTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type)) + if (TryResolveLegacyGenericCollectionTypeName(workflowJsonOptions, typeAlias, allowLegacyClrTypeNames, ref registeredTypes, out type)) return true; - Type? resolvedType; - try { - resolvedType = Type.GetType( + var resolvedType = Type.GetType( typeAlias, assemblyName => ResolveAssembly(registeredTypeSnapshot, assemblyName), (assembly, typeName, ignoreCase) => ResolveType(registeredTypeSnapshot, assembly, typeName, ignoreCase), false); + + type = resolvedType!; + return resolvedType != null; } catch (Exception e) when (e is ArgumentException or FileLoadException) { - resolvedType = null; + type = null!; + return false; } - - type = resolvedType!; - return resolvedType != null; } - private static IReadOnlyList GetRegisteredTypes(IWellKnownTypeRegistry wellKnownTypeRegistry) - { - return wellKnownTypeRegistry.ListTypes().ToArray(); - } - - private static bool TryResolveRegisteredSimpleAssemblyQualifiedName(IEnumerable registeredTypes, string typeAlias, out Type type) - { - type = registeredTypes.FirstOrDefault(x => - string.Equals(x.GetSimpleAssemblyQualifiedName(), typeAlias, StringComparison.Ordinal) || - string.Equals(x.AssemblyQualifiedName, typeAlias, StringComparison.Ordinal))!; - - return type != null; - } - - private static bool TryResolveLegacyGenericCollectionTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? registeredTypes, out Type type) + private static bool TryResolveLegacyGenericCollectionTypeName(WorkflowJsonOptions workflowJsonOptions, string typeAlias, bool allowLegacyClrTypeNames, ref IReadOnlyList? registeredTypes, out Type type) { type = null!; @@ -273,11 +261,9 @@ public static class WorkflowJsonTypeResolver if (!string.Equals(assemblyName, genericTypeDefinition.Assembly.GetName().Name, StringComparison.Ordinal)) continue; - var elementTypeAlias = typeAlias[prefix.Length..separatorIndex]; - if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType)) + if (!TryResolveType(workflowJsonOptions, typeAlias[prefix.Length..separatorIndex], allowLegacyClrTypeNames, ref registeredTypes, out var elementType)) return false; - // The resolver only closes known collection definitions over registered element types. #pragma warning disable IL2055 type = genericTypeDefinition.MakeGenericType(elementType); #pragma warning restore IL2055 diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Options/WorkflowJsonOptions.cs b/src/modules/Elsa.Workflows.Core/Serialization/Options/WorkflowJsonOptions.cs new file mode 100644 index 000000000..204257a52 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Options/WorkflowJsonOptions.cs @@ -0,0 +1,91 @@ +using System.Dynamic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.Extensions; + +namespace Elsa.Workflows.Serialization.Options; + +/// +/// Options used by workflow JSON serializers to map stable type aliases to CLR types. +/// +public class WorkflowJsonOptions +{ + private readonly Dictionary _typesByAlias = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _aliasesByType = new(); + + /// + /// Initializes a new instance of the class. + /// + public WorkflowJsonOptions() + { + RegisterDefaultTypeAliases(); + } + + /// + /// Gets or sets a value indicating whether unrestricted CLR type names can be resolved for backwards compatibility. + /// + public bool AllowLegacyClrTypeNames { get; set; } = true; + + /// + /// Registers a type alias. + /// + public WorkflowJsonOptions RegisterTypeAlias(Type type, string alias) + { + _typesByAlias[alias] = type; + _aliasesByType[type] = alias; + + if (type.IsPrimitive || type.IsValueType && Nullable.GetUnderlyingType(type) == null) + { + var nullableType = typeof(Nullable<>).MakeGenericType(type); + var nullableAlias = alias + "?"; + _typesByAlias[nullableAlias] = nullableType; + _aliasesByType[nullableType] = nullableAlias; + } + + return this; + } + + /// + /// Attempts to resolve the specified alias to a type. + /// + public bool TryGetType(string alias, out Type type) => _typesByAlias.TryGetValue(alias, out type!); + + /// + /// Attempts to get the primary alias registered for the specified type. + /// + public bool TryGetAlias(Type type, out string alias) => _aliasesByType.TryGetValue(type, out alias!); + + /// + /// Lists all registered types. + /// + public IEnumerable ListTypes() => _typesByAlias.Values.Distinct(); + + private void RegisterDefaultTypeAliases() + { + this.AddTypeAlias("Int16"); + this.AddTypeAlias("Int32"); + this.AddTypeAlias("Int64"); + this.AddTypeAlias("Long"); + this.AddTypeAlias("Single"); + this.AddTypeAlias("Object"); + this.AddTypeAlias("String"); + this.AddTypeAlias("Boolean"); + this.AddTypeAlias("Decimal"); + this.AddTypeAlias("Double"); + this.AddTypeAlias("ByteArray"); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias("JSON"); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias(); + this.AddTypeAlias>("StringDictionary"); + this.AddTypeAlias>("ObjectDictionary"); + this.AddTypeAlias>("StringMap"); + this.AddTypeAlias>("ObjectMap"); + } +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/BookmarkPayloadSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/BookmarkPayloadSerializer.cs index 9dab96949..60899d5c2 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/BookmarkPayloadSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/BookmarkPayloadSerializer.cs @@ -1,6 +1,7 @@ using System.Text.Json; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Serializers; @@ -12,7 +13,15 @@ public class BookmarkPayloadSerializer : IBookmarkPayloadSerializer /// /// Initializes a new instance of the class. /// - public BookmarkPayloadSerializer(IWellKnownTypeRegistry wellKnownTypeRegistry) + public BookmarkPayloadSerializer() + : this(Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions())) + { + } + + /// + /// Initializes a new instance of the class. + /// + public BookmarkPayloadSerializer(IOptions workflowJsonOptions) { _settings = new JsonSerializerOptions { @@ -21,7 +30,7 @@ public class BookmarkPayloadSerializer : IBookmarkPayloadSerializer PropertyNameCaseInsensitive = true, }; - _settings.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry)); + _settings.Converters.Add(new TypeJsonConverter(workflowJsonOptions)); _settings.Converters.Add(new FuncExpressionValueConverter()); } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs index faf4215b8..989cf6265 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs @@ -1,11 +1,12 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Elsa.Common.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; using Elsa.Workflows.Serialization.ReferenceHandlers; using Elsa.Workflows.State; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Serialization.Serializers; @@ -13,18 +14,26 @@ namespace Elsa.Workflows.Serialization.Serializers; /// Serializes and deserializes workflow states from and to JSON. /// public class JsonWorkflowStateSerializer : ConfigurableSerializer, IWorkflowStateSerializer -{ - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; - private readonly ILoggerFactory _loggerFactory; + { + private readonly ILoggerFactory _loggerFactory; + private readonly IOptions _workflowJsonOptions; /// /// Initializes a new instance of the class. /// - public JsonWorkflowStateSerializer(IServiceProvider serviceProvider, IWellKnownTypeRegistry wellKnownTypeRegistry, ILoggerFactory loggerFactory) + public JsonWorkflowStateSerializer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) + : this(serviceProvider, loggerFactory, Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions())) + { + } + + /// + /// Initializes a new instance of the class. + /// + public JsonWorkflowStateSerializer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, IOptions workflowJsonOptions) : base(serviceProvider) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; _loggerFactory = loggerFactory; + _workflowJsonOptions = workflowJsonOptions; } /// @@ -135,9 +144,9 @@ public class JsonWorkflowStateSerializer : ConfigurableSerializer, IWorkflowStat /// protected override void AddConverters(JsonSerializerOptions options) { - options.Converters.Add(new TypeJsonConverter(_wellKnownTypeRegistry)); - options.Converters.Add(new PolymorphicObjectConverterFactory(_wellKnownTypeRegistry)); - options.Converters.Add(new VariableConverterFactory(_wellKnownTypeRegistry, _loggerFactory)); + options.Converters.Add(new TypeJsonConverter(_workflowJsonOptions)); + options.Converters.Add(new PolymorphicObjectConverterFactory(_workflowJsonOptions)); + options.Converters.Add(new VariableConverterFactory(_loggerFactory, _workflowJsonOptions)); options.Converters.Add(new FuncExpressionValueConverter()); } } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs index 281e24b13..9a828338f 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs @@ -3,7 +3,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Common.Serialization; using Elsa.Expressions.Contracts; -using Elsa.Expressions.Services; using Elsa.Workflows.Serialization.Converters; using Microsoft.Extensions.DependencyInjection; @@ -72,10 +71,9 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer protected override void AddConverters(JsonSerializerOptions options) { var expressionDescriptorRegistry = ServiceProvider.GetRequiredService(); - var wellKnownTypeRegistry = ServiceProvider.GetRequiredService(); options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - options.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry)); + options.Converters.Add(CreateInstance()); options.Converters.Add(new SafeValueConverterFactory()); options.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry)); options.Converters.Add(new FuncExpressionValueConverter()); diff --git a/src/modules/Elsa.Workflows.Core/Services/Hasher.cs b/src/modules/Elsa.Workflows.Core/Services/Hasher.cs index 808a8684b..b80b373a8 100644 --- a/src/modules/Elsa.Workflows.Core/Services/Hasher.cs +++ b/src/modules/Elsa.Workflows.Core/Services/Hasher.cs @@ -2,8 +2,9 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows; @@ -12,7 +13,12 @@ public class Hasher : IHasher { private readonly JsonSerializerOptions _serializerOptions; - public Hasher(IWellKnownTypeRegistry wellKnownTypeRegistry) + public Hasher() + : this(Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions())) + { + } + + public Hasher(IOptions workflowJsonOptions) { _serializerOptions = new JsonSerializerOptions { @@ -21,7 +27,7 @@ public class Hasher : IHasher DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - _serializerOptions.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry)); + _serializerOptions.Converters.Add(new TypeJsonConverter(workflowJsonOptions)); _serializerOptions.Converters.Add(new ExcludeFromHashConverterFactory()); } @@ -50,4 +56,4 @@ public class Hasher : IHasher return JsonSerializer.Serialize(payload, payload.GetType(), _serializerOptions); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs b/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs index 91c19fd03..cb4fc0019 100644 --- a/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs +++ b/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs @@ -1,13 +1,12 @@ -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Extensions; using Elsa.Expressions.Helpers; -using Elsa.Expressions.Services; using Elsa.Extensions; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Elsa.Workflows; @@ -17,21 +16,40 @@ namespace Elsa.Workflows; public class VariableMapper { private readonly ILogger _logger; - private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + private readonly WorkflowJsonOptions _workflowJsonOptions; /// /// Initializes a new instance of the class. /// - /// The well-known type registry. /// The logger. - public VariableMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, ILogger logger) + public VariableMapper(ILogger logger) + : this(logger, Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions())) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The workflow JSON options. + public VariableMapper(ILogger logger, IOptions workflowJsonOptions) + : this(logger, workflowJsonOptions.Value) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The workflow JSON options. + public VariableMapper(ILogger logger, WorkflowJsonOptions workflowJsonOptions) { - _wellKnownTypeRegistry = wellKnownTypeRegistry; _logger = logger; + _workflowJsonOptions = workflowJsonOptions; } /// - public VariableMapper() : this(new WellKnownTypeRegistry(), NullLogger.Instance) + public VariableMapper() : this(NullLogger.Instance) { } @@ -42,7 +60,6 @@ public class VariableMapper public Variable Map(VariableModel source) { var type = ResolveVariableType(source.TypeName); - var variableGenericType = typeof(Variable<>).MakeGenericType(type); var variable = (Variable)Activator.CreateInstance(variableGenericType)!; @@ -67,8 +84,8 @@ public class VariableMapper var variableType = source.GetType(); var value = source.Value; var valueType = variableType.IsConstructedGenericType ? variableType.GetGenericArguments().FirstOrDefault() ?? typeof(object) : typeof(object); - var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(valueType); - var storageDriverTypeName = source.StorageDriverType?.GetSimpleAssemblyQualifiedName(); + var valueTypeAlias = WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, valueType); + var storageDriverTypeName = source.StorageDriverType != null ? WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, source.StorageDriverType) : null; var serializedValue = value.Format(); return new(source.Id, source.Name, valueTypeAlias, serializedValue, storageDriverTypeName); @@ -79,7 +96,7 @@ public class VariableMapper if (string.IsNullOrWhiteSpace(typeAlias)) return typeof(object); - if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type)) + if (WorkflowJsonTypeResolver.TryResolveType(_workflowJsonOptions, typeAlias, _workflowJsonOptions.AllowLegacyClrTypeNames, out var type)) return type; _logger.LogWarning("Failed to resolve variable type alias {VariableTypeName}", typeAlias); @@ -91,7 +108,7 @@ public class VariableMapper if (string.IsNullOrWhiteSpace(typeAlias)) return null; - if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type) && IsStorageDriverType(type)) + if (WorkflowJsonTypeResolver.TryResolveType(_workflowJsonOptions, typeAlias, _workflowJsonOptions.AllowLegacyClrTypeNames, out var type) && IsStorageDriverType(type)) return type; _logger.LogWarning("Failed to resolve storage driver type alias {StorageDriverTypeName}", typeAlias); diff --git a/src/modules/Elsa.Workflows.Core/ShellFeatures/FlowchartFeature.cs b/src/modules/Elsa.Workflows.Core/ShellFeatures/FlowchartFeature.cs index b7d9fdce3..9efb4ce38 100644 --- a/src/modules/Elsa.Workflows.Core/ShellFeatures/FlowchartFeature.cs +++ b/src/modules/Elsa.Workflows.Core/ShellFeatures/FlowchartFeature.cs @@ -1,7 +1,9 @@ using CShells.Features; using Elsa.Extensions; +using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Activities.Flowchart.Options; using Elsa.Workflows.Activities.Flowchart.Serialization; +using Elsa.Workflows.Serialization.Options; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -30,6 +32,7 @@ public class FlowchartFeature : IShellFeature if (FlowchartOptionsConfigurator != null) services.Configure(FlowchartOptionsConfigurator); + + services.Configure(options => options.AddTypeAlias("FlowScope")); } } - diff --git a/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs index b3617a45a..04e3b6bc8 100644 --- a/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs @@ -19,6 +19,7 @@ using Elsa.Workflows.Pipelines.WorkflowExecution; using Elsa.Workflows.PortResolvers; using Elsa.Workflows.Serialization.Configurators; using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; using Elsa.Workflows.Serialization.Serializers; using Elsa.Workflows.Services; using Elsa.Workflows.UIHints.CheckList; @@ -54,7 +55,12 @@ public class WorkflowsFeature : IShellFeature /// /// A delegate to configure the . /// - public Action ActivityExecutionPipeline { get; set; } = builder => builder.UseDefaultActivityInvoker(); + public Action ActivityExecutionPipeline { get; set; } = builder => builder + .UseLogging() + .UseExceptionHandling() + .UseExecutionLogging() + .UseNotifications() + .UseDefaultActivityInvoker(); /// /// A factory that instantiates a concrete . @@ -78,6 +84,8 @@ public class WorkflowsFeature : IShellFeature public void ConfigureServices(IServiceCollection services) { + services.Configure(options => options.RegisterWorkflowTypeAliases()); + services // Core. .AddScoped() @@ -184,7 +192,7 @@ public class WorkflowsFeature : IShellFeature // Logging .AddLogging(); - + // Overridable services services.AddScoped(); } diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ManagementServiceCollectionExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ManagementServiceCollectionExtensions.cs index 0eadcf65b..8dbdc87e3 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ManagementServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ManagementServiceCollectionExtensions.cs @@ -1,7 +1,8 @@ using System.Reflection; -using Elsa.Expressions.Extensions; +using Elsa.Extensions; using Elsa.Workflows.Management.Models; using Elsa.Workflows.Management.Options; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.DependencyInjection; // ReSharper disable once CheckNamespace @@ -100,6 +101,6 @@ public static class ManagementServiceCollectionExtensions { return services .AddVariableDescriptor(category) - .AddTypeAlias(alias); + .Configure(options => options.AddTypeAlias(alias)); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs index 55e72f630..be1d8ecba 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs @@ -2,6 +2,8 @@ using Elsa.Features.Services; using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Management.Features; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.DependencyInjection; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; @@ -72,9 +74,9 @@ public static class ModuleExtensions /// public static IModule AddVariableTypeAndAlias(this IModule module, string alias, string category) { - return module - .UseWorkflowManagement(management => management.AddVariableType(category)) - .AddTypeAlias(alias); + module.UseWorkflowManagement(management => management.AddVariableType(category)); + module.Services.Configure(options => options.AddTypeAlias(alias)); + return module; } /// @@ -85,4 +87,4 @@ public static class ModuleExtensions feature.Module.Configure(configure); return feature; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowManagementFeatureExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowManagementFeatureExtensions.cs index 12d91f384..57801be2d 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowManagementFeatureExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowManagementFeatureExtensions.cs @@ -1,6 +1,8 @@ using Elsa.Extensions; using Elsa.Workflows.Management.Features; +using Elsa.Workflows.Serialization.Options; using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.Management; @@ -15,7 +17,7 @@ public static class WorkflowManagementFeatureExtensions public static WorkflowManagementFeature AddVariableTypeAndAlias(this WorkflowManagementFeature management, string alias, string category) { management.AddVariableType(category); - management.Module.AddTypeAlias(alias); + management.Module.Services.Configure(options => options.AddTypeAlias(alias)); return management; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 46608c0c7..d6769d71f 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -8,7 +8,7 @@ using Elsa.Caching.Features; using Elsa.Common.Codecs; using Elsa.Common.Features; using Elsa.Expressions.Contracts; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -313,8 +313,8 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) options.IsReadOnlyMode = IsReadOnlyMode; }); - Services.Configure(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext))); Services.Configure(_ => { }); Services.Configure(_ => { }); + Services.Configure(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext))); } } diff --git a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs index cd0bc53b7..0f6644630 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs @@ -1,26 +1,28 @@ -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Extensions; using Elsa.Expressions.Helpers; using Elsa.Extensions; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Management.Mappers; /// /// Maps s to s and vice versa. /// -public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, IServiceScopeFactory scopeFactory, ILogger logger) +public class VariableDefinitionMapper(IOptions workflowJsonOptions, IServiceScopeFactory scopeFactory, ILogger logger) { + private readonly WorkflowJsonOptions _workflowJsonOptions = workflowJsonOptions.Value; + /// /// Maps a to a . /// public Variable? Map(VariableDefinition source) { - var aliasedType = wellKnownTypeRegistry.TryGetType(source.TypeName, out var aliasedTypeValue) ? aliasedTypeValue : null; - var type = aliasedType ?? Type.GetType(source.TypeName); + var type = WorkflowJsonTypeResolver.TryResolveType(_workflowJsonOptions, source.TypeName, _workflowJsonOptions.AllowLegacyClrTypeNames, out var resolvedType) ? resolvedType : null; if (type == null) { @@ -28,7 +30,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist return null; } - var valueType = aliasedType is { IsArray: true } ? source.IsArray ? aliasedType.MakeArrayType() : aliasedType : source.IsArray ? type.MakeArrayType() : type; + var valueType = type.IsArray ? source.IsArray ? type.MakeArrayType() : type : source.IsArray ? type.MakeArrayType() : type; var variableGenericType = typeof(Variable<>).MakeGenericType(valueType); var variable = (Variable)Activator.CreateInstance(variableGenericType)!; @@ -70,10 +72,10 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist { var variableType = source.GetType(); var valueType = variableType.IsConstructedGenericType ? variableType.GetGenericArguments().FirstOrDefault() ?? typeof(object) : typeof(object); - var valueTypeAlias = wellKnownTypeRegistry.TryGetAlias(valueType, out var alias) ? alias : null; + var valueTypeAlias = WorkflowJsonTypeResolver.TryGetAlias(_workflowJsonOptions, valueType, out var alias) ? alias : null; var value = source.Value; var serializedValue = value.Format(); - var storageDriverTypeName = source.StorageDriverType?.GetSimpleAssemblyQualifiedName(); + var storageDriverTypeName = source.StorageDriverType != null ? WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, source.StorageDriverType) : null; // Handles the case where an alias exists for an array or collection type. E.g. byte[] -> ByteArray. if (valueTypeAlias != null && (valueType.IsArray || valueType.IsCollectionType())) @@ -82,7 +84,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist var isArray = valueType.IsArray; var isCollection = valueType.IsCollectionType(); var elementValueType = isArray ? valueType.GetElementType()! : isCollection ? valueType.GenericTypeArguments[0] : valueType; - var elementTypeAlias = wellKnownTypeRegistry.GetAliasOrDefault(elementValueType); + var elementTypeAlias = WorkflowJsonTypeResolver.GetAliasOrLegacyClrTypeName(_workflowJsonOptions, elementValueType); return new(source.Id, source.Name, elementTypeAlias, isArray, serializedValue, storageDriverTypeName); } @@ -97,9 +99,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist if (string.IsNullOrEmpty(storageDriverTypeName)) return null; - var type = Type.GetType(storageDriverTypeName); - - if (type != null) + if (WorkflowJsonTypeResolver.TryResolveType(_workflowJsonOptions, storageDriverTypeName, _workflowJsonOptions.AllowLegacyClrTypeNames, out var type) && typeof(IStorageDriver).IsAssignableFrom(type)) return type; // TODO: The following code handles backward compatibility with variable definitions referencing older .NET type namespaces. @@ -115,4 +115,4 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist return null; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/ShellFeatures/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/ShellFeatures/WorkflowManagementFeature.cs index d34feb336..2622f6be7 100644 --- a/src/modules/Elsa.Workflows.Management/ShellFeatures/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/ShellFeatures/WorkflowManagementFeature.cs @@ -5,6 +5,7 @@ using CShells.Features; using Elsa.Caching.Features; using Elsa.Common.Features; using Elsa.Expressions.Contracts; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Attributes; using Elsa.Workflows.Features; @@ -131,5 +132,7 @@ public class WorkflowManagementFeature : IShellFeature // Register the default variable descriptors declared on this feature. services.AddVariableDescriptors(VariableDescriptors); + + services.Configure(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext))); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs b/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs index 45498dd5e..17a05faf8 100644 --- a/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs @@ -1,10 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Services; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Runtime.Comparers; @@ -18,14 +18,23 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer /// /// Initializes a new instance of the class. /// - public WorkflowTriggerEqualityComparer() : this(WellKnownTypeRegistry.CreateDefault()) + public WorkflowTriggerEqualityComparer() + : this(new WorkflowJsonOptions()) { } /// /// Initializes a new instance of the class. /// - public WorkflowTriggerEqualityComparer(IWellKnownTypeRegistry wellKnownTypeRegistry) + public WorkflowTriggerEqualityComparer(IOptions workflowJsonOptions) + : this(workflowJsonOptions.Value) + { + } + + /// + /// Initializes a new instance of the class. + /// + public WorkflowTriggerEqualityComparer(WorkflowJsonOptions workflowJsonOptions) { _settings = new() { @@ -44,8 +53,8 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer // polymorphic object properties serialize identically to their stored representation. _settings.Converters.Add(new JsonStringEnumConverter()); _settings.Converters.Add(JsonMetadataServices.TimeSpanConverter); - _settings.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry)); - _settings.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry)); + _settings.Converters.Add(new PolymorphicObjectConverterFactory(workflowJsonOptions)); + _settings.Converters.Add(new TypeJsonConverter(workflowJsonOptions)); } /// diff --git a/src/modules/Elsa.Workflows.Runtime/Discovery/WorkflowTypeScanner.cs b/src/modules/Elsa.Workflows.Runtime/Discovery/WorkflowTypeScanner.cs new file mode 100644 index 000000000..207a0d212 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Discovery/WorkflowTypeScanner.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Elsa.Workflows.Runtime.Discovery; + +internal static class WorkflowTypeScanner +{ + [RequiresUnreferencedCode("The assembly is required to be referenced.")] + public static IEnumerable GetWorkflowTypes(Assembly assembly) + { + return assembly.GetExportedTypes() + .Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false }); + } +} diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs index 40ef0c3f1..614c0d4c6 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs @@ -1,5 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Elsa.Workflows.Serialization.Options; +using Elsa.Extensions; +using Elsa.Workflows; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Discovery; using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Providers; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -51,4 +57,42 @@ public static class DependencyInjectionExtensions /// /// The type of the workflows provider to add. Must implement . public static IServiceCollection AddWorkflowsProvider(this IServiceCollection services) where T : class, IWorkflowsProvider => services.AddScoped(); -} \ No newline at end of file + + /// + /// Registers the specified code-first workflow type. + /// + public static IServiceCollection AddWorkflow(this IServiceCollection services) where T : IWorkflow => services.AddWorkflow(typeof(T)); + + /// + /// Registers the specified code-first workflow type. + /// + public static IServiceCollection AddWorkflow(this IServiceCollection services, Type workflowType) + { + AddWorkflowRegistration(services, workflowType); + return services; + } + + /// + /// Registers all code-first workflows contained in the assembly containing the specified marker type. + /// + [RequiresUnreferencedCode("The assembly is required to be referenced.")] + public static IServiceCollection AddWorkflowsFrom(this IServiceCollection services) => services.AddWorkflowsFrom(typeof(TMarker).Assembly); + + /// + /// Registers all code-first workflows in the specified assembly. + /// + [RequiresUnreferencedCode("The assembly is required to be referenced.")] + public static IServiceCollection AddWorkflowsFrom(this IServiceCollection services, Assembly assembly) + { + foreach (var workflowType in WorkflowTypeScanner.GetWorkflowTypes(assembly)) + AddWorkflowRegistration(services, workflowType); + + return services; + } + + private static void AddWorkflowRegistration(IServiceCollection services, Type workflowType) + { + services.PostConfigure(options => options.Workflows.Add(workflowType)); + services.Configure(options => options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName())); + } +} diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs index 952c28da7..0967c0276 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs @@ -1,5 +1,4 @@ using Elsa.Workflows; -using Elsa.Workflows.Runtime.Helpers; using Microsoft.Extensions.DependencyInjection; // ReSharper disable once CheckNamespace @@ -23,7 +22,10 @@ public static class WorkflowDictionaryExtensions /// public static void Add(this IDictionary>> dictionary, Type workflowType) { - WorkflowTypeValidator.Validate(workflowType); + ValidateWorkflowType(workflowType); + + if (string.IsNullOrWhiteSpace(workflowType.FullName)) + throw new ArgumentException($"Workflow type '{workflowType.Name}' must have a full name.", nameof(workflowType)); var key = workflowType.GetSimpleAssemblyQualifiedName(); var legacyKey = workflowType.FullName; @@ -45,8 +47,16 @@ public static class WorkflowDictionaryExtensions if (!string.IsNullOrWhiteSpace(legacyKey) && legacyKey != key) dictionary[legacyKey] = factory!; - - if (dictionary is IWorkflowTypeRegistry workflowTypeRegistry) - workflowTypeRegistry.AddWorkflowType(workflowType); } + + private static void ValidateWorkflowType(Type workflowType) + { + if (!typeof(IWorkflow).IsAssignableFrom(workflowType)) + throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must implement {nameof(IWorkflow)}.", nameof(workflowType)); + + if (workflowType.IsAbstract || workflowType.IsInterface || workflowType.ContainsGenericParameters) + throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must be a concrete, closed type.", nameof(workflowType)); + } + + private static string GetDisplayName(Type type) => type.FullName ?? type.Name; } diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 870c4148d..8393bf829 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -4,7 +4,7 @@ using Elsa.Common; using Elsa.Common.DistributedHosting; using Elsa.Common.Features; using Elsa.Common.RecurringTasks; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -15,14 +15,12 @@ using Elsa.Workflows.Management; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Services; using Elsa.Workflows.Runtime.ActivationValidators; -using Elsa.Workflows.Runtime.Bookmarks; +using Elsa.Workflows.Runtime.Comparers; +using Elsa.Workflows.Runtime.Discovery; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Handlers; -using Elsa.Workflows.Runtime.Helpers; -using Elsa.Workflows.Runtime.Models; using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Providers; -using Elsa.Workflows.Runtime.Stimuli; using Elsa.Workflows.Runtime.Stores; using Elsa.Workflows.Runtime.Tasks; using Elsa.Workflows.Runtime.UIHints; @@ -46,8 +44,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// /// A list of workflow builders configured during application startup. /// - public IDictionary>> Workflows { get; set; } = new WorkflowFactoryDictionary(); - private ISet WorkflowTypes { get; } = new HashSet(); + public IDictionary>> Workflows { get; set; } = new Dictionary>>(); /// /// A factory that instantiates a concrete . @@ -202,9 +199,8 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// public WorkflowRuntimeFeature AddWorkflow(Type workflowType) { - WorkflowTypeValidator.Validate(workflowType); Workflows.Add(workflowType); - WorkflowTypes.Add(workflowType); + Services.Configure(options => options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName())); return this; } @@ -214,11 +210,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) [RequiresUnreferencedCode("The assembly is required to be referenced.")] public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly) { - var workflowTypes = assembly.GetExportedTypes() - .Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false }) - .ToList(); - - foreach (var workflowType in workflowTypes) + foreach (var workflowType in WorkflowTypeScanner.GetWorkflowTypes(assembly)) AddWorkflow(workflowType); return this; @@ -275,7 +267,6 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) Services.Configure(WorkflowInboxCleanupOptions); Services.Configure(WorkflowDispatcherOptions); Services.Configure(BookmarkQueuePurgeOptions); - Services.Configure(RegisterWorkflowTypeAliases); Services.Configure(options => { options.Workflows = Workflows; }); Services.Configure(options => { @@ -314,6 +305,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) Services // Core. + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() @@ -447,13 +439,4 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) Services.TryAddScoped(WorkflowDispatchOutboxStore); Services.TryAddScoped(); } - - private void RegisterWorkflowTypeAliases(ExpressionOptions options) - { - var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry - ? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes) - : WorkflowTypes; - - WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes); - } } diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs deleted file mode 100644 index 4b7b977f2..000000000 --- a/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Elsa.Workflows.Runtime.Helpers; - -internal interface IWorkflowTypeRegistry -{ - IEnumerable WorkflowTypes { get; } - - void AddWorkflowType(Type workflowType); -} diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs deleted file mode 100644 index 872f80423..000000000 --- a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Elsa.Workflows; - -namespace Elsa.Workflows.Runtime.Helpers; - -internal sealed class WorkflowFactoryDictionary : Dictionary>>, IWorkflowTypeRegistry -{ - private readonly ISet _workflowTypes = new HashSet(); - - public IEnumerable WorkflowTypes => _workflowTypes; - - public void AddWorkflowType(Type workflowType) - { - WorkflowTypeValidator.Validate(workflowType); - _workflowTypes.Add(workflowType); - } -} diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs deleted file mode 100644 index 069aed8ab..000000000 --- a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Elsa.Workflows; - -namespace Elsa.Workflows.Runtime.Helpers; - -internal static class WorkflowTypeValidator -{ - public static void Validate(Type workflowType) - { - if (!typeof(IWorkflow).IsAssignableFrom(workflowType)) - throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must implement {nameof(IWorkflow)}.", nameof(workflowType)); - - if (workflowType.IsAbstract || workflowType.IsInterface || workflowType.IsGenericTypeDefinition || workflowType.ContainsGenericParameters) - throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must be a concrete, closed type.", nameof(workflowType)); - } - - private static string GetDisplayName(Type type) => type.FullName ?? type.Name; -} diff --git a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs index c69353ac9..b04d0f130 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs @@ -31,9 +31,9 @@ public class TriggerIndexer : ITriggerIndexer private readonly IServiceProvider _serviceProvider; private readonly IStimulusHasher _hasher; private readonly IDistributedLockProvider _distributedLockProvider; - private readonly WorkflowTriggerEqualityComparer _triggerEqualityComparer; private readonly DistributedLockingOptions _lockingOptions; private readonly ILogger _logger; + private readonly WorkflowTriggerEqualityComparer _triggerEqualityComparer; /// /// Constructor. @@ -49,8 +49,8 @@ public class TriggerIndexer : ITriggerIndexer IServiceProvider serviceProvider, IStimulusHasher hasher, IDistributedLockProvider distributedLockProvider, - IWellKnownTypeRegistry wellKnownTypeRegistry, IOptions lockingOptions, + WorkflowTriggerEqualityComparer triggerEqualityComparer, ILogger logger) { _activityVisitor = activityVisitor; @@ -62,8 +62,8 @@ public class TriggerIndexer : ITriggerIndexer _serviceProvider = serviceProvider; _hasher = hasher; _distributedLockProvider = distributedLockProvider; - _triggerEqualityComparer = new WorkflowTriggerEqualityComparer(wellKnownTypeRegistry); _lockingOptions = lockingOptions.Value; + _triggerEqualityComparer = triggerEqualityComparer; _logger = logger; _workflowDefinitionService = workflowDefinitionService; } diff --git a/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs index c66a829f5..d680b6529 100644 --- a/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs @@ -3,7 +3,7 @@ using System.Reflection; using CShells.Features; using Elsa.Common; using Elsa.Common.RecurringTasks; -using Elsa.Expressions.Options; +using Elsa.Workflows.Serialization.Options; using Elsa.Extensions; using Elsa.Mediator.Contracts; using Elsa.Workflows.CommitStates; @@ -11,9 +11,10 @@ using Elsa.Workflows.Management; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Services; using Elsa.Workflows.Runtime.ActivationValidators; +using Elsa.Workflows.Runtime.Comparers; +using Elsa.Workflows.Runtime.Discovery; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Handlers; -using Elsa.Workflows.Runtime.Helpers; using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Providers; using Elsa.Workflows.Runtime.Services; @@ -42,7 +43,7 @@ public class WorkflowRuntimeFeature : IShellFeature /// /// A list of workflow builders configured during application startup. /// - public IDictionary>> Workflows { get; set; } = new WorkflowFactoryDictionary(); + public IDictionary>> Workflows { get; set; } = new Dictionary>>(); private ISet WorkflowTypes { get; } = new HashSet(); /// @@ -164,7 +165,6 @@ public class WorkflowRuntimeFeature : IShellFeature /// public WorkflowRuntimeFeature AddWorkflow(Type workflowType) { - WorkflowTypeValidator.Validate(workflowType); Workflows.Add(workflowType); WorkflowTypes.Add(workflowType); return this; @@ -176,11 +176,7 @@ public class WorkflowRuntimeFeature : IShellFeature [RequiresUnreferencedCode("The assembly is required to be referenced.")] public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly) { - var workflowTypes = assembly.GetExportedTypes() - .Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false }) - .ToList(); - - foreach (var workflowType in workflowTypes) + foreach (var workflowType in WorkflowTypeScanner.GetWorkflowTypes(assembly)) AddWorkflow(workflowType); return this; @@ -189,7 +185,7 @@ public class WorkflowRuntimeFeature : IShellFeature public void ConfigureServices(IServiceCollection services) { // Options. - services.Configure(RegisterWorkflowTypeAliases); + services.Configure(RegisterWorkflowTypeAliases); services.Configure(options => { options.Workflows = Workflows; }); services.Configure(options => { @@ -231,6 +227,7 @@ public class WorkflowRuntimeFeature : IShellFeature .AddTransient() // Core. + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() @@ -240,6 +237,7 @@ public class WorkflowRuntimeFeature : IShellFeature .AddScoped(StimulusDispatcher) .AddScoped(WorkflowCancellationDispatcher) .AddScoped(RunTaskDispatcher) + .AddScoped(DispatchWorkflowCommandHandler) .AddScoped(ActivityExecutionLogSink) .AddScoped(WorkflowExecutionLogSink) .AddSingleton(BackgroundActivityScheduler) @@ -366,12 +364,9 @@ public class WorkflowRuntimeFeature : IShellFeature services.TryAddScoped(); } - private void RegisterWorkflowTypeAliases(ExpressionOptions options) + private void RegisterWorkflowTypeAliases(WorkflowJsonOptions options) { - var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry - ? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes) - : WorkflowTypes; - - WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes); + foreach (var workflowType in WorkflowTypes) + options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName()); } } diff --git a/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs b/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs deleted file mode 100644 index b3811a01e..000000000 --- a/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Elsa.Expressions.Options; -using Elsa.Extensions; -using Elsa.Workflows; -using Elsa.Workflows.Runtime.Bookmarks; -using Elsa.Workflows.Runtime.Stimuli; - -namespace Elsa.Workflows.Runtime; - -internal static class WorkflowRuntimeTypeAliasRegistrar -{ - public static void Register(ExpressionOptions options, IEnumerable workflowTypes) - { - options.RegisterTypeAlias(typeof(EventBookmarkPayload), nameof(EventBookmarkPayload)); - options.RegisterTypeAlias(typeof(ExecuteWorkflowPayload), nameof(ExecuteWorkflowPayload)); - options.RegisterTypeAlias(typeof(RunTaskBookmarkPayload), nameof(RunTaskBookmarkPayload)); - options.RegisterTypeAlias(typeof(BookmarkTokenPayload), nameof(BookmarkTokenPayload)); - options.RegisterTypeAlias(typeof(EventTokenPayload), nameof(EventTokenPayload)); - options.RegisterTypeAlias(typeof(ExecuteWorkflowResult), nameof(ExecuteWorkflowResult)); - options.RegisterTypeAlias(typeof(WorkflowInterruptedPayload), nameof(WorkflowInterruptedPayload)); - options.RegisterTypeAlias(typeof(BackgroundActivityStimulus), nameof(BackgroundActivityStimulus)); - options.RegisterTypeAlias(typeof(BulkDispatchWorkflowsStimulus), nameof(BulkDispatchWorkflowsStimulus)); - options.RegisterTypeAlias(typeof(DispatchWorkflowStimulus), nameof(DispatchWorkflowStimulus)); - options.RegisterTypeAlias(typeof(EventStimulus), nameof(EventStimulus)); - options.RegisterTypeAlias(typeof(ExecuteWorkflowStimulus), nameof(ExecuteWorkflowStimulus)); - options.RegisterTypeAlias(typeof(RunTaskStimulus), nameof(RunTaskStimulus)); - - foreach (var workflowType in workflowTypes.Where(IsConcreteWorkflowType).Distinct()) - options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName()); - } - - private static bool IsConcreteWorkflowType(Type type) - { - return typeof(IWorkflow).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false }; - } -} diff --git a/test/TlsSmoke/Directory.Build.props b/test/TlsSmoke/Directory.Build.props new file mode 100644 index 000000000..aa6d9a312 --- /dev/null +++ b/test/TlsSmoke/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/test/TlsSmoke/Program.cs b/test/TlsSmoke/Program.cs new file mode 100644 index 000000000..e2b8d9a3b --- /dev/null +++ b/test/TlsSmoke/Program.cs @@ -0,0 +1,16 @@ +if (args.Length != 1 || !Uri.TryCreate(args[0], UriKind.Absolute, out var uri)) +{ + Console.Error.WriteLine("Usage: TlsSmoke "); + return 2; +} + +using var httpClient = new HttpClient +{ + Timeout = TimeSpan.FromSeconds(30) +}; + +using var response = await httpClient.GetAsync(uri); +response.EnsureSuccessStatusCode(); + +Console.WriteLine($"TLS request to {uri} returned {(int)response.StatusCode}."); +return 0; diff --git a/test/TlsSmoke/TlsSmoke.csproj b/test/TlsSmoke/TlsSmoke.csproj new file mode 100644 index 000000000..cd19a3563 --- /dev/null +++ b/test/TlsSmoke/TlsSmoke.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs index 4ad856b7e..847bdebee 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs @@ -142,24 +142,25 @@ public class BulkDispatchWorkflowsTests : AppComponentTest return (T?)variables.FirstOrDefault(v => v.Variable.Name == variableName)?.Value; } - private async Task<(TestWorkflowExecutionResult Result, List CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync( + private async Task<(TestWorkflowExecutionResult Result, List CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync( string parentWorkflowDefinitionId, string childWorkflowDefinitionId, int expectedChildCount) { var result = await RunWorkflowAsync(parentWorkflowDefinitionId); - var completedChildWorkflows = await WaitForCompletedChildWorkflowInstancesAsync(result.WorkflowExecutionContext.Id, childWorkflowDefinitionId, expectedChildCount); + var completedChildWorkflows = await WaitForCompletedChildWorkflowsAsync(result.WorkflowExecutionContext.Id, childWorkflowDefinitionId, expectedChildCount); return (result, completedChildWorkflows); } - private async Task> WaitForCompletedChildWorkflowInstancesAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount) + private async Task> WaitForCompletedChildWorkflowsAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount) { + var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService(); var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(ChildWorkflowTimeoutSeconds); while (DateTimeOffset.UtcNow < timeoutAt) { - var completedChildWorkflows = await FindChildWorkflowInstancesAsync(parentWorkflowInstanceId, childWorkflowDefinitionId, WorkflowStatus.Finished); + var completedChildWorkflows = await FindChildWorkflowStatesAsync(workflowInstanceStore, parentWorkflowInstanceId, childWorkflowDefinitionId, WorkflowStatus.Finished); if (completedChildWorkflows.Count >= expectedChildCount) return completedChildWorkflows; @@ -167,12 +168,13 @@ public class BulkDispatchWorkflowsTests : AppComponentTest await Task.Delay(TimeSpan.FromMilliseconds(100)); } - var childWorkflows = await FindChildWorkflowInstancesAsync(parentWorkflowInstanceId, childWorkflowDefinitionId); + var childWorkflows = await FindChildWorkflowStatesAsync(workflowInstanceStore, parentWorkflowInstanceId, childWorkflowDefinitionId); var observedStates = string.Join(", ", childWorkflows.Select(x => $"{x.Id}:{x.Status}:{x.SubStatus}")); throw new TimeoutException($"Expected {expectedChildCount} completed child workflows with definition ID {childWorkflowDefinitionId}, but observed {childWorkflows.Count(x => x.Status == WorkflowStatus.Finished)}. Observed child workflow states: {observedStates}"); } - private async Task> FindChildWorkflowInstancesAsync( + private static async Task> FindChildWorkflowStatesAsync( + IWorkflowInstanceStore workflowInstanceStore, string parentWorkflowInstanceId, string childWorkflowDefinitionId, WorkflowStatus? workflowStatus = null) @@ -183,8 +185,8 @@ public class BulkDispatchWorkflowsTests : AppComponentTest ParentWorkflowInstanceIds = new[] { parentWorkflowInstanceId }, WorkflowStatus = workflowStatus }; - var instances = await _workflowInstanceStore.FindManyAsync(filter); - return instances.OrderBy(x => x.CreatedAt).ToList(); + var instances = await workflowInstanceStore.FindManyAsync(filter); + return instances.Select(x => x.WorkflowState).ToList(); } private async Task> WaitForChildWorkflowInstancesAsync( diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs index 8b6b696c5..b1390b510 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs @@ -73,10 +73,9 @@ public class PublishEventTests : AppComponentTest Assert.NotNull(receivedPayload); // Verify the payload structure and content - var payloadJson = JsonSerializer.Serialize(receivedPayload); - using var payloadDocument = JsonDocument.Parse(payloadJson); - var statusProperty = Assert.Single(payloadDocument.RootElement.EnumerateObject(), x => string.Equals(x.Name, "Status", StringComparison.OrdinalIgnoreCase)); - Assert.Equal("Shipped", statusProperty.Value.GetString()); + using var payloadDocument = JsonDocument.Parse(JsonSerializer.Serialize(receivedPayload)); + Assert.True(payloadDocument.RootElement.TryGetProperty("Status", out var status), "Received payload should contain a Status property"); + Assert.Equal("Shipped", status.GetString()); } private async Task GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000) diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs index 74969a8fd..90d00d2c3 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs @@ -1,15 +1,15 @@ using System.Reflection; using System.Security.Claims; -using System.Text.Json; -using ConsoleLogStreaming.Contracts; +using System.Text; +using ConsoleLogStreaming.Core.Models; using ConsoleLogStreaming.Core; -using ConsoleLogStreaming.SignalR; using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Features; using Elsa.Diagnostics.ConsoleLogs.Permissions; using Elsa.Diagnostics.ConsoleLogs.RealTime; using Elsa.Diagnostics.ConsoleLogs.Services; using FastEndpoints; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; @@ -60,18 +60,15 @@ public class ConsoleLogsAuthorizationTests { 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( + var endpoint = Activator.CreateInstance(endpointType, provider)!; + SetJsonRequest(endpointType, endpoint, """ { "workflowInstanceId": "workflow-instance-a" } - """, - requestDtoType, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + """); - var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]); + var result = endpointType.GetMethod("ExecuteAsync", [typeof(CancellationToken)])!.Invoke(endpoint, [CancellationToken.None]); await Assert.IsAssignableFrom(result); Assert.NotNull(provider.LastFilter); @@ -80,14 +77,59 @@ public class ConsoleLogsAuthorizationTests Assert.Equal("workflow-instance-a", workflowInstanceId); } + [Theory] + [InlineData("stdout", ConsoleStream.Stdout)] + [InlineData("stderr", ConsoleStream.Stderr)] + public async Task RecentEndpoint_MapsLowercaseStreamFilter(string stream, ConsoleStream expected) + { + 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)!; + SetJsonRequest(endpointType, endpoint, + $$""" + { + "stream": "{{stream}}" + } + """); + + var result = endpointType.GetMethod("ExecuteAsync", [typeof(CancellationToken)])!.Invoke(endpoint, [CancellationToken.None]); + await Assert.IsAssignableFrom(result); + + Assert.NotNull(provider.LastFilter); + Assert.Equal(expected, provider.LastFilter.Stream); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("all")] + public async Task RecentEndpoint_MapsAllStreamFilterToNull(string? stream) + { + 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)!; + var streamJson = stream == null ? "null" : $"\"{stream}\""; + SetJsonRequest(endpointType, endpoint, + $$""" + { + "stream": {{streamJson}} + } + """); + + var result = endpointType.GetMethod("ExecuteAsync", [typeof(CancellationToken)])!.Invoke(endpoint, [CancellationToken.None]); + await Assert.IsAssignableFrom(result); + + Assert.NotNull(provider.LastFilter); + Assert.Null(provider.LastFilter.Stream); + } + [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( + var endpoint = Activator.CreateInstance(endpointType, provider)!; + SetJsonRequest(endpointType, endpoint, """ { "workflowInstanceId": "workflow-instance-a", @@ -95,17 +137,30 @@ public class ConsoleLogsAuthorizationTests "activityId": "activity-a", "activityNodeId": "node-a" } - """, - requestDtoType, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + """); - var result = endpointType.GetMethod("ExecuteAsync")!.Invoke(endpoint, [request, CancellationToken.None]); + var result = endpointType.GetMethod("ExecuteAsync", [typeof(CancellationToken)])!.Invoke(endpoint, [CancellationToken.None]); await Assert.IsAssignableFrom(result); Assert.NotNull(provider.LastFilter); AssertActivityMetadata(provider.LastFilter.Metadata); } + [Fact] + public async Task RecentEndpoint_WhenJsonRequestHasUnknownEmptyBody_UsesEmptyFilter() + { + 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)!; + SetJsonRequest(endpointType, endpoint, "", includeContentLength: false); + + var result = endpointType.GetMethod("ExecuteAsync", [typeof(CancellationToken)])!.Invoke(endpoint, [CancellationToken.None]); + await Assert.IsAssignableFrom(result); + + Assert.NotNull(provider.LastFilter); + Assert.Empty(provider.LastFilter.Metadata); + } + [Fact] public async Task HubStream_MapsWorkflowInstanceIdToMetadataFilter() { @@ -167,13 +222,24 @@ public class ConsoleLogsAuthorizationTests Assert.Equal("node-a", metadata[ConsoleLogMetadataKeys.ActivityNodeId]); } + private static void SetJsonRequest(Type endpointType, object endpoint, string json, bool includeContentLength = true) + { + var bytes = Encoding.UTF8.GetBytes(json); + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(bytes); + if (includeContentLength) + context.Request.ContentLength = bytes.Length; + context.Request.ContentType = "application/json"; + + endpointType + .GetProperty("HttpContext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .SetValue(endpoint, context); + } + private static IReadOnlyCollection GetConfiguredPermissions(string endpointTypeName) { var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!; - 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 endpoint = Activator.CreateInstance(endpointType, new TestConsoleLogProvider())!; var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType); var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType); @@ -205,13 +271,13 @@ public class ConsoleLogsAuthorizationTests var genericTypeDefinition = type.GetGenericTypeDefinition(); var genericArguments = type.GetGenericArguments(); - if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,>)) + if (genericTypeDefinition == typeof(Abstractions.ElsaEndpoint<,>)) return (genericArguments[0], genericArguments[1]); - if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,,>)) + if (genericTypeDefinition == typeof(Abstractions.ElsaEndpoint<,,>)) return (genericArguments[0], genericArguments[1]); - if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpointWithoutRequest<>)) + if (genericTypeDefinition == typeof(Abstractions.ElsaEndpointWithoutRequest<>)) return (typeof(EmptyRequest), genericArguments[0]); } @@ -225,13 +291,11 @@ public class ConsoleLogsAuthorizationTests 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(); + var subscriptionManager = new ElsaConsoleLogSubscriptionManager(provider, hubContext, NullLogger.Instance); + var authorizer = new ElsaConsoleLogStreamHubAuthorizer(); - return new ElsaConsoleLogsHub(provider, mapper, authorizer, subscriptionManager) + return new ElsaConsoleLogsHub(provider, authorizer, subscriptionManager) { Context = new TestHubCallerContext(CreateUser(permissions)) }; @@ -250,83 +314,58 @@ public class ConsoleLogsAuthorizationTests private class TestConsoleLogProvider : IConsoleLogProvider { - private readonly TaskCompletionSource _subscriptionFilterSet = new(TaskCreationOptions.RunContinuationsAsynchronously); + 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 ConsoleLogFilter? LastFilter { get; private set; } + public ConsoleLogFilter? LastSubscriptionFilter { get; private set; } - public Task WaitForSubscriptionFilterAsync() => + public Task WaitForSubscriptionFilterAsync() => _subscriptionFilterSet.Task.WaitAsync(TimeSpan.FromSeconds(5)); - public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default) + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; } - public ValueTask GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default) + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) { LastFilter = filter; - return ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult()); + return ValueTask.FromResult(new RecentConsoleLogsResult()); } - public IAsyncEnumerable SubscribeAsync( - ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, + public IAsyncEnumerable SubscribeAsync( + ConsoleLogFilter filter, CancellationToken cancellationToken = default) { LastSubscriptionFilter = filter; _subscriptionFilterSet.TrySetResult(filter); - return AsyncEnumerable.Empty(); + 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 + private class TestHubContext : IHubContext { - public event Action? SourceChanged - { - add { } - remove { } - } - - public ConsoleLogStreaming.Core.Models.ConsoleLogSource Current { get; } = new() - { - Id = "test", - DisplayName = "Test" - }; - - public ConsoleLogStreaming.Core.Models.ConsoleLogSource MarkSeen(ConsoleLogStreaming.Core.Models.ConsoleLogSource source, DateTimeOffset timestamp) - { - return source with { LastSeen = timestamp }; - } - - public IReadOnlyCollection List() - { - return [Current]; - } - } - - private class TestHubContext : IHubContext - { - public IHubClients Clients { get; } = new TestHubClients(); + public IHubClients Clients { get; } = new TestHubClients(); public IGroupManager Groups { get; } = new TestGroupManager(); } - private class TestHubClients : IHubClients + private class TestHubClients : IHubClients { - public IConsoleLogsClient All => throw new NotSupportedException(); - public IConsoleLogsClient AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); - public IConsoleLogsClient Client(string connectionId) => throw new NotSupportedException(); - public IConsoleLogsClient Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); - public IConsoleLogsClient Group(string groupName) => throw new NotSupportedException(); - public IConsoleLogsClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); - public IConsoleLogsClient Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); - public IConsoleLogsClient User(string userId) => throw new NotSupportedException(); - public IConsoleLogsClient Users(IReadOnlyList userIds) => throw new NotSupportedException(); + public IElsaConsoleLogsClient All => throw new NotSupportedException(); + public IElsaConsoleLogsClient AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IElsaConsoleLogsClient Client(string connectionId) => throw new NotSupportedException(); + public IElsaConsoleLogsClient Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IElsaConsoleLogsClient Group(string groupName) => throw new NotSupportedException(); + public IElsaConsoleLogsClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IElsaConsoleLogsClient Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); + public IElsaConsoleLogsClient User(string userId) => throw new NotSupportedException(); + public IElsaConsoleLogsClient Users(IReadOnlyList userIds) => throw new NotSupportedException(); } private class TestGroupManager : IGroupManager diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs index ca31b3050..321d1a6f1 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs @@ -1,4 +1,9 @@ -using ConsoleLogStreaming.SignalR; +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Models; +using Elsa.Diagnostics.ConsoleLogs.Contracts; +using Elsa.Diagnostics.ConsoleLogs.RealTime; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests; @@ -7,6 +12,98 @@ public class ConsoleLogsHubSourceStatusTests [Fact] public void ClientContract_ExposesSourceChangeMethod() { - Assert.NotNull(typeof(IConsoleLogsClient).GetMethod(nameof(IConsoleLogsClient.ReceiveSourceChangedAsync))); + Assert.NotNull(typeof(IElsaConsoleLogsClient).GetMethod(nameof(IElsaConsoleLogsClient.ReceiveSourceChangedAsync))); + } + + [Fact] + public async Task PushedSubscription_ForwardsSourceChangeFromConsoleLines() + { + var source = new ConsoleLogSource { Id = "source-1", DisplayName = "Source 1" }; + var provider = new SingleLineProvider(new ConsoleLogLine { Text = "message", Source = source }); + var client = new TestConsoleLogsClient(); + var hubContext = new TestHubContext(client); + var manager = new ElsaConsoleLogSubscriptionManager(provider, hubContext, NullLogger.Instance); + + await manager.SubscribeAsync("connection-1", new ElsaConsoleLogFilter(), CancellationToken.None); + + Assert.Same(source, await client.SourceChanged.Task.WaitAsync(TimeSpan.FromSeconds(5))); + } + + private sealed class SingleLineProvider(ConsoleLogLine line) : IConsoleLogProvider + { + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new RecentConsoleLogsResult()); + } + + public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return ConsoleLogStreamingItem.FromLine(line); + await Task.Yield(); + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>([line.Source]); + } + } + + private sealed class TestConsoleLogsClient : IElsaConsoleLogsClient + { + public TaskCompletionSource SourceChanged { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ReceiveConsoleLogLineAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task ReceiveDroppedLinesAsync(ConsoleLogDroppedSummary summary, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task ReceiveSourceChangedAsync(ConsoleLogSource source, CancellationToken cancellationToken = default) + { + SourceChanged.TrySetResult(source); + return Task.CompletedTask; + } + } + + private sealed class TestHubContext(IElsaConsoleLogsClient client) : IHubContext + { + public IHubClients Clients { get; } = new TestHubClients(client); + + public IGroupManager Groups { get; } = new TestGroupManager(); + } + + private sealed class TestHubClients(IElsaConsoleLogsClient client) : IHubClients + { + public IElsaConsoleLogsClient All => client; + public IElsaConsoleLogsClient AllExcept(IReadOnlyList excludedConnectionIds) => client; + public IElsaConsoleLogsClient Client(string connectionId) => client; + public IElsaConsoleLogsClient Clients(IReadOnlyList connectionIds) => client; + public IElsaConsoleLogsClient Group(string groupName) => client; + public IElsaConsoleLogsClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => client; + public IElsaConsoleLogsClient Groups(IReadOnlyList groupNames) => client; + public IElsaConsoleLogsClient User(string userId) => client; + public IElsaConsoleLogsClient Users(IReadOnlyList userIds) => client; + } + + private sealed class TestGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } } diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsModuleTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsModuleTests.cs index 0dd1fc06f..40d05a073 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsModuleTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsModuleTests.cs @@ -25,5 +25,6 @@ public class ConsoleLogsModuleTests Assert.DoesNotContain("Elsa.Diagnostics.StructuredLogs", references); Assert.DoesNotContain("Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite", references); + Assert.DoesNotContain("ConsoleLogStreaming.Persistence.Sqlite", references); } } diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsRecentEndpointTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsRecentEndpointTests.cs index ac94f5456..7b226a5df 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsRecentEndpointTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsRecentEndpointTests.cs @@ -5,7 +5,7 @@ public class ConsoleLogsRecentEndpointTests [Fact] public void RecentEndpoint_ExistsInConsoleLogsAssembly() { - var endpointType = typeof(Elsa.Diagnostics.ConsoleLogs.Features.ConsoleLogsFeature) + var endpointType = typeof(Features.ConsoleLogsFeature) .Assembly .GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint"); diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs index 715d31b59..8fd387e33 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs @@ -5,7 +5,7 @@ public class ConsoleLogsSourcesEndpointTests [Fact] public void SourcesEndpoint_ExistsInConsoleLogsAssembly() { - var endpointType = typeof(Elsa.Diagnostics.ConsoleLogs.Features.ConsoleLogsFeature) + var endpointType = typeof(Features.ConsoleLogsFeature) .Assembly .GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources.Endpoint"); diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj index 67ef05beb..ac46ee106 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj new file mode 100644 index 000000000..ce0f941e2 --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Elsa.Diagnostics.OpenTelemetry.IntegrationTests.csproj @@ -0,0 +1,14 @@ + + + + [Elsa.Diagnostics.OpenTelemetry]* + 0 + true + + + + + + + + diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryAuthorizationTests.cs b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryAuthorizationTests.cs new file mode 100644 index 000000000..b5c8d37a3 --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryAuthorizationTests.cs @@ -0,0 +1,102 @@ +using System.Reflection; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Features; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using FastEndpoints; + +namespace Elsa.Diagnostics.OpenTelemetry.IntegrationTests; + +public class OpenTelemetryAuthorizationTests +{ + [Theory] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Resources.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Traces.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Trace.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Metrics.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Logs.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.Storage.Endpoint")] + [InlineData("Elsa.Diagnostics.OpenTelemetry.Endpoints.OpenTelemetry.CollectorConfiguration.Endpoint")] + public void RestEndpoints_RequireOpenTelemetryReadPermission(string endpointTypeName) + { + var permissions = GetConfiguredPermissions(endpointTypeName); + + Assert.Contains(OpenTelemetryPermissions.Read, permissions); + } + + private static IReadOnlyCollection GetConfiguredPermissions(string endpointTypeName) + { + var endpointType = typeof(OpenTelemetryFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!; + var endpoint = Activator.CreateInstance(endpointType, new TestOpenTelemetryProvider())!; + var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType); + var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType); + + endpointType + .GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .SetValue(endpoint, definition); + + endpointType.GetMethod("Configure")!.Invoke(endpoint, null); + + var permissions = definition + .GetType() + .GetProperty("AllowedPermissions", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .GetValue(definition); + + return Assert.IsAssignableFrom>(permissions).ToArray(); + } + + private static (Type RequestDtoType, Type ResponseDtoType) GetEndpointDtoTypes(Type endpointType) + { + var type = endpointType; + + while (type.BaseType != null) + { + type = type.BaseType; + + if (!type.IsGenericType) + continue; + + var genericTypeDefinition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,>)) + return (genericArguments[0], genericArguments[1]); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,,>)) + return (genericArguments[0], genericArguments[1]); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpointWithoutRequest<>)) + return (typeof(EmptyRequest), genericArguments[0]); + } + + throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'."); + } + + private class TestOpenTelemetryProvider : IOpenTelemetryProvider + { + public ValueTask GetResourcesAsync(OpenTelemetryResourceFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new OpenTelemetryResourceResult([], 0)); + + public ValueTask GetTracesAsync(OpenTelemetryTraceFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new OpenTelemetryTraceResult([], 0)); + + public ValueTask GetTraceAsync(string traceId, CancellationToken cancellationToken = default) => ValueTask.FromResult(null); + + public ValueTask GetMetricsAsync(OpenTelemetryMetricFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new OpenTelemetryMetricResult([], [], 0)); + + public ValueTask GetLogsAsync(OpenTelemetryLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new OpenTelemetryLogResult([], 0)); + + public ValueTask GetStorageDiagnosticsAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(new OpenTelemetryStorageDiagnostics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + + public ValueTask GetCollectorConfigurationAsync(CancellationToken cancellationToken = default) + { + var configuration = new CollectorConfiguration( + new CollectorEndpointInfo("http/protobuf", "/elsa/otlp/v1", true, null), + new CollectorEndpointInfo("grpc", null, false, "Disabled"), + "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + new Dictionary()); + + return ValueTask.FromResult(configuration); + } + } +} diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryHubTests.cs b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryHubTests.cs new file mode 100644 index 000000000..d65e12d8e --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OpenTelemetryHubTests.cs @@ -0,0 +1,238 @@ +using System.Security.Claims; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Permissions; +using Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; +using Elsa.Diagnostics.OpenTelemetry.RealTime; +using FastEndpoints; +using FastEndpoints.Security; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit.Sdk; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.IntegrationTests; + +public class OpenTelemetryHubTests +{ + private readonly DateTimeOffset _now = new(2026, 5, 26, 10, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task SubscribeAsync_WhenUserLacksPermission_DeniesAccess() + { + var hub = CreateHub(new TestLiveFeed(), "write:diagnostics:opentelemetry"); + + await Assert.ThrowsAsync(() => hub.SubscribeAsync(new())); + } + + [Theory] + [InlineData(OpenTelemetryPermissions.Read)] + [InlineData(PermissionNames.All)] + [InlineData("read:*")] + public async Task SubscribeAsync_WhenUserCanRead_ForwardsItemsToCaller(string permission) + { + var liveFeed = new TestLiveFeed(new OpenTelemetryStreamItem { Trace = Trace("trace-1") }); + var caller = new CapturingOpenTelemetryClient(); + var hub = CreateHub(liveFeed, permission, caller); + + await hub.SubscribeAsync(new OpenTelemetryTraceFilter { TraceId = "trace-1" }); + + await AssertEventuallyAsync(() => + { + Assert.Equal("trace-1", Assert.Single(caller.Items).Trace?.TraceId); + Assert.Equal("trace-1", liveFeed.Filter?.TraceId); + }); + } + + [Fact] + public async Task SubscribeAsync_WhenFilterTimeRangeIsInvalid_RejectsFilter() + { + var hub = CreateHub(new TestLiveFeed(), OpenTelemetryPermissions.Read); + + await Assert.ThrowsAsync(() => hub.SubscribeAsync(new OpenTelemetryTraceFilter + { + From = _now.AddMinutes(1), + To = _now + })); + } + + [Fact] + public async Task LiveFeed_WhenTraceFilterIsSet_OnlyPublishesMatchingTraces() + { + var liveFeed = new InMemoryOpenTelemetryLiveFeed(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions())); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var enumerator = liveFeed.SubscribeAsync(new OpenTelemetryTraceFilter { TraceId = "trace-keep" }, timeout.Token).GetAsyncEnumerator(timeout.Token); + var next = enumerator.MoveNextAsync().AsTask(); + + await liveFeed.PublishAsync(new OpenTelemetryBatch([], [Trace("trace-skip"), Trace("trace-keep")], [], [], [], []), timeout.Token); + + Assert.True(await next); + Assert.Equal("trace-keep", enumerator.Current.Trace?.TraceId); + } + + [Fact] + public async Task LiveFeed_WhenSubscriberQueueOverflows_PublishesDroppedItemSummary() + { + var liveFeed = new InMemoryOpenTelemetryLiveFeed(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions { SubscriberChannelCapacity = 1 })); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var enumerator = liveFeed.SubscribeAsync(new OpenTelemetryTraceFilter(), timeout.Token).GetAsyncEnumerator(timeout.Token); + var first = enumerator.MoveNextAsync().AsTask(); + + await liveFeed.PublishAsync(new OpenTelemetryBatch([], [Trace("trace-1"), Trace("trace-2"), Trace("trace-3")], [], [], [], []), timeout.Token); + + Assert.True(await first); + + OpenTelemetryStreamItem? summary = enumerator.Current.DroppedItems != null ? enumerator.Current : null; + for (var i = 0; summary == null && i < 5 && await enumerator.MoveNextAsync(); i++) + { + if (enumerator.Current.DroppedItems != null) + summary = enumerator.Current; + } + + Assert.NotNull(summary); + var nonNullSummary = summary!; + Assert.Equal(OpenTelemetrySignalType.Trace, nonNullSummary.DroppedItems!.SignalType); + Assert.Equal("SubscriberQueueFull", nonNullSummary.DroppedItems.Reason); + Assert.True(nonNullSummary.DroppedItems.Count > 0); + } + + private OpenTelemetryHub CreateHub(IOpenTelemetryLiveFeed liveFeed, string permission, IOpenTelemetryClient? caller = null) + { + caller ??= new CapturingOpenTelemetryClient(); + var hubContext = new TestHubContext(caller); + var subscriptionManager = new OpenTelemetrySubscriptionManager(liveFeed, hubContext, NullLogger.Instance); + + return new OpenTelemetryHub(subscriptionManager) + { + Context = new TestHubCallerContext(CreateUser(permission)), + Clients = new TestHubCallerClients(caller) + }; + } + + private ClaimsPrincipal CreateUser(string permission) + { + var permissionClaimType = (string)typeof(SecurityOptions) + .GetProperty(nameof(SecurityOptions.PermissionsClaimType))! + .GetValue(new Config().Security)!; + var identity = new ClaimsIdentity([new Claim(permissionClaimType, permission)], "Test"); + + return new ClaimsPrincipal(identity); + } + + private TelemetryTrace Trace(string traceId) + { + return new(traceId, $"{traceId}-root", traceId, _now, _now.AddMilliseconds(10), TimeSpan.FromMilliseconds(10), SpanStatus.Ok, [], [], 1); + } + + private class TestLiveFeed(params OpenTelemetryStreamItem[] items) : IOpenTelemetryLiveFeed + { + public OpenTelemetryTraceFilter? Filter { get; private set; } + + public ValueTask PublishAsync(OpenTelemetryBatch batch, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public async IAsyncEnumerable SubscribeAsync(OpenTelemetryTraceFilter filter, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Filter = filter; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + } + + private class CapturingOpenTelemetryClient : IOpenTelemetryClient + { + public List Items { get; } = []; + + public Task ReceiveAsync(OpenTelemetryStreamItem item) + { + Items.Add(item); + return Task.CompletedTask; + } + } + + private class TestHubCallerClients(IOpenTelemetryClient caller) : IHubCallerClients + { + public IOpenTelemetryClient Caller { get; } = caller; + public IOpenTelemetryClient Others => throw new NotSupportedException(); + public IOpenTelemetryClient All => throw new NotSupportedException(); + public IOpenTelemetryClient AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Client(string connectionId) => throw new NotSupportedException(); + public IOpenTelemetryClient Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Group(string groupName) => throw new NotSupportedException(); + public IOpenTelemetryClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); + public IOpenTelemetryClient OthersInGroup(string groupName) => throw new NotSupportedException(); + public IOpenTelemetryClient User(string userId) => throw new NotSupportedException(); + public IOpenTelemetryClient Users(IReadOnlyList userIds) => throw new NotSupportedException(); + } + + private class TestHubContext(IOpenTelemetryClient caller) : IHubContext + { + public IHubClients Clients { get; } = new TestHubClients(caller); + + public IGroupManager Groups { get; } = new TestGroupManager(); + } + + private class TestHubClients(IOpenTelemetryClient caller) : IHubClients + { + public IOpenTelemetryClient All => throw new NotSupportedException(); + public IOpenTelemetryClient AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Client(string connectionId) => caller; + public IOpenTelemetryClient Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Group(string groupName) => throw new NotSupportedException(); + public IOpenTelemetryClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IOpenTelemetryClient Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); + public IOpenTelemetryClient User(string userId) => throw new NotSupportedException(); + public IOpenTelemetryClient Users(IReadOnlyList userIds) => throw new NotSupportedException(); + } + + private class TestGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private static async Task AssertEventuallyAsync(Action assertion) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(3); + Exception? lastException = null; + + while (DateTimeOffset.UtcNow < deadline) + { + try + { + assertion(); + return; + } + catch (XunitException e) + { + lastException = e; + await Task.Delay(25); + } + } + + if (lastException != null) + throw lastException; + } + + private class TestHubCallerContext(ClaimsPrincipal user) : HubCallerContext + { + public override string ConnectionId { get; } = "connection-1"; + public override string? UserIdentifier { get; } = "user-1"; + public override ClaimsPrincipal? User { get; } = user; + public override IDictionary Items { get; } = new Dictionary(); + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override CancellationToken ConnectionAborted { get; } = CancellationToken.None; + + public override void Abort() + { + } + } +} diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpHttpIngestionTests.cs b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpHttpIngestionTests.cs new file mode 100644 index 000000000..d0c48fb7a --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpHttpIngestionTests.cs @@ -0,0 +1,189 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; +using Elsa.Diagnostics.OpenTelemetry.Contracts; +using Elsa.Diagnostics.OpenTelemetry.Extensions; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Diagnostics.OpenTelemetry.IntegrationTests; + +public class OtlpHttpIngestionTests : IAsyncLifetime +{ + private static readonly byte[] TraceId = Convert.FromHexString("00112233445566778899aabbccddeeff"); + private static readonly byte[] SpanId = Convert.FromHexString("0011223344556677"); + private static readonly byte[] ChildSpanId = Convert.FromHexString("8899aabbccddeeff"); + private static readonly DateTimeOffset Timestamp = new(2026, 5, 26, 10, 0, 0, TimeSpan.Zero); + + private WebApplication? _app; + private HttpClient _httpClient = null!; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddOpenTelemetryDiagnosticsServices(options => + { + options.AllowUnauthenticatedLoopback = true; + options.HttpEndpointPath = "/elsa/otlp/v1"; + }); + + _app = builder.Build(); + _app.Use((context, next) => + { + context.Connection.RemoteIpAddress = IPAddress.Loopback; + return next(); + }); + _app.MapOpenTelemetryHttpProtobufCollector(); + + await _app.StartAsync(); + _httpClient = _app.GetTestClient(); + } + + public async Task DisposeAsync() + { + _httpClient.Dispose(); + + if (_app == null) + return; + + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + [Fact] + public async Task PostTraces_WhenPayloadIsValid_StoresQueryableTrace() + { + using var content = new ByteArrayContent(CreateTracePayload()); + content.Headers.ContentType = new("application/x-protobuf"); + + var response = await _httpClient.PostAsync("/elsa/otlp/v1/traces", content); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var provider = _app!.Services.GetRequiredService(); + var result = await provider.GetTracesAsync(new OpenTelemetryTraceFilter { TraceId = "00112233445566778899aabbccddeeff" }); + var trace = Assert.Single(result.Items); + + Assert.Equal("Workflow/Approve", trace.Name); + Assert.Equal("wf-1", Assert.Single(trace.WorkflowInstanceIds)); + + var detail = await provider.GetTraceAsync(trace.TraceId); + + Assert.NotNull(detail); + Assert.Equal(2, detail.Spans.Count); + + var rootSpan = Assert.Single(detail.Spans, x => x.SpanId == "0011223344556677"); + Assert.Equal("order-workflow", rootSpan.Attributes["workflow.definition.id"]); + Assert.Equal("wf-1", rootSpan.Attributes["workflow.instance.id"]); + + var activitySpan = Assert.Single(detail.Spans, x => x.SpanId == "8899aabbccddeeff"); + Assert.Equal("0011223344556677", activitySpan.ParentSpanId); + Assert.Equal("approve-task", activitySpan.Attributes["activity.id"]); + Assert.Equal("node-approve", activitySpan.Attributes["activity.node.id"]); + } + + [Fact] + public async Task PostTraces_WhenPayloadExceedsConfiguredLimit_ReturnsPayloadTooLarge() + { + _app!.Services.GetRequiredService>().Value.MaxHttpRequestBodySize = 1; + using var content = new ByteArrayContent(CreateTracePayload()); + content.Headers.ContentType = new("application/x-protobuf"); + + var response = await _httpClient.PostAsync("/elsa/otlp/v1/traces", content); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + + [Fact] + public async Task PostTraces_WhenPayloadIsTruncated_ReturnsBadRequest() + { + using var content = new ByteArrayContent([0x0a, 0x04, 0x08]); + content.Headers.ContentType = new("application/x-protobuf"); + + var response = await _httpClient.PostAsync("/elsa/otlp/v1/traces", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static byte[] CreateTracePayload() + { + return Message(1, + Join( + Message(1, Resource()), + Message(2, + Join( + Message(2, + Join( + Bytes(1, TraceId), + Bytes(2, SpanId), + String(5, "Workflow/Approve"), + Varint(6, 1), + Varint(7, UnixNanos(Timestamp)), + Varint(8, UnixNanos(Timestamp.AddMilliseconds(25))), + Message(9, KeyValue("workflow.instance.id", "wf-1")), + Message(9, KeyValue("workflow.definition.id", "order-workflow")), + Message(15, Varint(3, 1)))), + Message(2, + Join( + Bytes(1, TraceId), + Bytes(2, ChildSpanId), + Bytes(4, SpanId), + String(5, "Activity/ApproveTask"), + Varint(6, 1), + Varint(7, UnixNanos(Timestamp.AddMilliseconds(5))), + Varint(8, UnixNanos(Timestamp.AddMilliseconds(20))), + Message(9, KeyValue("workflow.instance.id", "wf-1")), + Message(9, KeyValue("activity.id", "approve-task")), + Message(9, KeyValue("activity.node.id", "node-approve")), + Message(15, Varint(3, 1)))))))); + } + + private static byte[] Resource() + { + return Join( + Message(1, KeyValue("service.name", "elsa-server")), + Message(1, KeyValue("service.instance.id", "node-1")), + Message(1, KeyValue("telemetry.sdk.language", "dotnet"))); + } + + private static byte[] KeyValue(string key, string value) => Join(String(1, key), Message(2, String(1, value))); + + private static byte[] Message(int fieldNumber, byte[] value) => Join(Varint((ulong)((fieldNumber << 3) | 2)), Varint((ulong)value.Length), value); + + private static byte[] String(int fieldNumber, string value) => Message(fieldNumber, Encoding.UTF8.GetBytes(value)); + + private static byte[] Bytes(int fieldNumber, byte[] value) => Message(fieldNumber, value); + + private static byte[] Varint(int fieldNumber, ulong value) => Join(Varint((ulong)(fieldNumber << 3)), Varint(value)); + + private static byte[] Varint(ulong value) + { + var bytes = new List(); + while (value >= 0x80) + { + bytes.Add((byte)(value | 0x80)); + value >>= 7; + } + bytes.Add((byte)value); + return bytes.ToArray(); + } + + private static ulong UnixNanos(DateTimeOffset timestamp) => (ulong)(timestamp - DateTimeOffset.UnixEpoch).Ticks * 100; + + private static byte[] Join(params byte[][] segments) + { + var result = new byte[segments.Sum(x => x.Length)]; + var offset = 0; + foreach (var segment in segments) + { + segment.CopyTo(result, offset); + offset += segment.Length; + } + + return result; + } +} diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpIngestionSecurityTests.cs b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpIngestionSecurityTests.cs new file mode 100644 index 000000000..bee271fc8 --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/OtlpIngestionSecurityTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using Elsa.Diagnostics.OpenTelemetry.Extensions; +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.IntegrationTests; + +public class OtlpIngestionSecurityTests +{ + [Fact] + public async Task PostTraces_WhenLoopbackAndNoApiKey_AllowsDevelopmentIngestion() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + var response = await client.PostAsync("/elsa/otlp/v1/traces", CreateEmptyProtobufContent()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PostTraces_WhenNonLoopbackAndNoApiKey_RejectsIngestion() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/elsa/otlp/v1/traces") + { + Content = CreateEmptyProtobufContent() + }; + request.Headers.Add("x-test-remote-ip", "10.0.0.5"); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostTraces_WhenApiKeyMatches_AllowsNonLoopbackIngestion() + { + await using var app = await CreateAppAsync(options => options.ApiKey = "secret"); + using var client = app.GetTestClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/elsa/otlp/v1/traces") + { + Content = CreateEmptyProtobufContent() + }; + request.Headers.Add("x-test-remote-ip", "10.0.0.5"); + request.Headers.Add("x-otlp-api-key", "secret"); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CollectorConfiguration_WhenApiKeyIsConfigured_DoesNotExposeSecret() + { + var provider = new CollectorConfigurationProvider(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions { ApiKey = "secret" })); + + var configuration = await provider.GetAsync(); + + Assert.Equal("", configuration.RequiredHeaders["x-otlp-api-key"]); + Assert.DoesNotContain("secret", configuration.RequiredHeaders.Values); + } + + private static async Task CreateAppAsync(Action? configure = null) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddOpenTelemetryDiagnosticsServices(options => + { + options.AllowUnauthenticatedLoopback = true; + options.HttpEndpointPath = "/elsa/otlp/v1"; + configure?.Invoke(options); + }); + + var app = builder.Build(); + app.Use(async (context, next) => + { + context.Connection.RemoteIpAddress = context.Request.Headers.TryGetValue("x-test-remote-ip", out var value) && IPAddress.TryParse(value.ToString(), out var remoteIpAddress) + ? remoteIpAddress + : IPAddress.Loopback; + + await next(); + }); + app.MapOpenTelemetryHttpProtobufCollector(); + await app.StartAsync(); + return app; + } + + private static ByteArrayContent CreateEmptyProtobufContent() + { + var content = new ByteArrayContent([]); + content.Headers.ContentType = new("application/x-protobuf"); + return content; + } +} diff --git a/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Usings.cs b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/test/integration/Elsa.Diagnostics.OpenTelemetry.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs deleted file mode 100644 index a6fcd4dfb..000000000 --- a/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Reflection; -using Elsa.Expressions.JavaScript.ShellFeatures; -using Elsa.Expressions.Options; -using Elsa.Expressions.Services; -using Jint.Runtime; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Elsa.JavaScript.IntegrationTests; - -public class JavaScriptFeatureTests -{ - [Fact] - public void ConfigureServices_RegistersUniqueWrapperExceptionAlias() - { - var services = new ServiceCollection(); - var feature = new JavaScriptFeature(); - var wrapperExceptionType = typeof(JavaScriptException).GetNestedType("JavaScriptErrorWrapperException", BindingFlags.Public | BindingFlags.NonPublic); - - feature.ConfigureServices(services); - - using var serviceProvider = services.BuildServiceProvider(); - var expressionOptions = serviceProvider.GetRequiredService>(); - var registry = new WellKnownTypeRegistry(expressionOptions); - - Assert.NotNull(wrapperExceptionType); - Assert.True(registry.TryGetType("Jint.JavaScriptErrorWrapperException", out var type)); - Assert.Equal(wrapperExceptionType, type); - Assert.False(registry.TryGetType(wrapperExceptionType.Name, out _)); - } -} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs index 7684f1da3..e8e6343a9 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Elsa.Expressions.Options; using Elsa.Testing.Shared; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; @@ -10,9 +9,7 @@ namespace Elsa.Workflows.IntegrationTests.Serialization.JsonSerialization; public class SerializationTests(ITestOutputHelper testOutputHelper) { - private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper) - .ConfigureServices(services => services.Configure(options => options.RegisterTypeAlias(typeof(TestObject), nameof(TestObject)))) - .Build(); + private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper).Build(); [Theory(DisplayName = "write")] [InlineData(typeof(JsonObject), "JsonObjectIsland")] @@ -185,4 +182,4 @@ public class SerializationTests(ITestOutputHelper testOutputHelper) public class TestObject { public string? Data { get; set; } -} +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs index 29155c1b7..fe1f0cd8d 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs @@ -2,8 +2,8 @@ using System.Dynamic; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Elsa.Expressions.Services; using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; using Elsa.Workflows.Serialization.ReferenceHandlers; namespace Elsa.Workflows.IntegrationTests.Serialization.Polymorphism; @@ -14,7 +14,7 @@ public class Tests public void Test1() { var model = CreateModel(); - var expectedJson = File.ReadAllText("Serialization/Polymorphism/data.json").TrimEnd(); + var expectedJson = File.ReadAllText("Serialization/Polymorphism/data.json").Trim(); var actualJson = JsonSerializer.Serialize(model, GetSerializerOptions()); Assert.Equal(expectedJson, actualJson); } @@ -74,10 +74,6 @@ public class Tests private JsonSerializerOptions GetSerializerOptions() { var referenceHandler = new CrossScopedReferenceHandler(); - var wellKnownTypeRegistry = new WellKnownTypeRegistry(); - wellKnownTypeRegistry.RegisterType(typeof(Model), nameof(Model)); - wellKnownTypeRegistry.RegisterType(typeof(CustomDictionary), nameof(CustomDictionary)); - var options = new JsonSerializerOptions { ReferenceHandler = referenceHandler, @@ -88,7 +84,7 @@ public class Tests options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(JsonMetadataServices.TimeSpanConverter); - options.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry)); + options.Converters.Add(new PolymorphicObjectConverterFactory(new WorkflowJsonOptions())); return options; } diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json index 35109c75a..623a5cacc 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json @@ -1 +1 @@ -{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"CustomDictionary"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"List\u003CModel\u003E"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Model"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"HashSet\u003CModel\u003E"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}} +{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.CustomDictionary, Elsa.Workflows.IntegrationTests"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"System.Collections.Generic.List\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"System.Collections.Generic.HashSet\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}} \ No newline at end of file diff --git a/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs b/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs deleted file mode 100644 index dbecd8972..000000000 --- a/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json; -using Elsa.Common.Entities; - -namespace Elsa.Common.UnitTests.Entities; - -public class OrderDefinitionTests -{ - [Fact] - public void Serialize_DoesNotIncludeKeySelectorText() - { - var order = new OrderDefinition(x => x.Name, OrderDirection.Ascending); - - var json = JsonSerializer.Serialize(order); - - Assert.DoesNotContain(nameof(OrderDefinition.KeySelectorText), json); - } - - private sealed class TestEntity - { - public string Name { get; set; } = null!; - } -} diff --git a/test/unit/Elsa.Common.UnitTests/ShellHandlers/MediatorBackgroundTaskTests.cs b/test/unit/Elsa.Common.UnitTests/ShellHandlers/MediatorBackgroundTaskTests.cs new file mode 100644 index 000000000..1a69f350b --- /dev/null +++ b/test/unit/Elsa.Common.UnitTests/ShellHandlers/MediatorBackgroundTaskTests.cs @@ -0,0 +1,101 @@ +using Elsa.Common; +using Elsa.Common.ShellHandlers; +using Elsa.Mediator; +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Common.UnitTests.ShellHandlers; + +public class MediatorBackgroundTaskTests +{ + [Fact] + public async Task StartAsync_StartsMediatorCommandProcessor() + { + // Arrange + await using var serviceProvider = CreateServiceProvider(); + var backgroundTask = GetMediatorBackgroundTask(serviceProvider); + var commandSender = serviceProvider.GetRequiredService(); + var sink = serviceProvider.GetRequiredService(); + + // Act + await backgroundTask.StartAsync(CancellationToken.None); + + try + { + await commandSender.SendAsync(new TestCommand("hello"), CommandStrategy.Background, CancellationToken.None); + + // Assert + var completedTask = await Task.WhenAny(sink.Handled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(sink.Handled.Task, completedTask); + Assert.Equal("hello", await sink.Handled.Task); + } + finally + { + await backgroundTask.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task StopAsync_KeepsProcessorRunning_UntilAllBackgroundTaskReferencesStop() + { + // Arrange + await using var serviceProvider = CreateServiceProvider(); + await using var scope1 = serviceProvider.CreateAsyncScope(); + await using var scope2 = serviceProvider.CreateAsyncScope(); + var backgroundTask1 = GetMediatorBackgroundTask(scope1.ServiceProvider); + var backgroundTask2 = GetMediatorBackgroundTask(scope2.ServiceProvider); + var commandSender = serviceProvider.GetRequiredService(); + var sink = serviceProvider.GetRequiredService(); + + // Act + await backgroundTask1.StartAsync(CancellationToken.None); + await backgroundTask2.StartAsync(CancellationToken.None); + await backgroundTask1.StopAsync(CancellationToken.None); + + try + { + await commandSender.SendAsync(new TestCommand("hello"), CommandStrategy.Background, CancellationToken.None); + + // Assert + var completedTask = await Task.WhenAny(sink.Handled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(sink.Handled.Task, completedTask); + Assert.Equal("hello", await sink.Handled.Task); + } + finally + { + await backgroundTask2.StopAsync(CancellationToken.None); + } + } + + private static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + new ShellFeatures.MediatorFeature().ConfigureServices(services); + services.AddSingleton(); + services.AddCommandHandler(); + return services.BuildServiceProvider(); + } + + private static MediatorBackgroundTask GetMediatorBackgroundTask(IServiceProvider serviceProvider) + { + return serviceProvider.GetServices().OfType().Single(); + } + + private sealed record TestCommand(string Message) : ICommand; + + private sealed class CommandSink + { + public TaskCompletionSource Handled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class TestCommandHandler(CommandSink sink) : ICommandHandler + { + public Task HandleAsync(TestCommand command, CancellationToken cancellationToken) + { + sink.Handled.TrySetResult(command.Message); + return Task.FromResult(Unit.Instance); + } + } +} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs index 01db75930..23d0034e3 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleHostStateCollection.cs @@ -1,11 +1,9 @@ -using ConsoleLogStreaming.Core; - namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; /// /// 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. +/// or console capture. Members of this collection run sequentially, preventing parallel tests from racing +/// on the same static state. /// [CollectionDefinition(Name, DisableParallelization = true)] public sealed class ConsoleHostStateCollection diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs index e85ba4dff..e756b6ece 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogContextAccessorTests.cs @@ -1,5 +1,4 @@ using ConsoleLogStreaming.Core; -using ConsoleLogStreaming.Core.Capture; using ConsoleLogStreaming.Core.DependencyInjection; using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Services; @@ -13,22 +12,10 @@ using Microsoft.Extensions.DependencyInjection; namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; [Collection(ConsoleHostStateCollection.Name)] -public class ConsoleLogContextAccessorTests : IAsyncLifetime +public class ConsoleLogContextAccessorTests { 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() { @@ -90,49 +77,6 @@ public class ConsoleLogContextAccessorTests : IAsyncLifetime 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() { diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs index b3a0ecced..c82975ab1 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs @@ -1,9 +1,10 @@ using ConsoleLogStreaming.Core; using ConsoleLogStreaming.Core.Capture; -using ConsoleLogStreaming.Core.Hosting; -using ConsoleLogStreaming.Core.Providers; +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; @@ -28,93 +29,83 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime } [Fact] - public async Task AddConsoleLogsServices_ResolvesHostOwnedSingletons() + public async Task AddConsoleLogsServices_RegistersConsoleLogPipeline() { var services = new ServiceCollection(); + services.AddLogging(); services.AddConsoleLogsServices(); await using var serviceProvider = services.BuildServiceProvider(); - Assert.IsType(serviceProvider.GetRequiredService()); - 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()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.Same(serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); + Assert.Same(serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogCaptureShellInitializer)); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogCaptureShellDrainHandler)); AssertConsoleLogPipelineContributors(serviceProvider); - Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService)); - Assert.Same(ConsoleLogStreamingHost.Capture, serviceProvider.GetRequiredService()); - - 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_RegistersHostOwnedDependencies() + public async Task AddConsoleLogsServices_ShellInitializerStartsCapture() { var services = new ServiceCollection(); + services.AddLogging(); services.AddConsoleLogsServices(); await using var serviceProvider = services.BuildServiceProvider(); + var initializer = serviceProvider.GetServices().OfType().Single(); - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StartAsync(CancellationToken.None); + await initializer.InitializeAsync(CancellationToken.None); - 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); + try + { + var line = $"console-shell-capture-{Guid.NewGuid():N}"; + Console.WriteLine(line); - foreach (var hostedService in serviceProvider.GetServices()) - await hostedService.StopAsync(CancellationToken.None); + var provider = serviceProvider.GetRequiredService(); + + 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().OfType().Single(); + await drainHandler.DrainAsync(new NoopDrainExtensionHandle(), CancellationToken.None); + } } [Fact] - public async Task ShutdownAsync_ResetsReferenceLeases() - { - ConsoleLogStreamingHost.AddReference(); - ConsoleLogStreamingHost.EnsureInitialized(); - - await ConsoleLogStreamingHost.ShutdownAsync(); - - ConsoleLogStreamingHost.AddReference(); - ConsoleLogStreamingHost.EnsureInitialized(); - await ConsoleLogStreamingHost.ReleaseReferenceAsync(); - - var provider = new CustomProvider(); - Assert.True(ConsoleLogStreamingHost.ConfigureProvider(_ => provider)); - Assert.Same(provider, ConsoleLogStreamingHost.Provider); - } - - [Fact] - public async Task AddConsoleLogsHost_RegistersHostedFlushService() + public async Task AddConsoleLogsHost_RegistersHostedServicesAndCaptureDependencies() { var services = new ServiceCollection(); + services.AddLogging(); services.AddConsoleLogsHost(); await using var serviceProvider = services.BuildServiceProvider(); - - var contextAccessor = serviceProvider.GetRequiredService(); - Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogStreamingHostedService)); - Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); - Assert.Same(contextAccessor, serviceProvider.GetRequiredService()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.NotNull(serviceProvider.GetRequiredService()); + Assert.Same(serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); + Assert.Same(serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); + Assert.Contains(serviceProvider.GetServices(), x => x.GetType().Name == "ConsoleLogCaptureHostedService"); AssertConsoleLogPipelineContributors(serviceProvider); } [Fact] - public void AddConsoleLogsHost_AppliesConfigurationWhenCalledBeforeFirstAccess() + public void AddConsoleLogsHost_AppliesConfiguration() { var services = new ServiceCollection(); + services.AddLogging(); services.AddConsoleLogsHost(options => options.RecentCapacity = 17); using var serviceProvider = services.BuildServiceProvider(); @@ -123,16 +114,172 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime } [Fact] - public void AddConsoleLogsHost_DoesNotInitializeHostDuringRegistration() + public async Task DecoratedProvider_AttachesAmbientElsaMetadata() { - var services = new ServiceCollection(); - services.AddConsoleLogsHost(); + await using var serviceProvider = new ServiceCollection() + .AddLogging() + .AddConsoleLogsServices(options => options.SourceId = "test-source") + .BuildServiceProvider(); + var contextAccessor = serviceProvider.GetRequiredService(); + var provider = serviceProvider.GetRequiredService(); - Assert.True(ConsoleLogStreamingHost.Configure(options => options.RecentCapacity = 23)); + using (contextAccessor.PushWorkflowInstanceId("workflow-console")) + await provider.PublishAsync(new ConsoleLogLine { Text = "message", Source = new ConsoleLogSource { Id = "test-source" } }); - using var serviceProvider = services.BuildServiceProvider(); + var result = await provider.GetRecentAsync(new ConsoleLogFilter + { + Metadata = new Dictionary + { + [ConsoleLogMetadataKeys.WorkflowInstanceId] = "workflow-console" + } + }); - Assert.Equal(23, serviceProvider.GetRequiredService>().Value.RecentCapacity); + 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(); + var initializer = serviceProvider.GetServices().OfType().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(); + + await AssertEventuallyAsync(async () => + { + var result = await provider.GetRecentAsync(new() + { + Metadata = new Dictionary + { + [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().OfType().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(innerProvider) + .AddConsoleLogsServices() + .BuildServiceProvider(); + var contextAccessor = serviceProvider.GetRequiredService(); + var provider = serviceProvider.GetRequiredService(); + + 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(innerProvider) + .AddConsoleLogsServices() + .BuildServiceProvider(); + var provider = serviceProvider.GetRequiredService(); + + await provider.PublishAsync(CreateLine("workflow-a", "a")); + await provider.PublishAsync(CreateLine("workflow-b", "b")); + + var result = await provider.GetRecentAsync(new() + { + Metadata = new Dictionary + { + [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(innerProvider) + .AddConsoleLogsServices() + .BuildServiceProvider(); + var contextAccessor = serviceProvider.GetRequiredService(); + var provider = serviceProvider.GetRequiredService(); + + 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 + { + [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(innerProvider) + .AddConsoleLogsServices() + .BuildServiceProvider(); + var provider = serviceProvider.GetRequiredService(); + + var lines = await provider.SubscribeAsync(new() + { + Metadata = new Dictionary + { + [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) @@ -141,22 +288,131 @@ public class ConsoleLogsRegistrationTests : IAsyncLifetime Assert.Contains(serviceProvider.GetServices(), x => x.GetType() == typeof(ConsoleLogActivityExecutionPipelineContributor)); } - private sealed class CustomProvider : IConsoleLogProvider + private static async Task AssertEventuallyAsync(Func assertion) { - public ValueTask PublishAsync(ConsoleLogStreaming.Core.Models.ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + var deadline = DateTimeOffset.UtcNow.AddSeconds(3); + Exception? lastException = null; - public ValueTask GetRecentAsync(ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, CancellationToken cancellationToken = default) => - ValueTask.FromResult(new ConsoleLogStreaming.Core.Models.RecentConsoleLogsResult()); - - public async IAsyncEnumerable SubscribeAsync( - ConsoleLogStreaming.Core.Models.ConsoleLogFilter filter, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + while (DateTimeOffset.UtcNow < deadline) { - await Task.CompletedTask; - yield break; + try + { + await assertion(); + return; + } + catch (Xunit.Sdk.XunitException e) + { + lastException = e; + await Task.Delay(25); + } } - public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => - ValueTask.FromResult>([]); + 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 + { + [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 GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new RecentConsoleLogsResult()); + } + + public IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return AsyncEnumerable.Empty(); + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>([]); + } + } + + private sealed class MetadataIgnoringConsoleLogProvider : IConsoleLogProvider + { + public List RecentItems { get; } = []; + public List LiveItems { get; } = []; + + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + RecentItems.Add(line); + return ValueTask.CompletedTask; + } + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new RecentConsoleLogsResult { Items = RecentItems }); + } + + public async IAsyncEnumerable SubscribeAsync( + ConsoleLogFilter filter, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var line in LiveItems) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return ConsoleLogStreamingItem.FromLine(line); + await Task.Yield(); + } + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>([]); + } + } + + private sealed class MetadataDroppingConsoleLogProvider : IConsoleLogProvider + { + public List PublishedLines { get; } = []; + + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + PublishedLines.Add(line with { Metadata = new Dictionary() }); + return ValueTask.CompletedTask; + } + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new RecentConsoleLogsResult { Items = PublishedLines }); + } + + public IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return AsyncEnumerable.Empty(); + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>([]); + } + } + + private sealed class NoopDrainExtensionHandle : IDrainExtensionHandle + { + public bool TryExtend(TimeSpan requestedExtension, out TimeSpan grantedExtension) + { + grantedExtension = TimeSpan.Zero; + return false; + } } } 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 17fe4f464..b21be7aa8 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,6 +6,7 @@ + diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ElsaConsoleLogSubscriptionManagerTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ElsaConsoleLogSubscriptionManagerTests.cs new file mode 100644 index 000000000..2558ca593 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ElsaConsoleLogSubscriptionManagerTests.cs @@ -0,0 +1,156 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Models; +using Elsa.Diagnostics.ConsoleLogs.RealTime; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; + +public class ElsaConsoleLogSubscriptionManagerTests +{ + [Fact] + public async Task SubscribeAsync_DoesNotSendSourceChangedForRepeatedSource() + { + var source = new ConsoleLogSource + { + Id = "source-1", + DisplayName = "Source 1", + ServiceName = "service", + MachineName = "machine", + Health = ConsoleLogSourceHealth.Connected + }; + var provider = new StreamingConsoleLogProvider( + ConsoleLogStreamingItem.FromLine(CreateLine(source, "first")), + ConsoleLogStreamingItem.FromLine(CreateLine(source, "second"))); + var client = new RecordingConsoleLogsClient(); + var hubContext = new TestHubContext(client); + using var manager = new ElsaConsoleLogSubscriptionManager(provider, hubContext, NullLogger.Instance); + + await manager.SubscribeAsync("connection-1", new(), CancellationToken.None); + + await AssertEventuallyAsync(() => + { + Assert.Equal(2, client.Lines.Count); + Assert.Single(client.Sources); + }); + } + + private static ConsoleLogLine CreateLine(ConsoleLogSource source, string text) => new() + { + Text = text, + Source = source + }; + + private static async Task AssertEventuallyAsync(Action assertion) + { + Xunit.Sdk.XunitException? lastException = null; + + for (var i = 0; i < 40; i++) + { + try + { + assertion(); + return; + } + catch (Xunit.Sdk.XunitException e) + { + lastException = e; + await Task.Delay(25); + } + } + + if (lastException != null) + throw lastException; + } + + private sealed class StreamingConsoleLogProvider(params ConsoleLogStreamingItem[] items) : IConsoleLogProvider + { + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new RecentConsoleLogsResult()); + } + + public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>([]); + } + } + + private sealed class RecordingConsoleLogsClient : IElsaConsoleLogsClient + { + public List Lines { get; } = []; + public List Sources { get; } = []; + + public Task ReceiveConsoleLogLineAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) + { + Lines.Add(line); + return Task.CompletedTask; + } + + public Task ReceiveDroppedLinesAsync(ConsoleLogDroppedSummary summary, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task ReceiveSourceChangedAsync(ConsoleLogSource source, CancellationToken cancellationToken = default) + { + Sources.Add(source); + return Task.CompletedTask; + } + } + + private sealed class TestHubContext(IElsaConsoleLogsClient client) : IHubContext + { + public IHubClients Clients { get; } = new TestHubClients(client); + public IGroupManager Groups { get; } = new TestGroupManager(); + } + + private sealed class TestHubClients(IElsaConsoleLogsClient client) : IHubClients + { + public IElsaConsoleLogsClient All => client; + + public IElsaConsoleLogsClient AllExcept(IReadOnlyList excludedConnectionIds) => client; + + public IElsaConsoleLogsClient Client(string connectionId) => client; + + public IElsaConsoleLogsClient Clients(IReadOnlyList connectionIds) => client; + + public IElsaConsoleLogsClient Group(string groupName) => client; + + public IElsaConsoleLogsClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => client; + + public IElsaConsoleLogsClient Groups(IReadOnlyList groupNames) => client; + + public IElsaConsoleLogsClient User(string userId) => client; + + public IElsaConsoleLogsClient Users(IReadOnlyList userIds) => client; + } + + private sealed class TestGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } +} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/RecentConsoleLogsEndpointTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/RecentConsoleLogsEndpointTests.cs new file mode 100644 index 000000000..064c9f793 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/RecentConsoleLogsEndpointTests.cs @@ -0,0 +1,16 @@ +using System.Text; +using System.Text.Json; +using Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent; + +namespace Elsa.Diagnostics.ConsoleLogs.UnitTests; + +public class RecentConsoleLogsEndpointTests +{ + [Fact] + public async Task DeserializeJsonBodyAsync_WhenJsonIsMalformed_ThrowsJsonException() + { + await using var body = new MemoryStream(Encoding.UTF8.GetBytes("{")); + + await Assert.ThrowsAsync(async () => await Endpoint.DeserializeJsonBodyAsync(body, CancellationToken.None)); + } +} diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs index fd1941521..8457b59a5 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs @@ -1,4 +1,4 @@ global using System.Runtime.CompilerServices; -global using ConsoleLogStreaming.Contracts; +global using ConsoleLogStreaming.Core.Models; global using Microsoft.Extensions.Options; global using Xunit; diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj new file mode 100644 index 000000000..4c9e11f6b --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Elsa.Diagnostics.OpenTelemetry.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + [Elsa.Diagnostics.OpenTelemetry]* + 0 + + + + + + + diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/InMemory/RingBufferTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/InMemory/RingBufferTests.cs new file mode 100644 index 000000000..3ec14fc7a --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/InMemory/RingBufferTests.cs @@ -0,0 +1,20 @@ +using Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.InMemory; + +public class RingBufferTests +{ + [Fact] + public void Add_WhenCapacityIsExceeded_DropsOldestItems() + { + var buffer = new RingBuffer(3); + + buffer.Add(1); + buffer.Add(2); + buffer.Add(3); + buffer.Add(4); + + Assert.Equal([2, 3, 4], buffer.Snapshot()); + Assert.Equal(1, buffer.DroppedCount); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpHttpProtobufParserTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpHttpProtobufParserTests.cs new file mode 100644 index 000000000..1d796473f --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpHttpProtobufParserTests.cs @@ -0,0 +1,142 @@ +using System.Buffers.Binary; +using System.Text; +using Elsa.Diagnostics.OpenTelemetry.Ingestion.HttpProtobuf; +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Ingestion; + +public class OtlpHttpProtobufParserTests +{ + private static readonly byte[] TraceId = Convert.FromHexString("00112233445566778899aabbccddeeff"); + private static readonly byte[] SpanId = Convert.FromHexString("0011223344556677"); + private static readonly DateTimeOffset Timestamp = new(2026, 5, 26, 10, 0, 0, TimeSpan.Zero); + + [Fact(DisplayName = "OTLP trace payload is normalized to trace, span, resource, and workflow metadata")] + public void ParseTraces() + { + var payload = Message(1, + Join(Message(1, Resource()), + Message(2, Message(2, + Join(Bytes(1, TraceId), + Bytes(2, SpanId), + String(5, "Workflow/Approve"), + Varint(6, 1), + Varint(7, UnixNanos(Timestamp)), + Varint(8, UnixNanos(Timestamp.AddMilliseconds(25))), + Message(9, KeyValue("workflow.instance.id", "wf-1")), + Message(15, Varint(3, 2))))))); + + var batch = OtlpHttpProtobufParser.ParseTraces(payload); + var span = Assert.Single(batch.Spans); + var trace = Assert.Single(batch.Traces); + + Assert.Equal("elsa-server:node-1", span.ResourceId); + Assert.Equal("00112233445566778899aabbccddeeff", trace.TraceId); + Assert.Equal("0011223344556677", trace.RootSpanId); + Assert.Equal("wf-1", Assert.Single(trace.WorkflowInstanceIds)); + Assert.Equal(SpanStatus.Error, trace.Status); + } + + [Fact(DisplayName = "OTLP metric payload is normalized to metric instruments and points")] + public void ParseMetrics() + { + var point = + Join(Varint(3, UnixNanos(Timestamp)), + Fixed64(4, 42.5), + Message(7, KeyValue("workflow.definition.id", "orders"))); + var metric = + Join(String(1, "workflow.duration"), + String(2, "Workflow duration"), + String(3, "ms"), + Message(5, Message(1, point))); + var payload = Message(1, Join(Message(1, Resource()), Message(2, Message(2, metric)))); + + var batch = OtlpHttpProtobufParser.ParseMetrics(payload); + var instrument = Assert.Single(batch.Instruments); + var metricPoint = Assert.Single(batch.MetricPoints); + + Assert.Equal("workflow.duration", instrument.Name); + Assert.Equal("workflow.duration", metricPoint.InstrumentName); + Assert.Equal(MetricKind.Gauge, instrument.Kind); + Assert.Equal(42.5, metricPoint.Value); + Assert.Equal("orders", metricPoint.Attributes["workflow.definition.id"]); + } + + [Fact(DisplayName = "OTLP log payload is normalized to correlated OTLP log records")] + public void ParseLogs() + { + var log = + Join(Varint(1, UnixNanos(Timestamp)), + Varint(2, 17), + String(3, "Error"), + Message(5, AnyString("boom")), + Bytes(9, TraceId), + Bytes(10, SpanId), + Message(6, KeyValue("workflow.instance.id", "wf-1"))); + var payload = Message(1, Join(Message(1, Resource()), Message(2, Message(2, log)))); + + var batch = OtlpHttpProtobufParser.ParseLogs(payload); + var record = Assert.Single(batch.Logs); + + Assert.Equal("elsa-server:node-1", record.ResourceId); + Assert.Equal("Error", record.SeverityText); + Assert.Equal("boom", record.Body); + Assert.Equal("00112233445566778899aabbccddeeff", record.TraceId); + Assert.Equal("0011223344556677", record.SpanId); + } + + private static byte[] Resource() + { + return Join( + Message(1, KeyValue("service.name", "elsa-server")), + Message(1, KeyValue("service.instance.id", "node-1")), + Message(1, KeyValue("telemetry.sdk.language", "dotnet"))); + } + + private static byte[] KeyValue(string key, string value) => Join(String(1, key), Message(2, AnyString(value))); + + private static byte[] AnyString(string value) => String(1, value); + + private static byte[] Message(int fieldNumber, byte[] value) => Join(Varint((ulong)((fieldNumber << 3) | 2)), Varint((ulong)value.Length), value); + + private static byte[] String(int fieldNumber, string value) => Message(fieldNumber, Encoding.UTF8.GetBytes(value)); + + private static byte[] Bytes(int fieldNumber, byte[] value) => Message(fieldNumber, value); + + private static byte[] Varint(int fieldNumber, ulong value) => Join(Varint((ulong)(fieldNumber << 3)), Varint(value)); + + private static byte[] Fixed64(int fieldNumber, double value) + { + var bytes = new byte[9]; + bytes[0] = (byte)((fieldNumber << 3) | 1); + BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(1), (ulong)BitConverter.DoubleToInt64Bits(value)); + return bytes; + } + + private static byte[] Varint(ulong value) + { + var bytes = new List(); + while (value >= 0x80) + { + bytes.Add((byte)(value | 0x80)); + value >>= 7; + } + bytes.Add((byte)value); + return bytes.ToArray(); + } + + private static ulong UnixNanos(DateTimeOffset timestamp) => (ulong)(timestamp - DateTimeOffset.UnixEpoch).Ticks * 100; + + private static byte[] Join(params byte[][] segments) + { + var result = new byte[segments.Sum(x => x.Length)]; + var offset = 0; + foreach (var segment in segments) + { + segment.CopyTo(result, offset); + offset += segment.Length; + } + + return result; + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpIngestionSecurityTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpIngestionSecurityTests.cs new file mode 100644 index 000000000..4ebee00f4 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Ingestion/OtlpIngestionSecurityTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using Elsa.Diagnostics.OpenTelemetry.Ingestion; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Ingestion; + +public class OtlpIngestionSecurityTests +{ + [Fact(DisplayName = "Loopback ingestion is allowed without API key in development mode")] + public void AllowsLoopbackWithoutApiKey() + { + var context = CreateContext(IPAddress.Loopback); + + var authorized = OtlpIngestionSecurity.IsAuthorized(context, new OpenTelemetryDiagnosticsOptions()); + + Assert.True(authorized); + } + + [Fact(DisplayName = "Non-loopback ingestion is rejected without API key")] + public void RejectsNonLoopbackWithoutApiKey() + { + var context = CreateContext(IPAddress.Parse("10.0.0.5")); + + var authorized = OtlpIngestionSecurity.IsAuthorized(context, new OpenTelemetryDiagnosticsOptions()); + + Assert.False(authorized); + } + + [Fact(DisplayName = "Missing remote address is rejected without API key")] + public void RejectsMissingRemoteAddressWithoutApiKey() + { + var context = CreateContext(null); + + var authorized = OtlpIngestionSecurity.IsAuthorized(context, new OpenTelemetryDiagnosticsOptions()); + + Assert.False(authorized); + } + + [Fact(DisplayName = "Configured API key header authorizes ingestion")] + public void AllowsConfiguredApiKey() + { + var context = CreateContext(IPAddress.Parse("10.0.0.5")); + context.Request.Headers["x-otlp-api-key"] = "secret"; + + var authorized = OtlpIngestionSecurity.IsAuthorized(context, new OpenTelemetryDiagnosticsOptions { ApiKey = "secret" }); + + Assert.True(authorized); + } + + [Fact(DisplayName = "Incorrect API key header rejects ingestion")] + public void RejectsIncorrectConfiguredApiKey() + { + var context = CreateContext(IPAddress.Parse("10.0.0.5")); + context.Request.Headers["x-otlp-api-key"] = "not-secret"; + + var authorized = OtlpIngestionSecurity.IsAuthorized(context, new OpenTelemetryDiagnosticsOptions { ApiKey = "secret" }); + + Assert.False(authorized); + } + + private static DefaultHttpContext CreateContext(IPAddress? remoteAddress) + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = remoteAddress; + return context; + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/InMemoryOpenTelemetryStoreTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/InMemoryOpenTelemetryStoreTests.cs new file mode 100644 index 000000000..52087d27a --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/InMemoryOpenTelemetryStoreTests.cs @@ -0,0 +1,110 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Providers; + +public class InMemoryOpenTelemetryStoreTests +{ + [Fact] + public async Task WriteAsync_WhenTraceCapacityIsExceeded_DropsOldestTraceAndReportsCount() + { + var context = new OpenTelemetryStoreTestContext(new OpenTelemetryDiagnosticsOptions + { + TraceCapacity = 2, + SpanCapacity = 10, + MetricPointCapacity = 10, + LogRecordCapacity = 10 + }); + var resource = context.Resource("resource-a", "api"); + + await context.WriteAsync(new OpenTelemetryBatch([resource], [context.Trace("trace-1", resource.Id, context.Now.AddSeconds(1))], [], [], [], [])); + await context.WriteAsync(new OpenTelemetryBatch([], [context.Trace("trace-2", resource.Id, context.Now.AddSeconds(2))], [], [], [], [])); + await context.WriteAsync(new OpenTelemetryBatch([], [context.Trace("trace-3", resource.Id, context.Now.AddSeconds(3))], [], [], [], [])); + + var result = await context.Store.QueryTracesAsync(new OpenTelemetryTraceFilter { Take = 10 }); + var diagnostics = await context.Store.GetDiagnosticsAsync(); + + Assert.Equal(["trace-2", "trace-3"], result.Items.Select(x => x.TraceId)); + Assert.Equal(1, result.DroppedCount); + Assert.Equal(1, diagnostics.DroppedTraceCount); + Assert.Equal(2, diagnostics.TraceCount); + } + + [Fact] + public async Task QueryResourcesAsync_WhenTakeExceedsMaxQuerySize_ClampsResult() + { + var context = new OpenTelemetryStoreTestContext(new OpenTelemetryDiagnosticsOptions { MaxQuerySize = 1 }); + + await context.WriteAsync(new OpenTelemetryBatch([context.Resource("resource-a", "api"), context.Resource("resource-b", "worker")], [], [], [], [], [])); + + var result = await context.Store.QueryResourcesAsync(new OpenTelemetryResourceFilter { Take = 10 }); + + Assert.Single(result.Items); + } + + [Fact] + public async Task QueryTracesAsync_WhenTraceIdAppearsInMultipleBatches_ReturnsLatestTrace() + { + var context = new OpenTelemetryStoreTestContext(new OpenTelemetryDiagnosticsOptions { TraceCapacity = 10 }); + var resource = context.Resource("resource-a", "api"); + + await context.WriteAsync(new OpenTelemetryBatch([resource], [context.Trace("trace-1", resource.Id, context.Now.AddSeconds(1))], [], [], [], [])); + await context.WriteAsync(new OpenTelemetryBatch([], [context.Trace("trace-1", resource.Id, context.Now.AddSeconds(2))], [], [], [], [])); + + var result = await context.Store.QueryTracesAsync(new OpenTelemetryTraceFilter { Take = 10 }); + + var trace = Assert.Single(result.Items); + Assert.Equal(context.Now.AddSeconds(2), trace.StartTime); + } + + [Fact] + public async Task WriteAsync_WhenResourceCapacityIsExceeded_DropsOldestResource() + { + var context = new OpenTelemetryStoreTestContext(new OpenTelemetryDiagnosticsOptions { ResourceCapacity = 2 }); + + await context.WriteAsync(new OpenTelemetryBatch( + [ + context.Resource("resource-a", "api", context.Now.AddSeconds(1)), + context.Resource("resource-b", "worker", context.Now.AddSeconds(2)), + context.Resource("resource-c", "jobs", context.Now.AddSeconds(3)) + ], + [], [], [], [], [])); + + var result = await context.Store.QueryResourcesAsync(new OpenTelemetryResourceFilter { Take = 10 }); + + Assert.Equal(["resource-c", "resource-b"], result.Items.Select(x => x.Id)); + Assert.Equal(1, result.DroppedCount); + } + + [Fact] + public async Task Constructor_WhenCapacitiesAreMisconfigured_ClampsToMinimumCapacity() + { + var context = new OpenTelemetryStoreTestContext(new OpenTelemetryDiagnosticsOptions + { + TraceCapacity = 0, + SpanCapacity = 0, + MetricPointCapacity = 0, + LogRecordCapacity = 0 + }); + var resource = context.Resource("resource-a", "api"); + + await context.WriteAsync(new OpenTelemetryBatch( + [resource], + [context.Trace("trace-1", resource.Id)], + [context.Span("span-1", "trace-1", "span-1", resource.Id)], + [context.Instrument("instrument-1", resource.Id, "metric")], + [context.Point("point-1", "instrument-1", resource.Id)], + [context.Log("log-1", resource.Id, "trace-1")])); + + var diagnostics = await context.Store.GetDiagnosticsAsync(); + + Assert.Equal(1, diagnostics.TraceCount); + Assert.Equal(1, diagnostics.SpanCount); + Assert.Equal(1, diagnostics.MetricPointCount); + Assert.Equal(1, diagnostics.LogRecordCount); + Assert.Equal(1, diagnostics.TraceCapacity); + Assert.Equal(1, diagnostics.SpanCapacity); + Assert.Equal(1, diagnostics.MetricPointCapacity); + Assert.Equal(1, diagnostics.LogRecordCapacity); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryMetricQueryTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryMetricQueryTests.cs new file mode 100644 index 000000000..2787c6978 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryMetricQueryTests.cs @@ -0,0 +1,45 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Providers; + +public class OpenTelemetryMetricQueryTests +{ + [Fact] + public async Task QueryMetricsAsync_WhenFilteringByInstrumentName_ReturnsMatchingInstrumentPoints() + { + var context = new OpenTelemetryStoreTestContext(); + var resource = context.Resource("resource-api", "api"); + var duration = context.Instrument("instrument-duration", resource.Id, "workflow.duration"); + var count = context.Instrument("instrument-count", resource.Id, "workflow.count"); + await context.WriteAsync(new OpenTelemetryBatch([resource], [], [], [duration, count], [context.Point("point-1", duration.Id, resource.Id), context.Point("point-2", count.Id, resource.Id)], [])); + + var result = await context.Store.QueryMetricsAsync(new OpenTelemetryMetricFilter { InstrumentName = "duration" }); + + Assert.Equal(duration.Id, Assert.Single(result.Instruments).Id); + Assert.Equal("point-1", Assert.Single(result.Points).Id); + } + + [Fact] + public async Task QueryMetricsAsync_WhenFilteringByInstrumentName_AppliesInstrumentFilterBeforeTake() + { + var context = new OpenTelemetryStoreTestContext(); + var resource = context.Resource("resource-api", "api"); + var duration = context.Instrument("instrument-duration", resource.Id, "workflow.duration"); + var count = context.Instrument("instrument-count", resource.Id, "workflow.count"); + await context.WriteAsync(new OpenTelemetryBatch( + [resource], + [], + [], + [duration, count], + [ + context.Point("point-1", duration.Id, resource.Id, context.Now), + context.Point("point-2", count.Id, resource.Id, context.Now.AddSeconds(1)) + ], + [])); + + var result = await context.Store.QueryMetricsAsync(new OpenTelemetryMetricFilter { InstrumentName = "duration", Take = 1 }); + + Assert.Equal(duration.Id, Assert.Single(result.Instruments).Id); + Assert.Equal("point-1", Assert.Single(result.Points).Id); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryStoreTestContext.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryStoreTestContext.cs new file mode 100644 index 000000000..998bf2595 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryStoreTestContext.cs @@ -0,0 +1,59 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Providers.InMemory; +using Elsa.Diagnostics.OpenTelemetry.Services; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Providers; + +public class OpenTelemetryStoreTestContext +{ + public OpenTelemetryStoreTestContext(OpenTelemetryDiagnosticsOptions? options = null) + { + Options = options ?? new OpenTelemetryDiagnosticsOptions(); + SourceRegistry = new OpenTelemetrySourceRegistry(OptionsFactory.Create(Options)); + Store = new InMemoryOpenTelemetryStore(OptionsFactory.Create(Options), SourceRegistry); + } + + public OpenTelemetryDiagnosticsOptions Options { get; } + public OpenTelemetrySourceRegistry SourceRegistry { get; } + public InMemoryOpenTelemetryStore Store { get; } + public DateTimeOffset Now { get; } = new(2026, 5, 26, 10, 0, 0, TimeSpan.Zero); + + public async Task WriteAsync(OpenTelemetryBatch batch) + { + await Store.WriteAsync(batch); + } + + public TelemetryResource Resource(string id, string serviceName, DateTimeOffset? lastSeen = null, TelemetryResourceStatus status = TelemetryResourceStatus.Active) + { + return new(id, serviceName, $"{id}-instance", "dotnet", new Dictionary(), lastSeen ?? Now, status); + } + + public TelemetryTrace Trace(string traceId, string resourceId, DateTimeOffset? startTime = null, SpanStatus status = SpanStatus.Ok, params string[] workflowInstanceIds) + { + var start = startTime ?? Now; + return new(traceId, $"{traceId}-root", $"trace-{traceId}", start, start.AddMilliseconds(25), TimeSpan.FromMilliseconds(25), status, [resourceId], workflowInstanceIds, 2); + } + + public TelemetrySpan Span(string id, string traceId, string spanId, string resourceId, DateTimeOffset? startTime = null) + { + var start = startTime ?? Now; + return new(id, traceId, spanId, null, resourceId, $"span-{spanId}", "internal", start, start.AddMilliseconds(10), SpanStatus.Ok, null, new Dictionary(), [], []); + } + + public MetricInstrument Instrument(string id, string resourceId, string name) + { + return new(id, resourceId, name, "ms", null, MetricKind.Gauge, new Dictionary()); + } + + public MetricPoint Point(string id, string instrumentId, string resourceId, DateTimeOffset? timestamp = null) + { + return new(id, instrumentId, instrumentId, resourceId, timestamp ?? Now, 42, null, null, new Dictionary(), null, null); + } + + public OtlpLogRecord Log(string id, string resourceId, string traceId, string severity = "Information", string body = "message") + { + return new(id, resourceId, Now, severity, null, body, traceId, $"{traceId}-span", new Dictionary()); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryTraceQueryTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryTraceQueryTests.cs new file mode 100644 index 000000000..ac2154d66 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OpenTelemetryTraceQueryTests.cs @@ -0,0 +1,38 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Providers; + +public class OpenTelemetryTraceQueryTests +{ + [Fact] + public async Task QueryTracesAsync_WhenFilteringByServiceName_ReturnsMatchingResourceTraces() + { + var context = new OpenTelemetryStoreTestContext(); + var api = context.Resource("resource-api", "api"); + var worker = context.Resource("resource-worker", "worker"); + await context.WriteAsync(new OpenTelemetryBatch([api, worker], [context.Trace("trace-api", api.Id), context.Trace("trace-worker", worker.Id)], [], [], [], [])); + + var result = await context.Store.QueryTracesAsync(new OpenTelemetryTraceFilter { ServiceName = "api" }); + + Assert.Equal("trace-api", Assert.Single(result.Items).TraceId); + } + + [Fact] + public async Task GetTraceAsync_ReturnsOrderedSpansResourcesAndTraceLogs() + { + var context = new OpenTelemetryStoreTestContext(); + var resource = context.Resource("resource-api", "api"); + var trace = context.Trace("trace-1", resource.Id); + var laterSpan = context.Span("span-record-2", trace.TraceId, "span-2", resource.Id, context.Now.AddSeconds(2)); + var earlierSpan = context.Span("span-record-1", trace.TraceId, "span-1", resource.Id, context.Now.AddSeconds(1)); + var log = context.Log("log-1", resource.Id, trace.TraceId); + await context.WriteAsync(new OpenTelemetryBatch([resource], [trace], [laterSpan, earlierSpan], [], [], [log])); + + var detail = await context.Store.GetTraceAsync(trace.TraceId); + + Assert.NotNull(detail); + Assert.Equal(["span-1", "span-2"], detail.Spans.Select(x => x.SpanId)); + Assert.Equal(resource.Id, Assert.Single(detail.Resources).Id); + Assert.Equal(log.Id, Assert.Single(detail.Logs).Id); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OtlpLogQueryTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OtlpLogQueryTests.cs new file mode 100644 index 000000000..1328b85ab --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Providers/OtlpLogQueryTests.cs @@ -0,0 +1,28 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Providers; + +public class OtlpLogQueryTests +{ + [Fact] + public async Task QueryLogsAsync_WhenFilteringByTraceAndSeverity_ReturnsMatchingLogs() + { + var context = new OpenTelemetryStoreTestContext(); + var resource = context.Resource("resource-api", "api"); + await context.WriteAsync(new OpenTelemetryBatch( + [resource], + [], + [], + [], + [], + [ + context.Log("log-1", resource.Id, "trace-1", "Information"), + context.Log("log-2", resource.Id, "trace-1", "Error"), + context.Log("log-3", resource.Id, "trace-2", "Error") + ])); + + var result = await context.Store.QueryLogsAsync(new OpenTelemetryLogFilter { TraceId = "trace-1", Severity = "Error" }); + + Assert.Equal("log-2", Assert.Single(result.Items).Id); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/CollectorConfigurationTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/CollectorConfigurationTests.cs new file mode 100644 index 000000000..2d171a761 --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/CollectorConfigurationTests.cs @@ -0,0 +1,41 @@ +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Services; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Services; + +public class CollectorConfigurationTests +{ + [Fact] + public async Task GetAsync_WhenGrpcIsDisabled_ReturnsDisabledGrpcMetadataWithoutSecretValue() + { + var provider = new CollectorConfigurationProvider(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions + { + ApiKey = "secret-value", + EnableGrpc = false + })); + + var configuration = await provider.GetAsync(); + + Assert.True(configuration.Http.Enabled); + Assert.False(configuration.Grpc.Enabled); + Assert.Null(configuration.Grpc.Endpoint); + Assert.Equal("", configuration.RequiredHeaders["x-otlp-api-key"]); + } + + [Fact] + public async Task GetAsync_WhenGrpcIsEnabled_ReturnsConfiguredGrpcEndpoint() + { + var provider = new CollectorConfigurationProvider(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions + { + EnableGrpc = true, + GrpcEndpointPath = "https://localhost:4317" + })); + + var configuration = await provider.GetAsync(); + + Assert.True(configuration.Grpc.Enabled); + Assert.Equal("https://localhost:4317", configuration.Grpc.Endpoint); + Assert.Null(configuration.Grpc.DisabledReason); + } +} diff --git a/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/OpenTelemetryRedactorTests.cs b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/OpenTelemetryRedactorTests.cs new file mode 100644 index 000000000..b100324dd --- /dev/null +++ b/test/unit/Elsa.Diagnostics.OpenTelemetry.UnitTests/Services/OpenTelemetryRedactorTests.cs @@ -0,0 +1,39 @@ +using Elsa.Diagnostics.OpenTelemetry.Models; +using Elsa.Diagnostics.OpenTelemetry.Options; +using Elsa.Diagnostics.OpenTelemetry.Services; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Diagnostics.OpenTelemetry.UnitTests.Services; + +public class OpenTelemetryRedactorTests +{ + [Fact] + public void Redact_WhenSensitiveAttributeNameExists_RedactsValue() + { + var redactor = new OpenTelemetryRedactor(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions())); + var log = new OtlpLogRecord("1", "resource", DateTimeOffset.UtcNow, "Information", null, "hello", null, null, new Dictionary { ["password"] = "secret" }); + var batch = new OpenTelemetryBatch([], [], [], [], [], [log]); + + var result = redactor.Redact(batch); + + Assert.Equal("[Redacted]", result.Logs.Single().Attributes["password"]); + } + + [Fact] + public void Redact_WhenAttributeKeysDifferOnlyByCase_UsesLastValue() + { + var redactor = new OpenTelemetryRedactor(OptionsFactory.Create(new OpenTelemetryDiagnosticsOptions())); + var log = new OtlpLogRecord("1", "resource", DateTimeOffset.UtcNow, "Information", null, "hello", null, null, new Dictionary + { + ["Password"] = "first", + ["password"] = "second" + }); + var batch = new OpenTelemetryBatch([], [], [], [], [], [log]); + + var result = redactor.Redact(batch); + + var attributes = result.Logs.Single().Attributes; + Assert.Single(attributes); + Assert.Equal("[Redacted]", attributes["Password"]); + } +} diff --git a/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs b/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs deleted file mode 100644 index 2774848a7..000000000 --- a/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Elsa.Expressions.Options; -using Elsa.Expressions.Services; -using Elsa.Http.Bookmarks; -using Elsa.Http.ShellFeatures; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Elsa.Http.UnitTests.ShellFeatures; - -public class HttpFeatureTests -{ - [Fact] - public void ConfigureServices_RegistersHttpTypeAliases() - { - var services = new ServiceCollection(); - var feature = new HttpFeature(); - - feature.ConfigureServices(services); - - using var serviceProvider = services.BuildServiceProvider(); - var expressionOptions = serviceProvider.GetRequiredService>(); - var registry = new WellKnownTypeRegistry(expressionOptions); - - Assert.True(registry.TryGetType(nameof(HttpEndpointBookmarkPayload), out var type)); - Assert.Equal(typeof(HttpEndpointBookmarkPayload), type); - Assert.True(registry.TryGetType(nameof(HttpRequestException), out var exceptionType)); - Assert.Equal(typeof(HttpRequestException), exceptionType); - } -} diff --git a/test/unit/Elsa.Mediator.UnitTests/BackgroundCommandProcessorTests.cs b/test/unit/Elsa.Mediator.UnitTests/BackgroundCommandProcessorTests.cs new file mode 100644 index 000000000..b831000e5 --- /dev/null +++ b/test/unit/Elsa.Mediator.UnitTests/BackgroundCommandProcessorTests.cs @@ -0,0 +1,108 @@ +using Elsa.Mediator.Channels; +using Elsa.Mediator.CommandStrategies; +using Elsa.Mediator.Contexts; +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Command; +using Elsa.Mediator.Models; +using Elsa.Mediator.Options; +using Elsa.Mediator.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elsa.Mediator.UnitTests; + +public class BackgroundCommandProcessorTests +{ + [Fact] + public async Task ExecuteAsync_UsesQueuedContextStrategyAndCancellationToken() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddScoped(); + await using var serviceProvider = services.BuildServiceProvider(); + var channel = new CommandsChannel(); + var processor = new BackgroundCommandProcessor( + Microsoft.Extensions.Options.Options.Create(new MediatorOptions { CommandWorkerCount = 1 }), + channel, + serviceProvider.GetRequiredService(), + NullLogger.Instance); + using var processorCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var commandCancellationTokenSource = new CancellationTokenSource(); + var runTask = processor.ExecuteAsync(processorCancellationTokenSource.Token); + var strategy = new TestCommandStrategy(); + var context = new CommandContext(new TestCommand(), strategy, typeof(Unit), new Dictionary(), serviceProvider, commandCancellationTokenSource.Token); + + await commandCancellationTokenSource.CancelAsync(); + await channel.Writer.WriteAsync(context, processorCancellationTokenSource.Token); + + var recorder = serviceProvider.GetRequiredService(); + var recordedCall = await recorder.WaitForCallAsync(processorCancellationTokenSource.Token); + await processorCancellationTokenSource.CancelAsync(); + await runTask; + + Assert.Same(strategy, recordedCall.Strategy); + Assert.True(recordedCall.CancellationToken.IsCancellationRequested); + } + + [Fact] + public async Task BackgroundStrategy_QueuesExecutableContext() + { + var services = new ServiceCollection(); + services.AddSingleton(); + await using var serviceProvider = services.BuildServiceProvider(); + var channel = serviceProvider.GetRequiredService(); + var commandContext = new CommandContext(new TestCommand(), CommandStrategy.Background, typeof(Unit), new Dictionary(), serviceProvider, CancellationToken.None); + var strategyContext = new CommandStrategyContext(commandContext, new TestCommandHandler(), serviceProvider); + + await CommandStrategy.Background.ExecuteAsync(strategyContext); + + var queuedContext = await channel.Reader.ReadAsync(); + + Assert.Same(CommandStrategy.Default, queuedContext.CommandStrategy); + Assert.Same(commandContext.Command, queuedContext.Command); + Assert.Same(commandContext.Headers, queuedContext.Headers); + } + + private sealed class RecordingCommandSender(CommandSenderRecorder recorder) : ICommandSender + { + public Task SendAsync(ICommand command, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, IDictionary headers, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default) + { + recorder.Record(new(strategy, cancellationToken)); + return Task.CompletedTask; + } + } + + private sealed class CommandSenderRecorder + { + private readonly TaskCompletionSource _completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Record(RecordedCommandCall call) => _completed.TrySetResult(call); + + public Task WaitForCallAsync(CancellationToken cancellationToken) => _completed.Task.WaitAsync(cancellationToken); + } + + private sealed record RecordedCommandCall(ICommandStrategy? Strategy, CancellationToken CancellationToken); + + private sealed record TestCommand : ICommand; + + private sealed class TestCommandHandler : ICommandHandler + { + public Task HandleAsync(TestCommand command, CancellationToken cancellationToken) => Task.FromResult(Unit.Instance); + } + + private sealed class TestCommandStrategy : DefaultStrategy + { + } +} diff --git a/test/unit/Elsa.Mediator.UnitTests/BackgroundNotificationProcessorTests.cs b/test/unit/Elsa.Mediator.UnitTests/BackgroundNotificationProcessorTests.cs new file mode 100644 index 000000000..d1fe97828 --- /dev/null +++ b/test/unit/Elsa.Mediator.UnitTests/BackgroundNotificationProcessorTests.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using Elsa.Mediator.Channels; +using Elsa.Mediator.Contexts; +using Elsa.Mediator.Contracts; +using Elsa.Mediator.Middleware.Notification; +using Elsa.Mediator.Options; +using Elsa.Mediator.PublishingStrategies; +using Elsa.Mediator.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elsa.Mediator.UnitTests; + +public class BackgroundNotificationProcessorTests +{ + [Fact] + public async Task ExecuteAsync_CreatesIndependentScopePerWorker() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + await using var serviceProvider = services.BuildServiceProvider(); + var channel = new NotificationsChannel(); + var processor = new BackgroundNotificationProcessor( + Microsoft.Extensions.Options.Options.Create(new MediatorOptions { NotificationWorkerCount = 2 }), + channel, + serviceProvider.GetRequiredService(), + NullLogger.Instance); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var runTask = processor.ExecuteAsync(cancellationTokenSource.Token); + var notificationStrategy = NotificationStrategy.Sequential; + + await channel.Writer.WriteAsync(new NotificationContext(new TestNotification(), notificationStrategy, serviceProvider), cancellationTokenSource.Token); + await channel.Writer.WriteAsync(new NotificationContext(new TestNotification(), notificationStrategy, serviceProvider), cancellationTokenSource.Token); + + var recorder = serviceProvider.GetRequiredService(); + await recorder.WaitForCallsAsync(2, cancellationTokenSource.Token); + await cancellationTokenSource.CancelAsync(); + await runTask; + + Assert.Equal(2, recorder.ScopeIds.Distinct().Count()); + } + + [Fact] + public async Task ExecuteAsync_UsesQueuedContextStrategy() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + await using var serviceProvider = services.BuildServiceProvider(); + var channel = new NotificationsChannel(); + var processor = new BackgroundNotificationProcessor( + Microsoft.Extensions.Options.Options.Create(new MediatorOptions { NotificationWorkerCount = 1 }), + channel, + serviceProvider.GetRequiredService(), + NullLogger.Instance); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var runTask = processor.ExecuteAsync(cancellationTokenSource.Token); + var strategy = new TestNotificationStrategy(); + + await channel.Writer.WriteAsync(new NotificationContext(new TestNotification(), strategy, serviceProvider), cancellationTokenSource.Token); + + var recorder = serviceProvider.GetRequiredService(); + var recordedCall = await recorder.WaitForCallAsync(cancellationTokenSource.Token); + await cancellationTokenSource.CancelAsync(); + await runTask; + + Assert.Same(strategy, recordedCall.Strategy); + } + + [Fact] + public async Task BackgroundStrategy_QueuesExecutableContext() + { + var services = new ServiceCollection(); + services.AddSingleton(); + await using var serviceProvider = services.BuildServiceProvider(); + var channel = serviceProvider.GetRequiredService(); + var notificationContext = new NotificationContext(new TestNotification(), NotificationStrategy.Background, serviceProvider); + var strategyContext = new NotificationStrategyContext(notificationContext, [], NullLogger.Instance, serviceProvider); + + await NotificationStrategy.Background.PublishAsync(strategyContext); + + var queuedContext = await channel.Reader.ReadAsync(); + + Assert.Same(NotificationStrategy.Sequential, queuedContext.NotificationStrategy); + Assert.Same(notificationContext.Notification, queuedContext.Notification); + } + + private sealed class RecordingNotificationSender(WorkerScopeMarker scopeMarker, NotificationSenderRecorder recorder) : INotificationSender + { + public Task SendAsync(INotification notification, CancellationToken cancellationToken = default) + { + recorder.Record(scopeMarker.Id); + return Task.CompletedTask; + } + + public Task SendAsync(INotification notification, IEventPublishingStrategy? strategy, CancellationToken cancellationToken = default) + { + recorder.Record(scopeMarker.Id, strategy); + return Task.CompletedTask; + } + } + + private sealed class NotificationSenderRecorder + { + private int _callCount; + private readonly TaskCompletionSource _completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ConcurrentBag ScopeIds { get; } = []; + private readonly TaskCompletionSource _firstCall = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Record(Guid scopeId, IEventPublishingStrategy? strategy = null) + { + ScopeIds.Add(scopeId); + _firstCall.TrySetResult(new(strategy)); + + if (Interlocked.Increment(ref _callCount) == 2) + _completed.TrySetResult(); + } + + public async Task WaitForCallsAsync(int expectedCallCount, CancellationToken cancellationToken) + { + if (Volatile.Read(ref _callCount) >= expectedCallCount) + return; + + await _completed.Task.WaitAsync(cancellationToken); + } + + public Task WaitForCallAsync(CancellationToken cancellationToken) => _firstCall.Task.WaitAsync(cancellationToken); + } + + private sealed class WorkerScopeMarker + { + public Guid Id { get; } = Guid.NewGuid(); + } + + private sealed record TestNotification : INotification; + + private sealed record RecordedNotificationCall(IEventPublishingStrategy? Strategy); + + private sealed class TestNotificationStrategy : SequentialProcessingStrategy + { + } +} diff --git a/test/unit/Elsa.Workflows.Api.UnitTests/ActivityExecutions/ActivityExecutionsEndpointTests.cs b/test/unit/Elsa.Workflows.Api.UnitTests/ActivityExecutions/ActivityExecutionsEndpointTests.cs new file mode 100644 index 000000000..39294d194 --- /dev/null +++ b/test/unit/Elsa.Workflows.Api.UnitTests/ActivityExecutions/ActivityExecutionsEndpointTests.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using Elsa.Workflows.Runtime.Entities; +using FastEndpoints; +using WorkflowsApiFeature = Elsa.Workflows.Api.Features.WorkflowsApiFeature; + +namespace Elsa.Workflows.Api.UnitTests.ActivityExecutions; + +public class ActivityExecutionsEndpointTests +{ + [Fact] + public void GetEndpoint_ExposesPathAndQueryRoutes() + { + var endpointType = typeof(WorkflowsApiFeature).Assembly.GetType("Elsa.Workflows.Api.Endpoints.ActivityExecutions.Get.Endpoint", throwOnError: true)!; + var endpoint = Activator.CreateInstance(endpointType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, [null], null)!; + var definition = new EndpointDefinition(endpointType, typeof(EmptyRequest), typeof(ActivityExecutionRecord)); + + endpointType + .GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .SetValue(endpoint, definition); + + endpointType.GetMethod("Configure")!.Invoke(endpoint, null); + + Assert.Contains("/activity-executions/{id}", definition.Routes); + Assert.Contains("/activity-executions/{*id}", definition.Routes); + Assert.Contains("/activity-executions/by-id", definition.Routes); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs index 54ce2cd71..c90ab0a78 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Elsa.Common.Serialization; -using Elsa.Expressions.Services; using Elsa.Workflows.Activities; using Elsa.Workflows.Helpers; using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; @@ -9,6 +8,7 @@ using Elsa.Workflows.Models; using Elsa.Workflows.Serialization.Configurators; using Elsa.Workflows.Serialization.Converters; using Elsa.Workflows.Serialization.Helpers; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.Logging; using NSubstitute; @@ -162,7 +162,7 @@ public sealed class ActivityJsonConverterTests Converters = { sut, - new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()), + new TypeJsonConverter(new WorkflowJsonOptions()), new PolymorphicObjectConverterFactory() }, TypeInfoResolver = new ModifiableJsonTypeInfoResolver(new CustomConstructorConfigurator().GetModifiers()) @@ -352,4 +352,4 @@ public sealed class ActivityJsonConverterTests } private static ILogger CreateLogger() => LoggerFactory.Create(_ => { }).CreateLogger(); -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs deleted file mode 100644 index 493432cd4..000000000 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Elsa.Workflows.Attributes; -using Elsa.Workflows.Serialization.Converters; - -namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters; - -public class ExcludeFromHashConverterTests -{ - private readonly JsonSerializerOptions _options = new() - { - Converters = { new ExcludeFromHashConverterFactory() } - }; - - [Fact] - public void Write_IncludesConditionallyIgnoredProperty_WhenValueIsNotNull() - { - var json = JsonSerializer.Serialize(new ConditionalIgnoreModel { Name = "Alice" }, _options); - - Assert.Contains("\"Name\":\"Alice\"", json); - } - - [Fact] - public void Write_ExcludesConditionallyIgnoredProperty_WhenValueIsNull() - { - var json = JsonSerializer.Serialize(new ConditionalIgnoreModel(), _options); - - Assert.DoesNotContain("\"Name\"", json); - } - - [Fact] - public void Write_ExcludesAlwaysIgnoredProperty() - { - var json = JsonSerializer.Serialize(new AlwaysIgnoreModel { Secret = "hidden" }, _options); - - Assert.DoesNotContain("\"Secret\"", json); - } - - [Fact] - public void Write_SkipsIndexerProperties() - { - var json = JsonSerializer.Serialize(new IndexerModel(), _options); - - Assert.DoesNotContain("\"Item\"", json); - } - - [Fact] - public void Write_DoesNotEvaluateExcludedProperty() - { - var json = JsonSerializer.Serialize(new ExcludedThrowingModel(), _options); - - Assert.DoesNotContain("\"Secret\"", json); - } - - [Fact] - public void Write_DoesNotEvaluateAlwaysIgnoredProperty() - { - var json = JsonSerializer.Serialize(new AlwaysIgnoredThrowingModel(), _options); - - Assert.DoesNotContain("\"Secret\"", json); - } - - [Fact] - public void Write_IncludesNullableValueTypeWithDefaultUnderlyingValue_WhenIgnoringDefaults() - { - var json = JsonSerializer.Serialize(new DefaultIgnoreNullableModel { Count = 0 }, _options); - - Assert.Contains("\"Count\":0", json); - } - - [Fact] - public void Write_IncludesProperty_WhenJsonIgnoreConditionIsUnknown() - { - var json = JsonSerializer.Serialize(new UnknownIgnoreConditionModel { Name = "Alice" }, _options); - - Assert.Contains("\"Name\":\"Alice\"", json); - } - - [Fact] - public void Write_ExcludesStaticProperties() - { - var json = JsonSerializer.Serialize(new StaticPropertyModel { Name = "Alice" }, _options); - - Assert.Contains("\"Name\":\"Alice\"", json); - Assert.DoesNotContain("\"Secret\"", json); - } - - [Fact] - public void Write_PreservesDeclarationOrder() - { - var json = JsonSerializer.Serialize(new OrderedModel { B = "second", A = "first" }, _options); - - Assert.True(json.IndexOf("\"B\"", StringComparison.Ordinal) < json.IndexOf("\"A\"", StringComparison.Ordinal)); - } - - [Fact] - public void Write_OrdersInheritedProperties_ByDeclaringTypeThenDeclarationOrder() - { - var json = JsonSerializer.Serialize(new DerivedOrderedModel - { - BaseB = "base second", - BaseA = "base first", - DerivedB = "derived second", - DerivedA = "derived first" - }, _options); - - Assert.True(json.IndexOf("\"DerivedB\"", StringComparison.Ordinal) < json.IndexOf("\"DerivedA\"", StringComparison.Ordinal)); - Assert.True(json.IndexOf("\"DerivedA\"", StringComparison.Ordinal) < json.IndexOf("\"BaseB\"", StringComparison.Ordinal)); - Assert.True(json.IndexOf("\"BaseB\"", StringComparison.Ordinal) < json.IndexOf("\"BaseA\"", StringComparison.Ordinal)); - } - - private sealed class ConditionalIgnoreModel - { - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Name { get; set; } - } - - private sealed class AlwaysIgnoreModel - { - [JsonIgnore] - public string? Secret { get; set; } - } - - private sealed class IndexerModel - { - public string this[int index] => index.ToString(); - } - - private sealed class ExcludedThrowingModel - { - [ExcludeFromHash] - public string Secret => throw new InvalidOperationException(); - } - - private sealed class AlwaysIgnoredThrowingModel - { - [JsonIgnore] - public string Secret => throw new InvalidOperationException(); - } - - private sealed class DefaultIgnoreNullableModel - { - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public int? Count { get; set; } - } - - private sealed class UnknownIgnoreConditionModel - { - [JsonIgnore(Condition = (JsonIgnoreCondition)999)] - public string? Name { get; set; } - } - - private sealed class StaticPropertyModel - { - public static string Secret => throw new InvalidOperationException(); - - public string? Name { get; set; } - } - - private sealed class OrderedModel - { - public string? B { get; set; } - - public string? A { get; set; } - } - - private class BaseOrderedModel - { - public string? BaseB { get; set; } - - public string? BaseA { get; set; } - } - - private sealed class DerivedOrderedModel : BaseOrderedModel - { - public string? DerivedB { get; set; } - - public string? DerivedA { get; set; } - } -} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs index fd1a5d414..71f2b1396 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs @@ -1,200 +1,104 @@ using System.Text.Json; -using System.Text.Json.Nodes; -using Elsa.Expressions.Options; -using Elsa.Expressions.Services; +using Elsa.Expressions.Models; using Elsa.Extensions; +using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Exceptions; -using Elsa.Workflows.Memory; +using Elsa.Workflows.IncidentStrategies; +using Elsa.Workflows.Models; using Elsa.Workflows.Serialization.Converters; using Elsa.Workflows.Serialization.Helpers; -using Elsa.Workflows.State; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters; -public sealed class WorkflowJsonTypeResolverTests +public class WorkflowJsonTypeResolverTests { private static readonly string UnsafeAssemblyQualifiedTypeAlias = typeof(System.Text.StringBuilder).AssemblyQualifiedName!; - private readonly WellKnownTypeRegistry _wellKnownTypeRegistry = new(Microsoft.Extensions.Options.Options.Create(new ExpressionOptions())); + private static readonly string TrustedAssemblyQualifiedTypeAlias = typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(); private readonly JsonSerializerOptions _options; + private readonly JsonSerializerOptions _strictOptions; public WorkflowJsonTypeResolverTests() { - _wellKnownTypeRegistry.RegisterType(typeof(ExceptionState), nameof(ExceptionState)); - _wellKnownTypeRegistry.RegisterType(typeof(FaultException), nameof(FaultException)); - _wellKnownTypeRegistry.RegisterType(typeof(RegisteredPayload), "RegisteredPayload"); - _options = CreateOptions(_wellKnownTypeRegistry); + _options = CreateOptions(); + _strictOptions = CreateOptions(false); } - [Theory] - [InlineData("String", typeof(string))] - [InlineData("String[]", typeof(string[]))] - [InlineData("String[][]", typeof(string[][]))] - [InlineData("List", typeof(List))] - [InlineData("List", typeof(List))] - [InlineData("List>", typeof(List>))] - [InlineData("ExceptionState", typeof(ExceptionState))] - [InlineData("FaultException", typeof(FaultException))] - [InlineData("ObjectDictionary", typeof(IDictionary))] - public void When_DeserializeRegisteredTypeAlias_Then_ReturnsExpectedType(string typeAlias, Type expectedType) + [Fact] + public void TypeJsonConverter_ResolvesClrAssemblyQualifiedType() { + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(UnsafeAssemblyQualifiedTypeAlias), _options); + + Assert.Equal(typeof(System.Text.StringBuilder), result); + } + + [Fact] + public void TypeJsonConverter_RejectsUnregisteredClrAssemblyQualifiedType_WhenLegacyClrTypeNamesDisabled() + { + Assert.Throws(() => JsonSerializer.Deserialize(JsonSerializer.Serialize(UnsafeAssemblyQualifiedTypeAlias), _strictOptions)); + } + + [Fact] + public void TypeJsonConverter_ResolvesTrustedClrAssemblyQualifiedType() + { + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(TrustedAssemblyQualifiedTypeAlias), _options); + + Assert.Equal(typeof(UnregisteredPayload), result); + } + + [Fact] + public void TypeJsonConverter_ResolvesIncidentStrategyClrType() + { + var typeAlias = typeof(ContinueWithIncidentsStrategy).GetSimpleAssemblyQualifiedName(); + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(typeAlias), _options); - Assert.Equal(expectedType, result); + Assert.Equal(typeof(ContinueWithIncidentsStrategy), result); } [Fact] - public void When_DeserializeRegisteredLegacyAssemblyQualifiedTypeAlias_Then_ReturnsExpectedType() + public void TypeJsonConverter_ResolvesFaultExceptionClrType() { - var typeAlias = typeof(RegisteredPayload).GetSimpleAssemblyQualifiedName(); + var typeAlias = typeof(FaultException).GetSimpleAssemblyQualifiedName(); - var result = JsonSerializer.Deserialize(JsonString(typeAlias), _options); + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(typeAlias), _options); - Assert.Equal(typeof(RegisteredPayload), result); + Assert.Equal(typeof(FaultException), result); } [Fact] - public void When_DeserializeRegisteredLegacyGenericCollectionTypeAlias_Then_ReturnsExpectedType() + public void PolymorphicObjectConverter_ResolvesClrAssemblyQualifiedType() { - var typeAlias = typeof(List).GetSimpleAssemblyQualifiedName(); - - var result = JsonSerializer.Deserialize(JsonString(typeAlias), _options); - - Assert.Equal(typeof(List), result); - } - - [Theory] - [InlineData(typeof(string), "String")] - [InlineData(typeof(string[]), "String[]")] - [InlineData(typeof(string[][]), "String[][]")] - [InlineData(typeof(List), "List")] - [InlineData(typeof(List), "List")] - [InlineData(typeof(List>), "List>")] - [InlineData(typeof(ExceptionState), "ExceptionState")] - [InlineData(typeof(FaultException), "FaultException")] - public void When_SerializeSupportedType_Then_EmitsAliasThatCanBeDeserialized(Type type, string expectedAlias) - { - var json = JsonSerializer.Serialize(type, _options); - var alias = JsonSerializer.Deserialize(json); - var result = JsonSerializer.Deserialize(json, _options); - - Assert.Equal(expectedAlias, alias); - Assert.Equal(type, result); - } - - [Theory] - [InlineData(typeof(IEnumerable), "List", typeof(List))] - [InlineData(typeof(ICollection), "List", typeof(List))] - [InlineData(typeof(IList), "List", typeof(List))] - [InlineData(typeof(IReadOnlyCollection), "List", typeof(List))] - [InlineData(typeof(IReadOnlyList), "List", typeof(List))] - [InlineData(typeof(ISet), "HashSet", typeof(HashSet))] - public void When_SerializeInterfaceCollectionType_Then_EmitsInstantiableAlias(Type type, string expectedAlias, Type expectedRoundTripType) - { - var json = JsonSerializer.Serialize(type, _options); - var alias = JsonSerializer.Deserialize(json); - var result = JsonSerializer.Deserialize(json, _options); - - Assert.Equal(expectedAlias, alias); - Assert.Equal(expectedRoundTripType, result); - } - - [Theory] - [MemberData(nameof(JsonIslandValues))] - public void When_SerializeSpecialJsonIslandType_Then_CanBeDeserialized(object value, Type expectedType) - { - _wellKnownTypeRegistry.RegisterType(typeof(JObject), nameof(JObject)); - _wellKnownTypeRegistry.RegisterType(typeof(JArray), nameof(JArray)); - - var json = JsonSerializer.Serialize(value, _options); - var result = JsonSerializer.Deserialize(json, _options); - - Assert.IsType(expectedType, result); - } - - [Theory] - [InlineData(typeof(System.Text.StringBuilder))] - [InlineData(typeof(System.Text.StringBuilder[]))] - [InlineData(typeof(List))] - public void When_SerializeUnsupportedType_Then_EmitsSafeUnregisteredTypeAlias(Type type) - { - var json = JsonSerializer.Serialize(type, _options); - var alias = JsonSerializer.Deserialize(json); - var result = JsonSerializer.Deserialize(json, _options); - - Assert.StartsWith("UnregisteredClrType:", alias); - Assert.Equal(typeof(Exception), result); - } - - [Fact] - public void When_SerializeExceptionStateWithUnregisteredExceptionType_Then_DoesNotThrow() - { - var exceptionState = ExceptionState.FromException(new NullReferenceException("Test")); - - var json = JsonSerializer.Serialize(exceptionState, _options); - var result = JsonSerializer.Deserialize(json, _options)!; - - Assert.Contains("UnregisteredClrType:", json); - Assert.Equal(typeof(Exception), result.Type); - Assert.Equal("Test", result.Message); - } - - [Fact] - public void When_ConfigureWorkflowsFeature_Then_RegistersCoreAliases() - { - var services = new ServiceCollection(); - var module = services.CreateModule(); - module.UseWorkflows(); - module.Apply(); - using var serviceProvider = services.BuildServiceProvider(); - var expressionOptions = serviceProvider.GetRequiredService>(); - var registry = new WellKnownTypeRegistry(expressionOptions); - - var aliasRegistered = registry.TryGetAlias(typeof(NullReferenceException), out var alias); - var typeRegistered = registry.TryGetType(nameof(NullReferenceException), out var type); - - Assert.True(aliasRegistered); - Assert.Equal(nameof(NullReferenceException), alias); - Assert.True(typeRegistered); - Assert.Equal(typeof(NullReferenceException), type); - Assert.True(registry.TryGetAlias(typeof(MemoryStorageDriver), out var memoryStorageDriverAlias)); - Assert.Equal(nameof(MemoryStorageDriver), memoryStorageDriverAlias); - Assert.True(registry.TryGetType(nameof(MemoryStorageDriver), out var memoryStorageDriverType)); - Assert.Equal(typeof(MemoryStorageDriver), memoryStorageDriverType); - Assert.True(registry.TryGetType(typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName(), out var legacyMemoryStorageDriverType)); - Assert.Equal(typeof(MemoryStorageDriver), legacyMemoryStorageDriverType); - } - - [Fact] - public void When_SerializePolymorphicObjectWithUnregisteredType_Then_OmitsTypeMetadata() - { - var json = JsonSerializer.Serialize(new UnregisteredPayload { Name = "Alice" }, _options); + var json = $$""" + { + "name": "Alice", + "_type": {{JsonSerializer.Serialize(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName())}} + } + """; var result = JsonSerializer.Deserialize(json, _options); - Assert.DoesNotContain("\"_type\"", json); - var payload = Assert.IsAssignableFrom>(result); - Assert.Equal("Alice", payload["name"]); + var payload = Assert.IsType(result); + Assert.Equal("Alice", payload.Name); } [Fact] - public void When_DeserializeUnknownAssemblyQualifiedTypeAlias_Then_ThrowsJsonException() + public void PolymorphicObjectConverter_RejectsUnregisteredClrAssemblyQualifiedType_WhenLegacyClrTypeNamesDisabled() { - Assert.Throws(() => JsonSerializer.Deserialize(JsonString(UnsafeAssemblyQualifiedTypeAlias), _options)); + var json = $$""" + { + "name": "Alice", + "_type": {{JsonSerializer.Serialize(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName())}} + } + """; + + Assert.Throws(() => JsonSerializer.Deserialize(json, _strictOptions)); } [Fact] - public void When_DeserializeUnknownGenericElementTypeAlias_Then_ThrowsJsonException() - { - var typeAlias = $"List<{UnsafeAssemblyQualifiedTypeAlias}>"; - - Assert.Throws(() => JsonSerializer.Deserialize(JsonString(typeAlias), _options)); - } - - [Fact] - public void When_DeserializePolymorphicObjectWithRegisteredTypeAlias_Then_ReturnsTypedObject() + public void PolymorphicObjectConverter_ResolvesRegisteredTypeAlias() { var json = """ { @@ -209,142 +113,148 @@ public sealed class WorkflowJsonTypeResolverTests Assert.Equal("Alice", payload.Name); } - [Theory] - [InlineData("IEnumerable", typeof(List))] - [InlineData("ICollection", typeof(List))] - [InlineData("IList", typeof(List))] - [InlineData("IReadOnlyCollection", typeof(List))] - [InlineData("IReadOnlyList", typeof(List))] - [InlineData("ISet", typeof(HashSet))] - public void When_DeserializePolymorphicCollectionInterface_Then_ReturnsConcreteCollection(string typeAlias, Type expectedType) + [Fact] + public void PolymorphicObjectConverter_WritesClrTypeMetadataForUnregisteredTypes() { - var json = $$""" - { - "_items": ["Alice"], - "_type": "{{typeAlias}}" - } - """; - + var json = JsonSerializer.Serialize(new UnregisteredPayload { Name = "Alice" }, _options); var result = JsonSerializer.Deserialize(json, _options); - Assert.IsType(expectedType, result); - } - - [Fact] - public void When_DeserializePolymorphicObjectWithNonInstantiableType_Then_ThrowsJsonException() - { - _wellKnownTypeRegistry.RegisterType(typeof(AbstractPayload), "AbstractPayload"); - var json = """ - { - "_type": "AbstractPayload" - } - """; - - Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); - } - - [Fact] - public void When_DeserializePolymorphicObjectWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException() - { - var json = $$""" - { - "capacity": 16, - "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} - } - """; - - Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); - } - - [Fact] - public void When_DeserializePolymorphicObjectWithoutTypeJsonConverterAndUnknownAssemblyQualifiedType_Then_ThrowsJsonException() - { - var options = CreatePolymorphicOnlyOptions(_wellKnownTypeRegistry); - var json = $$""" - { - "capacity": 16, - "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} - } - """; - - Assert.Throws(() => JsonSerializer.Deserialize(json, options)); - } - - [Fact] - public void When_DeserializeDictionaryObjectPayloadWithRegisteredTypeAlias_Then_ReturnsTypedObjectValue() - { - var json = """ - { - "payload": { - "name": "Alice", - "_type": "RegisteredPayload" - } - } - """; - - var result = JsonSerializer.Deserialize>(json, _options)!; - - var payload = Assert.IsType(result["payload"]); + Assert.Contains("\"_type\"", json); + var payload = Assert.IsType(result); Assert.Equal("Alice", payload.Name); } [Fact] - public void When_DeserializeDictionaryObjectPayloadWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException() + public void PolymorphicObjectConverter_RejectsUnregisteredClrTypeMetadata_WhenLegacyClrTypeNamesDisabled() { - var json = $$""" - { - "payload": { - "capacity": 16, - "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} - } - } - """; - - Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + Assert.Throws(() => JsonSerializer.Serialize(new UnregisteredPayload { Name = "Alice" }, _strictOptions)); } [Fact] - public void When_RegistryChangesAfterLegacyResolutionAttempt_Then_LegacyResolutionUsesCurrentRegistry() + public void TypeJsonConverter_WritesUnregisteredTypesAsClrTypeNames() { - var typeAlias = typeof(LateRegisteredPayload).GetSimpleAssemblyQualifiedName(); - Assert.False(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out _)); + var json = JsonSerializer.Serialize(typeof(UnregisteredPayload), _options); + var alias = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, _options); - _wellKnownTypeRegistry.RegisterType(typeof(LateRegisteredPayload), "LateRegisteredPayload"); - - Assert.True(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var result)); - Assert.Equal(typeof(LateRegisteredPayload), result); + Assert.Equal(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), alias); + Assert.Equal(typeof(UnregisteredPayload), result); } - private static JsonSerializerOptions CreateOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new() + [Fact] + public void TypeJsonConverter_RejectsUnregisteredClrTypeMetadata_WhenLegacyClrTypeNamesDisabled() + { + Assert.Throws(() => JsonSerializer.Serialize(typeof(UnregisteredPayload), _strictOptions)); + } + + [Fact] + public void OutputJsonConverter_WritesUnregisteredTypesAsClrTypeNames() + { + var options = CreateOptions(); + options.Converters.Add(new OutputJsonConverter(Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions()))); + var output = new Output(new MemoryBlockReference("result")); + + var json = JsonSerializer.Serialize(output, options); + + using var doc = JsonDocument.Parse(json); + Assert.Equal(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), doc.RootElement.GetProperty("typeName").GetString()); + } + + [Fact] + public void OutputJsonConverter_RejectsUnregisteredClrTypeMetadata_WhenLegacyClrTypeNamesDisabled() + { + var workflowJsonOptions = new WorkflowJsonOptions + { + AllowLegacyClrTypeNames = false + }; + var options = CreateOptions(); + options.Converters.Add(new OutputJsonConverter(Microsoft.Extensions.Options.Options.Create(workflowJsonOptions))); + var output = new Output(new MemoryBlockReference("result")); + + Assert.Throws(() => JsonSerializer.Serialize(output, options)); + } + + [Fact] + public void TypeJsonConverter_ResolvesRegisteredLegacyAssemblyQualifiedName() + { + var typeAlias = typeof(RegisteredPayload).GetSimpleAssemblyQualifiedName(); + + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(typeAlias), _strictOptions); + + Assert.Equal(typeof(RegisteredPayload), result); + } + + [Fact] + public void TypeJsonConverter_ResolvesRegisteredAliasesCaseInsensitively() + { + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize("registeredpayload"), _strictOptions); + + Assert.Equal(typeof(RegisteredPayload), result); + } + + [Fact] + public void TypeJsonConverter_WritesPreferredAlias_WhenTypeHasMultipleAliases() + { + var json = JsonSerializer.Serialize(typeof(FlowJoinMode), _strictOptions); + + Assert.Equal("\"FlowJoinMode\"", json); + } + + [Fact] + public void TypeJsonConverter_RoundTripsNullableValueTypeAliases_WhenLegacyClrTypeNamesDisabled() + { + var json = JsonSerializer.Serialize(typeof(int?), _strictOptions); + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize("int32?"), _strictOptions); + + Assert.Equal("\"Int32?\"", json); + Assert.Equal(typeof(int?), result); + } + + [Fact] + public void ShellWorkflowsFeature_RegistersWorkflowJsonTypeAliases() + { + var services = new ServiceCollection(); + new Elsa.Workflows.ShellFeatures.WorkflowsFeature().ConfigureServices(services); + var workflowJsonOptions = services.BuildServiceProvider().GetRequiredService>().Value; + + var faultStrategy = WorkflowJsonTypeResolver.ResolveType(workflowJsonOptions, nameof(FaultStrategy), false); + + Assert.Equal(typeof(FaultStrategy), faultStrategy); + } + + [Fact] + public void ShellFlowchartFeature_RegistersFlowScopeTypeAlias() + { + var services = new ServiceCollection(); + new Elsa.Workflows.ShellFeatures.FlowchartFeature().ConfigureServices(services); + var workflowJsonOptions = services.BuildServiceProvider().GetRequiredService>().Value; + + var flowScope = WorkflowJsonTypeResolver.ResolveType(workflowJsonOptions, "FlowScope", false); + + Assert.Equal(typeof(FlowScope), flowScope); + } + + private JsonSerializerOptions CreateOptions(bool allowLegacyClrTypeNames = true) + { + var workflowJsonOptions = new WorkflowJsonOptions + { + AllowLegacyClrTypeNames = allowLegacyClrTypeNames + }; + workflowJsonOptions.RegisterWorkflowTypeAliases(); + workflowJsonOptions.RegisterTypeAlias(typeof(RegisteredPayload), "RegisteredPayload"); + workflowJsonOptions.RegisterTypeAlias(typeof(FaultStrategy), nameof(FaultStrategy)); + + return new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, Converters = { - new PolymorphicObjectConverterFactory(wellKnownTypeRegistry), - new TypeJsonConverter(wellKnownTypeRegistry) + new PolymorphicObjectConverterFactory(workflowJsonOptions), + new TypeJsonConverter(workflowJsonOptions) } }; - - private static JsonSerializerOptions CreatePolymorphicOnlyOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - Converters = - { - new PolymorphicObjectConverterFactory(wellKnownTypeRegistry) - } - }; - - private static string JsonString(string value) => JsonSerializer.Serialize(value); - - public static TheoryData JsonIslandValues() => new() - { - { new JObject { ["name"] = "Alice" }, typeof(JObject) }, - { new JArray("Alice", "Bob"), typeof(JArray) }, - { new JsonObject { ["name"] = "Alice" }, typeof(JsonObject) }, - { new JsonArray("Alice", "Bob"), typeof(JsonArray) } - }; + } public sealed class RegisteredPayload { @@ -355,14 +265,4 @@ public sealed class WorkflowJsonTypeResolverTests { public string? Name { get; set; } } - - public abstract class AbstractPayload - { - public string? Name { get; set; } - } - - public sealed class LateRegisteredPayload - { - public string? Name { get; set; } - } } diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs index 4cc8b8eb1..62c6042ab 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs @@ -1,7 +1,8 @@ -using Elsa.Expressions.Services; +using System.Text.Json; using Elsa.Extensions; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Options; using Elsa.Workflows.Services; using Microsoft.Extensions.Logging.Abstractions; @@ -9,15 +10,15 @@ namespace Elsa.Workflows.Core.UnitTests.Services; public class VariableMapperTests { - private readonly WellKnownTypeRegistry _registry = new(); + private readonly WorkflowJsonOptions _workflowJsonOptions = new(); private readonly VariableMapper _mapper; public VariableMapperTests() { - _registry.RegisterType(typeof(string), "String"); - _registry.RegisterType(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver)); - _registry.RegisterType(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()); - _mapper = new(_registry, NullLogger.Instance); + _workflowJsonOptions.RegisterTypeAlias(typeof(string), "String"); + _workflowJsonOptions.RegisterTypeAlias(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver)); + _workflowJsonOptions.RegisterTypeAlias(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()); + _mapper = new(NullLogger.Instance, _workflowJsonOptions); } [Fact] @@ -37,7 +38,7 @@ public class VariableMapperTests } [Fact] - public void Map_ResolvesRegisteredMemoryStorageDriverAssemblyQualifiedName() + public void Map_ResolvesRegisteredStorageDriverAssemblyQualifiedName() { var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName())); @@ -55,10 +56,37 @@ public class VariableMapperTests [Fact] public void Map_DoesNotUseRegisteredNonStorageDriverAliasAsStorageDriver() { - _registry.RegisterType(typeof(string), "NotAStorageDriver"); + _workflowJsonOptions.RegisterTypeAlias(typeof(string), "NotAStorageDriver"); var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", "NotAStorageDriver")); Assert.Null(variable.StorageDriverType); } + + [Fact] + public void Map_WritesUnregisteredVariableTypeAsClrTypeName() + { + var variable = new Variable("payload", new()); + + var model = _mapper.Map(variable); + + Assert.Equal(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), model.TypeName); + } + + [Fact] + public void Map_RejectsUnregisteredVariableType_WhenLegacyClrTypeNamesDisabled() + { + var workflowJsonOptions = new WorkflowJsonOptions + { + AllowLegacyClrTypeNames = false + }; + var mapper = new VariableMapper(NullLogger.Instance, workflowJsonOptions); + var variable = new Variable("payload", new()); + + Assert.Throws(() => mapper.Map(variable)); + } + + private sealed class UnregisteredPayload + { + } } diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Mappers/VariableDefinitionMapperTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Mappers/VariableDefinitionMapperTests.cs new file mode 100644 index 000000000..2a6ba1acb --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Mappers/VariableDefinitionMapperTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using Elsa.Extensions; +using Elsa.Workflows.Management.Mappers; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Elsa.Workflows.Management.UnitTests.Mappers; + +public class VariableDefinitionMapperTests +{ + private readonly IServiceScopeFactory _scopeFactory = Substitute.For(); + private readonly WorkflowJsonOptions _workflowJsonOptions = new(); + private readonly VariableDefinitionMapper _mapper; + + public VariableDefinitionMapperTests() + { + _workflowJsonOptions.RegisterTypeAlias(typeof(string), "String"); + _workflowJsonOptions.RegisterTypeAlias(typeof(MemoryStorageDriver), nameof(MemoryStorageDriver)); + _mapper = CreateMapper(_workflowJsonOptions); + } + + [Fact] + public void Map_ResolvesUnregisteredClrTypeName_WhenLegacyClrTypeNamesEnabled() + { + var definition = new VariableDefinition("id", "payload", typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), false, null, null); + + var variable = _mapper.Map(definition); + + Assert.IsType>(variable); + } + + [Fact] + public void Map_DoesNotResolveUnregisteredClrTypeName_WhenLegacyClrTypeNamesDisabled() + { + var workflowJsonOptions = new WorkflowJsonOptions + { + AllowLegacyClrTypeNames = false + }; + var mapper = CreateMapper(workflowJsonOptions); + var definition = new VariableDefinition("id", "payload", typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), false, null, null); + + var variable = mapper.Map(definition); + + Assert.Null(variable); + } + + [Fact] + public void Map_WritesUnregisteredVariableTypeAsClrTypeName() + { + var variable = new Variable("payload", new()); + + var definition = _mapper.Map(variable); + + Assert.Equal(typeof(UnregisteredPayload).GetSimpleAssemblyQualifiedName(), definition.TypeName); + } + + [Fact] + public void Map_RejectsUnregisteredVariableType_WhenLegacyClrTypeNamesDisabled() + { + var workflowJsonOptions = new WorkflowJsonOptions + { + AllowLegacyClrTypeNames = false + }; + var mapper = CreateMapper(workflowJsonOptions); + var variable = new Variable("payload", new()); + + Assert.Throws(() => mapper.Map(variable)); + } + + private VariableDefinitionMapper CreateMapper(WorkflowJsonOptions workflowJsonOptions) + { + return new(Microsoft.Extensions.Options.Options.Create(workflowJsonOptions), _scopeFactory, NullLogger.Instance); + } + + private sealed class UnregisteredPayload + { + } +} diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowDefinitionExporterRegressionTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowDefinitionExporterRegressionTests.cs index f9748dc49..5e7eba5d3 100644 --- a/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowDefinitionExporterRegressionTests.cs +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/WorkflowDefinitionExporterRegressionTests.cs @@ -1,12 +1,12 @@ using System.IO.Compression; using System.Text.Json; -using Elsa.Expressions.Contracts; using Elsa.Workflows.Activities; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Services; using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -60,9 +60,8 @@ public class WorkflowDefinitionExporterRegressionTests private WorkflowDefinitionExporter CreateExporter() { var activitySerializer = Substitute.For(); - var wellKnownTypeRegistry = Substitute.For(); var scopeFactory = Substitute.For(); - var variableDefinitionMapper = new VariableDefinitionMapper(wellKnownTypeRegistry, scopeFactory, NullLogger.Instance); + var variableDefinitionMapper = new VariableDefinitionMapper(Microsoft.Extensions.Options.Options.Create(new WorkflowJsonOptions()), scopeFactory, NullLogger.Instance); var workflowDefinitionMapper = new WorkflowDefinitionMapper(activitySerializer, _workflowDefinitionService, variableDefinitionMapper); var fileNameSanitizer = new DefaultFileNameSanitizer(); @@ -77,4 +76,4 @@ public class WorkflowDefinitionExporterRegressionTests return new(workflow, rootNode, [rootNode]); } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs index 6299d3a86..d20a45359 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs @@ -1,10 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Services; using Elsa.Workflows.Helpers; using Elsa.Workflows.Runtime.Comparers; using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Options; namespace Elsa.Workflows.Runtime.UnitTests.Comparers; @@ -27,8 +27,8 @@ public class WorkflowTriggerEqualityComparerTests /// A simple payload class that mimics real trigger payloads like HttpEndpointBookmarkPayload. /// private record TestPayload(string Path, string Method); - private record TypedPayload(Type PayloadType, object Value); - private record NestedPayload(string Name); + + private record TypePayload(Type TargetType); [Fact(DisplayName = "Fresh and round-tripped triggers with identical logical content should be considered equal")] public void FreshAndRoundTrippedTriggers_ShouldBeEqual() @@ -41,7 +41,7 @@ public class WorkflowTriggerEqualityComparerTests var roundTrippedPayload = SimulatePayloadRoundTrip(freshPayload); var loadedTrigger = CreateTrigger("trigger-1", roundTrippedPayload); - var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry()); + var comparer = new WorkflowTriggerEqualityComparer(); // Act var areEqual = comparer.Equals(freshTrigger, loadedTrigger); @@ -72,7 +72,7 @@ public class WorkflowTriggerEqualityComparerTests var newTriggers = new List { freshTrigger }; // Act: this is exactly what TriggerIndexer.IndexTriggersInternalAsync does - var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer(CreateTypeRegistry())); + var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer()); // Assert: the diff should find no changes. // Before the fix, it reported Removed=[existingTrigger] and Added=[freshTrigger] @@ -82,6 +82,22 @@ public class WorkflowTriggerEqualityComparerTests Assert.Single(diff.Unchanged); } + [Fact(DisplayName = "Comparer should use configured type aliases when normalizing trigger payloads")] + public void FreshAndRoundTrippedTriggers_WithRegisteredTypeAlias_ShouldBeEqual() + { + var workflowJsonOptions = new WorkflowJsonOptions(); + workflowJsonOptions.RegisterTypeAlias(typeof(CustomAliasTarget), "CustomAlias"); + var payload = new TypePayload(typeof(CustomAliasTarget)); + var roundTrippedPayload = SimulatePayloadRoundTrip(payload, workflowJsonOptions); + var freshTrigger = CreateTrigger("trigger-1", payload); + var loadedTrigger = CreateTrigger("trigger-1", roundTrippedPayload); + var comparer = new WorkflowTriggerEqualityComparer(workflowJsonOptions); + + var areEqual = comparer.Equals(freshTrigger, loadedTrigger); + + Assert.True(areEqual); + } + [Fact(DisplayName = "Documents the underlying System.Text.Json casing behavior that necessitated the fix")] public void PayloadSerializationMismatch_ProducesDifferentJson() { @@ -110,50 +126,6 @@ public class WorkflowTriggerEqualityComparerTests Assert.Equal("{\"Path\":\"/api/test\",\"Method\":\"GET\"}", freshJson); Assert.Equal("{\"path\":\"/api/test\",\"method\":\"GET\"}", roundTrippedJson); } - - [Fact(DisplayName = "Comparer serializes typed payload properties using the registered type aliases")] - public void TypedPayloadProperties_ShouldSerializeWithRegisteredAliases() - { - var payload = new TypedPayload(typeof(NestedPayload), new NestedPayload("orders")); - var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry()); - var trigger = CreateTrigger("trigger-1", payload); - var expectedAliasPayload = JsonSerializer.Deserialize( - """ - { - "payloadType": "NestedPayload", - "value": { - "name": "orders", - "_type": "NestedPayload" - } - } - """, - PayloadSerializerOptions); - var assemblyQualifiedPayload = JsonSerializer.Deserialize( - $$""" - { - "payloadType": "{{typeof(NestedPayload).AssemblyQualifiedName}}", - "value": { - "name": "orders", - "_type": "{{typeof(NestedPayload).AssemblyQualifiedName}}" - } - } - """, - PayloadSerializerOptions); - var missingTypePayload = JsonSerializer.Deserialize( - """ - { - "payloadType": "NestedPayload", - "value": { - "name": "orders" - } - } - """, - PayloadSerializerOptions); - - Assert.True(comparer.Equals(trigger, CreateTrigger("trigger-2", expectedAliasPayload!))); - Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-3", assemblyQualifiedPayload!))); - Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-4", missingTypePayload!))); - } /// /// IPayloadSerializer options: camelCase with case-insensitive deserialization. @@ -190,13 +162,20 @@ public class WorkflowTriggerEqualityComparerTests return deserialized!; } - private static IWellKnownTypeRegistry CreateTypeRegistry() + private static object SimulatePayloadRoundTrip(object payload, WorkflowJsonOptions workflowJsonOptions) { - var registry = WellKnownTypeRegistry.CreateDefault(); - registry.RegisterType(typeof(TestPayload), nameof(TestPayload)); - registry.RegisterType(typeof(TypedPayload), nameof(TypedPayload)); - registry.RegisterType(typeof(NestedPayload), nameof(NestedPayload)); - return registry; + var options = CreatePayloadSerializerOptions(workflowJsonOptions); + var json = JsonSerializer.Serialize(payload, options); + return JsonSerializer.Deserialize(json, options)!; + } + + private static JsonSerializerOptions CreatePayloadSerializerOptions(WorkflowJsonOptions workflowJsonOptions) + { + var options = new JsonSerializerOptions(PayloadSerializerOptions); + options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new PolymorphicObjectConverterFactory(workflowJsonOptions)); + options.Converters.Add(new TypeJsonConverter(workflowJsonOptions)); + return options; } /// @@ -219,4 +198,6 @@ public class WorkflowTriggerEqualityComparerTests Hash = hash, Payload = payload }; + + private sealed class CustomAliasTarget; } diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Extensions/ServiceCollectionWorkflowExtensionsTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Extensions/ServiceCollectionWorkflowExtensionsTests.cs new file mode 100644 index 000000000..97084a465 --- /dev/null +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Extensions/ServiceCollectionWorkflowExtensionsTests.cs @@ -0,0 +1,29 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Runtime.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.Runtime.UnitTests.Extensions; + +public class ServiceCollectionWorkflowExtensionsTests +{ + [Fact] + public void AddWorkflow_PostConfiguresRuntimeOptions() + { + var services = new ServiceCollection(); + var configuredWorkflows = new Dictionary>>(); + + services.Configure(options => options.Workflows = configuredWorkflows); + services.AddWorkflow(); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + var workflowKey = typeof(ServiceCollectionRegisteredWorkflow).GetSimpleAssemblyQualifiedName(); + + Assert.Same(configuredWorkflows, options.Workflows); + Assert.Contains(workflowKey, options.Workflows.Keys); + } +} + +public class ServiceCollectionRegisteredWorkflow : WorkflowBase; diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs deleted file mode 100644 index d68ca615b..000000000 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Reflection; -using Elsa.Expressions.Options; -using Elsa.Features.Attributes; -using Elsa.Features.Services; -using Elsa.Extensions; -using Elsa.Workflows; -using Elsa.Workflows.Activities; -using Elsa.Workflows.Features; -using Elsa.Workflows.Runtime.Options; -using Elsa.Workflows.Runtime.Providers; -using NSubstitute; -using RuntimeFeature = Elsa.Workflows.Runtime.Features.WorkflowRuntimeFeature; -using ShellRuntimeFeature = Elsa.Workflows.Runtime.ShellFeatures.WorkflowRuntimeFeature; - -namespace Elsa.Workflows.Runtime.UnitTests.Features; - -public class WorkflowRuntimeFeatureTests -{ - private readonly RuntimeFeature _feature = new(Substitute.For()); - private readonly ShellRuntimeFeature _shellFeature = new(); - - [Fact] - public void AddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow() - { - Assert.Throws(() => _feature.AddWorkflow(typeof(NotAWorkflow))); - } - - [Theory] - [MemberData(nameof(NonInstantiableWorkflowTypes))] - public void AddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType) - { - Assert.Throws(() => _feature.AddWorkflow(workflowType)); - } - - [Fact] - public void ShellAddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow() - { - Assert.Throws(() => _shellFeature.AddWorkflow(typeof(NotAWorkflow))); - } - - [Theory] - [MemberData(nameof(NonInstantiableWorkflowTypes))] - public void ShellAddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType) - { - Assert.Throws(() => _shellFeature.AddWorkflow(workflowType)); - } - - [Fact] - public void AddWorkflow_AllowsClosedGenericWorkflowType() - { - var workflowType = typeof(GenericWorkflow); - - _feature.AddWorkflow(workflowType); - - Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys); - Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys); - } - - [Fact] - public void ShellAddWorkflow_AllowsClosedGenericWorkflowType() - { - var workflowType = typeof(GenericWorkflow); - - _shellFeature.AddWorkflow(workflowType); - - Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _shellFeature.Workflows.Keys); - Assert.Contains(workflowType.FullName!, _shellFeature.Workflows.Keys); - } - - [Fact] - public void WorkflowsAdd_RegistersWorkflowTypeAlias() - { - var workflowType = typeof(GenericWorkflow); - var options = new ExpressionOptions(); - - _feature.Workflows.Add(workflowType); - - RegisterWorkflowTypeAliases(_feature, options); - - Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]); - } - - [Fact] - public void WorkflowsAdd_DoesNotThrow_WhenLegacyKeyAlreadyExists() - { - var workflowType = typeof(GenericWorkflow); - _feature.Workflows.Add(workflowType.FullName!, _ => new ValueTask(new GenericWorkflow())); - - _feature.Workflows.Add(workflowType); - - Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys); - Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys); - Assert.Same(_feature.Workflows[workflowType.FullName!], _feature.Workflows[workflowType.GetSimpleAssemblyQualifiedName()]); - } - - [Fact] - public async Task ClrWorkflowsProvider_MaterializesWorkflowOnce_WhenCanonicalAndLegacyKeysExist() - { - CountingWorkflow.CreatedCount = 0; - var builder = Substitute.For(); - var builderFactory = Substitute.For(); - var provider = new ClrWorkflowsProvider( - Microsoft.Extensions.Options.Options.Create(new RuntimeOptions { Workflows = _feature.Workflows }), - builderFactory, - Substitute.For()); - builderFactory.CreateBuilder().Returns(builder); - builder.BuildWorkflowAsync(Arg.Any()).Returns(new Workflow()); - - _feature.Workflows.Add(typeof(CountingWorkflow)); - var workflows = await provider.GetWorkflowsAsync(); - - Assert.Single(workflows); - Assert.Equal(1, CountingWorkflow.CreatedCount); - } - - [Fact] - public void ShellWorkflowsAdd_RegistersWorkflowTypeAlias() - { - var workflowType = typeof(GenericWorkflow); - var options = new ExpressionOptions(); - - _shellFeature.Workflows.Add(workflowType); - - RegisterWorkflowTypeAliases(_shellFeature, options); - - Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]); - } - - [Fact] - public void WorkflowsAdd_Throws_WhenTypeDoesNotImplementWorkflow() - { - Assert.Throws(() => _feature.Workflows.Add(typeof(NotAWorkflow))); - } - - [Fact] - public void RuntimeFeature_DependsOnWorkflowsFeature() - { - var dependencyTypes = typeof(RuntimeFeature) - .GetCustomAttributes() - .Select(x => x.Type); - - Assert.Contains(typeof(WorkflowsFeature), dependencyTypes); - } - - [Fact] - public void RegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes() - { - var workflowType = typeof(GenericWorkflow); - var options = new ExpressionOptions(); - _feature.AddWorkflow(workflowType); - _feature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask(new GenericWorkflow()); - - RegisterWorkflowTypeAliases(_feature, options); - - Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]); - Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys); - } - - [Fact] - public void ShellRegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes() - { - var workflowType = typeof(GenericWorkflow); - var options = new ExpressionOptions(); - _shellFeature.AddWorkflow(workflowType); - _shellFeature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask(new GenericWorkflow()); - - RegisterWorkflowTypeAliases(_shellFeature, options); - - Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]); - Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys); - } - - public static TheoryData NonInstantiableWorkflowTypes() => new() - { - typeof(IWorkflow), - typeof(WorkflowBase), - typeof(GenericWorkflow<>) - }; - - private sealed class NotAWorkflow - { - } - - private sealed class GenericWorkflow : IWorkflow - { - public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default) - { - return ValueTask.CompletedTask; - } - } - - public sealed class CountingWorkflow : IWorkflow - { - public static int CreatedCount { get; set; } - - public CountingWorkflow() - { - CreatedCount++; - } - - public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default) - { - return ValueTask.CompletedTask; - } - } - - private static void RegisterWorkflowTypeAliases(object feature, ExpressionOptions options) - { - feature.GetType() - .GetMethod("RegisterWorkflowTypeAliases", BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(feature, new object[] { options }); - } -}