From 51d8c9977553ea5d69dd4de3e85b780d2eeba107 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 Aug 2025 14:13:38 +0200 Subject: [PATCH 01/21] Update workflow `base_version` to `3.5.1` and adjust branch reference for patch release --- .github/workflows/packages.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index ec55bf739..e38eb1c68 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -15,7 +15,7 @@ on: release: types: [ prereleased, published ] env: - base_version: '3.5.0' + base_version: '3.5.1' feedz_feed_source: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json' nuget_feed_source: 'https://api.nuget.org/v3/index.json' @@ -48,7 +48,7 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/develop/3.5.0 + git branch --remote --contains | grep origin/patch/3.5.1 else git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/${BRANCH_NAME} From 28876da5e80f215de84e309040328d4653d35a9b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 Aug 2025 14:22:55 +0200 Subject: [PATCH 02/21] Update workflow `packages.yml` to use `preview` as a version tag prefix --- .github/workflows/packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index e38eb1c68..9458a3d0a 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -60,7 +60,7 @@ jobs: TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV else - echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV + echo "VERSION=${{env.base_version}}-preview.${{github.run_number}}" >> $GITHUB_ENV fi # - name: Set up JDK 17 # uses: actions/setup-java@v2 From c54f60ff0735e5ac6948ceff3fb3b9c03792e399 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 23 Aug 2025 15:51:01 +0200 Subject: [PATCH 03/21] Update workflows to target `patch/3.5.1` branch and remove `preview` from Docker tag names --- .github/workflows/elsa-server-and-studio.yml | 4 ++-- .github/workflows/elsa-server.yml | 4 ++-- .github/workflows/elsa-studio.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml index ba0db7875..848d269ef 100644 --- a/.github/workflows/elsa-server-and-studio.yml +++ b/.github/workflows/elsa-server-and-studio.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - patch/3.5.1 jobs: push_to_registry: @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-and-studio-v3-5-0-preview + elsaworkflows/elsa-server-and-studio-v3-5-0 flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-server.yml b/.github/workflows/elsa-server.yml index 6dfe94700..a6f1d8091 100644 --- a/.github/workflows/elsa-server.yml +++ b/.github/workflows/elsa-server.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - patch/3.5.1 jobs: push_to_registry: @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-v3-5-0-preview + elsaworkflows/elsa-server-v3-5-0 flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index d3c8ac075..c050b33e7 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - main + - patch/3.5.1 jobs: push_to_registry: @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-studio-v3-5-0-preview + elsaworkflows/elsa-studio-v3-5-0 flavor: | latest=true # generate Docker tags based on the following events/attributes From 8645493b043d20f50026ce41dae4c6aef348b512 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 25 Aug 2025 09:55:27 +0200 Subject: [PATCH 04/21] Introduces modular logging framework (#6872) * Initial implementation of log activity + base sink * Refactor logging implementation: replace `Elsa.ProcessLogging` with a new modular `Elsa.Logging` framework, introducing support for configurable log sinks, enhanced logging extensibility, and updated dependencies in consuming projects. * Enhance logging framework: introduce custom `NullableBoolConverter` and update JSON serialization/deserialization logic for log sink handling. * Update description for `Log` activity input: clarify target sinks configuration * Set default value of `SinkNames` input in `Log` activity to non-nullable collection * Set `DisplayName` for `Sinks` input in `Log` activity * Refactor logging framework: update `ILogSink` and `ILogSinkRouter` to support arguments and attributes, enhance `Log` activity to use updated interfaces, and add default category handling. * Refactor logging framework: simplify argument handling in `ILogSink` and `ILogSinkRouter`, update `Log` activity inputs, and improve message formatting in `MelLogSink`. * Update logging framework to simplify log sink creation, enhance category filtering, and refactor `ILogSink`/`ILogSinkRouter` interface methods. * Introduce modular logging framework enhancements: add `Console` and `Serilog` logging features, refactor `ILogSink` framework, and update projects to align with a modular architecture. * Refactor logging framework: introduce `AddCategoryFilters` extension, replace `DefaultCategory` handling with enhanced category filters, and update sink creation logic for consistency. * Refactor logging framework: rename `SinkOptions` to `LogSinkOptions`, standardize naming across log sink types, and update configuration and sink factory logic for consistency. * Enhance logging framework: add `ConfigureDefaults` methods, update `ILogSinkCatalog` to use `IServiceScopeFactory`, and improve logging configuration handling and defaults setup. * Introduce asynchronous log entry processing: add `ILogEntryQueue`, `LogEntryBackgroundWorker`, and related models to enable queue-based logging and background processing. Update `Log` activity to enqueue log entries for processing. * Add unit and integration tests for `Elsa.Logging.Core` library, refactor logger setup in `Elsa.Server.Web`, enhance logging configuration, and standardize `Directory.Packages.props` file. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add documentation comments to logging framework classes, interfaces, methods, and factories to enhance code readability and maintainability. Remove unused `CustomPurpleConsoleFormatter` class and `logs` folder from server project. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Make `LogEntryInstruction` and `LogEntryQueue` classes public and simplify return statement in `LogSinkCatalog.ListAsync` method. * Standardize terminology in `ILogSink` interface and `LoggerSink` implementation: rename `properties` to `attributes`. Update project files and solution structure to reflect integration test additions. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update documentation comments in `LoggingFeature` and `LogEntryInstruction` to clarify functionality and improve precision. * Add README for `Elsa.Logging` module with configuration examples, usage details, and extension guidance. * Add `Dictionary` UI hint to `InputUIHints` and update `Attributes` in `Log` activity to use it. * Update `Log` activity default category to "Process", add integration tests for logging, and enhance null safety in `ConfigurationLogSinkProvider`. * Remove `UseLoggingFramework` middleware from `Program.cs` to streamline workflow initialization. --------- Co-authored-by: lucas.hipolito Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Directory.Packages.props | 349 +++++++++--------- Elsa.sln | 50 ++- .../Elsa.Server.Web/Elsa.Server.Web.csproj | 7 + src/apps/Elsa.Server.Web/Program.cs | 59 ++- src/apps/Elsa.Server.Web/appsettings.json | 48 +++ .../Elsa.ServerAndStudio.Web.csproj | 1 + src/apps/Elsa.ServerAndStudio.Web/Program.cs | 1 + .../RunActivityExtensions.cs | 9 +- .../ConsoleLogSinkFactory.cs | 66 ++++ .../ConsoleLogSinkOptions.cs | 27 ++ .../ConsoleLoggingFeature.cs | 23 ++ .../Elsa.Logging.Console.csproj | 15 + .../Elsa.Logging.Console/FodyWeavers.xml | 3 + .../LoggingFeatureExtensions.cs | 20 + .../Contracts/ILogEntryQueue.cs | 29 ++ .../Elsa.Logging.Core/Contracts/ILogSink.cs | 27 ++ .../Contracts/ILogSinkCatalog.cs | 27 ++ .../Contracts/ILogSinkFactory.cs | 21 ++ .../Contracts/ILogSinkProvider.cs | 10 + .../Contracts/ILogSinkRouter.cs | 18 + .../Elsa.Logging.Core.csproj | 20 + .../LogSinkServiceCollectionExtensions.cs | 20 + .../Extensions/LoggingBuilderExtensions.cs | 36 ++ src/modules/Elsa.Logging.Core/FodyWeavers.xml | 3 + .../LogEntryBackgroundWorker.cs | 30 ++ .../Models/LogEntryInstruction.cs | 16 + .../Models/LogSinkEnvelope.cs | 14 + .../Models/LogSinkOptions.cs | 12 + .../Options/LoggingOptions.cs | 19 + .../Providers/ConfigurationLogSinkProvider.cs | 74 ++++ .../Providers/StaticLogSinkProvider.cs | 17 + .../Serialization/NullableBoolConverter.cs | 44 +++ .../Services/LogEntryQueue.cs | 25 ++ .../Services/LogSinkCatalog.cs | 36 ++ .../Services/LogSinkRouter.cs | 39 ++ .../Elsa.Logging.Core/Sinks/LoggerSink.cs | 55 +++ .../Elsa.Logging.Serilog.csproj | 22 ++ .../Elsa.Logging.Serilog/FodyWeavers.xml | 3 + .../LoggingFeatureExtensions.cs | 22 ++ .../SerilogLogSinkFactory.cs | 54 +++ .../SerilogLogSinkOptions.cs | 15 + .../SerilogLoggingFeature.cs | 22 ++ src/modules/Elsa.Logging/Activities/Log.cs | 115 ++++++ src/modules/Elsa.Logging/Elsa.Logging.csproj | 16 + .../Extensions/ModuleExtensions.cs | 16 + .../Elsa.Logging/Features/LoggingFeature.cs | 59 +++ src/modules/Elsa.Logging/FodyWeavers.xml | 3 + src/modules/Elsa.Logging/README.md | 141 +++++++ .../UI/LogSinkCheckListUIHintHandler.cs | 18 + .../Elsa.OpenTelemetry.csproj | 2 +- .../UIHints/InputUIHints.cs | 1 + test/Directory.Build.props | 41 +- .../Elsa.Logging.Core.IntegrationTests.csproj | 10 + .../LogActivityTests.cs | 60 +++ .../LogSinkRouterTests.cs | 56 +++ .../Elsa.Logging.Core.UnitTests.csproj | 7 + .../LogEntryQueueTests.cs | 27 ++ .../LogSinkRouterTests.cs | 49 +++ .../LoggerSinkTests.cs | 25 ++ 59 files changed, 1853 insertions(+), 201 deletions(-) create mode 100644 src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs create mode 100644 src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs create mode 100644 src/modules/Elsa.Logging.Console/ConsoleLoggingFeature.cs create mode 100644 src/modules/Elsa.Logging.Console/Elsa.Logging.Console.csproj create mode 100644 src/modules/Elsa.Logging.Console/FodyWeavers.xml create mode 100644 src/modules/Elsa.Logging.Console/LoggingFeatureExtensions.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogEntryQueue.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogSink.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogSinkCatalog.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogSinkFactory.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogSinkProvider.cs create mode 100644 src/modules/Elsa.Logging.Core/Contracts/ILogSinkRouter.cs create mode 100644 src/modules/Elsa.Logging.Core/Elsa.Logging.Core.csproj create mode 100644 src/modules/Elsa.Logging.Core/Extensions/LogSinkServiceCollectionExtensions.cs create mode 100644 src/modules/Elsa.Logging.Core/Extensions/LoggingBuilderExtensions.cs create mode 100644 src/modules/Elsa.Logging.Core/FodyWeavers.xml create mode 100644 src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs create mode 100644 src/modules/Elsa.Logging.Core/Models/LogEntryInstruction.cs create mode 100644 src/modules/Elsa.Logging.Core/Models/LogSinkEnvelope.cs create mode 100644 src/modules/Elsa.Logging.Core/Models/LogSinkOptions.cs create mode 100644 src/modules/Elsa.Logging.Core/Options/LoggingOptions.cs create mode 100644 src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs create mode 100644 src/modules/Elsa.Logging.Core/Providers/StaticLogSinkProvider.cs create mode 100644 src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs create mode 100644 src/modules/Elsa.Logging.Core/Services/LogEntryQueue.cs create mode 100644 src/modules/Elsa.Logging.Core/Services/LogSinkCatalog.cs create mode 100644 src/modules/Elsa.Logging.Core/Services/LogSinkRouter.cs create mode 100644 src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs create mode 100644 src/modules/Elsa.Logging.Serilog/Elsa.Logging.Serilog.csproj create mode 100644 src/modules/Elsa.Logging.Serilog/FodyWeavers.xml create mode 100644 src/modules/Elsa.Logging.Serilog/LoggingFeatureExtensions.cs create mode 100644 src/modules/Elsa.Logging.Serilog/SerilogLogSinkFactory.cs create mode 100644 src/modules/Elsa.Logging.Serilog/SerilogLogSinkOptions.cs create mode 100644 src/modules/Elsa.Logging.Serilog/SerilogLoggingFeature.cs create mode 100644 src/modules/Elsa.Logging/Activities/Log.cs create mode 100644 src/modules/Elsa.Logging/Elsa.Logging.csproj create mode 100644 src/modules/Elsa.Logging/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Logging/Features/LoggingFeature.cs create mode 100644 src/modules/Elsa.Logging/FodyWeavers.xml create mode 100644 src/modules/Elsa.Logging/README.md create mode 100644 src/modules/Elsa.Logging/UI/LogSinkCheckListUIHintHandler.cs create mode 100644 test/integration/Elsa.Logging.Core.IntegrationTests/Elsa.Logging.Core.IntegrationTests.csproj create mode 100644 test/integration/Elsa.Logging.Core.IntegrationTests/LogActivityTests.cs create mode 100644 test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/Elsa.Logging.Core.UnitTests.csproj create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/LogEntryQueueTests.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/LogSinkRouterTests.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8395c3aae..6af29175e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,174 +1,179 @@ - - true - true - - - 3.5.0 - 9.0.8 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + + + 3.5.0 + 9.0.8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index 29feedd62..5a0c4d2ae 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -94,6 +94,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}" + ProjectSection(SolutionItems) = preProject + test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}" ProjectSection(SolutionItems) = preProject @@ -416,6 +419,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Compression", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Http", "src\modules\Elsa.IO.Http\Elsa.IO.Http.csproj", "{C583AF05-D517-4B7F-8955-6B61500ED3D8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics", "{1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging", "src\modules\Elsa.Logging\Elsa.Logging.csproj", "{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Console", "src\modules\Elsa.Logging.Console\Elsa.Logging.Console.csproj", "{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Serilog", "src\modules\Elsa.Logging.Serilog\Elsa.Logging.Serilog.csproj", "{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core", "src\modules\Elsa.Logging.Core\Elsa.Logging.Core.csproj", "{48A85A19-B654-4570-B332-653BC0B6A846}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.IntegrationTests", "test\integration\Elsa.Logging.Core.IntegrationTests\Elsa.Logging.Core.IntegrationTests.csproj", "{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.UnitTests", "test\unit\Elsa.Logging.Core.UnitTests\Elsa.Logging.Core.UnitTests.csproj", "{4229B9B3-60D3-4CFE-B147-B3865212C6C8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -864,6 +881,30 @@ Global {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.Build.0 = Release|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.Build.0 = Release|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.Build.0 = Release|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.Build.0 = Release|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.Build.0 = Release|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.Build.0 = Release|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -967,7 +1008,6 @@ Global {690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {060FD0BA-BD78-48E1-A8A7-4906A5AD5E39} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {169A82A5-2DB3-40EA-801E-14C08D743DF7} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} - {2CDF3E1C-267D-4198-B1C7-7E1F548FC120} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {01B96BB9-35E8-4364-ACB8-6D12A14D8DBA} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {47FBCB04-0C2D-453C-BE2F-7052CAC22524} = {EB3A7401-0DE3-476F-9E6F-057F1F4590FB} {B32DB9B2-AD6C-48A5-8682-4373CB045185} = {C80C8231-D35C-4ACC-9ED6-9F3DB221535E} @@ -1017,6 +1057,14 @@ Global {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} {C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {68A0BC44-8A3E-4C45-8AFF-0662B81D2739} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {2CDF3E1C-267D-4198-B1C7-7E1F548FC120} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {2CE3BD1E-0966-47DF-B870-6A4EB7EA0188} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {48A85A19-B654-4570-B332-653BC0B6A846} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} + {A5C87AAF-E607-4DA7-B2E1-08FEAA41B293} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {4229B9B3-60D3-4CFE-B147-B3865212C6C8} = {18453B51-25EB-4317-A4B3-B10518252E92} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 09c85bc09..63c2383d4 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -8,8 +8,11 @@ + + + @@ -74,6 +77,10 @@ + + + + diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index c97a88c76..b973b255d 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -21,6 +21,8 @@ using Elsa.Features.Services; using Elsa.Identity.Multitenancy; using Elsa.Kafka; using Elsa.Kafka.Factories; +using Elsa.Logging.Extensions; +using Elsa.Logging.Sinks; using Elsa.MassTransit.Extensions; using Elsa.MongoDb.Extensions; using Elsa.MongoDb.Modules.Alterations; @@ -67,6 +69,8 @@ using JetBrains.Annotations; using Medallion.Threading.FileSystem; using Medallion.Threading.Postgres; using Medallion.Threading.Redis; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -76,6 +80,8 @@ using Proto.Persistence.Sqlite; using Proto.Persistence.SqlServer; using Proto.Remote; using Proto.Remote.GrpcNet; +using Serilog; +using Serilog.Formatting.Compact; using StackExchange.Redis; // ReSharper disable RedundantAssignment @@ -237,7 +243,7 @@ services identity.UseEntityFrameworkCore(ef => { ef.UseContextPooling = useDbContextPooling; - + if (sqlDatabaseProvider == SqlDatabaseProvider.SqlServer) ef.UseSqlServer(sqlServerConnectionString); else if (sqlDatabaseProvider == SqlDatabaseProvider.PostgreSql) @@ -532,6 +538,7 @@ services } }) .UseOpenTelemetry(otel => otel.UseNewRootActivityForRemoteParent = true) + .UseLoggingFramework() .UseWorkflowContexts(); if (useQuartz) @@ -737,6 +744,56 @@ services }); } + // 1) Console target via built-in provider + var consoleLogger = LoggerFactory.Create(lb => + { + lb.ClearProviders(); + lb.AddConsole(); + lb.AddFilter("Demo", LogLevel.Debug); + lb.SetMinimumLevel(LogLevel.Information); + }); + + // 2) Pretty File target via Serilog (text template) + var filePrettyFactory = LoggerFactory.Create(lb => + { + var serilogConfig = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File("App_Data/logs/activity-pretty-.log", + rollingInterval: RollingInterval.Day, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + lb.ClearProviders(); + lb.AddFilter("Demo", LogLevel.Debug); + lb.AddSerilog(serilogConfig, dispose: true); + }); + + // 3) JSON File target via Serilog (compact JSON) + var fileJsonFactory = LoggerFactory.Create(lb => + { + var serilogJson = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File(new CompactJsonFormatter(), "App_Data/logs/activity-json-.log", + rollingInterval: RollingInterval.Day) + .CreateLogger(); + + lb.ClearProviders(); + lb.AddSerilog(serilogJson, dispose: true); + }); + + elsa.UseLoggingFramework(logging => + { + // Get sinks from configuration. + logging.UseConsole(); + logging.UseSerilog(); + logging.ConfigureDefaults(options => configuration.GetSection("LoggingFramework").Bind(options)); + + // Add sinks manually. + logging.AddLogSink(new LoggerSink("Console (via code)", consoleLogger)); + logging.AddLogSink(new LoggerSink("File (pretty)", filePrettyFactory)); + logging.AddLogSink(new LoggerSink("File (JSON)", fileJsonFactory)); + }); + elsa.UseWebhooks(webhooks => webhooks.ConfigureSinks += options => builder.Configuration.GetSection("Webhooks").Bind(options)); elsa.InstallDropIns(options => options.DropInRootDirectory = Path.Combine(Directory.GetCurrentDirectory(), "App_Data", "DropIns")); elsa.AddSwagger(); diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index bf9d5c532..d9a5a15be 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -24,6 +24,54 @@ "Redis": "localhost:6379,abortConnect=false" }, "DatabaseProvider": "Sqlite", + "LoggingFramework": { + "Defaults": ["Console", "FilePretty", "FileJson"], + "Sinks": [ + { + "Type": "Console", + "Name": "Console", + "Options": { + "MinLevel": "Information", + "CategoryFilters": { + "Process": "Information", + "Process.Nested": "Debug", + "Process.Nested.Inner": "Information" + }, + "Formatter": "Default", + "TimestampFormat": "HH:mm:ss ", + "DisableColors": true + } + }, + { + "Type": "Console", + "Name": "ConsoleSystemd", + "Options": { + "MinLevel": "Information", + "Formatter": "Systemd" + } + }, + { + "Type": "Serilog", + "Name": "FilePretty", + "Options": { + "Path": "App_Data/logs/activity-pretty-.log", + "RollingInterval": "Day", + "Template": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", + "MinLevel": "Information" + } + }, + { + "Type": "Serilog", + "Name": "FileJson", + "Options": { + "Path": "App_Data/logs/activity-json-.log", + "RollingInterval": "Day", + "Formatter": "CompactJson", + "MinLevel": "Debug" + } + } + ] + }, "Multitenancy": { "Tenants": [ { diff --git a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 707e9f6a5..82a4b1017 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -36,6 +36,7 @@ + diff --git a/src/apps/Elsa.ServerAndStudio.Web/Program.cs b/src/apps/Elsa.ServerAndStudio.Web/Program.cs index 2987cf105..2c3483e31 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Program.cs +++ b/src/apps/Elsa.ServerAndStudio.Web/Program.cs @@ -14,6 +14,7 @@ using Microsoft.Data.Sqlite; using WebhooksCore.Options; using Elsa.Connections.Middleware; using Elsa.IO.Http.Features; +using Elsa.Logging.Extensions; using Proto.Persistence.Sqlite; const bool useMassTransit = true; diff --git a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs index 52bc601bb..4efb894ef 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs @@ -1,14 +1,6 @@ -using Elsa.Common.Models; using Elsa.Workflows; -using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Entities; -using Elsa.Workflows.Management.Models; using Elsa.Workflows.Models; using Elsa.Workflows.Options; -using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; -using Elsa.Workflows.Runtime.Messages; -using Elsa.Workflows.State; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -45,6 +37,7 @@ public static class RunActivityExtensions /// The result of running the activity. public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default) { + await services.PopulateRegistriesAsync(); var workflowRunner = services.GetRequiredService(); var result = await workflowRunner.RunAsync(activity, options, cancellationToken); return result; diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs new file mode 100644 index 000000000..78e816737 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs @@ -0,0 +1,66 @@ +using Elsa.Logging.Contracts; +using Elsa.Logging.Extensions; +using Elsa.Logging.Sinks; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Console; + +/// +/// A log sink factory implementation for creating console-based log sinks. +/// +/// +/// This factory is responsible for setting up and configuring console-based log sinks using +/// the provided. It supports multiple formatter configurations +/// like "simple", "systemd", or default console logging. It also allows customization of aspects +/// such as timestamp format, color behavior, and log level. +/// +/// +public sealed class ConsoleLogSinkFactory : ILogSinkFactory +{ + /// + public string Type => "Console"; + + /// + public ILogSink Create(string name, ConsoleLogSinkOptions options) + { + var factory = LoggerFactory.Create(builder => + { + builder.ClearProviders(); + builder.AddCategoryFilters(options); + + var min = options.MinLevel ?? LogLevel.Information; + + switch (options.Formatter.ToLowerInvariant()) + { + case "simple": + builder.AddSimpleConsole(o => + { + if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat; + if (options.ColorBehavior is not null) o.ColorBehavior = options.ColorBehavior.Value; + if (options.SingleLine is not null) o.SingleLine = options.SingleLine.Value; + if (options.UseUtcTimestamp is not null) o.UseUtcTimestamp = options.UseUtcTimestamp.Value; + if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value; + }); + break; + case "systemd": + builder.AddSystemdConsole(o => + { + if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat; + if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value; + }); + break; + default: + builder.AddConsole(o => + { + if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat; + if (options.DisableColors is not null) o.DisableColors = options.DisableColors.Value; + if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value; + }); + break; + } + + builder.SetMinimumLevel(min); + }); + return new LoggerSink(name, factory); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs new file mode 100644 index 000000000..57002fed7 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs @@ -0,0 +1,27 @@ +using Elsa.Logging.Models; +using Microsoft.Extensions.Logging.Console; + +namespace Elsa.Logging.Console; + +/// +/// Represents configuration options for a console-based log sink. +/// +/// +/// Provides properties to configure the appearance and behavior of log output when using a console sink. +/// Includes support for various log formatter types, timestamp customization, and scope inclusion settings. +/// +public sealed record ConsoleLogSinkOptions : LogSinkOptions +{ + // "Default" | "Simple" | "Systemd" + public string Formatter { get; init; } = "Default"; + public string? TimestampFormat { get; init; } + public bool? IncludeScopes { get; init; } + + // Default console + public bool? DisableColors { get; init; } + + // Simple console + public LoggerColorBehavior? ColorBehavior { get; init; } + public bool? SingleLine { get; init; } + public bool? UseUtcTimestamp { get; init; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/ConsoleLoggingFeature.cs b/src/modules/Elsa.Logging.Console/ConsoleLoggingFeature.cs new file mode 100644 index 000000000..884878fa7 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/ConsoleLoggingFeature.cs @@ -0,0 +1,23 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Logging.Console; +using Elsa.Logging.Contracts; +using Elsa.Logging.Features; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable once CheckNamespace +namespace Elsa.Logging; + +/// +/// A feature that installs Console logging services. +/// +[DependsOn(typeof(LoggingFeature))] +public class ConsoleLoggingFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/Elsa.Logging.Console.csproj b/src/modules/Elsa.Logging.Console/Elsa.Logging.Console.csproj new file mode 100644 index 000000000..c5122687e --- /dev/null +++ b/src/modules/Elsa.Logging.Console/Elsa.Logging.Console.csproj @@ -0,0 +1,15 @@ + + + + + Provides Console logging integration with the Elsa Logging Framework. + + elsa module logging diagnostics console + + + + + + + + diff --git a/src/modules/Elsa.Logging.Console/FodyWeavers.xml b/src/modules/Elsa.Logging.Console/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Logging.Console/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/LoggingFeatureExtensions.cs b/src/modules/Elsa.Logging.Console/LoggingFeatureExtensions.cs new file mode 100644 index 000000000..b0e770f56 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/LoggingFeatureExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Logging.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Logging.Extensions; + +/// +/// Provides extension methods for enhancing the functionality of the . +/// +public static class LoggingFeatureExtensions +{ + /// + /// Installs the Console logging feature. + /// + public static IModule UseConsole(this LoggingFeature feature, Action? configure = null) + { + return feature.Module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogEntryQueue.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogEntryQueue.cs new file mode 100644 index 000000000..6e389a2dc --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogEntryQueue.cs @@ -0,0 +1,29 @@ +using Elsa.Logging.Models; + +namespace Elsa.Logging.Contracts; + +/// +/// Represents a queue interface for managing log entry instructions. +/// Provides methods to enqueue log entry instructions and dequeue them asynchronously. +/// +public interface ILogEntryQueue +{ + /// + /// Enqueues a log entry instruction asynchronously into the log entry queue. + /// + /// + /// The log entry instruction to be added to the queue. This includes details like sink names, category, + /// log level, message, arguments, and attributes for structured logging. + /// + ValueTask EnqueueAsync(LogEntryInstruction instruction); + + /// + /// Retrieves log entry instructions asynchronously from the log entry queue. + /// + /// + /// An asynchronous enumerable sequence of log entry instructions. + /// Each instruction contains details such as sink names, category, log level, message, + /// arguments, and attributes for structured logging. + /// + IAsyncEnumerable DequeueAsync(); +} diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogSink.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogSink.cs new file mode 100644 index 000000000..84e0805ae --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogSink.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Contracts; + +/// +/// Defines a contract for a log sink, responsible for handling log messages. +/// Implementers of this interface can capture, process, and store log records based on their specific logic or requirements. +/// +public interface ILogSink +{ + /// + /// Gets the name of the log sink. + /// This property uniquely identifies the log sink and is typically used for routing or retrieving specific sinks. + /// + string Name { get; } + + /// + /// Writes a log entry asynchronously to a specific log sink. + /// + /// The name of the logger instance. + /// The severity level of the log entry. + /// The message to be logged. + /// Optional arguments related to the message. + /// Optional additional log attributes. + /// Cancellation token to observe while waiting for the task to complete. + ValueTask WriteAsync(string name, LogLevel level, string message, object? arguments, IDictionary? attributes = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogSinkCatalog.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkCatalog.cs new file mode 100644 index 000000000..785caee4c --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkCatalog.cs @@ -0,0 +1,27 @@ +namespace Elsa.Logging.Contracts; + +/// +/// Represents a catalog for managing log sinks in the logging system. +/// Provides functionality to list all available log sinks and retrieve a specific log sink by its identifier. +/// +public interface ILogSinkCatalog +{ + /// + /// Asynchronously retrieves a list of available log sinks. + /// + /// A token to monitor for cancellation requests. + /// + /// A collection of representing the available log sinks. + /// + Task> ListAsync(CancellationToken cancellationToken = default); + + /// + /// Asynchronously retrieves a specific log sink by its identifier. + /// + /// The identifier of the log sink to retrieve. + /// A token to monitor for cancellation requests. + /// + /// An instance of representing the log sink if found; otherwise, null. + /// + Task GetAsync(string id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogSinkFactory.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkFactory.cs new file mode 100644 index 000000000..c11ef9e0e --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkFactory.cs @@ -0,0 +1,21 @@ +using Elsa.Logging.Models; +using JetBrains.Annotations; + +namespace Elsa.Logging.Contracts; + +/// +/// Represents a factory interface for producing log sink implementations, which handle logging based on specific configuration or options. +/// +public interface ILogSinkFactory +{ + string Type { get; } +} + +/// +/// Defines a factory for creating instances of log sinks designed for specific logging options types. +/// +public interface ILogSinkFactory : ILogSinkFactory where TOptions : LogSinkOptions +{ + [UsedImplicitly] + ILogSink Create(string name, TOptions options); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogSinkProvider.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkProvider.cs new file mode 100644 index 000000000..a452b352e --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkProvider.cs @@ -0,0 +1,10 @@ +namespace Elsa.Logging.Contracts; + +/// +/// Represents a provider responsible for retrieving a collection of log sinks. +/// A log sink is an abstraction for a destination where log entries can be sent, such as a database or a file system. +/// +public interface ILogSinkProvider +{ + Task> GetLogSinksAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Contracts/ILogSinkRouter.cs b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkRouter.cs new file mode 100644 index 000000000..08c81ab65 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Contracts/ILogSinkRouter.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Contracts; + +/// +/// Defines a contract for routing log entries to specific log sinks based on provided criteria. +/// +public interface ILogSinkRouter +{ + ValueTask WriteAsync( + IEnumerable sinkNames, + string name, + LogLevel level, + string message, + object? arguments, + IDictionary? attributes = null, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Elsa.Logging.Core.csproj b/src/modules/Elsa.Logging.Core/Elsa.Logging.Core.csproj new file mode 100644 index 000000000..877c9cb0c --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Elsa.Logging.Core.csproj @@ -0,0 +1,20 @@ + + + + + Provides a logging framework to create log entries from workflows. + + elsa module logging diagnostics + Elsa.Logging + + + + + + + + + + + + diff --git a/src/modules/Elsa.Logging.Core/Extensions/LogSinkServiceCollectionExtensions.cs b/src/modules/Elsa.Logging.Core/Extensions/LogSinkServiceCollectionExtensions.cs new file mode 100644 index 000000000..261d3deb1 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Extensions/LogSinkServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.Logging.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Logging.Extensions; + +/// +/// Provides extension methods for registering log sink implementations within the dependency injection system. +/// +public static class LogSinkServiceCollectionExtensions +{ + /// + /// Registers a log sink implementation of type within the dependency injection system. + /// + /// The type of the log sink to register. Must implement . + /// The to add the log sink to. + public static IServiceCollection AddLogSink(this IServiceCollection services) where T : class, ILogSink + { + return services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Extensions/LoggingBuilderExtensions.cs b/src/modules/Elsa.Logging.Core/Extensions/LoggingBuilderExtensions.cs new file mode 100644 index 000000000..31e6a23b6 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Extensions/LoggingBuilderExtensions.cs @@ -0,0 +1,36 @@ +using Elsa.Logging.Models; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Extensions; + +/// +/// Provides extension methods for configuring logging in an . +/// +public static class LoggingBuilderExtensions +{ + /// + /// Adds category filters to the logging builder using the specified . + /// + /// The to configure. + /// The containing category filter definitions. + public static ILoggingBuilder AddCategoryFilters(this ILoggingBuilder builder, LogSinkOptions logSinkOptions) + { + return builder.AddCategoryFilters(logSinkOptions.CategoryFilters); + } + + /// + /// Adds category filters to the logging builder using the specified . + /// + /// The to configure. + /// The containing category filter definitions. + public static ILoggingBuilder AddCategoryFilters(this ILoggingBuilder builder, IDictionary? categoryFilters) + { + if (categoryFilters is null) + return builder; + + foreach (var filter in categoryFilters) + builder.AddFilter(filter.Key, filter.Value); + + return builder; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/FodyWeavers.xml b/src/modules/Elsa.Logging.Core/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Logging.Core/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs b/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs new file mode 100644 index 000000000..0e1df3f31 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs @@ -0,0 +1,30 @@ +using Elsa.Logging.Contracts; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Logging.HostedServices; + +/// +/// Represents a background worker responsible for processing log entries from a queue +/// and routing them to appropriate log sinks. +/// +[UsedImplicitly] +public class LogEntryBackgroundWorker(ILogEntryQueue queue, IServiceScopeFactory scopeFactory) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var instruction in queue.DequeueAsync().WithCancellation(stoppingToken)) + { + using var scope = scopeFactory.CreateScope(); + var router = scope.ServiceProvider.GetRequiredService(); + await router.WriteAsync( + instruction.SinkNames, + instruction.Category, + instruction.Level, + instruction.Message, + instruction.Arguments, + instruction.Attributes, stoppingToken); + } + } +} diff --git a/src/modules/Elsa.Logging.Core/Models/LogEntryInstruction.cs b/src/modules/Elsa.Logging.Core/Models/LogEntryInstruction.cs new file mode 100644 index 000000000..83cfa70cf --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Models/LogEntryInstruction.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Models; + +/// +/// Represents the instruction for a log entry to be added to a queue, including the details of the log message, its level, category, associated sinks, arguments, and additional attributes. +/// +public class LogEntryInstruction +{ + public ICollection SinkNames { get; init; } = new List(); + public string Category { get; init; } = null!; + public LogLevel Level { get; init; } = LogLevel.Information; + public string Message { get; init; } = string.Empty; + public object? Arguments { get; init; } + public IDictionary Attributes { get; init; } = new Dictionary(); +} diff --git a/src/modules/Elsa.Logging.Core/Models/LogSinkEnvelope.cs b/src/modules/Elsa.Logging.Core/Models/LogSinkEnvelope.cs new file mode 100644 index 000000000..0eef8a09e --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Models/LogSinkEnvelope.cs @@ -0,0 +1,14 @@ +using System.Text.Json; + +namespace Elsa.Logging.Models; + +/// +/// Represents an envelope for defining a log sink within the logging framework. +/// This class serves as a container for specifying the type, name, and options for configuring log sinks. +/// +public sealed class LogSinkEnvelope +{ + public string Type { get; set; } = null!; + public string Name { get; set; } = null!; + public JsonElement Options { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Models/LogSinkOptions.cs b/src/modules/Elsa.Logging.Core/Models/LogSinkOptions.cs new file mode 100644 index 000000000..19ce73bb6 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Models/LogSinkOptions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Models; + +/// +/// Represents a base configuration class for defining options specific to log sinks. +/// +public abstract record LogSinkOptions +{ + public LogLevel? MinLevel { get; init; } + public Dictionary? CategoryFilters { get; init; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Options/LoggingOptions.cs b/src/modules/Elsa.Logging.Core/Options/LoggingOptions.cs new file mode 100644 index 000000000..a155a1ae4 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Options/LoggingOptions.cs @@ -0,0 +1,19 @@ +using Elsa.Logging.Contracts; + +namespace Elsa.Logging.Options; + +/// +/// Represents configuration options for logging within the system. +/// +public class LoggingOptions +{ + /// + /// Default sinks. + /// + public HashSet Defaults { get; set; } = new(); + + /// + /// Sinks registered by the host. To register sinks from configuration, use the infrastructure. + /// + public ICollection Sinks { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs b/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs new file mode 100644 index 000000000..42108c70b --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Extensions; +using Elsa.Logging.Contracts; +using Elsa.Logging.Models; +using Elsa.Logging.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Providers; + +/// +/// Provides a log sink provider that retrieves log sinks based on configuration settings. +/// This implementation uses the application's configuration and a collection of log sink factories +/// to instantiate the required log sinks. +/// +public class ConfigurationLogSinkProvider : ILogSinkProvider +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = + { + new NullableBoolConverter(), + new JsonStringEnumConverter() + } + }; + + private readonly IConfiguration _configuration; + private readonly IDictionary _factories; + private readonly ILogger _logger; + + public ConfigurationLogSinkProvider(IConfiguration configuration, IEnumerable factories, ILogger logger) + { + _configuration = configuration; + _factories = factories.ToDictionary(x => x.Type); + _logger = logger; + } + + public Task> GetLogSinksAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetLogSinks()); + } + + private IEnumerable GetLogSinks() + { + var json = _configuration.GetSectionAsJson("LoggingFramework:Sinks"); + var specs = JsonSerializer.Deserialize>(json, _jsonSerializerOptions); + + if (specs == null) + return []; + + var builtSinks = new List(); + + foreach (var spec in specs) + { + var factoryType = spec.Type; + + if (!_factories.TryGetValue(factoryType, out var f)) + { + _logger.LogWarning("No factory found for type '{Type}'.", factoryType); + continue; + } + + var sinkOptionsType = f.GetType().GetInterfaces().First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILogSinkFactory<>)).GetGenericArguments()[0]; + var sinkOptions = spec.Options.Deserialize(sinkOptionsType, _jsonSerializerOptions); + var createMethod = f.GetType().GetMethod("Create", [typeof(string), sinkOptionsType])!; + var sink = (ILogSink)createMethod.Invoke(f, [spec.Name, sinkOptions])!; + builtSinks.Add(sink); + } + + return builtSinks; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Providers/StaticLogSinkProvider.cs b/src/modules/Elsa.Logging.Core/Providers/StaticLogSinkProvider.cs new file mode 100644 index 000000000..ca8949cba --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Providers/StaticLogSinkProvider.cs @@ -0,0 +1,17 @@ +using Elsa.Logging.Contracts; +using Elsa.Logging.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Logging.Providers; + +/// +/// Provides a static implementation of the interface. +/// This provider retrieves log sinks from a predefined configuration. +/// +public class StaticLogSinkProvider(IOptions options) : ILogSinkProvider +{ + public Task> GetLogSinksAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>(options.Value.Sinks); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs b/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs new file mode 100644 index 000000000..d875c6e29 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Logging.Serialization; + +/// +/// A custom JSON converter for nullable boolean values. +/// Provides functionality to serialize and deserialize nullable boolean values +/// in JSON, including support for the string representation of boolean values +/// ("true", "false") and handling of null cases. +/// +public class NullableBoolConverter : JsonConverter +{ + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + // Handle real boolean + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + // Handle string "true"/"false" + case JsonTokenType.String: + { + var value = reader.GetString(); + if (bool.TryParse(value, out var b)) + return b; + break; + } + } + + // Handle null + return reader.TokenType == JsonTokenType.Null ? null : throw new JsonException($"Cannot convert {reader.TokenType} to bool?"); + } + + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteBooleanValue(value.Value); + else + writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Services/LogEntryQueue.cs b/src/modules/Elsa.Logging.Core/Services/LogEntryQueue.cs new file mode 100644 index 000000000..6f2314405 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Services/LogEntryQueue.cs @@ -0,0 +1,25 @@ +using System.Threading.Channels; +using Elsa.Logging.Contracts; +using Elsa.Logging.Models; + +namespace Elsa.Logging.Services; + +/// +public class LogEntryQueue : ILogEntryQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + + /// + public async ValueTask EnqueueAsync(LogEntryInstruction instruction) + { + await _channel.Writer.WriteAsync(instruction); + } + + /// + public async IAsyncEnumerable DequeueAsync() + { + while (await _channel.Reader.WaitToReadAsync()) + while (_channel.Reader.TryRead(out var instruction)) + yield return instruction; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Services/LogSinkCatalog.cs b/src/modules/Elsa.Logging.Core/Services/LogSinkCatalog.cs new file mode 100644 index 000000000..195999cc2 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Services/LogSinkCatalog.cs @@ -0,0 +1,36 @@ +using Elsa.Logging.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Logging.Services; + +/// +public class LogSinkCatalog(IServiceScopeFactory scopeFactory) : ILogSinkCatalog +{ + private readonly Lazy>> _sinksLazy = new(() => LoadSinksAsync(scopeFactory), LazyThreadSafetyMode.ExecutionAndPublication); + + /// + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + return await _sinksLazy.Value; + } + + /// + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + var sinks = await _sinksLazy.Value; + return sinks.FirstOrDefault(sink => sink.Name == name); + } + + private static async Task> LoadSinksAsync(IServiceScopeFactory scopeFactory, CancellationToken cancellationToken = default) + { + using var scope = scopeFactory.CreateScope(); + var providers = scope.ServiceProvider.GetServices(); + var allSinks = new List(); + foreach (var provider in providers) + { + var sinks = await provider.GetLogSinksAsync(cancellationToken); + allSinks.AddRange(sinks); + } + return allSinks; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Services/LogSinkRouter.cs b/src/modules/Elsa.Logging.Core/Services/LogSinkRouter.cs new file mode 100644 index 000000000..70b9ebca8 --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Services/LogSinkRouter.cs @@ -0,0 +1,39 @@ +using Elsa.Logging.Contracts; +using Elsa.Logging.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Logging.Services; + +/// +public sealed class LogSinkRouter : ILogSinkRouter +{ + private readonly ILogSinkCatalog _catalog; + private readonly Lazy>> _lazyTargets; + private readonly string[] _defaults; + + public LogSinkRouter(ILogSinkCatalog catalog, IOptions options) + { + _catalog = catalog; + _lazyTargets = new(GetLogSinksAsync, LazyThreadSafetyMode.ExecutionAndPublication); + _defaults = options.Value.Defaults.ToArray(); + } + + public async ValueTask WriteAsync(IEnumerable sinkNames, string name, LogLevel level, string message, object? arguments, IDictionary? attributes = null, CancellationToken cancellationToken = default) + { + var targetNamesArray = sinkNames as string[] ?? sinkNames.ToArray(); + var names = targetNamesArray.Any() ? targetNamesArray : _defaults; + var uniqueNames = new HashSet(names, StringComparer.OrdinalIgnoreCase); + var targets = await _lazyTargets.Value; + + foreach (var n in uniqueNames) + if (targets.TryGetValue(n, out var t)) + await t.WriteAsync(name, level, message, arguments, attributes, cancellationToken); + } + + private async Task> GetLogSinksAsync() + { + var sinks = await _catalog.ListAsync(); + return sinks.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs new file mode 100644 index 000000000..b97b4b15d --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs @@ -0,0 +1,55 @@ +using Elsa.Logging.Contracts; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Sinks; + +/// +/// A Microsoft.Extensions.Logging (MEL) sink, built from a private LoggerFactory configured with any providers. +/// +public sealed class LoggerSink(string name, ILoggerFactory factory) : ILogSink +{ + /// + public string Name { get; } = name; + + /// + public ValueTask WriteAsync(string name, LogLevel level, string message, object? arguments, IDictionary? attributes = null, CancellationToken cancellationToken = default) + { + var logger = factory.CreateLogger(name); + + if (!logger.IsEnabled(level)) + return ValueTask.CompletedTask; + + using var scope = attributes is null ? null : logger.BeginScope(attributes); + logger.Log(level, 0, arguments, null, (state, ex) => FormatMessage(message, state)); + return ValueTask.CompletedTask; + } + + private static string FormatMessage(string message, object? state) + { + // If the state is an array, use string.Format. + if (state is object[] { Length: > 0 } args) + return string.Format(message, args); + + // Otherwise, use string interpolation. No need to use StringBuilder here, since the message is not expected to be long. + var formattedMessage = message; + + // If the state is a dictionary, use string interpolation. + if (state is IDictionary dict) + { + foreach (var kvp in dict) + formattedMessage = formattedMessage.Replace($"{{{kvp.Key}}}", kvp.Value?.ToString()); + } + // Otherwise, use reflection to find properties on the state object. + else if (state is not null) + { + var props = state.GetType().GetProperties(); + foreach (var prop in props) + { + var value = prop.GetValue(state); + formattedMessage = formattedMessage.Replace($"{{{prop.Name}}}", value?.ToString()); + } + } + + return formattedMessage; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Serilog/Elsa.Logging.Serilog.csproj b/src/modules/Elsa.Logging.Serilog/Elsa.Logging.Serilog.csproj new file mode 100644 index 000000000..fe97d7ef0 --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/Elsa.Logging.Serilog.csproj @@ -0,0 +1,22 @@ + + + + + Provides Serilog logging integration with the Elsa Logging Framework. + + elsa module logging diagnostics serilog + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Logging.Serilog/FodyWeavers.xml b/src/modules/Elsa.Logging.Serilog/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Serilog/LoggingFeatureExtensions.cs b/src/modules/Elsa.Logging.Serilog/LoggingFeatureExtensions.cs new file mode 100644 index 000000000..4fe0f74a4 --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/LoggingFeatureExtensions.cs @@ -0,0 +1,22 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Logging.Features; +using Elsa.Logging.Serilog; + +// ReSharper disable once CheckNamespace +namespace Elsa.Logging.Extensions; + +/// +/// Provides extension methods for configuring logging features within the Elsa framework, +/// including support for Serilog logging. +/// +public static class LoggingFeatureExtensions +{ + /// + /// Installs the Serilog logging feature. + /// + public static IModule UseSerilog(this LoggingFeature feature, Action? configure = null) + { + return feature.Module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Serilog/SerilogLogSinkFactory.cs b/src/modules/Elsa.Logging.Serilog/SerilogLogSinkFactory.cs new file mode 100644 index 000000000..f645477c9 --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/SerilogLogSinkFactory.cs @@ -0,0 +1,54 @@ +using Elsa.Logging.Contracts; +using Elsa.Logging.Extensions; +using Elsa.Logging.Sinks; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Formatting.Compact; + +namespace Elsa.Logging.Serilog; + +/// +/// A factory for creating instances of log sinks configured for use with Serilog. This class implements +/// the interface and provides the capability to create Serilog-based +/// logging instances based on the provided options. +/// +public sealed class SerilogLogSinkFactory : ILogSinkFactory +{ + /// + public string Type => "Serilog"; + + /// + public ILogSink Create(string name, SerilogLogSinkOptions options) + { + var cfg = new LoggerConfiguration().MinimumLevel.Verbose(); + + if (string.Equals(options.Formatter, "CompactJson", StringComparison.OrdinalIgnoreCase)) + { + cfg = cfg.WriteTo.File(new CompactJsonFormatter(), options.Path, + rollingInterval: MapRolling(options.RollingInterval), + retainedFileCountLimit: options.RetentionCount); + } + else + { + cfg = cfg.WriteTo.File(options.Path, + rollingInterval: MapRolling(options.RollingInterval), + retainedFileCountLimit: options.RetentionCount, + outputTemplate: string.IsNullOrWhiteSpace(options.Template) + ? "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + : options.Template); + } + + var serilog = cfg.CreateLogger(); + var factory = LoggerFactory.Create(lb => + { + lb.ClearProviders(); + lb.AddSerilog(serilog, dispose: true); + lb.AddCategoryFilters(options); + lb.SetMinimumLevel(options.MinLevel ?? LogLevel.Information); + }); + + return new LoggerSink(name, factory); + } + + static RollingInterval MapRolling(string? v) => Enum.TryParse(v, true, out var ri) ? ri : RollingInterval.Day; +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Serilog/SerilogLogSinkOptions.cs b/src/modules/Elsa.Logging.Serilog/SerilogLogSinkOptions.cs new file mode 100644 index 000000000..4ef7c99ff --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/SerilogLogSinkOptions.cs @@ -0,0 +1,15 @@ +using Elsa.Logging.Models; + +namespace Elsa.Logging.Serilog; + +/// +/// Provides configuration options for a Serilog-based log sink. +/// +public sealed record SerilogLogSinkOptions : LogSinkOptions +{ + public string Path { get; init; } = ""; + public string RollingInterval { get; init; } = "Day"; + public int? RetentionCount { get; init; } + public string? Template { get; init; } + public string? Formatter { get; init; } // "CompactJson" | null => text +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Serilog/SerilogLoggingFeature.cs b/src/modules/Elsa.Logging.Serilog/SerilogLoggingFeature.cs new file mode 100644 index 000000000..2f250279f --- /dev/null +++ b/src/modules/Elsa.Logging.Serilog/SerilogLoggingFeature.cs @@ -0,0 +1,22 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Logging.Contracts; +using Elsa.Logging.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Logging.Serilog; + +/// +/// A feature that installs Serilog logging services for Elsa. +/// +[DependsOn(typeof(LoggingFeature))] +public class SerilogLoggingFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services + .AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging/Activities/Log.cs b/src/modules/Elsa.Logging/Activities/Log.cs new file mode 100644 index 000000000..5728441ba --- /dev/null +++ b/src/modules/Elsa.Logging/Activities/Log.cs @@ -0,0 +1,115 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using Elsa.Extensions; +using Elsa.Logging.Contracts; +using Elsa.Logging.Models; +using Elsa.Logging.UI; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Activities; + +/// +/// Emits structured log entries into configured sinks. +/// +[Activity("Elsa", "Diagnostics", "Emit structured log entries into configured sinks.")] +public class Log : CodeActivity +{ + /// + [JsonConstructor] + protected Log(string? source = null, int? line = null) : base(source, line) + { + } + + /// + public Log(string message, LogLevel level = LogLevel.Information, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) + : base(source, line) + { + Message = new(message); + Level = new(level); + } + + /// + public Log(Input message, Input level, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) + : base(source, line) + { + Message = message; + Level = level; + } + + /// + /// The log message. + /// + [Input(Description = "The log message to emit.")] + public Input Message { get; set; } = new(string.Empty); + + /// + /// Additional attributes to include in the log entry. + /// + [Input(Description = "Values of named or indexed placeholders in the log message.")] + public Input Arguments { get; set; } = null!; + + /// + /// The log level. + /// + [Input(Description = "The log level (Trace, Debug, Information, Warning, Error, Critical).")] + public Input Level { get; set; } = new(LogLevel.Information); + + /// + /// The log message. + /// + [Input(Description = "The category. Defaults to 'Process'.", DefaultValue = "Process")] + public Input Category { get; set; } = new("Process"); + + /// + /// Additional attributes to include in the log entry. + /// + [Input( + Description = "Flat dictionary of key/value pairs to include as attributes.", + UIHint = InputUIHints.Dictionary + )] + public Input> Attributes { get; set; } = null!; + + /// + /// Target sinks to write to. + /// + [Input( + DisplayName = "Sinks", + Description = "Target sinks to write to.", + UIHint = InputUIHints.CheckList, + UIHandler = typeof(LogSinkCheckListUIHintHandler) + )] + public Input> SinkNames { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var message = Message.Get(context); + var level = Level.Get(context); + var arguments = Arguments.GetOrDefault(context); + var attributes = Attributes.GetOrDefault(context) ?? new Dictionary(); + var sinkNames = SinkNames.GetOrDefault(context) ?? new List(); + var category = Category.GetOrDefault(context); + if (string.IsNullOrWhiteSpace(category)) category = "Process"; + + attributes["WorkflowDefinitionId"] = context.WorkflowExecutionContext.Workflow.Identity.DefinitionId; + attributes["WorkflowDefinitionVersionId"] = context.WorkflowExecutionContext.Workflow.Identity.Id; + attributes["WorkflowDefinitionVersion"] = context.WorkflowExecutionContext.Workflow.Identity.Version; + attributes["WorkflowInstanceId"] = context.WorkflowExecutionContext.Id; + + var queue = context.GetRequiredService(); + var instruction = new LogEntryInstruction + { + SinkNames = sinkNames, + Category = category, + Level = level, + Message = message, + Arguments = arguments, + Attributes = attributes + }; + await queue.EnqueueAsync(instruction); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging/Elsa.Logging.csproj b/src/modules/Elsa.Logging/Elsa.Logging.csproj new file mode 100644 index 000000000..b4f0b319f --- /dev/null +++ b/src/modules/Elsa.Logging/Elsa.Logging.csproj @@ -0,0 +1,16 @@ + + + + + Provides functionality to log workflow and activity execution details to log sinks. + + elsa module logging diagnostics + + + + + + + + + diff --git a/src/modules/Elsa.Logging/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Logging/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..ccdc39e12 --- /dev/null +++ b/src/modules/Elsa.Logging/Extensions/ModuleExtensions.cs @@ -0,0 +1,16 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Logging.Features; + +namespace Elsa.Logging.Extensions; + +public static class ModuleExtensions +{ + /// + /// Installs the logging module. + /// + public static IModule UseLoggingFramework(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging/Features/LoggingFeature.cs b/src/modules/Elsa.Logging/Features/LoggingFeature.cs new file mode 100644 index 000000000..51b51429d --- /dev/null +++ b/src/modules/Elsa.Logging/Features/LoggingFeature.cs @@ -0,0 +1,59 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Logging.Activities; +using Elsa.Logging.Contracts; +using Elsa.Logging.HostedServices; +using Elsa.Logging.Options; +using Elsa.Logging.Providers; +using Elsa.Logging.Services; +using Elsa.Logging.UI; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Logging.Features; + +/// +/// A feature that installs Logging services for dynamic log sink configuration. +/// +public class LoggingFeature(IModule module) : FeatureBase(module) +{ + public override void Configure() + { + Module.AddActivity(); + } + + public LoggingFeature AddLogSink(ILogSink sink) + { + Services.Configure(options => options.Sinks.Add(sink)); + return this; + } + + public LoggingFeature ConfigureDefaults(params string[] defaults) + { + ConfigureDefaults(options => options.Defaults = [..defaults]); + return this; + } + + public LoggingFeature ConfigureDefaults(Action configure) + { + Services.Configure(configure); + return this; + } + + public override void ConfigureHostedServices() + { + Module.ConfigureHostedService(); + } + + public override void Apply() + { + Services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton() + .AddSingleton(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging/FodyWeavers.xml b/src/modules/Elsa.Logging/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Logging/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Logging/README.md b/src/modules/Elsa.Logging/README.md new file mode 100644 index 000000000..cbca39d98 --- /dev/null +++ b/src/modules/Elsa.Logging/README.md @@ -0,0 +1,141 @@ +# Logging Framework + +The logging framework provides a flexible and extensible way to capture, structure, and route log entries to various sinks. +You can configure logging programmatically or via configuration files, and extend the framework with custom sinks for complete control. + +## Programmatic Configuration + +You can configure logging sinks directly in your application code. For example, in your `Program.cs`: + +```csharp +elsa.UseLoggingFramework(logging => +{ + // Use built-in sinks. + logging.UseConsole(); + logging.UseSerilog(); + + // Configure sinks from appsettings.json. + logging.ConfigureDefaults(options => configuration.GetSection("LoggingFramework").Bind(options)); + + // Add sinks manually. + logging.AddLogSink(new LoggerSink("Console (via code)", consoleLogger)); + logging.AddLogSink(new LoggerSink("File (pretty)", filePrettyFactory)); + logging.AddLogSink(new LoggerSink("File (JSON)", fileJsonFactory)); +}); +``` + +This example demonstrates how to use built-in sinks, bind configuration from `appsettings.json`, and manually add custom sinks. + +## Configuration via appsettings.json + +You can also configure logging sinks declaratively in your `appsettings.json` file: + +```json +{ + "LoggingFramework": { + "Defaults": [ + "Console", + "FilePretty", + "FileJson" + ], + "Sinks": [ + { + "Type": "Console", + "Name": "Console", + "Options": { + "MinLevel": "Information", + "CategoryFilters": { + "Process": "Information", + "Process.Nested": "Debug", + "Process.Nested.Inner": "Information" + }, + "Formatter": "Default", + "TimestampFormat": "HH:mm:ss ", + "DisableColors": true + } + }, + { + "Type": "Serilog", + "Name": "FilePretty", + "Options": { + "Path": "App_Data/logs/activity-pretty-.log", + "RollingInterval": "Day", + "Template": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", + "MinLevel": "Information" + } + }, + { + "Type": "Serilog", + "Name": "FileJson", + "Options": { + "Path": "App_Data/logs/activity-json-.log", + "RollingInterval": "Day", + "Formatter": "CompactJson", + "MinLevel": "Debug" + } + } + ] + } +} +``` + +Each sink specifies its type, name, and options. Elsa will automatically discover and configure these sinks at startup. + +## Log Activity + +Elsa provides a built-in `Log` activity for emitting structured log entries from workflows. The activity supports the following properties: + +- **Message**: The log message to emit. +- **Level**: The log level (Trace, Debug, Information, Warning, Error, Critical). +- **Category**: The log category (defaults to "Process"). +- **Arguments**: Values for placeholders in the log message. +- **Attributes**: Additional key/value pairs to include as attributes. +- **SinkNames**: Target sinks to write to (can be selected from available sinks). + +Example usage in a workflow: + +```csharp +new Log("Workflow started", LogLevel.Information) +``` + +You can also specify sinks and attributes: + +```csharp +new Log +{ + Message = new("Order received: {OrderId}"), + Arguments = new(new { OrderId = orderId }), + SinkNames = new(new[] { "FileJson" }) +} +``` + +## Extending with Custom Sinks + +For complete control over logging, it is recommended to implement your own `ILogSinkFactory`. This allows you to create custom logging sinks tailored to your requirements. Elsa provides examples such as `ConsoleLogSinkFactory` and `SerilogLogSinkFactory` that you can use as references. + +To implement a custom sink: + +1. Create a class that implements `ILogSinkFactory`. +2. Register your factory in the DI container. +3. Reference your sink type in configuration or code. + +Example: + +```csharp +public class MyCustomLogSinkFactory : ILogSinkFactory +{ + public string Type => "MyCustom"; + public ILogSink Create(string name, MyCustomOptions options) + { + // Create and return your custom sink. + } +} + +// Register in DI: +services.AddScoped(); +``` + +## References +- See `ConsoleLogSinkFactory` and `SerilogLogSinkFactory` for implementation examples. +- Configure sinks in code or via configuration for maximum flexibility. +- Use the `Log` activity in workflows to emit structured log entries. diff --git a/src/modules/Elsa.Logging/UI/LogSinkCheckListUIHintHandler.cs b/src/modules/Elsa.Logging/UI/LogSinkCheckListUIHintHandler.cs new file mode 100644 index 000000000..7a60a6aec --- /dev/null +++ b/src/modules/Elsa.Logging/UI/LogSinkCheckListUIHintHandler.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using Elsa.Logging.Contracts; +using Elsa.Workflows.UIHints.CheckList; + +namespace Elsa.Logging.UI; + +/// +/// Provides checklist options for UI components to select log sinks. This class retrieves available log sinks +/// from the provided and maps them to checklist items for UI rendering purposes. +/// +public class LogSinkCheckListUIHintHandler(ILogSinkCatalog catalog) : CheckListOptionsProviderBase +{ + protected override async ValueTask> GetItemsAsync(PropertyInfo propertyInfo, object? context, CancellationToken cancellationToken) + { + var sinks = await catalog.ListAsync(cancellationToken); + return sinks.Select(x => new CheckListItem(x.Name, x.Name)).ToList(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj index dc06e309b..bae6f5593 100644 --- a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj +++ b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj @@ -4,7 +4,7 @@ Provides OpenTelemetry sources for tracing workflow and activity execution. - elsa module open-telemetry + elsa module diagnostics open-telemetry diff --git a/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs b/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs index 07637b045..c94f48e76 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/InputUIHints.cs @@ -9,6 +9,7 @@ public static class InputUIHints public const string Checkbox = "checkbox"; public const string CheckList = "checklist"; public const string CodeEditor = "code-editor"; + public const string Dictionary = "dictionary"; public const string DateTimePicker = "datetime-picker"; public const string DropDown = "dropdown"; public const string DynamicOutcomes = "dynamic-outcomes"; diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 740c5090c..03f0023c2 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,24 +1,29 @@ - + - - net9.0 - enable - enable - false - true - false - + + net9.0 + enable + enable + false + true + false + - - - - - - - - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/Elsa.Logging.Core.IntegrationTests.csproj b/test/integration/Elsa.Logging.Core.IntegrationTests/Elsa.Logging.Core.IntegrationTests.csproj new file mode 100644 index 000000000..0a245467d --- /dev/null +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/Elsa.Logging.Core.IntegrationTests.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/LogActivityTests.cs b/test/integration/Elsa.Logging.Core.IntegrationTests/LogActivityTests.cs new file mode 100644 index 000000000..a5364587d --- /dev/null +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/LogActivityTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Immutable; +using Elsa.Logging.Activities; +using Elsa.Logging.Contracts; +using Elsa.Logging.Extensions; +using Elsa.Logging.Models; +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Elsa.Logging.Core.IntegrationTests; + +public class LogActivityTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + + public LogActivityTests(ITestOutputHelper testOutputHelper) + { + var applicationBuilder = new TestApplicationBuilder(testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .ConfigureElsa(elsa => + { + elsa.Services.AddSingleton(); + elsa.UseLoggingFramework(); + }); + _services = applicationBuilder.Build(); + } + + [Fact(DisplayName = "The Log activity logs the expected message")] + public async Task Test1() + { + var activity = new Log("Hello {Name}", LogLevel.Debug); + await _services.RunActivityAsync(activity); + var logEntryQueue = _services.GetRequiredService(); + var logEntry = await logEntryQueue.DequeueAsync().FirstAsync(); + Assert.Equal("Hello {Name}", logEntry.Message); + Assert.Equal(LogLevel.Debug, logEntry.Level); + Assert.Equal("Process", logEntry.Category); + } +} + +public class TestLogEntryQueue : ILogEntryQueue +{ + private IImmutableQueue _queue = ImmutableQueue.Empty; + public ValueTask EnqueueAsync(LogEntryInstruction instruction) + { + _queue = _queue.Enqueue(instruction); + return ValueTask.CompletedTask; + } + + public async IAsyncEnumerable DequeueAsync() + { + while (!_queue.IsEmpty) + { + _queue = _queue.Dequeue(out var instruction); + yield return instruction; + } + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs b/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs new file mode 100644 index 000000000..9c859fb4f --- /dev/null +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs @@ -0,0 +1,56 @@ +using Elsa.Logging.Services; +using Elsa.Logging.Sinks; +using Elsa.Logging.Models; +using Elsa.Logging.Contracts; +using Elsa.Logging.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Elsa.Logging.Core.IntegrationTests; + +public class LogSinkRouterTests +{ + [Fact] + public async Task LogEntryInstruction_ShouldFlowThroughQueueAndRouterToSink() + { + var loggerFactoryMock = new Mock(); + var loggerMock = new Mock(); + loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())).Returns(loggerMock.Object); + loggerMock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + var sink = new LoggerSink("TestSink", loggerFactoryMock.Object); + var catalogMock = new Mock(); + catalogMock.Setup(c => c.ListAsync(CancellationToken.None)).ReturnsAsync(new List + { + sink + }); + var optionsMock = new Mock>(); + optionsMock.Setup(o => o.Value).Returns(new LoggingOptions + { + Defaults = ["TestSink"] + }); + var router = new LogSinkRouter(catalogMock.Object, optionsMock.Object); + var queue = new LogEntryQueue(); + var instruction = new LogEntryInstruction + { + SinkNames = ["TestSink"], + Category = "TestLogger", + Level = LogLevel.Information, + Message = "Test message" + }; + + await queue.EnqueueAsync(instruction); + await foreach (var dequeued in queue.DequeueAsync()) + { + await router.WriteAsync(dequeued.SinkNames, dequeued.Category, dequeued.Level, dequeued.Message, dequeued.Arguments, dequeued.Attributes); + break; + } + + loggerMock.Verify(l => l.Log( + LogLevel.Information, + 0, + null, + null, + It.IsAny>()!), Times.Once); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/Elsa.Logging.Core.UnitTests.csproj b/test/unit/Elsa.Logging.Core.UnitTests/Elsa.Logging.Core.UnitTests.csproj new file mode 100644 index 000000000..693b786d0 --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/Elsa.Logging.Core.UnitTests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/unit/Elsa.Logging.Core.UnitTests/LogEntryQueueTests.cs b/test/unit/Elsa.Logging.Core.UnitTests/LogEntryQueueTests.cs new file mode 100644 index 000000000..1effe2e49 --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/LogEntryQueueTests.cs @@ -0,0 +1,27 @@ +using Elsa.Logging.Services; +using Elsa.Logging.Models; +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Core.UnitTests; + +public class LogEntryQueueTests +{ + [Fact] + public async Task EnqueueAndDequeue_ShouldReturnSameLogEntryInstruction() + { + var queue = new LogEntryQueue(); + var instruction = new LogEntryInstruction + { + SinkNames = ["TestSink"], + Category = "TestLogger", + Level = LogLevel.Information, + Message = "Test message" + }; + await queue.EnqueueAsync(instruction); + await foreach (var dequeued in queue.DequeueAsync()) + { + Assert.Equal(instruction, dequeued); + break; + } + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/LogSinkRouterTests.cs b/test/unit/Elsa.Logging.Core.UnitTests/LogSinkRouterTests.cs new file mode 100644 index 000000000..a244480cd --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/LogSinkRouterTests.cs @@ -0,0 +1,49 @@ +using Elsa.Logging.Services; +using Elsa.Logging.Contracts; +using Elsa.Logging.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Elsa.Logging.Core.UnitTests; + +public class LogSinkRouterTests +{ + [Fact] + public async Task WriteAsync_ShouldCallSinkWithLogEntry() + { + var sinkMock = new Mock(); + sinkMock.Setup(s => s.Name).Returns("TestSink"); + sinkMock.Setup(s => s.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())).Returns(ValueTask.CompletedTask); + + var catalogMock = new Mock(); + catalogMock.Setup(c => c.ListAsync(CancellationToken.None)).ReturnsAsync(new List + { + sinkMock.Object + }); + + var optionsMock = new Mock>(); + optionsMock.Setup(o => o.Value).Returns(new LoggingOptions + { + Defaults = ["TestSink"] + }); + + var router = new LogSinkRouter(catalogMock.Object, optionsMock.Object); + await router.WriteAsync([ + "TestSink" + ], "TestLogger", LogLevel.Information, "Test message", null, null); + sinkMock.Verify(s => s.WriteAsync( + "TestLogger", + LogLevel.Information, + "Test message", + null, + null, + It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs b/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs new file mode 100644 index 000000000..fecfc436e --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs @@ -0,0 +1,25 @@ +using Elsa.Logging.Sinks; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Elsa.Logging.Core.UnitTests; + +public class LoggerSinkTests +{ + [Fact] + public async Task WriteAsync_ShouldLogMessage() + { + var loggerFactoryMock = new Mock(); + var loggerMock = new Mock(); + loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())).Returns(loggerMock.Object); + loggerMock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + var sink = new LoggerSink("TestLogger", loggerFactoryMock.Object); + await sink.WriteAsync("TestLogger", LogLevel.Information, "Test message", null, null); + loggerMock.Verify(l => l.Log( + LogLevel.Information, + 0, + null, + null, + It.IsAny>()), Times.Once); + } +} \ No newline at end of file From ca85fe0f6d97486d088c3a384e9c3a4880c33cd4 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 28 Aug 2025 08:53:15 +0200 Subject: [PATCH 05/21] Improve Logger With Destructuring of ExpandoObject (#6877) * Initial implementation of log activity + base sink * Refactor logging implementation: replace `Elsa.ProcessLogging` with a new modular `Elsa.Logging` framework, introducing support for configurable log sinks, enhanced logging extensibility, and updated dependencies in consuming projects. * Enhance logging framework: introduce custom `NullableBoolConverter` and update JSON serialization/deserialization logic for log sink handling. * Update description for `Log` activity input: clarify target sinks configuration * Set default value of `SinkNames` input in `Log` activity to non-nullable collection * Set `DisplayName` for `Sinks` input in `Log` activity * Refactor logging framework: update `ILogSink` and `ILogSinkRouter` to support arguments and attributes, enhance `Log` activity to use updated interfaces, and add default category handling. * Refactor logging framework: simplify argument handling in `ILogSink` and `ILogSinkRouter`, update `Log` activity inputs, and improve message formatting in `MelLogSink`. * Update logging framework to simplify log sink creation, enhance category filtering, and refactor `ILogSink`/`ILogSinkRouter` interface methods. * Introduce modular logging framework enhancements: add `Console` and `Serilog` logging features, refactor `ILogSink` framework, and update projects to align with a modular architecture. * Refactor logging framework: introduce `AddCategoryFilters` extension, replace `DefaultCategory` handling with enhanced category filters, and update sink creation logic for consistency. * Refactor logging framework: rename `SinkOptions` to `LogSinkOptions`, standardize naming across log sink types, and update configuration and sink factory logic for consistency. * Enhance logging framework: add `ConfigureDefaults` methods, update `ILogSinkCatalog` to use `IServiceScopeFactory`, and improve logging configuration handling and defaults setup. * Introduce asynchronous log entry processing: add `ILogEntryQueue`, `LogEntryBackgroundWorker`, and related models to enable queue-based logging and background processing. Update `Log` activity to enqueue log entries for processing. * Add unit and integration tests for `Elsa.Logging.Core` library, refactor logger setup in `Elsa.Server.Web`, enhance logging configuration, and standardize `Directory.Packages.props` file. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add documentation comments to logging framework classes, interfaces, methods, and factories to enhance code readability and maintainability. Remove unused `CustomPurpleConsoleFormatter` class and `logs` folder from server project. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Make `LogEntryInstruction` and `LogEntryQueue` classes public and simplify return statement in `LogSinkCatalog.ListAsync` method. * Standardize terminology in `ILogSink` interface and `LoggerSink` implementation: rename `properties` to `attributes`. Update project files and solution structure to reflect integration test additions. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update documentation comments in `LoggingFeature` and `LogEntryInstruction` to clarify functionality and improve precision. * Add README for `Elsa.Logging` module with configuration examples, usage details, and extension guidance. * Add `Dictionary` UI hint to `InputUIHints` and update `Attributes` in `Log` activity to use it. * Update `Log` activity default category to "Process", add integration tests for logging, and enhance null safety in `ConfigurationLogSinkProvider`. * Remove `UseLoggingFramework` middleware from `Program.cs` to streamline workflow initialization. * Refactor `LoggerSink` to simplify logging logic and remove unused `FormatMessage` method. Enhance `Log` activity argument handling by introducing JSON parsing for string inputs. * Refactor logging tests to improve consistency and update variable initialization in `WorkflowExecutionContext` for cleaner syntax. * Replace mocked logger dependencies in tests with `TestLogger` and `TestLoggerFactory` for improved readability and maintainability. * Add `JsonDestructuringConsoleFormatter` for structured JSON logging and update logging configuration to support new formatter. * Refactor JSON converters: replace `NullableBoolConverter` with `NullableBooleanConverter` and `BooleanConverter` for improved readability and consistency. * Add error handling and logging to `LogEntryBackgroundWorker` Introduce exception handling with logging in `LogEntryBackgroundWorker` to capture and log errors during log entry processing. Added `ILogger` dependency for structured error reporting. --------- Co-authored-by: lucas.hipolito Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/apps/Elsa.Server.Web/appsettings.json | 17 +++- .../Converters/BooleanConverter.cs | 29 +++++++ .../Converters/NullableBooleanConverter.cs | 34 ++++++++ .../ConsoleLogSinkFactory.cs | 20 +++-- .../ConsoleLogSinkOptions.cs | 3 +- .../JsonDestructuringConsoleFormatter.cs | 84 +++++++++++++++++++ .../LogEntryBackgroundWorker.cs | 28 ++++--- .../Providers/ConfigurationLogSinkProvider.cs | 5 +- .../Serialization/NullableBoolConverter.cs | 44 ---------- .../Elsa.Logging.Core/Sinks/LoggerSink.cs | 37 +------- src/modules/Elsa.Logging/Activities/Log.cs | 21 +++++ .../Contexts/WorkflowExecutionContext.cs | 4 +- .../Helpers/TestLogger.cs | 22 +++++ .../Helpers/TestLoggerFactory.cs | 10 +++ .../LogSinkRouterTests.cs | 22 +++-- .../Helpers/TestLogger.cs | 22 +++++ .../Helpers/TestLoggerFactory.cs | 10 +++ .../LoggerSinkTests.cs | 19 ++--- 18 files changed, 309 insertions(+), 122 deletions(-) create mode 100644 src/modules/Elsa.Common/Converters/BooleanConverter.cs create mode 100644 src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs create mode 100644 src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs delete mode 100644 src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs create mode 100644 test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLogger.cs create mode 100644 test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLoggerFactory.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLogger.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLoggerFactory.cs diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index d9a5a15be..88f86b20b 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -25,7 +25,11 @@ }, "DatabaseProvider": "Sqlite", "LoggingFramework": { - "Defaults": ["Console", "FilePretty", "FileJson"], + "Defaults": [ + "Console", + "FilePretty", + "FileJson" + ], "Sinks": [ { "Type": "Console", @@ -42,6 +46,17 @@ "DisableColors": true } }, + { + "Type": "Console", + "Name": "Datadog", + "Options": { + "MinLevel": "Information", + "Formatter": "json", + "TimestampFormat": "O", + "UseUtcTimestamp": true, + "DisableColors": true + } + }, { "Type": "Console", "Name": "ConsoleSystemd", diff --git a/src/modules/Elsa.Common/Converters/BooleanConverter.cs b/src/modules/Elsa.Common/Converters/BooleanConverter.cs new file mode 100644 index 000000000..253c65f64 --- /dev/null +++ b/src/modules/Elsa.Common/Converters/BooleanConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Common.Converters; + +public class BooleanConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.String: + var value = reader.GetString(); + if (bool.TryParse(value, out var b)) + return b; + break; + } + throw new JsonException($"Cannot convert {reader.TokenType} to bool"); + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs b/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs new file mode 100644 index 000000000..7aed5ffdc --- /dev/null +++ b/src/modules/Elsa.Common/Converters/NullableBooleanConverter.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Common.Converters; + +public class NullableBooleanConverter : JsonConverter +{ + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.String: + var value = reader.GetString(); + if (bool.TryParse(value, out var b)) + return b; + break; + case JsonTokenType.Null: + return null; + } + throw new JsonException($"Cannot convert {reader.TokenType} to bool?"); + } + + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteBooleanValue(value.Value); + else + writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs index 78e816737..cbc8e511d 100644 --- a/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs @@ -1,7 +1,9 @@ using Elsa.Logging.Contracts; using Elsa.Logging.Extensions; using Elsa.Logging.Sinks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; namespace Elsa.Logging.Console; @@ -20,13 +22,19 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory public string Type => "Console"; - /// public ILogSink Create(string name, ConsoleLogSinkOptions options) { var factory = LoggerFactory.Create(builder => { builder.ClearProviders(); builder.AddCategoryFilters(options); + + builder.Services.Configure(cfo => + { + if (options.TimestampFormat is not null) cfo.TimestampFormat = options.TimestampFormat; + if (options.IncludeScopes is not null) cfo.IncludeScopes = options.IncludeScopes.Value; + if (options.UseUtcTimestamp is not null) cfo.UseUtcTimestamp = options.UseUtcTimestamp.Value; + }); var min = options.MinLevel ?? LogLevel.Information; @@ -49,14 +57,16 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory(); builder.AddConsole(o => { - if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat; - if (options.DisableColors is not null) o.DisableColors = options.DisableColors.Value; - if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value; + o.FormatterName = JsonDestructuringConsoleFormatter.FormatterName; }); break; + default: + builder.AddConsole(); + break; } builder.SetMinimumLevel(min); diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs index 57002fed7..b146fe451 100644 --- a/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs @@ -15,7 +15,7 @@ public sealed record ConsoleLogSinkOptions : LogSinkOptions // "Default" | "Simple" | "Systemd" public string Formatter { get; init; } = "Default"; public string? TimestampFormat { get; init; } - public bool? IncludeScopes { get; init; } + public bool? IncludeScopes { get; init; } = true; // Default console public bool? DisableColors { get; init; } @@ -24,4 +24,5 @@ public sealed record ConsoleLogSinkOptions : LogSinkOptions public LoggerColorBehavior? ColorBehavior { get; init; } public bool? SingleLine { get; init; } public bool? UseUtcTimestamp { get; init; } + public bool JsonIndented { get; set; } = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs b/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs new file mode 100644 index 000000000..ceff5f680 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs @@ -0,0 +1,84 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace Elsa.Logging.Console; + +/// +/// A custom console formatter that formats log entries as JSON with destructured data. +/// +/// +/// This formatter outputs log information in a JSON format, including details such as log level, category, message, +/// state, and scopes. The formatter is designed to provide a structured and easily parseable log output for improved log analysis. +/// +public sealed class JsonDestructuringConsoleFormatter() : ConsoleFormatter(FormatterName) +{ + public const string FormatterName = "json-destructuring"; + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopes, TextWriter writer) + { + using var stream = new MemoryStream(); + using var json = new Utf8JsonWriter(stream, new() + { + Indented = false + }); + + json.WriteStartObject(); + json.WriteString("LogLevel", logEntry.LogLevel.ToString()); + json.WriteString("Category", logEntry.Category); + json.WriteString("Message", logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception)); + + // STATE (TState might be IReadOnlyList>) + if (logEntry.State is IEnumerable> kvs) + { + json.WriteStartObject("State"); + foreach (var kv in kvs) + { + if (kv.Key == "{OriginalFormat}") + { + json.WriteString(kv.Key, kv.Value?.ToString()); + continue; + } + + json.WritePropertyName(kv.Key); + JsonSerializer.Serialize(json, kv.Value); + } + + json.WriteEndObject(); + } + + // SCOPES + if (scopes is not null) + { + json.WriteStartArray("Scopes"); + scopes.ForEachScope((scope, state) => + { + switch (scope) + { + case IEnumerable> scopeKvs: + state.WriteStartObject(); + foreach (var kv in scopeKvs) + { + state.WritePropertyName(kv.Key); + JsonSerializer.Serialize(state, kv.Value); + } + + state.WriteEndObject(); + break; + default: + JsonSerializer.Serialize(state, scope); + break; + } + }, json); + json.WriteEndArray(); + } + + json.WriteEndObject(); + json.Flush(); + + // Write the JSON string to the TextWriter + writer.Write(Encoding.UTF8.GetString(stream.ToArray())); + writer.WriteLine(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs b/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs index 0e1df3f31..fbea54b9b 100644 --- a/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs +++ b/src/modules/Elsa.Logging.Core/HostedServices/LogEntryBackgroundWorker.cs @@ -2,6 +2,7 @@ using Elsa.Logging.Contracts; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Elsa.Logging.HostedServices; @@ -10,21 +11,28 @@ namespace Elsa.Logging.HostedServices; /// and routing them to appropriate log sinks. /// [UsedImplicitly] -public class LogEntryBackgroundWorker(ILogEntryQueue queue, IServiceScopeFactory scopeFactory) : BackgroundService +public class LogEntryBackgroundWorker(ILogEntryQueue queue, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var instruction in queue.DequeueAsync().WithCancellation(stoppingToken)) { - using var scope = scopeFactory.CreateScope(); - var router = scope.ServiceProvider.GetRequiredService(); - await router.WriteAsync( - instruction.SinkNames, - instruction.Category, - instruction.Level, - instruction.Message, - instruction.Arguments, - instruction.Attributes, stoppingToken); + try + { + using var scope = scopeFactory.CreateScope(); + var router = scope.ServiceProvider.GetRequiredService(); + await router.WriteAsync( + instruction.SinkNames, + instruction.Category, + instruction.Level, + instruction.Message, + instruction.Arguments, + instruction.Attributes, stoppingToken); + } + catch (Exception e) + { + logger.LogError(e, "An error occurred while processing a log entry {@LogEntry}", instruction); + } } } } diff --git a/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs b/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs index 42108c70b..c306fc9d5 100644 --- a/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs +++ b/src/modules/Elsa.Logging.Core/Providers/ConfigurationLogSinkProvider.cs @@ -1,9 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Elsa.Common.Converters; using Elsa.Extensions; using Elsa.Logging.Contracts; using Elsa.Logging.Models; -using Elsa.Logging.Serialization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -21,7 +21,8 @@ public class ConfigurationLogSinkProvider : ILogSinkProvider PropertyNameCaseInsensitive = true, Converters = { - new NullableBoolConverter(), + new NullableBooleanConverter(), + new BooleanConverter(), new JsonStringEnumConverter() } }; diff --git a/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs b/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs deleted file mode 100644 index d875c6e29..000000000 --- a/src/modules/Elsa.Logging.Core/Serialization/NullableBoolConverter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Elsa.Logging.Serialization; - -/// -/// A custom JSON converter for nullable boolean values. -/// Provides functionality to serialize and deserialize nullable boolean values -/// in JSON, including support for the string representation of boolean values -/// ("true", "false") and handling of null cases. -/// -public class NullableBoolConverter : JsonConverter -{ - public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - switch (reader.TokenType) - { - // Handle real boolean - case JsonTokenType.True: - return true; - case JsonTokenType.False: - return false; - // Handle string "true"/"false" - case JsonTokenType.String: - { - var value = reader.GetString(); - if (bool.TryParse(value, out var b)) - return b; - break; - } - } - - // Handle null - return reader.TokenType == JsonTokenType.Null ? null : throw new JsonException($"Cannot convert {reader.TokenType} to bool?"); - } - - public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) - { - if (value.HasValue) - writer.WriteBooleanValue(value.Value); - else - writer.WriteNullValue(); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs index b97b4b15d..af7cb0c01 100644 --- a/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs +++ b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs @@ -14,42 +14,13 @@ public sealed class LoggerSink(string name, ILoggerFactory factory) : ILogSink /// public ValueTask WriteAsync(string name, LogLevel level, string message, object? arguments, IDictionary? attributes = null, CancellationToken cancellationToken = default) { - var logger = factory.CreateLogger(name); + var l = factory.CreateLogger(name); - if (!logger.IsEnabled(level)) + if (!l.IsEnabled(level)) return ValueTask.CompletedTask; - using var scope = attributes is null ? null : logger.BeginScope(attributes); - logger.Log(level, 0, arguments, null, (state, ex) => FormatMessage(message, state)); + using var scope = attributes is null ? null : l.BeginScope(attributes); + l.Log(level, 0, null, message, arguments); return ValueTask.CompletedTask; } - - private static string FormatMessage(string message, object? state) - { - // If the state is an array, use string.Format. - if (state is object[] { Length: > 0 } args) - return string.Format(message, args); - - // Otherwise, use string interpolation. No need to use StringBuilder here, since the message is not expected to be long. - var formattedMessage = message; - - // If the state is a dictionary, use string interpolation. - if (state is IDictionary dict) - { - foreach (var kvp in dict) - formattedMessage = formattedMessage.Replace($"{{{kvp.Key}}}", kvp.Value?.ToString()); - } - // Otherwise, use reflection to find properties on the state object. - else if (state is not null) - { - var props = state.GetType().GetProperties(); - foreach (var prop in props) - { - var value = prop.GetValue(state); - formattedMessage = formattedMessage.Replace($"{{{prop.Name}}}", value?.ToString()); - } - } - - return formattedMessage; - } } \ No newline at end of file diff --git a/src/modules/Elsa.Logging/Activities/Log.cs b/src/modules/Elsa.Logging/Activities/Log.cs index 5728441ba..355dc863d 100644 --- a/src/modules/Elsa.Logging/Activities/Log.cs +++ b/src/modules/Elsa.Logging/Activities/Log.cs @@ -1,4 +1,6 @@ +using System.Dynamic; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Extensions; using Elsa.Logging.Contracts; @@ -90,6 +92,13 @@ public class Log : CodeActivity var message = Message.Get(context); var level = Level.Get(context); var arguments = Arguments.GetOrDefault(context); + + if (arguments is string argumentString) + { + // Could be JSON created from e.g., Liquid template. If so, parse it into an ExpandoObject. + arguments = TryParseJson(argumentString); + } + var attributes = Attributes.GetOrDefault(context) ?? new Dictionary(); var sinkNames = SinkNames.GetOrDefault(context) ?? new List(); var category = Category.GetOrDefault(context); @@ -112,4 +121,16 @@ public class Log : CodeActivity }; await queue.EnqueueAsync(instruction); } + + private object TryParseJson(string json) + { + try + { + return JsonSerializer.Deserialize(json) ?? new ExpandoObject(); + } + catch + { + return json; + } + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index ada56a486..d664b5e83 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -551,7 +551,7 @@ public partial class WorkflowExecutionContext : IExecutionContext var now = SystemClock.UtcNow; var id = IdentityGenerator.GenerateId(); var activityExecutionContext = new ActivityExecutionContext(id, this, parentContext, activity, activityDescriptor, now, tag, SystemClock, CancellationToken); - var variablesToDeclare = options?.Variables ?? Array.Empty(); + var variablesToDeclare = options?.Variables ?? []; var variableContainer = new[] { activityExecutionContext.ActivityNode @@ -614,7 +614,7 @@ public partial class WorkflowExecutionContext : IExecutionContext { // Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables. // This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing. - // We may ultimately restore direct output access, but in a different way. + // We may ultimately restore direct output access, but differently. return ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null); } diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLogger.cs b/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLogger.cs new file mode 100644 index 000000000..beda56faa --- /dev/null +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLogger.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Core.IntegrationTests.Helpers; + +class TestLogger : ILogger +{ + public List<(LogLevel level, EventId eventId, object state, Exception? exception, Delegate formatter)> Calls { get; } = new(); + public bool IsEnabledCalled { get; private set; } + + public bool IsEnabled(LogLevel logLevel) + { + IsEnabledCalled = true; + return true; + } + + public IDisposable BeginScope(TState state) => null!; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Calls.Add((logLevel, eventId, state!, exception, formatter)); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLoggerFactory.cs b/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLoggerFactory.cs new file mode 100644 index 000000000..230ef345f --- /dev/null +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/Helpers/TestLoggerFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Core.IntegrationTests.Helpers; + +class TestLoggerFactory(ILogger logger) : ILoggerFactory +{ + public ILogger CreateLogger(string categoryName) => logger; + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } +} \ No newline at end of file diff --git a/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs b/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs index 9c859fb4f..2a4372739 100644 --- a/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs +++ b/test/integration/Elsa.Logging.Core.IntegrationTests/LogSinkRouterTests.cs @@ -5,6 +5,7 @@ using Elsa.Logging.Contracts; using Elsa.Logging.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Elsa.Logging.Core.IntegrationTests.Helpers; using Moq; namespace Elsa.Logging.Core.IntegrationTests; @@ -14,11 +15,9 @@ public class LogSinkRouterTests [Fact] public async Task LogEntryInstruction_ShouldFlowThroughQueueAndRouterToSink() { - var loggerFactoryMock = new Mock(); - var loggerMock = new Mock(); - loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())).Returns(loggerMock.Object); - loggerMock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); - var sink = new LoggerSink("TestSink", loggerFactoryMock.Object); + var testLogger = new TestLogger(); + var loggerFactory = new TestLoggerFactory(testLogger); + var sink = new LoggerSink("TestSink", loggerFactory); var catalogMock = new Mock(); catalogMock.Setup(c => c.ListAsync(CancellationToken.None)).ReturnsAsync(new List { @@ -38,7 +37,7 @@ public class LogSinkRouterTests Level = LogLevel.Information, Message = "Test message" }; - + await queue.EnqueueAsync(instruction); await foreach (var dequeued in queue.DequeueAsync()) { @@ -46,11 +45,10 @@ public class LogSinkRouterTests break; } - loggerMock.Verify(l => l.Log( - LogLevel.Information, - 0, - null, - null, - It.IsAny>()!), Times.Once); + // Assert that Log was called once with expected parameters + Assert.Single(testLogger.Calls); + var call = testLogger.Calls[0]; + Assert.Equal(LogLevel.Information, call.level); + Assert.Null(call.exception); } } \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLogger.cs b/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLogger.cs new file mode 100644 index 000000000..4fc697d5a --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLogger.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Core.UnitTests.Helpers; + +class TestLogger : ILogger +{ + public List<(LogLevel level, EventId eventId, object state, Exception? exception, Delegate formatter)> Calls { get; } = new(); + public bool IsEnabledCalled { get; private set; } + + public bool IsEnabled(LogLevel logLevel) + { + IsEnabledCalled = true; + return true; + } + + public IDisposable BeginScope(TState state) => null!; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Calls.Add((logLevel, eventId, state!, exception, formatter)); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLoggerFactory.cs b/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLoggerFactory.cs new file mode 100644 index 000000000..8ee648a74 --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/Helpers/TestLoggerFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Logging; + +namespace Elsa.Logging.Core.UnitTests.Helpers; + +class TestLoggerFactory(ILogger logger) : ILoggerFactory +{ + public ILogger CreateLogger(string categoryName) => logger; + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } +} \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs b/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs index fecfc436e..8125c6997 100644 --- a/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs +++ b/test/unit/Elsa.Logging.Core.UnitTests/LoggerSinkTests.cs @@ -1,6 +1,6 @@ +using Elsa.Logging.Core.UnitTests.Helpers; using Elsa.Logging.Sinks; using Microsoft.Extensions.Logging; -using Moq; namespace Elsa.Logging.Core.UnitTests; @@ -9,17 +9,12 @@ public class LoggerSinkTests [Fact] public async Task WriteAsync_ShouldLogMessage() { - var loggerFactoryMock = new Mock(); - var loggerMock = new Mock(); - loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())).Returns(loggerMock.Object); - loggerMock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); - var sink = new LoggerSink("TestLogger", loggerFactoryMock.Object); + var testLogger = new TestLogger(); + var loggerFactory = new TestLoggerFactory(testLogger); + var sink = new LoggerSink("TestLogger", loggerFactory); await sink.WriteAsync("TestLogger", LogLevel.Information, "Test message", null, null); - loggerMock.Verify(l => l.Log( - LogLevel.Information, - 0, - null, - null, - It.IsAny>()), Times.Once); + Assert.Single(testLogger.Calls); + var call = testLogger.Calls[0]; + Assert.Equal(LogLevel.Information, call.level); } } \ No newline at end of file From 6764d4acc404f345c5a5266c96b5fde2e3f94d39 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 28 Aug 2025 21:10:32 +0200 Subject: [PATCH 06/21] Fix log string template argument processing (#6880) * Refactor logging system and replace JsonDestructuringConsoleFormatter. Removed `JsonDestructuringConsoleFormatter` in favor of native JSON console formatting. Introduced `LogArgumentHelper` to process log arguments efficiently and added comprehensive unit tests. Updated `ConsoleLogSinkOptions` to use extensible `JsonFormatterOptions`. * Refactor `LogArgumentHelper` methods for clarity and remove redundant string handling logic. * Update src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor `LogArgumentHelper` to improve type handling and dictionary conversion Introduced a temporary list in `ConvertDictionaryToPairs` for better handling of dictionary entries. Updated property-to-pair conversion to cast results as objects, ensuring type consistency. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ConsoleLogSinkFactory.cs | 18 +- .../ConsoleLogSinkOptions.cs | 2 +- .../JsonDestructuringConsoleFormatter.cs | 84 ----- .../JsonFormatterOptions.cs | 6 + .../Helpers/LogArgumentHelper.cs | 132 ++++++++ .../Elsa.Logging.Core/Sinks/LoggerSink.cs | 10 +- .../LogArgumentHelperTests.cs | 290 ++++++++++++++++++ 7 files changed, 449 insertions(+), 93 deletions(-) delete mode 100644 src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs create mode 100644 src/modules/Elsa.Logging.Console/JsonFormatterOptions.cs create mode 100644 src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs create mode 100644 test/unit/Elsa.Logging.Core.UnitTests/LogArgumentHelperTests.cs diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs index cbc8e511d..1797328da 100644 --- a/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkFactory.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; +using System.Text.Json; using Elsa.Logging.Contracts; using Elsa.Logging.Extensions; using Elsa.Logging.Sinks; @@ -28,7 +30,7 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory(cfo => { if (options.TimestampFormat is not null) cfo.TimestampFormat = options.TimestampFormat; @@ -58,10 +60,18 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory(); - builder.AddConsole(o => + builder.AddJsonConsole(o => { - o.FormatterName = JsonDestructuringConsoleFormatter.FormatterName; + if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat; + if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value; + if (options.UseUtcTimestamp is not null) o.UseUtcTimestamp = options.UseUtcTimestamp.Value; + if (options.JsonOptions is not null) + { + o.JsonWriterOptions = new() + { + Indented = options.JsonOptions.Indented, + }; + } }); break; default: diff --git a/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs index b146fe451..fa574538b 100644 --- a/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs +++ b/src/modules/Elsa.Logging.Console/ConsoleLogSinkOptions.cs @@ -24,5 +24,5 @@ public sealed record ConsoleLogSinkOptions : LogSinkOptions public LoggerColorBehavior? ColorBehavior { get; init; } public bool? SingleLine { get; init; } public bool? UseUtcTimestamp { get; init; } - public bool JsonIndented { get; set; } = true; + public JsonFormatterOptions? JsonOptions { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs b/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs deleted file mode 100644 index ceff5f680..000000000 --- a/src/modules/Elsa.Logging.Console/JsonDestructuringConsoleFormatter.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Console; - -namespace Elsa.Logging.Console; - -/// -/// A custom console formatter that formats log entries as JSON with destructured data. -/// -/// -/// This formatter outputs log information in a JSON format, including details such as log level, category, message, -/// state, and scopes. The formatter is designed to provide a structured and easily parseable log output for improved log analysis. -/// -public sealed class JsonDestructuringConsoleFormatter() : ConsoleFormatter(FormatterName) -{ - public const string FormatterName = "json-destructuring"; - public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopes, TextWriter writer) - { - using var stream = new MemoryStream(); - using var json = new Utf8JsonWriter(stream, new() - { - Indented = false - }); - - json.WriteStartObject(); - json.WriteString("LogLevel", logEntry.LogLevel.ToString()); - json.WriteString("Category", logEntry.Category); - json.WriteString("Message", logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception)); - - // STATE (TState might be IReadOnlyList>) - if (logEntry.State is IEnumerable> kvs) - { - json.WriteStartObject("State"); - foreach (var kv in kvs) - { - if (kv.Key == "{OriginalFormat}") - { - json.WriteString(kv.Key, kv.Value?.ToString()); - continue; - } - - json.WritePropertyName(kv.Key); - JsonSerializer.Serialize(json, kv.Value); - } - - json.WriteEndObject(); - } - - // SCOPES - if (scopes is not null) - { - json.WriteStartArray("Scopes"); - scopes.ForEachScope((scope, state) => - { - switch (scope) - { - case IEnumerable> scopeKvs: - state.WriteStartObject(); - foreach (var kv in scopeKvs) - { - state.WritePropertyName(kv.Key); - JsonSerializer.Serialize(state, kv.Value); - } - - state.WriteEndObject(); - break; - default: - JsonSerializer.Serialize(state, scope); - break; - } - }, json); - json.WriteEndArray(); - } - - json.WriteEndObject(); - json.Flush(); - - // Write the JSON string to the TextWriter - writer.Write(Encoding.UTF8.GetString(stream.ToArray())); - writer.WriteLine(); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Console/JsonFormatterOptions.cs b/src/modules/Elsa.Logging.Console/JsonFormatterOptions.cs new file mode 100644 index 000000000..35c84af54 --- /dev/null +++ b/src/modules/Elsa.Logging.Console/JsonFormatterOptions.cs @@ -0,0 +1,6 @@ +namespace Elsa.Logging.Console; + +public sealed record JsonFormatterOptions +{ + public bool Indented { get; set; } = true; +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs b/src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs new file mode 100644 index 000000000..b9340938b --- /dev/null +++ b/src/modules/Elsa.Logging.Core/Helpers/LogArgumentHelper.cs @@ -0,0 +1,132 @@ +using System.Collections; +using System.Reflection; + +namespace Elsa.Logging.Helpers; + +public static class LogArgumentHelper +{ + public static object[] ToArgumentsArray(object? input) + { + switch (input) + { + case null: + return []; + case object[] objArray: + return objArray; + case IDictionary dict when !dict.GetType().IsGenericType: + // Only use ConvertDictionaryToPairs for non-generic IDictionary + return ConvertDictionaryToPairs(dict); + } + + if (TryAsGenericDictionary(input, out var fromGenericDict)) + return fromGenericDict; + + // IEnumerable of KeyValuePair<,> + if (TryAsKeyValuePairEnumerable(input, out var fromKvpEnumerable)) + return fromKvpEnumerable; + + // Any IEnumerable (but not string or byte[]) + if (input is IEnumerable enumerable and not string and not byte[]) + return EnumerableToArray(enumerable); + + // Prevent treating string as object with properties + if (input is string) + return [input]; + + // Plain/anonymous object → public readable properties to name/value pairs + return MapObjectToPropertyPairs(input); + } + + private static object[] ConvertDictionaryToPairs(IDictionary dict) + { + var pairs = new List(); + foreach (DictionaryEntry entry in dict) + pairs.Add(new KeyValuePair(entry.Key, entry.Value)); + return pairs.ToArray(); + } + + private static bool TryAsGenericDictionary(object input, out object[] pairs) + { + var t = input.GetType(); + + // Find IDictionary<,> or IReadOnlyDictionary<,> among implemented interfaces + var dictionaryInterface = t.GetInterfaces() + .Concat([t]) + .FirstOrDefault(i => + i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IDictionary<,>) || + i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))); + + if (dictionaryInterface is null) + { + pairs = []; + return false; + } + + var keyProp = dictionaryInterface.GetProperty("Keys")!; + var idxProp = dictionaryInterface.GetProperty("Item")!; // this[TKey key] + var keys = (IEnumerable)keyProp.GetValue(input)!; + + var list = new List(); + foreach (object? key in keys) + { + var value = idxProp.GetValue(input, new[] { key }); + list.Add(new KeyValuePair(key, value)); + } + pairs = list.ToArray(); + return true; + } + + private static bool TryAsKeyValuePairEnumerable(object input, out object[] pairs) + { + var t = input.GetType(); + + // Look for IEnumerable where T is KeyValuePair<,> + var elementInterface = t.GetInterfaces() + .Concat([t]) + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IEnumerable<>) && + IsKeyValuePair(i.GetGenericArguments()[0])); + + if (elementInterface is null) + { + pairs = []; + return false; + } + + // We can safely enumerate via non-generic IEnumerable + var list = new List(); + foreach (var item in (IEnumerable)input) + { + // Item is a KeyValuePair<,> boxed as object; get Key/Value via reflection + var it = item.GetType(); + var key = it.GetProperty("Key")!.GetValue(item); + var value = it.GetProperty("Value")!.GetValue(item); + list.Add(new KeyValuePair(key, value)); + } + + pairs = list.ToArray(); + return true; + } + + private static bool IsKeyValuePair(Type t) + { + return t.IsGenericType && t.GetGenericTypeDefinition() == typeof(KeyValuePair<,>); + } + + private static object[] EnumerableToArray(IEnumerable enumerable) + { + return enumerable.Cast().ToArray(); + } + + private static object[] MapObjectToPropertyPairs(object input) + { + // Handles regular objects and anonymous types (public get-only props) + var props = input.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead); + + return props.Select(prop => new KeyValuePair(prop.Name, prop.GetValue(input))).Cast().ToArray(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs index af7cb0c01..e644df0cc 100644 --- a/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs +++ b/src/modules/Elsa.Logging.Core/Sinks/LoggerSink.cs @@ -1,4 +1,5 @@ using Elsa.Logging.Contracts; +using Elsa.Logging.Helpers; using Microsoft.Extensions.Logging; namespace Elsa.Logging.Sinks; @@ -14,13 +15,14 @@ public sealed class LoggerSink(string name, ILoggerFactory factory) : ILogSink /// public ValueTask WriteAsync(string name, LogLevel level, string message, object? arguments, IDictionary? attributes = null, CancellationToken cancellationToken = default) { - var l = factory.CreateLogger(name); + var logger = factory.CreateLogger(name); - if (!l.IsEnabled(level)) + if (!logger.IsEnabled(level)) return ValueTask.CompletedTask; - using var scope = attributes is null ? null : l.BeginScope(attributes); - l.Log(level, 0, null, message, arguments); + var mappedArguments = LogArgumentHelper.ToArgumentsArray(arguments); + using var scope = attributes is null ? null : logger.BeginScope(attributes); + logger.Log(level, 0, null, message, mappedArguments); return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/test/unit/Elsa.Logging.Core.UnitTests/LogArgumentHelperTests.cs b/test/unit/Elsa.Logging.Core.UnitTests/LogArgumentHelperTests.cs new file mode 100644 index 000000000..7f2d08374 --- /dev/null +++ b/test/unit/Elsa.Logging.Core.UnitTests/LogArgumentHelperTests.cs @@ -0,0 +1,290 @@ +using System.Collections; +using System.Dynamic; +using System.Reflection; +using Elsa.Logging.Helpers; + +namespace Elsa.Logging.Core.UnitTests +{ + public class LogArgumentHelperTests + { + [Fact] + public void Null_Returns_Empty_Array() + { + var result = LogArgumentHelper.ToArgumentsArray(null); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ObjectArray_PassedThrough_AsIs() + { + object[] input = + { + 1, "two", 3.0 + }; + var result = LogArgumentHelper.ToArgumentsArray(input); + Assert.Same(input, result); // ensure it's not copied + } + + [Fact] + public void NonGeneric_IDictionary_To_KeyValuePairs() + { + var ht = new Hashtable + { + ["Name"] = "Alice", + ["Age"] = 30 + }; + + var result = LogArgumentHelper.ToArgumentsArray(ht); + var dict = PairsToDictionary(result); + + Assert.Equal(2, dict.Count); + Assert.Equal("Alice", dict["Name"]); + Assert.Equal(30, dict["Age"]); + } + + [Fact] + public void Generic_IDictionary_To_KeyValuePairs() + { + IDictionary dictIn = new Dictionary + { + ["Name"] = "Bob", + ["Active"] = true + }; + + var result = LogArgumentHelper.ToArgumentsArray(dictIn); + var dict = PairsToDictionary(result); + + Assert.Equal(2, dict.Count); + Assert.Equal("Bob", dict["Name"]); + Assert.Equal(true, dict["Active"]); + } + + [Fact] + public void IReadOnlyDictionary_To_KeyValuePairs() + { + IReadOnlyDictionary ro = new Dictionary + { + ["A"] = 1, + ["B"] = 2 + }; + + var result = LogArgumentHelper.ToArgumentsArray(ro); + var dict = PairsToDictionary(result); + + Assert.Equal(2, dict.Count); + Assert.Equal(1, dict["A"]); + Assert.Equal(2, dict["B"]); + } + + [Fact] + public void Enumerable_Of_KeyValuePair_To_ObjectKeyValuePairs() + { + var kvList = new List> + { + new("X", 42), + new("Y", "why") + }; + + var result = LogArgumentHelper.ToArgumentsArray(kvList); + var dict = PairsToDictionary(result); + + Assert.Equal(2, dict.Count); + Assert.Equal(42, dict["X"]); + Assert.Equal("why", dict["Y"]); + } + + [Fact] + public void Enumerable_To_ObjectArray() + { + var list = new List + { + 1, + "two", + 3.0 + }; + var result = LogArgumentHelper.ToArgumentsArray(list); + + Assert.Equal(3, result.Length); + Assert.Equal(1, result[0]); + Assert.Equal("two", result[1]); + Assert.Equal(3.0, result[2]); + } + + [Fact] + public void Array_To_ObjectArray() + { + var arr = new[] + { + 10, 20, 30 + }; + var result = LogArgumentHelper.ToArgumentsArray(arr); + + Assert.Equal(new object[] + { + 10, 20, 30 + }, result); + } + + [Fact] + public void String_Is_Not_Treated_As_Enumerable() + { + // The helper intentionally excludes string from IEnumerable mapping. + // It will therefore treat string as a single atomic value. + var input = "hello"; + var result = LogArgumentHelper.ToArgumentsArray(input); + + // Expect a single-element array containing the string itself + Assert.Single(result); + Assert.Equal("hello", result[0]); + } + + [Fact] + public void ByteArray_Is_Not_Treated_As_Enumerable() + { + var input = new byte[] + { + 1, 2, 3, 4 + }; + var result = LogArgumentHelper.ToArgumentsArray(input); + + // Expect property pairs (e.g., Length, LongLength, Rank, etc.) + Assert.NotEmpty(result); + var dict = PairsToDictionary(result); + Assert.True(dict.ContainsKey("Length")); + Assert.Equal(4, dict["Length"]); + } + + private sealed class Poco + { + public string Name { get; set; } = default!; + public int Age { get; set; } + public bool IsActive { get; init; } + } + + [Fact] + public void PlainObject_To_PropertyPairs() + { + var poco = new Poco + { + Name = "Carol", + Age = 27, + IsActive = true + }; + + var result = LogArgumentHelper.ToArgumentsArray(poco); + var dict = PairsToDictionary(result); + + Assert.Equal(3, dict.Count); + Assert.Equal("Carol", dict["Name"]); + Assert.Equal(27, dict["Age"]); + Assert.Equal(true, dict["IsActive"]); + } + + [Fact] + public void AnonymousType_To_PropertyPairs() + { + var anon = new + { + First = "Dave", + Score = 99, + Flag = false + }; + + var result = LogArgumentHelper.ToArgumentsArray(anon); + var dict = PairsToDictionary(result); + + Assert.Equal(3, dict.Count); + Assert.Equal("Dave", dict["First"]); + Assert.Equal(99, dict["Score"]); + Assert.Equal(false, dict["Flag"]); + } + + [Fact] + public void ExpandoObject_As_Dictionary_To_KeyValuePairs() + { + dynamic expando = new ExpandoObject(); + expando.City = "Utrecht"; + expando.Zip = "3511"; + expando.Pop = 360000; + + var result = LogArgumentHelper.ToArgumentsArray((object)expando); + var dict = PairsToDictionary(result); + + Assert.Equal(3, dict.Count); + Assert.Equal("Utrecht", dict["City"]); + Assert.Equal("3511", dict["Zip"]); + Assert.Equal(360000, dict["Pop"]); + } + + [Fact] + public void MixedKeyTypes_In_Dictionary_Are_Supported() + { + var ht = new Hashtable + { + [1] = "one", + ["two"] = 2 + }; + + var result = LogArgumentHelper.ToArgumentsArray(ht); + var dict = PairsToDictionary(result); + + Assert.Equal("one", dict[1]); + Assert.Equal(2, dict["two"]); + } + + [Fact] + public void Enumerable_Of_ComplexItems_To_ObjectArray() + { + var list = new List + { + new() + { + Name = "A", + Age = 1, + IsActive = true + }, + new() + { + Name = "B", + Age = 2, + IsActive = false + }, + }; + + var result = LogArgumentHelper.ToArgumentsArray(list); + Assert.Equal(2, result.Length); + Assert.IsType(result[0]); + Assert.IsType(result[1]); + + var p0 = (Poco)result[0]; + var p1 = (Poco)result[1]; + + Assert.Equal("A", p0.Name); + Assert.Equal("B", p1.Name); + } + + private static IDictionary PairsToDictionary(object[] pairs) + { + var dict = new Dictionary(); + + foreach (var item in pairs) + { + var t = item.GetType(); + var isKvp = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(KeyValuePair<,>); + Assert.True(isKvp, $"Element is not a KeyValuePair<,> but was {t}"); + + var key = t.GetProperty("Key", BindingFlags.Public | BindingFlags.Instance)!.GetValue(item); + var value = t.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance)!.GetValue(item); + + // Keys may collide in some collections; last write wins here for simplicity + if (key != null) + { + dict[key] = value; + } + } + + return dict; + } + } +} \ No newline at end of file From 3702fcfc7cee42b2c08e5617c14212cc7f20b618 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 1 Sep 2025 09:49:07 +0200 Subject: [PATCH 07/21] Restore ability to send no body and no content type to Execute endpoint (#6882) * Refactor workflow execution endpoints and introduce helper utility Replaced `EndpointBase` class with lighter `WorkflowExecutionHelper` for handling workflow execution logic. Simplified Get & Post endpoints and added comprehensive component tests for enhanced validation. * Update GetTests to expect NotFound instead of BadRequest --- .../Execute/EndpointBase.cs | 106 ------------------ .../Execute/GetEndpoint.cs | 21 +++- .../WorkflowDefinitions/Execute/Models.cs | 1 - .../Execute/PostEndpoint.cs | 49 +++++++- .../Execute/WorkflowExecutionHelper.cs | 91 +++++++++++++++ .../Elsa.Workflows.ComponentTests.csproj | 3 + .../Helpers/Fixtures/WorkflowServer.cs | 10 +- .../WorkflowDefinitions/Execute/GetTests.cs | 44 ++++++++ .../WorkflowDefinitions/Execute/PostTests.cs | 63 +++++++++++ .../RestApis/Workflows/hello-world.json | 42 +++++++ 10 files changed, 317 insertions(+), 113 deletions(-) delete mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs deleted file mode 100644 index 399fd4096..000000000 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Net.Mime; -using Elsa.Abstractions; -using Elsa.Common.Models; -using Elsa.Workflows.Management; -using Elsa.Workflows.Runtime; -using Elsa.Workflows.State; -using Microsoft.AspNetCore.Http; - -namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; - -/// -/// This abstract class provides the necessary infrastructure to handle the execution of workflows, including setup of routes, permissions, -/// and processing of HTTP requests to execute workflows. -/// -internal abstract class EndpointBase( - IWorkflowDefinitionService workflowDefinitionService, - IWorkflowRuntime workflowRuntime, - IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) - : ElsaEndpoint where T : IExecutionRequest, new() -{ - /// - public override void Configure() - { - Routes("/workflow-definitions/{definitionId}/execute"); - ConfigurePermissions("exec:workflow-definitions"); - } - - /// - public override async Task HandleAsync(T request, CancellationToken cancellationToken) - { - var definitionId = request.DefinitionId; - var versionOptions = request.VersionOptions ?? VersionOptions.Published; - var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken); - - if (workflowGraph == null) - { - await SendNotFoundAsync(cancellationToken); - return; - } - - var startRequest = new StartWorkflowRequest - { - Workflow = workflowGraph.Workflow, - CorrelationId = request.CorrelationId, - Name = request.Name, - Input = request.GetInputAsDictionary(), - Variables = request.GetVariablesAsDictionary(), - TriggerActivityId = request.TriggerActivityId, - ActivityHandle = request.ActivityHandle - }; - - var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken); - - if(!HttpContext.Response.HasStarted) - HttpContext.Response.Headers.Append("x-elsa-workflow-cannot-start", startResponse.CannotStart.ToString()); - - if (startResponse.CannotStart) - { - await SendOkAsync(cancellationToken); - return; - } - - var instanceId = startResponse.WorkflowInstanceId!; - - // Write the workflow instance ID to the response header. - // This allows clients to read the header even if the workflow writes a response body - // (in which case, we can't transmit a JSON body that includes the instance ID). - if(!HttpContext.Response.HasStarted) - HttpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId); - - var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); - - // If a workflow fault occurred, respond appropriately with a 500 internal server error. - if (startResponse.SubStatus == WorkflowSubStatus.Faulted) - { - var workflowState = await workflowClient.ExportStateAsync(cancellationToken); - await HandleFaultAsync(workflowState, cancellationToken); - } - else - { - if (!HttpContext.Response.HasStarted) - { - // Write a response header to indicate that the response is a workflow state response. - // This is used by tools like Elsa Studio to determine if the response is in response to a workflow execution manually triggered by the user. - HttpContext.Response.Headers.Append("x-elsa-response", "true"); - - // Only write a response if the workflow didn't change the HTTP status code. - if (HttpContext.Response.StatusCode == StatusCodes.Status200OK) - { - var workflowState = await workflowClient.ExportStateAsync(cancellationToken); - await SendOkAsync(new(workflowState), cancellationToken); - } - } - } - } - - private async Task HandleFaultAsync(WorkflowState workflowState, CancellationToken cancellationToken) - { - var faultedResponse = apiSerializer.Serialize(new Response(workflowState)); - - HttpContext.Response.ContentType = MediaTypeNames.Application.Json; - HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; - await HttpContext.Response.WriteAsync(faultedResponse, cancellationToken); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs index 8f0f8e148..4bd54724a 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs @@ -1,3 +1,4 @@ +using Elsa.Abstractions; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -12,13 +13,27 @@ internal class GetEndpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) - : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer) + IApiSerializer apiSerializer) + : ElsaEndpoint { /// public override void Configure() { - base.Configure(); + Routes("/workflow-definitions/{definitionId}/execute"); + ConfigurePermissions("exec:workflow-definitions"); Verbs(FastEndpoints.Http.GET); } + + /// + public override async Task HandleAsync(GetRequest request, CancellationToken cancellationToken) + { + await WorkflowExecutionHelper.ExecuteWorkflowAsync( + request, + workflowDefinitionService, + workflowRuntime, + workflowStarter, + apiSerializer, + HttpContext, + cancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs index 468ded31c..2ec0598cd 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs @@ -1,5 +1,4 @@ using System.Dynamic; -using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Common.Models; using Elsa.Expressions.Helpers; diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index 2727e32a0..796fe7b32 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using Elsa.Abstractions; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -13,12 +15,55 @@ internal class PostEndpoint( IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer) - : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer) + : ElsaEndpointWithoutRequest { /// public override void Configure() { - base.Configure(); + Routes("/workflow-definitions/{definitionId}/execute"); + ConfigurePermissions("exec:workflow-definitions"); Verbs(FastEndpoints.Http.POST); } + + /// + public override async Task HandleAsync(CancellationToken cancellationToken) + { + PostRequest? request = null; + + if (HttpContext.Request is { ContentLength: > 0, ContentType: "application/json" or null }) + { + try + { + request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body, cancellationToken: cancellationToken); + } + catch + { + AddError("Invalid request body."); + } + } + + request ??= new(); + + var definitionId = Route("definitionId"); + + if (string.IsNullOrWhiteSpace(definitionId)) + AddError("Missing workflow definition ID."); + else + request.DefinitionId = definitionId; + + if (ValidationFailed) + { + await SendErrorsAsync(cancellation: cancellationToken); + return; + } + + await WorkflowExecutionHelper.ExecuteWorkflowAsync( + request, + workflowDefinitionService, + workflowRuntime, + workflowStarter, + apiSerializer, + HttpContext, + cancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs new file mode 100644 index 000000000..359c1cb13 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs @@ -0,0 +1,91 @@ +using System.Net.Mime; +using Elsa.Common.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.State; +using FastEndpoints; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; + +public static class WorkflowExecutionHelper +{ + public static async Task ExecuteWorkflowAsync( + IExecutionRequest request, + IWorkflowDefinitionService workflowDefinitionService, + IWorkflowRuntime workflowRuntime, + IWorkflowStarter workflowStarter, + IApiSerializer apiSerializer, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var definitionId = request.DefinitionId; + var versionOptions = request.VersionOptions ?? VersionOptions.Published; + var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken); + + if (workflowGraph == null) + { + await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken); + return; + } + + var startRequest = new StartWorkflowRequest + { + Workflow = workflowGraph.Workflow, + CorrelationId = request.CorrelationId, + Name = request.Name, + Input = request.GetInputAsDictionary(), + Variables = request.GetVariablesAsDictionary(), + TriggerActivityId = request.TriggerActivityId, + ActivityHandle = request.ActivityHandle + }; + + var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken); + + if(!httpContext.Response.HasStarted) + httpContext.Response.Headers.Append("x-elsa-workflow-cannot-start", startResponse.CannotStart.ToString()); + + if (startResponse.CannotStart) + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + await httpContext.Response.SendOkAsync(cancellationToken); + return; + } + + var instanceId = startResponse.WorkflowInstanceId!; + + if(!httpContext.Response.HasStarted) + httpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId); + + var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); + + if (startResponse.SubStatus == WorkflowSubStatus.Faulted) + { + var workflowState = await workflowClient.ExportStateAsync(cancellationToken); + await HandleFaultAsync(workflowState, apiSerializer, httpContext, cancellationToken); + } + else + { + if (!httpContext.Response.HasStarted) + { + httpContext.Response.Headers.Append("x-elsa-response", "true"); + if (httpContext.Response.StatusCode == StatusCodes.Status200OK) + { + var workflowState = await workflowClient.ExportStateAsync(cancellationToken); + var response = apiSerializer.Serialize(new Response(workflowState)); + httpContext.Response.ContentType = MediaTypeNames.Application.Json; + await httpContext.Response.WriteAsync(response, cancellationToken); + } + } + } + } + + private static async Task HandleFaultAsync(WorkflowState workflowState, IApiSerializer apiSerializer, HttpContext httpContext, CancellationToken cancellationToken) + { + var faultedResponse = apiSerializer.Serialize(new Response(workflowState)); + httpContext.Response.ContentType = MediaTypeNames.Application.Json; + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsync(faultedResponse, cancellationToken); + } +} + diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index fc4ba4f47..bbca0a0bd 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -87,6 +87,9 @@ Always + + Always + diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 67f5b73d8..275c0f450 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -34,11 +34,19 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl public TClient CreateApiClient() { var client = CreateClient(); - client.BaseAddress = new(client.BaseAddress!, "/elsa/api"); + client.BaseAddress = new(client.BaseAddress!, "/elsa/api/"); client.Timeout = TimeSpan.FromMinutes(1); return RestService.For(client, CreateRefitSettings(Services)); } + public HttpClient CreateHttpClient() + { + var client = CreateClient(); + client.BaseAddress = new(client.BaseAddress!, "/elsa/api/"); + client.Timeout = TimeSpan.FromMinutes(1); + return client; + } + public HttpClient CreateHttpWorkflowClient() { var client = CreateClient(); diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs new file mode 100644 index 000000000..aa3f5e656 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute; + +public class GetTests(App app) : AppComponentTest(app) +{ + private const string DefinitionId = "3790068018ac4f02"; + private const string Url = "workflow-definitions/{0}/execute"; + + [Fact] + public async Task Get_WithCorrelationId_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var url = string.Format(Url, DefinitionId) + "?correlationId=" + Guid.NewGuid(); + using var response = await client.GetAsync(url); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Get_WithoutCorrelationId_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var url = string.Format(Url, DefinitionId); + using var response = await client.GetAsync(url); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Get_MissingDefinitionId_ShouldReturnValidationError() + { + var client = WorkflowServer.CreateHttpClient(); + var url = "/workflow-definitions//execute"; + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs new file mode 100644 index 000000000..8def3a819 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute; + +public class PostTests(App app) : AppComponentTest(app) +{ + private const string DefinitionId = "3790068018ac4f02"; + private const string Url = "workflow-definitions/{0}/execute"; + + [Fact] + public async Task Post_WithValidJsonBody_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var requestBody = JsonSerializer.Serialize(new PostRequest + { + CorrelationId = Guid.NewGuid().ToString() + }); + var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(string.Format(Url, DefinitionId), content); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_WithoutBodyAndWithoutContentType_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId)); + // No content, no content-type + using var response = await client.SendAsync(request); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_WithoutBodyButWithContentType_ShouldReturnOk() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId)); + request.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"); + using var response = await client.SendAsync(request); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); + } + + [Fact] + public async Task Post_MissingDefinitionId_ShouldReturnValidationError() + { + var client = WorkflowServer.CreateHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/workflow-definitions//execute"); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json new file mode 100644 index 000000000..307ecb8b6 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json @@ -0,0 +1,42 @@ +{ + "id": "2790068018ac4f01", + "definitionId": "3790068018ac4f02", + "name": "Hello World", + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "969b0703a9379c3b", + "nodeId": "Workflow1:969b0703a9379c3b", + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Hello World!" + } + }, + "id": "b039045bb7443e57", + "nodeId": "Workflow1:969b0703a9379c3b:b039045bb7443e57", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "metadata": { + "designer": { + "position": { + "x": -231.796875, + "y": 269 + }, + "size": { + "width": 139.296875, + "height": 50 + } + } + } + } + ], + "connections": [] + } +} \ No newline at end of file From 9739aebb8209246ce373926e1525b3d3088d5693 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 2 Sep 2025 09:33:27 +0200 Subject: [PATCH 08/21] Update component tests to expect NotFound for missing DefinitionId and fix BaseAddress inconsistency (#6885) --- .../Helpers/Fixtures/WorkflowServer.cs | 2 +- .../Endpoints/WorkflowDefinitions/Execute/GetTests.cs | 2 +- .../Endpoints/WorkflowDefinitions/Execute/PostTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 275c0f450..c9102d9e9 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -34,7 +34,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl public TClient CreateApiClient() { var client = CreateClient(); - client.BaseAddress = new(client.BaseAddress!, "/elsa/api/"); + client.BaseAddress = new(client.BaseAddress!, "/elsa/api"); client.Timeout = TimeSpan.FromMinutes(1); return RestService.For(client, CreateRefitSettings(Services)); } diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs index aa3f5e656..f3cfc0f31 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs @@ -34,7 +34,7 @@ public class GetTests(App app) : AppComponentTest(app) } [Fact] - public async Task Get_MissingDefinitionId_ShouldReturnValidationError() + public async Task Get_MissingDefinitionId_ShouldReturnNotFoundError() { var client = WorkflowServer.CreateHttpClient(); var url = "/workflow-definitions//execute"; diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs index 8def3a819..8e27585fb 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs @@ -53,11 +53,11 @@ public class PostTests(App app) : AppComponentTest(app) } [Fact] - public async Task Post_MissingDefinitionId_ShouldReturnValidationError() + public async Task Post_MissingDefinitionId_ShouldReturnNotFoundError() { var client = WorkflowServer.CreateHttpClient(); var request = new HttpRequestMessage(HttpMethod.Post, "/workflow-definitions//execute"); using var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } \ No newline at end of file From 48be5a156935d5151f58bcda25a6f0d493cc56c4 Mon Sep 17 00:00:00 2001 From: MariusVuscanNx <96233009+MariusVuscanNx@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:21:20 +0300 Subject: [PATCH 09/21] Enrich Activity Spans with Correlation ID (#6888) --- .../OpenTelemetryTracingActivityExecutionMiddleware.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs index 617f4acb2..6143cd669 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs @@ -69,6 +69,9 @@ public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareD { span.AddEvent(new("pending")); } + + if (!string.IsNullOrWhiteSpace(context.WorkflowExecutionContext.CorrelationId)) + span.SetTag("workflow.correlation_id", context.WorkflowExecutionContext.CorrelationId); } } From 17bb1d08c948d991e2fe677e61cd9fb0b5cab2ba Mon Sep 17 00:00:00 2001 From: MariusVuscanNx <96233009+MariusVuscanNx@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:43:37 +0300 Subject: [PATCH 10/21] Otel improvements (#6890) * Otel improvements * Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs Co-authored-by: Sipke Schoorstra * Update src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs Co-authored-by: Sipke Schoorstra * Fixed comments --------- Co-authored-by: Sipke Schoorstra --- ...metryTracingActivityExecutionMiddleware.cs | 46 +++++++++++++++++-- ...metryTracingWorkflowExecutionMiddleware.cs | 12 +++-- .../Services/ResilientActivityInvoker.cs | 32 +++++++------ .../ActivityExecutionContextExtensions.cs | 25 ++++++++++ 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs index 6143cd669..83fbfe75b 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using Elsa.Common; using Elsa.Extensions; using Elsa.OpenTelemetry.Contracts; @@ -6,6 +7,7 @@ using Elsa.OpenTelemetry.Helpers; using Elsa.OpenTelemetry.Models; using Elsa.Workflows; using Elsa.Workflows.Pipelines.ActivityExecution; +using Humanizer; using JetBrains.Annotations; using Activity = System.Diagnostics.Activity; using ActivityKind = System.Diagnostics.ActivityKind; @@ -36,9 +38,9 @@ public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareD span.SetTag("activity.version", activity.Version); span.SetTag("activity.instance.id", context.Id); span.SetTag("activity.tenant.id", context.WorkflowExecutionContext.Workflow.Identity.TenantId); - + var activityKind = context.ActivityDescriptor.Kind; - if (activityKind == Elsa.Workflows.ActivityKind.Job || (activityKind == Workflows.ActivityKind.Task && activity.GetRunAsynchronously())) + if (activityKind == Elsa.Workflows.ActivityKind.Job || (activityKind == Workflows.ActivityKind.Task && activity.GetRunAsynchronously())) span.SetTag("span.type", "job"); span.AddEvent(new("executing")); @@ -54,7 +56,7 @@ public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareD var errorSpanHandler = context.GetServices() .OrderBy(x => x.Order) .FirstOrDefault(x => x.CanHandle(errorSpanHandlerContext)); - + errorSpanHandler?.Handle(errorSpanHandlerContext); } else if (context.Status == ActivityStatus.Canceled) @@ -72,6 +74,44 @@ public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareD if (!string.IsNullOrWhiteSpace(context.WorkflowExecutionContext.CorrelationId)) span.SetTag("workflow.correlation_id", context.WorkflowExecutionContext.CorrelationId); + + span.SetTag("workflow.instance.id", context.WorkflowExecutionContext.Id); + span.SetTag("workflow.definition.id", context.WorkflowExecutionContext.Workflow.Identity.DefinitionId); + span.SetTag("workflow.definition.version", context.WorkflowExecutionContext.Workflow.Identity.Version); + + SetExtensions(context, span); + } + + private void SetExtensions(ActivityExecutionContext context, Activity span) + { + var extensions = context.GetExtensionsMetadata(); + if (extensions is not null) + { + foreach (var key in extensions.Keys) + { + span.SetTag($"activity.extensions.{ToOtelFormat(key)}", extensions[key]); + } + } + } + + public string ToOtelFormat(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + var humanized = input.Humanize(); + + var sb = new StringBuilder(humanized.Length); + + foreach (var c in humanized) + { + if (char.IsWhiteSpace(c)) + sb.Append('_'); + else + sb.Append(char.ToLowerInvariant(c)); + } + + return sb.ToString(); } } diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs index 5d3996ea2..937255a7e 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs @@ -96,10 +96,16 @@ public class OpenTelemetryTracingWorkflowExecutionMiddleware(WorkflowMiddlewareD if (context.Incidents.Any()) { - span.SetTag("workflow.incidents.count", context.Incidents.Count); - + var incidentTagsList = new List(); foreach (var incident in context.Incidents) - span.AddEvent(new("incident", incident.Timestamp, CreateIncidentTags(incident))); + { + var incidentTags = CreateIncidentTags(incident); + incidentTagsList.Add(incidentTags); + span.AddEvent(new("incident", incident.Timestamp, incidentTags)); + } + + span.SetTag("workflow.incidents.items", incidentTagsList); + span.SetTag("workflow.incidents.count", context.Incidents.Count); } if (!string.IsNullOrWhiteSpace(context.CorrelationId)) diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs index 8bf2c250f..bc86d879e 100644 --- a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs +++ b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Elsa.Expressions.Helpers; +using Elsa.Extensions; using Elsa.Resilience.Entities; using Elsa.Resilience.Extensions; using Elsa.Resilience.Models; @@ -12,55 +13,56 @@ using Polly.Telemetry; namespace Elsa.Resilience; public class ResilientActivityInvoker( - IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator, - IRetryAttemptRecorder retryAttemptRecorder, - IIdentityGenerator identityGenerator, + IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator, + IRetryAttemptRecorder retryAttemptRecorder, + IIdentityGenerator identityGenerator, ResilienceStrategySerializer resilienceStrategySerializer) : IResilientActivityInvoker { private const string ResilienceStrategyIdPropKey = "resilienceStrategy"; + private const string RetryAttemptsCountKey = "RetryAttemptsCount"; public async Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default) { // Get the resilience strategy. var strategyConfig = GetStrategyConfig(activity); var resilienceStrategy = await resilienceStrategyConfigEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken); - + // If no resilience strategy is configured, execute the action as-is. if (resilienceStrategy == null) return await action(); - + // Record the applied strategy as part of the activity execution context for diagnostics. var resilienceStrategyModel = JsonSerializer.SerializeToNode(resilienceStrategy, resilienceStrategySerializer.SerializerOptions)!; context.SetResilienceStrategy(resilienceStrategyModel); - + // Create a resilience pipeline builder. var builder = CreateResiliencePipelineBuilder(); var retries = new List(); context.TransientProperties[RetryAttempt.RetriesKey] = retries; - + // Create a resilience context. var resilienceContext = ResilienceContextPool.Shared.Get(cancellationToken); resilienceContext.Properties.Set(new(nameof(ActivityExecutionContext)), context); - + try { // Configure the resilience pipeline. await resilienceStrategy.ConfigurePipeline(builder, resilienceContext); var pipeline = builder.Build(); - + // Execute the action within the resilience pipeline. var result = await pipeline.ExecuteAsync(async _ => await action(), resilienceContext); - + // Record the retry attempts. await RecordRetryAttempts(activity, context, retries, cancellationToken); - + return result; } finally { ResilienceContextPool.Shared.Return(resilienceContext); } - + } private async Task RecordRetryAttempts(IResilientActivity activity, ActivityExecutionContext context, ICollection attempts, CancellationToken cancellationToken = default) @@ -70,9 +72,11 @@ public class ResilientActivityInvoker( var records = Map(context, activity, attempts); var recordContext = new RecordRetryAttemptsContext(context, records, cancellationToken); await retryAttemptRecorder.RecordAsync(recordContext); - + // Propagate a flag that retries have occurred. This information can then be used to show the retry attempts in the workflow designer. context.SetRetriesAttemptedFlag(); + + context.SetExtensionsMetadata(RetryAttemptsCountKey, attempts.Count); } } @@ -89,7 +93,7 @@ public class ResilientActivityInvoker( ? null : value.ConvertTo(); } - + private ICollection Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, ICollection attempts) { return attempts.Select(x => Map(activityExecutionContext, resilientActivity, x)).ToList(); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index 0e7715a5f..ceb0f01e5 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -24,6 +24,8 @@ namespace Elsa.Extensions; [PublicAPI] public static partial class ActivityExecutionContextExtensions { + private const string ExtensionsMetadataKey = "Extensions"; + /// /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. /// @@ -436,6 +438,29 @@ public static partial class ActivityExecutionContextExtensions } } + /// + /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity. + /// + public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value) + { + var extensionsDictionary = context.GetExtensionsMetadata(); + + if(extensionsDictionary == null) extensionsDictionary = new(); + + extensionsDictionary[key] = value; + + context.Metadata[ExtensionsMetadataKey] = extensionsDictionary; + + } + + /// + /// Retrives the extensin data from the metdata. Represents specific data that is exposed generically for an activity. + /// + public static Dictionary? GetExtensionsMetadata(this ActivityExecutionContext context) + { + return context.Metadata[ExtensionsMetadataKey] as Dictionary; + } + internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; } \ No newline at end of file From 90b4e7d6c5e914514fd7be6a0d600b2cfc54ee3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Hip=C3=B3lito?= Date: Thu, 4 Sep 2025 10:57:48 +0200 Subject: [PATCH 11/21] Initial implementation of log activity + base sink (#6859) * Initial implementation of log activity + base sink * Update src/modules/Elsa.ProcessLogging/Extensions/ModuleExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor logging implementation: replace `Elsa.ProcessLogging` with a new modular `Elsa.Logging` framework, introducing support for configurable log sinks, enhanced logging extensibility, and updated dependencies in consuming projects. * Enhance logging framework: introduce custom `NullableBoolConverter` and update JSON serialization/deserialization logic for log sink handling. * Update description for `Log` activity input: clarify target sinks configuration * Set default value of `SinkNames` input in `Log` activity to non-nullable collection * Set `DisplayName` for `Sinks` input in `Log` activity * Refactor logging framework: update `ILogSink` and `ILogSinkRouter` to support arguments and attributes, enhance `Log` activity to use updated interfaces, and add default category handling. * Refactor logging framework: simplify argument handling in `ILogSink` and `ILogSinkRouter`, update `Log` activity inputs, and improve message formatting in `MelLogSink`. * Update logging framework to simplify log sink creation, enhance category filtering, and refactor `ILogSink`/`ILogSinkRouter` interface methods. * Introduce modular logging framework enhancements: add `Console` and `Serilog` logging features, refactor `ILogSink` framework, and update projects to align with a modular architecture. * Refactor logging framework: introduce `AddCategoryFilters` extension, replace `DefaultCategory` handling with enhanced category filters, and update sink creation logic for consistency. * Refactor logging framework: rename `SinkOptions` to `LogSinkOptions`, standardize naming across log sink types, and update configuration and sink factory logic for consistency. * Enhance logging framework: add `ConfigureDefaults` methods, update `ILogSinkCatalog` to use `IServiceScopeFactory`, and improve logging configuration handling and defaults setup. * Introduce asynchronous log entry processing: add `ILogEntryQueue`, `LogEntryBackgroundWorker`, and related models to enable queue-based logging and background processing. Update `Log` activity to enqueue log entries for processing. * Add unit and integration tests for `Elsa.Logging.Core` library, refactor logger setup in `Elsa.Server.Web`, enhance logging configuration, and standardize `Directory.Packages.props` file. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add documentation comments to logging framework classes, interfaces, methods, and factories to enhance code readability and maintainability. Remove unused `CustomPurpleConsoleFormatter` class and `logs` folder from server project. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Make `LogEntryInstruction` and `LogEntryQueue` classes public and simplify return statement in `LogSinkCatalog.ListAsync` method. * Standardize terminology in `ILogSink` interface and `LoggerSink` implementation: rename `properties` to `attributes`. Update project files and solution structure to reflect integration test additions. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update documentation comments in `LoggingFeature` and `LogEntryInstruction` to clarify functionality and improve precision. * Add README for `Elsa.Logging` module with configuration examples, usage details, and extension guidance. * Add `Dictionary` UI hint to `InputUIHints` and update `Attributes` in `Log` activity to use it. * Update `Log` activity default category to "Process", add integration tests for logging, and enhance null safety in `ConfigurationLogSinkProvider`. * Remove `UseLoggingFramework` middleware from `Program.cs` to streamline workflow initialization. * Log Activity * Auto stash before merge of "feat/6854-process-log-activity" and "patch/3.5.1" * Refactor `LoggerSink` to simplify logging logic and remove unused `FormatMessage` method. Enhance `Log` activity argument handling by introducing JSON parsing for string inputs. * Refactor logging tests to improve consistency and update variable initialization in `WorkflowExecutionContext` for cleaner syntax. * Replace mocked logger dependencies in tests with `TestLogger` and `TestLoggerFactory` for improved readability and maintainability. * Add `JsonDestructuringConsoleFormatter` for structured JSON logging and update logging configuration to support new formatter. * Handling Dictionary component values with expressions * Small improvements * Increasing studio version * Introduce custom activity input evaluators and support evaluator type registration. (#6889) * Introduce custom activity input evaluators and support evaluator type registration. Added `DefaultActivityInputEvaluator` and `DictionaryValueEvaluator` for custom input evaluation logic. Modified `InputAttribute` and `InputDescriptor` to support specifying evaluator types. Updated relevant activity, service, and feature registrations to integrate the new evaluators. * Remove legacy Dictionary UI hint implementation and refactor `DictionaryValueEvaluator` integration. * Removed obsolete `Dictionary` UI hint-related classes. * Introduced `DictionaryUIHintInputModifier` for centralized modifier logic. * Relocated `DictionaryValueEvaluator` to the appropriate namespace and updated feature registrations. * Fix formatting of `Category` property in `Log` activity. * Refactor input evaluation: replace `ActivatorUtilities` with `GetRequiredService` and update `DictionaryValueEvaluator` to handle dynamic types. * Remove unused logging-related imports from `Program.cs`. * Update src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add logging to `DictionaryValueEvaluator` for missing property warnings. --------- Co-authored-by: Sipke Schoorstra Co-authored-by: lukhipolito-nexxbiz Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- src/apps/Elsa.Server.Web/Program.cs | 2 - src/modules/Elsa.Logging/Activities/Log.cs | 29 +++++----- src/modules/Elsa.Logging/Elsa.Logging.csproj | 6 +- .../Attributes/InputAttribute.cs | 6 ++ .../Contexts/ActivityInputEvaluatorContext.cs | 12 ++++ .../Contracts/IActivityInputEvaluator.cs | 6 ++ ...cutionContextExtensions.InputEvaluation.cs | 11 +++- .../Features/WorkflowsFeature.cs | 8 ++- .../Models/InputDescriptor.cs | 22 +++++--- .../Services/ActivityDescriber.cs | 6 +- .../Services/DefaultActivityInputEvaluator.cs | 14 +++++ .../DictionaryUIHintInputModifier.cs | 14 +++++ .../Dictionary/DictionaryValueEvaluator.cs | 55 +++++++++++++++++++ 14 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6af29175e..990f42ecb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true - 3.5.0 + 3.5.1-preview.1211 9.0.8 diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index b973b255d..64fbe93c4 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -69,8 +69,6 @@ using JetBrains.Annotations; using Medallion.Threading.FileSystem; using Medallion.Threading.Postgres; using Medallion.Threading.Redis; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; diff --git a/src/modules/Elsa.Logging/Activities/Log.cs b/src/modules/Elsa.Logging/Activities/Log.cs index 355dc863d..653fc8ff6 100644 --- a/src/modules/Elsa.Logging/Activities/Log.cs +++ b/src/modules/Elsa.Logging/Activities/Log.cs @@ -47,12 +47,22 @@ public class Log : CodeActivity /// [Input(Description = "The log message to emit.")] public Input Message { get; set; } = new(string.Empty); - + + /// + /// Arguments for the templated string message. + /// + [Input(Description = "Values of named or indexed placeholders in the log message.")] + public Input Arguments { get; set; } = null!; + /// /// Additional attributes to include in the log entry. /// - [Input(Description = "Values of named or indexed placeholders in the log message.")] - public Input Arguments { get; set; } = null!; + [Input( + Description = "Flat dictionary of key/value pairs to include as attributes.", + DisplayName = "Attributes", + UIHint = InputUIHints.Dictionary + )] + public Input> Attributes { get; set; } = null!; /// /// The log level. @@ -66,15 +76,6 @@ public class Log : CodeActivity [Input(Description = "The category. Defaults to 'Process'.", DefaultValue = "Process")] public Input Category { get; set; } = new("Process"); - /// - /// Additional attributes to include in the log entry. - /// - [Input( - Description = "Flat dictionary of key/value pairs to include as attributes.", - UIHint = InputUIHints.Dictionary - )] - public Input> Attributes { get; set; } = null!; - /// /// Target sinks to write to. /// @@ -98,8 +99,8 @@ public class Log : CodeActivity // Could be JSON created from e.g., Liquid template. If so, parse it into an ExpandoObject. arguments = TryParseJson(argumentString); } - - var attributes = Attributes.GetOrDefault(context) ?? new Dictionary(); + + var attributes = Attributes.GetOrDefault(context) ?? new Dictionary()!; var sinkNames = SinkNames.GetOrDefault(context) ?? new List(); var category = Category.GetOrDefault(context); if (string.IsNullOrWhiteSpace(category)) category = "Process"; diff --git a/src/modules/Elsa.Logging/Elsa.Logging.csproj b/src/modules/Elsa.Logging/Elsa.Logging.csproj index b4f0b319f..beb7ec790 100644 --- a/src/modules/Elsa.Logging/Elsa.Logging.csproj +++ b/src/modules/Elsa.Logging/Elsa.Logging.csproj @@ -1,4 +1,4 @@ - + @@ -8,9 +8,9 @@ - + - + \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs index 98335c6b0..982f409d0 100644 --- a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs +++ b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs @@ -78,6 +78,12 @@ public class InputAttribute : Attribute /// public bool AutoEvaluate { get; set; } = true; + /// + /// Specifies the type of a custom evaluator to use for evaluating the input property value. + /// The evaluator type determines how the value for the property is resolved at runtime. + /// + public Type? EvaluatorType { get; set; } + /// /// A value indicating whether this input can be serialized as part of the workflow instance, /// diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs new file mode 100644 index 000000000..99e1a4572 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityInputEvaluatorContext.cs @@ -0,0 +1,12 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public record ActivityInputEvaluatorContext( + ActivityExecutionContext ActivityExecutionContext, + ExpressionExecutionContext ExpressionExecutionContext, + InputDescriptor InputDescriptor, + Input Input, + IExpressionEvaluator ExpressionEvaluator); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs new file mode 100644 index 000000000..ec94417a4 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInputEvaluator.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows; + +public interface IActivityInputEvaluator +{ + Task EvaluateAsync(ActivityInputEvaluatorContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs index f47aa314d..db798e5cf 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -91,9 +91,16 @@ public static partial class ActivityExecutionContextExtensions } else { - var evaluator = context.GetRequiredService(); + var expressionEvaluator = context.GetRequiredService(); var expressionExecutionContext = context.ExpressionExecutionContext; - value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator); + + if (wrappedInput?.Expression != null) + { + var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType); + var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator); + value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext); + } } var memoryReference = wrappedInput?.MemoryBlockReference(); diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index cc3f81aad..0608b8d92 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -22,6 +22,7 @@ using Elsa.Workflows.Serialization.Helpers; using Elsa.Workflows.Serialization.Serializers; using Elsa.Workflows.Services; 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; @@ -187,6 +188,7 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // Incident Strategies. .AddTransient() @@ -228,17 +230,17 @@ public class WorkflowsFeature : FeatureBase // Instantiation strategies. .AddScoped() - // UI hints. + // UI. .AddScoped() .AddScoped() .AddScoped() .AddScoped() - - // UI property handlers. .AddScoped() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddSingleton() // Logger state generators. .AddSingleton(WorkflowLoggerStateGenerator) diff --git a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs index 8c7f24fe0..9d699e9f1 100644 --- a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs @@ -21,19 +21,20 @@ public class InputDescriptor : PropertyDescriptor bool isWrapped, string uiHint, string displayName, - string? description = default, - string? category = default, + string? description = null, + string? category = null, float order = 0, - object? defaultValue = default, + object? defaultValue = null, string? defaultSyntax = "Literal", bool isReadOnly = false, bool isBrowsable = true, bool isSerializable = true, bool isSynthetic = false, bool autoEvaluate = true, - Type? storageDriverType = default, - PropertyInfo? propertyInfo = default, - IDictionary? uiSpecifications = default + Type? evaluatorType = null, + Type? storageDriverType = null, + PropertyInfo? propertyInfo = null, + IDictionary? uiSpecifications = null ) { Name = name; @@ -50,6 +51,7 @@ public class InputDescriptor : PropertyDescriptor DefaultSyntax = defaultSyntax; IsReadOnly = isReadOnly; AutoEvaluate = autoEvaluate; + EvaluatorType = evaluatorType; StorageDriverType = storageDriverType; IsSynthetic = isSynthetic; IsBrowsable = isBrowsable; @@ -66,7 +68,7 @@ public class InputDescriptor : PropertyDescriptor /// /// A string value that hints at what UI control might be used to render in a UI tool. /// - public string UIHint { get; set; } = default!; + public string UIHint { get; set; } = null!; /// /// The category to which this input belongs. Can be used by UI to e.g. render different inputs in different tabs. @@ -104,6 +106,12 @@ public class InputDescriptor : PropertyDescriptor /// True if the expression should be evaluated automatically, false otherwise. Defaults to true. /// public bool AutoEvaluate { get; set; } = true; + + /// + /// Specifies the type of a custom evaluator to use for evaluating the input property value. + /// The evaluator type determines how the value for the property is resolved at runtime. + /// + public Type? EvaluatorType { get; set; } /// /// A dictionary of UI specifications to be used by the UI. diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 9533f4652..4810b915f 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -150,8 +150,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); - return new InputDescriptor - ( + return new( inputAttribute?.Name ?? propertyInfo.Name, wrappedPropertyType, propertyInfo.GetValue, @@ -169,7 +168,8 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve inputAttribute?.IsSerializable ?? true, false, autoEvaluate, - default, + inputAttribute?.EvaluatorType, + null, propertyInfo, uiSpecification ); diff --git a/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs new file mode 100644 index 000000000..ca89d369f --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityInputEvaluator.cs @@ -0,0 +1,14 @@ +using Elsa.Extensions; + +namespace Elsa.Workflows; + +public class DefaultActivityInputEvaluator : IActivityInputEvaluator +{ + public async Task EvaluateAsync(ActivityInputEvaluatorContext context) + { + var wrappedInput = context.Input; + var evaluator = context.ExpressionEvaluator; + var expressionExecutionContext = context.ExpressionExecutionContext; + return await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs new file mode 100644 index 000000000..21e104d4b --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryUIHintInputModifier.cs @@ -0,0 +1,14 @@ +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.UIHints.Dictionary; + +public class DictionaryUIHintInputModifier : IActivityDescriptorModifier +{ + public void Modify(ActivityDescriptor descriptor) + { + var dictionaryInputs = descriptor.Inputs.Where(x => x.UIHint == InputUIHints.Dictionary).ToList(); + + foreach (var dictionaryInput in dictionaryInputs) + dictionaryInput.EvaluatorType = typeof(DictionaryValueEvaluator); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs new file mode 100644 index 000000000..0efcffb67 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/Dictionary/DictionaryValueEvaluator.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.UIHints.Dictionary; + +public class DictionaryValueEvaluator(ILogger logger) : IActivityInputEvaluator +{ + public async Task EvaluateAsync(ActivityInputEvaluatorContext context) + { + var wrappedInput = context.Input; + var evaluator = context.ExpressionEvaluator; + var expressionExecutionContext = context.ExpressionExecutionContext; + var inputDescriptor = context.InputDescriptor; + var defaultValue = inputDescriptor.DefaultValue; + var value = wrappedInput.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + if (value is IDictionary dictionary && inputDescriptor.UIHint == InputUIHints.Dictionary) + { + var tempDictionary = new Dictionary(dictionary.Count); + foreach (var dict in dictionary) + { + if (dict.Value is not JsonElement json) + { + // Not a JSON object, so just use the value as-is. + tempDictionary[dict.Key] = dict.Value; + continue; + } + + // JSON object, so extract the type and value properties. + var hasType = json.TryGetProperty("type", out var typeProperty); + var hasValue = json.TryGetProperty("value", out var valueProperty); + + if (!hasType || !hasValue) + { + // Skip this entry or handle as needed (e.g., log, throw, etc.) + logger.LogWarning("Dictionary entry is missing type or value property: {Json}", JsonSerializer.Serialize(json)); + continue; + } + + // Evaluate the expression. + var expression = new Expression(typeProperty.ToString(), valueProperty.ToString()); + var val = await evaluator.EvaluateAsync(expression, expressionExecutionContext); + + // Add the evaluated value to the dictionary. + tempDictionary[dict.Key] = val; + } + + // Replace the original dictionary with the evaluated one. + value = tempDictionary; + } + + return value; + } +} \ No newline at end of file From 43fb93fb3ae0ac05b769ffd2668bf2bbe36b80b4 Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Thu, 4 Sep 2025 18:06:26 +0200 Subject: [PATCH 12/21] Fixing unit tests (#6891) * Fixing unit tests * Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: lucas.hipolito Co-authored-by: Sipke Schoorstra Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Extensions/ActivityExecutionContextExtensions.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index ceb0f01e5..5beda310d 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -443,22 +443,19 @@ public static partial class ActivityExecutionContextExtensions /// public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value) { - var extensionsDictionary = context.GetExtensionsMetadata(); - - if(extensionsDictionary == null) extensionsDictionary = new(); - + var extensionsDictionary = context.GetExtensionsMetadata() ?? new Dictionary(); + extensionsDictionary[key] = value; context.Metadata[ExtensionsMetadataKey] = extensionsDictionary; - } /// - /// Retrives the extensin data from the metdata. Represents specific data that is exposed generically for an activity. + /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity. /// public static Dictionary? GetExtensionsMetadata(this ActivityExecutionContext context) { - return context.Metadata[ExtensionsMetadataKey] as Dictionary; + return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; } internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; From 007b84aea15c78f8ab844d861d27c2f4b1e086f4 Mon Sep 17 00:00:00 2001 From: MariusVuscanNx <96233009+MariusVuscanNx@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:17:58 +0300 Subject: [PATCH 13/21] Made incidents items flat as otel does not support complex objects like arrays (#6893) --- ...metryTracingWorkflowExecutionMiddleware.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs index 937255a7e..687369d7d 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs @@ -96,21 +96,29 @@ public class OpenTelemetryTracingWorkflowExecutionMiddleware(WorkflowMiddlewareD if (context.Incidents.Any()) { - var incidentTagsList = new List(); - foreach (var incident in context.Incidents) + var incidentsList = context.Incidents.ToList(); + for (int i = 0; i < incidentsList.Count; i++) { - var incidentTags = CreateIncidentTags(incident); - incidentTagsList.Add(incidentTags); - span.AddEvent(new("incident", incident.Timestamp, incidentTags)); + AddIncidentToSpan(span, incidentsList[i], i); } - span.SetTag("workflow.incidents.items", incidentTagsList); - span.SetTag("workflow.incidents.count", context.Incidents.Count); + span.SetTag("workflow.incidents.count", incidentsList.Count); } if (!string.IsNullOrWhiteSpace(context.CorrelationId)) span.SetTag("workflow.correlation_id", context.CorrelationId); } + + private void AddIncidentToSpan(Activity span, ActivityIncident incident, int index) + { + var tags = CreateIncidentTags(incident); + span.AddEvent(new ActivityEvent("incident", incident.Timestamp, tags)); + + foreach (var tag in tags) + { + span.SetTag($"workflow.incidents.{index}.{tag.Key}", tag.Value); + } + } private Activity? CreateTraceActivity(WorkflowExecutionContext context, string? workflowName) { From dc6d57a1e5fd5af09535e7ba8dd91d82da87ee46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Vasile=20Vu=C8=99can?= Date: Wed, 10 Sep 2025 11:39:24 +0300 Subject: [PATCH 14/21] Made execute endpoint model case insensitive --- .../Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index 796fe7b32..e5a574bf7 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -34,7 +34,11 @@ internal class PostEndpoint( { try { - request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body, cancellationToken: cancellationToken); + request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }, cancellationToken: cancellationToken); } catch { From 5a0112fbd6098daa4dd3e6997c1648d4be94847e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Vasile=20Vu=C8=99can?= Date: Wed, 10 Sep 2025 14:37:07 +0300 Subject: [PATCH 15/21] Explicitly set content lenght --- .../Elsa.Http/Activities/SendHttpRequestBase.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 898c76365..fcf53304e 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -216,6 +216,21 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul async Task SendRequestAsyncCore(CancellationToken ct = default) { var request = PrepareRequest(context); + + if (request.Content != null && request.Content.Headers.ContentLength is null) + { + var bytes = await request.Content.ReadAsByteArrayAsync(ct); + + var buffered = new ByteArrayContent(bytes); + + foreach (var h in request.Content.Headers) + buffered.Headers.TryAddWithoutValidation(h.Key, h.Value); + + buffered.Headers.ContentLength = bytes.Length; + + request.Content = buffered; + } + return await httpClient.SendAsync(request, ct); } } From 001f406e5cf24cdb7d1b117a3496fbe415d3241e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Vasile=20Vu=C8=99can?= Date: Wed, 10 Sep 2025 14:39:36 +0300 Subject: [PATCH 16/21] push --- .github/workflows/packages.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 9458a3d0a..291e25d85 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -8,6 +8,7 @@ on: - 'perf/*' - 'patch/*' - 'feat/*' + - 'feature/*' - 'enh/*' - 'rc/*' - 'develop/*' From 4ec48e1a2c927395645b8a8a1f64b111482a3b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Vasile=20Vu=C8=99can?= Date: Wed, 10 Sep 2025 15:01:29 +0300 Subject: [PATCH 17/21] Fixed content type check from execute endpoint --- .../Elsa.Http/Activities/SendHttpRequestBase.cs | 14 -------------- .../WorkflowDefinitions/Execute/PostEndpoint.cs | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index fcf53304e..34e90abc7 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -217,20 +217,6 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul { var request = PrepareRequest(context); - if (request.Content != null && request.Content.Headers.ContentLength is null) - { - var bytes = await request.Content.ReadAsByteArrayAsync(ct); - - var buffered = new ByteArrayContent(bytes); - - foreach (var h in request.Content.Headers) - buffered.Headers.TryAddWithoutValidation(h.Key, h.Value); - - buffered.Headers.ContentLength = bytes.Length; - - request.Content = buffered; - } - return await httpClient.SendAsync(request, ct); } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index e5a574bf7..6f787ae4d 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -30,7 +30,7 @@ internal class PostEndpoint( { PostRequest? request = null; - if (HttpContext.Request is { ContentLength: > 0, ContentType: "application/json" or null }) + if (HttpContext.Request.ContentLength > 0 && (HttpContext.Request.ContentType?.Contains("application/json") ?? true)) { try { From eeec5949efb434a3c050fef2daf07c1746b98774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Vasile=20Vu=C8=99can?= Date: Wed, 10 Sep 2025 15:02:33 +0300 Subject: [PATCH 18/21] revert --- .github/workflows/packages.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 291e25d85..9458a3d0a 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -8,7 +8,6 @@ on: - 'perf/*' - 'patch/*' - 'feat/*' - - 'feature/*' - 'enh/*' - 'rc/*' - 'develop/*' From c549f49dfbb03073c195f488a189d01097a0ec20 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Sep 2025 22:30:25 +0200 Subject: [PATCH 19/21] Fix race condition when sending same stimuli (#6895) * Introduce `WorkflowResumer` service and deprecate `BookmarkResumer`. - Adds `IWorkflowResumer` and its implementation for workflow resumption. - Marks `BookmarkResumer` and related interfaces as obsolete. - Refactors dependent services to use `WorkflowResumer`. - Enhances `ResumeBookmarkRequest` to include `ActivityInstanceId`. - Updates logging and queue handling logic to align with the new resumption approach. * Update lock key prefix in `WorkflowResumer` for consistency with service naming. * Add exception handling for distributed lock acquisition in `WorkflowResumer` - Wrap distributed lock logic with `try-catch` to handle `TimeoutException`. - Improve error message when lock acquisition fails due to timeout. - Preserve existing workflow resumption behavior and logging. * Update src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Optimize `BookmarkFilter` hashing logic for improved performance and readability. * Merge remote-tracking branch 'origin/enh/locked-bookmark-resumption-2' into enh/locked-bookmark-resumption-2 * Remove unused variable and redundant line breaks for cleaner code. * Clean up logging configuration by removing unused debug log levels. * Update src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Handle collections in `BookmarkFilter` hashing to ensure determinism and improve compatibility. * Refactor `BookmarkFilter` hashing logic for clarity and consistency. * Improve `TimeoutException` handling with a more descriptive message in `WorkflowResumer`. * Update src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Simplify `BookmarkFilter` by utilizing `using` directives and refining type references. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../IBookmarkBoundWorkflowService.cs | 1 + .../Contracts/IBookmarkResumer.cs | 1 + .../Contracts/IWorkflowResumer.cs | 39 +++++ .../Features/WorkflowRuntimeFeature.cs | 1 + .../Filters/BookmarkFilter.cs | 41 ++++++ .../Requests/ResumeBookmarkRequest.cs | 10 +- .../Services/BookmarkBoundWorkflowService.cs | 1 + .../Services/BookmarkQueueProcessor.cs | 10 +- .../Services/BookmarkResumer.cs | 1 + .../Services/StimulusSender.cs | 98 ++++++------- .../Services/StoreBookmarkQueue.cs | 24 +--- .../Services/WorkflowResumer.cs | 135 ++++++++++++++++++ 12 files changed, 277 insertions(+), 85 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs index 72c3e1f7d..72891723b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkBoundWorkflowService.cs @@ -5,6 +5,7 @@ namespace Elsa.Workflows.Runtime; /// /// Represents a service that looks up bookmark-bound workflows. /// +[Obsolete("Will be removed in a future version.")] public interface IBookmarkBoundWorkflowService { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs index 865553096..8e62b5688 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkResumer.cs @@ -6,6 +6,7 @@ namespace Elsa.Workflows.Runtime; /// /// Resumes workflows using a given stimulus or bookmark filter. /// +[Obsolete("Use IWorkflowResumer instead.")] public interface IBookmarkResumer { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs new file mode 100644 index 000000000..ce1b56e06 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowResumer.cs @@ -0,0 +1,39 @@ +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Messages; +using Elsa.Workflows.Runtime.Options; + +namespace Elsa.Workflows.Runtime; + +/// +/// Resumes workflows using a given stimulus or bookmark filter. +/// +public interface IWorkflowResumer +{ + /// + /// Resumes the workflows associated with the bookmarks matching the given stimulus. + /// + Task> ResumeAsync(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// + /// Resumes the workflow associated with the bookmark specified by the given bookmark ID. + /// + Task ResumeAsync(string bookmarkId, IDictionary input, CancellationToken cancellationToken = default); + + /// + /// Resumes the workflows associated with the bookmarks matching the given stimulus. If a workflow instance ID is specified, only resumes workflows associated with that instance. + /// + Task> ResumeAsync(object stimulus, string? workflowInstanceId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// + /// Resumes the workflow associated with the bookmark specified by the given bookmark ID. + /// + Task ResumeAsync(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity; + + /// Resumes the workflows associated with the bookmarks matching the given request. + Task> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default); + + /// + /// Resumes the workflows matching the given bookmark filter. + /// + Task> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index d8f57b739..ccde9984f 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -276,6 +276,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs b/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs index 544c5ddb7..b632362f3 100644 --- a/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs +++ b/src/modules/Elsa.Workflows.Runtime/Filters/BookmarkFilter.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Text; using Elsa.Workflows.Runtime.Entities; namespace Elsa.Workflows.Runtime.Filters; @@ -7,6 +9,9 @@ namespace Elsa.Workflows.Runtime.Filters; /// public class BookmarkFilter { + // Cache the properties of BookmarkFilter for performance. + private static readonly System.Reflection.PropertyInfo[] CachedProperties = typeof(BookmarkFilter).GetProperties(); + /// /// Gets or sets the ID of the bookmark. /// @@ -86,4 +91,40 @@ public class BookmarkFilter { Names = activityTypeNames.ToList() }; + + public string GetHashableString() + { + // Return a hashable string representation of the filter, excluding null values. + var sb = new StringBuilder(); + foreach (var prop in CachedProperties) + { + var value = prop.GetValue(this); + if (value == null) + continue; + + string valueString; + // Handle collections (excluding string) + if (value is IEnumerable enumerable and not string) + { + var items = new List(); + foreach (var item in enumerable) + { + if (item != null) + items.Add(item.ToString()!); + } + items.Sort(StringComparer.Ordinal); + valueString = string.Join(",", items); + } + else + { + var toStringResult = value.ToString(); + if (toStringResult == null) + continue; + valueString = toStringResult; + } + sb.Append($"{prop.Name}:{valueString};"); + } + + return sb.ToString(); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs b/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs index 58a29bed6..4688ae0bb 100644 --- a/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs +++ b/src/modules/Elsa.Workflows.Runtime/Requests/ResumeBookmarkRequest.cs @@ -4,14 +4,20 @@ namespace Elsa.Workflows.Runtime; public class ResumeBookmarkRequest { - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// The ID of the bookmark that triggered the workflow instance, if any. - public string BookmarkId { get; set; } = default!; + public string BookmarkId { get; set; } = null!; /// The handle of the activity to schedule, if any. + [Obsolete("Use ActivityInstanceId instead")] public ActivityHandle? ActivityHandle { get; set; } + /// + /// The ID of the activity instance to resume, if any. + /// + public string? ActivityInstanceId { get; set; } + /// Any additional properties to associate with the workflow instance. public IDictionary? Properties { get; set; } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs index 7fc4e2c4b..9c4c58561 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkBoundWorkflowService.cs @@ -4,6 +4,7 @@ using Elsa.Workflows.Runtime.Options; namespace Elsa.Workflows.Runtime; /// +[Obsolete("Will be removed in a future version.")] public class BookmarkBoundWorkflowService(IWorkflowMatcher workflowMatcher) : IBookmarkBoundWorkflowService { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs index 425b4c63f..341e0ef69 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueProcessor.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; -public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer bookmarkResumer, ILogger logger) : IBookmarkQueueProcessor +public class BookmarkQueueProcessor(IBookmarkQueueStore store, IWorkflowResumer workflowResumer, ILogger logger) : IBookmarkQueueProcessor { public async Task ProcessAsync(CancellationToken cancellationToken = default) { @@ -41,16 +41,16 @@ public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer logger.LogDebug("Processing bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName); - var result = await bookmarkResumer.ResumeAsync(filter, options, cancellationToken); + var responses = (await workflowResumer.ResumeAsync(filter, options, cancellationToken)).ToList(); - if (result.Matched) + if (responses.Count > 0) { - logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName); + logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash} for activity type {ActivityType}", responses.Count, item.StimulusHash, item.ActivityTypeName); await store.DeleteAsync(item.Id, cancellationToken); } else { - logger.LogDebug("No matching bookmark found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName); + logger.LogDebug("No matching bookmarks found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType} with stimulus {StimulusHash}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName, item.StimulusHash); } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs index d91c37aa0..206cd7244 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkResumer.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; /// +[Obsolete("Use WorkflowResumer instead.")] public class BookmarkResumer(IWorkflowRuntime workflowRuntime, IBookmarkStore bookmarkStore, IStimulusHasher stimulusHasher, ILogger logger) : IBookmarkResumer { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs b/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs index bbb688550..2d93bb75b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StimulusSender.cs @@ -1,6 +1,5 @@ -using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Filters; using Elsa.Workflows.Runtime.Messages; -using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Results; using Microsoft.Extensions.Logging; using Open.Linq.AsyncExtensions; @@ -11,9 +10,8 @@ namespace Elsa.Workflows.Runtime; public class StimulusSender( IStimulusHasher stimulusHasher, ITriggerBoundWorkflowService triggerBoundWorkflowService, - IBookmarkBoundWorkflowService bookmarkBoundWorkflowService, + IWorkflowResumer workflowResumer, IBookmarkQueue bookmarkQueue, - IWorkflowRuntime workflowRuntime, ITriggerInvoker triggerInvoker, ILogger logger) : IStimulusSender { @@ -65,15 +63,15 @@ public class StimulusSender( Properties = properties, ParentWorkflowInstanceId = parentId }; - + var response = await triggerInvoker.InvokeAsync(triggerRequest, cancellationToken); - + if (response.CannotStart) { logger.LogWarning("Workflow activation strategy disallowed starting workflow {WorkflowDefinitionHandle} with correlation ID {CorrelationId}", workflow.DefinitionHandle, correlationId); continue; } - + responses.Add(response.ToRunWorkflowInstanceResponse()); } } @@ -83,60 +81,48 @@ public class StimulusSender( private async Task> ResumeExistingWorkflowsAsync(string stimulusHash, StimulusMetadata? metadata, CancellationToken cancellationToken) { - var bookmarkOptions = metadata != null - ? new FindBookmarkOptions - { - CorrelationId = metadata.CorrelationId, - WorkflowInstanceId = metadata.WorkflowInstanceId, - ActivityInstanceId = metadata.ActivityInstanceId, - } - : null; - var bookmarkBoundWorkflows = await bookmarkBoundWorkflowService.FindManyAsync(stimulusHash, bookmarkOptions, cancellationToken).ToList(); var input = metadata?.Input; var properties = metadata?.Properties; - var activityHandle = metadata?.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(metadata.ActivityInstanceId) : null; - var responses = new List(); - - if (bookmarkBoundWorkflows.Count > 0) + + var bookmarkFilter = new BookmarkFilter { - foreach (var bookmarkBoundWorkflow in bookmarkBoundWorkflows) - { - var workflowInstanceId = bookmarkBoundWorkflow.WorkflowInstanceId; - var workflowClient = await workflowRuntime.CreateClientAsync(workflowInstanceId, cancellationToken); + Hash = stimulusHash, + CorrelationId = metadata?.CorrelationId, + WorkflowInstanceId = metadata?.WorkflowInstanceId, + ActivityInstanceId = metadata?.ActivityInstanceId, + BookmarkId = metadata?.BookmarkId + }; + var responses = (await workflowResumer.ResumeAsync(bookmarkFilter, new() + { + Input = input, + Properties = properties + }, cancellationToken)).ToList(); - foreach (var storedBookmark in bookmarkBoundWorkflow.Bookmarks) - { - var request = new RunWorkflowInstanceRequest - { - Input = input, - Properties = properties, - ActivityHandle = activityHandle, - BookmarkId = storedBookmark.Id, - }; - var response = await workflowClient.RunInstanceAsync(request, cancellationToken); - responses.Add(response); - } + if (responses.Count > 0) + { + logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash}", responses.Count, stimulusHash); + return responses; + } + + // If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future. + var workflowInstanceId = metadata?.WorkflowInstanceId; + + var bookmarkQueueItem = new NewBookmarkQueueItem + { + WorkflowInstanceId = workflowInstanceId, + BookmarkId = metadata?.BookmarkId, + CorrelationId = metadata?.CorrelationId, + StimulusHash = stimulusHash, + Options = new() + { + Input = input, + Properties = properties } - } - else - { - // If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future. - var workflowInstanceId = metadata?.WorkflowInstanceId; - - var bookmarkQueueItem = new NewBookmarkQueueItem - { - WorkflowInstanceId = workflowInstanceId, - BookmarkId = metadata?.BookmarkId, - CorrelationId = metadata?.CorrelationId, - StimulusHash = stimulusHash, - Options = new() - { - Input = input, - Properties = properties - } - }; - await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken); - } + }; + + logger.LogDebug("Bookmark queue item enqueued with stimulus: {StimulusHash}", bookmarkQueueItem.StimulusHash); + + await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken); return responses; } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs index b3edde3f1..8a7c87894 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreBookmarkQueue.cs @@ -1,13 +1,11 @@ using Elsa.Common; using Elsa.Workflows.Runtime.Entities; -using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime; public class StoreBookmarkQueue( IBookmarkQueueStore store, - IBookmarkResumer resumer, IBookmarkQueueSignaler bookmarkQueueSignaler, ISystemClock systemClock, IIdentityGenerator identityGenerator, @@ -15,26 +13,6 @@ public class StoreBookmarkQueue( { public async Task EnqueueAsync(NewBookmarkQueueItem item, CancellationToken cancellationToken = default) { - var filter = new BookmarkFilter - { - BookmarkId = item.BookmarkId, - CorrelationId = item.CorrelationId, - Hash = item.StimulusHash, - WorkflowInstanceId = item.WorkflowInstanceId, - Name = item.ActivityTypeName - }; - - var result = await resumer.ResumeAsync(filter, item.Options, cancellationToken); - - if (result.Matched) - { - logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName); - return; - } - - // There was no matching bookmark yet, or the associated workflow instance hasn't been stored in the DB yet. Store the queue item for the system to pick up whenever the bookmark or workflow instance becomes present. - logger.LogDebug("No bookmark with ID {BookmarkId} found for workflow {WorkflowInstance} for activity type {ActivityType}. Adding the request to the bookmark queue", item.BookmarkId, item.WorkflowInstanceId, item.ActivityTypeName); - var entity = new BookmarkQueueItem { Id = identityGenerator.GenerateId(), @@ -48,6 +26,8 @@ public class StoreBookmarkQueue( CreatedAt = systemClock.UtcNow, }; + logger.LogDebug("Enqueuing bookmark queue item {BookmarkQueueItemId} with bookmark {BookmarkId} and stimulus {StimulusHash}", entity.Id, entity.BookmarkId, entity.StimulusHash); + await store.AddAsync(entity, cancellationToken); // Trigger the bookmark queue processor. diff --git a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs new file mode 100644 index 000000000..08b651227 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowResumer.cs @@ -0,0 +1,135 @@ +using Elsa.Common.DistributedHosting; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Runtime.Exceptions; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Messages; +using Elsa.Workflows.Runtime.Options; +using Medallion.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.Runtime; + +/// +public class WorkflowResumer( + IWorkflowRuntime workflowRuntime, + IBookmarkStore bookmarkStore, + IStimulusHasher stimulusHasher, + IDistributedLockProvider distributedLockProvider, + IOptions distributedLockingOptions, + ILogger logger) : IWorkflowResumer +{ + /// + public Task> ResumeAsync(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + return ResumeAsync(stimulus, null, options, cancellationToken); + } + + /// + public async Task> ResumeAsync(object stimulus, string? workflowInstanceId = null, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var stimulusHash = stimulusHasher.Hash(activityTypeName, stimulus); + var bookmarkFilter = new BookmarkFilter + { + Name = activityTypeName, + WorkflowInstanceId = workflowInstanceId, + Hash = stimulusHash, + }; + return await ResumeAsync(bookmarkFilter, options, cancellationToken); + } + + /// + public async Task ResumeAsync(string bookmarkId, IDictionary input, CancellationToken cancellationToken = default) + { + var bookmarkFilter = new BookmarkFilter + { + BookmarkId = bookmarkId + }; + var options = new ResumeBookmarkOptions + { + Input = input + }; + var responses = await ResumeAsync(bookmarkFilter, options, cancellationToken); + return responses.FirstOrDefault(); + } + + /// + public async Task ResumeAsync(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity + { + var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var bookmarkFilter = new BookmarkFilter + { + Name = activityTypeName, + BookmarkId = bookmarkId + }; + var response = await ResumeAsync(bookmarkFilter, options, cancellationToken); + return response.FirstOrDefault(); + } + + public async Task> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default) + { + var filter = new BookmarkFilter + { + BookmarkId = request.BookmarkId, + ActivityInstanceId = request.ActivityInstanceId ?? request.ActivityHandle?.ActivityInstanceId, + }; + + var resumeOptions = new ResumeBookmarkOptions() + { + Input = request.Input, + Properties = request.Properties, + }; + return await ResumeAsync(filter, resumeOptions, cancellationToken); + } + + /// + public async Task> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) + { + var hashableFilterString = filter.GetHashableString(); + var lockKey = $"workflow-resumer:{hashableFilterString}"; + + try + { + await using var filterLock = await distributedLockProvider.AcquireLockAsync(lockKey, distributedLockingOptions.Value.LockAcquisitionTimeout, cancellationToken); + var bookmarks = (await bookmarkStore.FindManyAsync(filter, cancellationToken)).ToList(); + + if (bookmarks.Count == 0) + { + logger.LogDebug("No bookmarks found in store for filter {@Filter}", filter); + return []; + } + + var responses = new List(); + foreach (var bookmark in bookmarks) + { + var workflowClient = await workflowRuntime.CreateClientAsync(bookmark.WorkflowInstanceId, cancellationToken); + var runRequest = new RunWorkflowInstanceRequest + { + Input = options?.Input, + Properties = options?.Properties, + BookmarkId = bookmark.Id + }; + + try + { + var response = await workflowClient.RunInstanceAsync(runRequest, cancellationToken); + logger.LogDebug("Resumed workflow instance {WorkflowInstanceId} with bookmark {BookmarkId}", bookmark.WorkflowInstanceId, bookmark.Id); + responses.Add(response); + } + catch (WorkflowInstanceNotFoundException) + { + // The workflow instance does not (yet) exist in the DB. + logger.LogDebug("No workflow instance with ID {WorkflowInstanceId} found for bookmark {BookmarkId} at this time.", bookmark.WorkflowInstanceId, bookmark.Id); + } + } + + return responses; + } + catch (TimeoutException e) + { + // Rethrow but with a more specific message. + throw new TimeoutException($"Could not acquire distributed lock with key '{lockKey}' within the configured timeout of {distributedLockingOptions.Value.LockAcquisitionTimeout}.", e); + } + } +} \ No newline at end of file From c019cfa3daba42bdfe60a74d3e79aef267777d58 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 12 Sep 2025 19:58:19 +0200 Subject: [PATCH 20/21] - Fix persistence issue by converting `ChangeTrackingDictionary` to a standard dictionary in `BackgroundActivityInvoker`. - Adjust lock acquisition timeout in `appsettings.json` for improved distributed locking behavior. - Remove unused Oracle setup and volume configurations from `docker-compose.yml`. --- docker/docker-compose.yml | 2 -- src/apps/Elsa.Server.Web/appsettings.json | 2 +- .../Services/BackgroundActivityInvoker.cs | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8acb41bf3..debf66283 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -45,7 +45,6 @@ - "5500:5500" volumes: - ./data/oracle-data:/opt/oracle/oradata - - ./setup/oracle-setup:/opt/oracle/scripts/setup mongodb: image: mongo:latest @@ -158,7 +157,6 @@ volumes: sqlserver_data: postgres-data: - oracle-data-free1: mysql_data2: cockroachdb-data: mongodb_data: diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 88f86b20b..cf6cabbd9 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -218,7 +218,7 @@ "Runtime": { "DistributedLocking": { "Provider": "File", - "LockAcquisitionTimeout": "00:00:10" + "LockAcquisitionTimeout": "00:10:00" }, "DistributedLockProvider": "File", "WorkflowDispatcher": { diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs index 49c37e7b2..641e9c50d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs @@ -74,7 +74,7 @@ public class BackgroundActivityInvoker( [inputKey] = outputValues, [journalDataKey] = activityExecutionContext.JournalData, [bookmarksKey] = activityExecutionContext.Bookmarks.ToList(), - [propsKey] = activityExecutionContext.Properties + [propsKey] = activityExecutionContext.Properties.ToDictionary() // ChangeTrackingDictionary is not persistable, so we need to create a copy of the dictionary. }; if (outcomes != null) bookmarkProps[outcomesKey] = outcomes; From b613ff6b6140d7a3ea9035dbacea193876cf7091 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 15 Sep 2025 11:56:01 +0200 Subject: [PATCH 21/21] Addresses workflow identity reload conflict (#6909) * Refactor `IndexTriggersAsync` to use `WorkflowDefinition` and update null assignment for serialization logic. * Change default parameter value from `default` to `null` in `UseFluentStorageProvider` method signature. * Add in-memory workflows provider and materializer for integration tests Introduced `InMemoryWorkflowsProvider` and `InMemoryWorkflowMaterializer` to support integration testing scenarios for workflow definition population. Enhanced workflow handling with fluent method `WithId` for `WorkflowBuilder`. Updated event publishing and dependency injection logic. * Remove extraneous whitespace in DefaultWorkflowDefinitionStorePopulator. * Refine test class documentation for `WorkflowDefinitionStorePopulation` scenario. --- .../Extensions/ModuleExtensions.cs | 2 +- .../Builders/WorkflowBuilder.cs | 6 ++ .../Contracts/IWorkflowBuilder.cs | 7 ++ .../Activities/Event.cs | 2 +- .../DependencyInjectionExtensions.cs | 13 +++- .../Models/MaterializedWorkflow.cs | 2 +- ...DefaultWorkflowDefinitionStorePopulator.cs | 8 +- .../Services/EventPublisher.cs | 5 +- .../InMemoryWorkflowMaterializer.cs | 20 +++++ .../InMemoryWorkflowProvider.cs | 23 ++++++ .../Tests.cs | 73 +++++++++++++++++++ 11 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs index 3fb0c3347..85faeb9d8 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/ModuleExtensions.cs @@ -29,7 +29,7 @@ public static class ModuleExtensions /// The module. /// The configuration delegate. /// The module. - public static IModule UseFluentStorageProvider(this IModule module, Action? configure = default) + public static IModule UseFluentStorageProvider(this IModule module, Action? configure = null) { module.Use(configure); return module; diff --git a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs index fb48a93b8..b30d7669e 100644 --- a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs @@ -63,6 +63,12 @@ public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphSer DefinitionId = definitionId; return this; } + + public IWorkflowBuilder WithId(string id) + { + Id = id; + return this; + } /// public IWorkflowBuilder WithTenantId(string tenantId) diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs index 363fcbf11..fc9a78cdb 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs @@ -96,6 +96,13 @@ public interface IWorkflowBuilder /// The definition ID to use for the workflow being built. IWorkflowBuilder WithDefinitionId(string definitionId); + /// + /// A fluent method for setting the property. + /// + /// The unique identifier to use for the workflow being built. + /// The current instance for method chaining. + IWorkflowBuilder WithId(string id); + /// /// A fluent method for setting the property. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs index 024e5c492..bbcd5f0ef 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs @@ -15,7 +15,7 @@ namespace Elsa.Workflows.Runtime.Activities; [UsedImplicitly] public class Event : Trigger { - internal const string EventInputWorkflowInputKey = "__EventPayloadWorkflowInput"; + public const string EventInputWorkflowInputKey = "__EventPayloadWorkflowInput"; /// internal Event([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs index 31e29f8f8..1a32aa76d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs @@ -10,10 +10,15 @@ namespace Microsoft.Extensions.DependencyInjection; public static class DependencyInjectionExtensions { /// - /// Adds the to the service collection. + /// Adds the specified workflow provider type to the service collection. /// - /// The service collection. - /// The type of the workflow definition provider. - /// The service collection. + /// The type of the workflow provider to add. Must implement . + [Obsolete("Use AddWorkflowsProvider instead.", false)] public static IServiceCollection AddWorkflowDefinitionProvider(this IServiceCollection services) where T : class, IWorkflowsProvider => services.AddScoped(); + + /// + /// Adds the specified workflows provider type to the service collection. + /// + /// The type of the workflow provider to add. Must implement . + public static IServiceCollection AddWorkflowsProvider(this IServiceCollection services) where T : class, IWorkflowsProvider => services.AddScoped(); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs index 909f2e502..4feafd362 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs @@ -9,4 +9,4 @@ namespace Elsa.Workflows.Runtime; /// The name of the provider that provided the workflow definition. /// The name of the materializer that materialized the workflow. /// The context of the materializer that materialized the workflow. -public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = default); \ No newline at end of file +public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs index 5bd0e7939..d21d298fe 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs @@ -56,7 +56,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP { var providers = _workflowDefinitionProviders(); var workflowDefinitions = new List(); - + foreach (var provider in providers) { var results = await provider.GetWorkflowsAsync(cancellationToken).AsTask().ToList(); @@ -84,7 +84,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP var workflowDefinition = await AddOrUpdateAsync(materializedWorkflow, cancellationToken); if (indexTriggers) - await IndexTriggersAsync(materializedWorkflow, cancellationToken); + await IndexTriggersAsync(workflowDefinition, cancellationToken); return workflowDefinition; } @@ -118,7 +118,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP // Serialize materializer context. var materializerContext = materializedWorkflow.MaterializerContext; - var materializerContextJson = materializerContext != null ? _payloadSerializer.Serialize(materializerContext) : default; + var materializerContextJson = materializerContext != null ? _payloadSerializer.Serialize(materializerContext) : null; // Serialize the workflow root. var workflowJson = _activitySerializer.Serialize(workflow.Root); @@ -260,7 +260,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP } } - private async Task IndexTriggersAsync(MaterializedWorkflow materializedWorkflow, CancellationToken cancellationToken) => await _triggerIndexer.IndexTriggersAsync(materializedWorkflow.Workflow, cancellationToken); + private async Task IndexTriggersAsync(WorkflowDefinition workflowDefinition, CancellationToken cancellationToken) => await _triggerIndexer.IndexTriggersAsync(workflowDefinition, cancellationToken); /// /// Syncs the items in the primary list with existing items in the secondary list, even when the object instances are not the same (but their IDs are). diff --git a/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs b/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs index 641223fa2..a760accbe 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs @@ -29,16 +29,17 @@ public class EventPublisher(IStimulusSender stimulusSender, IStimulusDispatcher WorkflowInstanceId = workflowInstanceId, Input = workflowInput }; + var triggerName = ActivityTypeNameHelper.GenerateTypeName(); if (asynchronous) { await stimulusDispatcher.SendAsync(new() { - ActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(), + ActivityTypeName = triggerName, Stimulus = stimulus, Metadata = metadata }, cancellationToken); } else - await stimulusSender.SendAsync(stimulus, metadata, cancellationToken); + await stimulusSender.SendAsync(triggerName, stimulus, metadata, cancellationToken); } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs new file mode 100644 index 000000000..461831eac --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs @@ -0,0 +1,20 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +public class InMemoryWorkflowMaterializer(Workflow workflow) : IWorkflowMaterializer +{ + public string Name => "InMemory"; + + public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) + { + var materializedWorkflow = new Workflow + { + Identity = new(definition.DefinitionId, definition.Version, definition.Id), + Root = workflow.Root + }; + return new(materializedWorkflow); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs new file mode 100644 index 000000000..1dc95db61 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +public class InMemoryWorkflowsProvider(Workflow workflow) : IWorkflowsProvider +{ + public string Name => "InMemory"; + + public ValueTask> GetWorkflowsAsync(CancellationToken cancellationToken = default) + { + var materializedWorkflow = new MaterializedWorkflow( + Workflow: workflow, + ProviderName: "InMemory", + MaterializerName: "InMemory", + MaterializerContext: null + ); + + return new([materializedWorkflow]); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs new file mode 100644 index 000000000..a4cdd4859 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs @@ -0,0 +1,73 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Management; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.Runtime.Stimuli; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +/// +/// Represents a test class for integration testing various scenarios related to the +/// `WorkflowDefinitionStorePopulation`. This class primarily focuses +/// on ensuring correct behavior when workflow definitions are published or updated, and their effects +/// on consuming workflows and triggers. +/// +public class Tests +{ + private readonly IServiceProvider _services; + private readonly Workflow _shiftyWorkflow; + + public Tests(ITestOutputHelper testOutputHelper) + { + _shiftyWorkflow = new() + { + Identity = new( + DefinitionId: "WorkflowWithTrigger", + Version: 1, + Id: "1", + TenantId: "default" + ), + Root = new Event("Foo") + { + CanStartWorkflow = true + } + }; + + _services = new TestApplicationBuilder(testOutputHelper) + .ConfigureServices(services => services + .AddScoped(_ => new InMemoryWorkflowsProvider(_shiftyWorkflow)) + .AddScoped(_ => new InMemoryWorkflowMaterializer(_shiftyWorkflow))) + .Build(); + } + + /// + /// When a dependency workflow is published, all consuming workflows are updated to point to the new version of the dependency. + /// + [Fact(DisplayName = "When a workflow definition from a given source has a different Id than the one in the store, the trigger should still point to the workflow definition version ID in the store.")] + public async Task Test1() + { + // Initial population of the store from workflow providers. + await _services.PopulateRegistriesAsync(); + + // Artificially change the workflow definition version ID. + _shiftyWorkflow.Identity = _shiftyWorkflow.Identity with + { + Id = ":1" + }; + + // Emulate reloading of workflow definitions. + await _services.PopulateRegistriesAsync(); + + // Triggering the workflow should still work. + var stimulusSender = _services.GetRequiredService(); + var stimulus = new EventStimulus("Foo"); + var triggerName = ActivityTypeNameHelper.GenerateTypeName(); + var result = await stimulusSender.SendAsync(triggerName, stimulus); + + Assert.NotEmpty(result.WorkflowInstanceResponses); + } +} \ No newline at end of file