From 2e152721cb2af27cb035504658e7d78d72ccc679 Mon Sep 17 00:00:00 2001 From: David Justo Date: Thu, 30 May 2024 16:42:32 -0700 Subject: [PATCH 01/17] add code mirror pipeline (#2838) --- eng/ci/code-mirror.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 eng/ci/code-mirror.yml diff --git a/eng/ci/code-mirror.yml b/eng/ci/code-mirror.yml new file mode 100644 index 000000000..0a2196b95 --- /dev/null +++ b/eng/ci/code-mirror.yml @@ -0,0 +1,20 @@ +trigger: + branches: + include: + # These are the branches we'll mirror to our internal ADO instance + # Keep this set limited as appropriate (don't mirror individual user branches). + - main + - dev + +resources: + repositories: + - repository: eng + type: git + name: engineering + ref: refs/tags/release + +variables: + - template: ci/variables/cfs.yml@eng + +extends: + template: ci/code-mirror.yml@eng From fb58edf265f6471ae6221e8e1bce15b42ab01682 Mon Sep 17 00:00:00 2001 From: David Justo Date: Fri, 31 May 2024 13:53:56 -0700 Subject: [PATCH 02/17] Clear NuGet security warnings (#2842) --- nuget.config | 1 + test/SmokeTests/OOProcSmokeTests/durableJS/Nuget.config | 3 ++- test/SmokeTests/OOProcSmokeTests/durableJava/Nuget.config | 3 ++- test/SmokeTests/OOProcSmokeTests/durablePy/Nuget.config | 3 ++- test/TimeoutTests/Python/Nuget.config | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/nuget.config b/nuget.config index 652118ea6..d580aab15 100644 --- a/nuget.config +++ b/nuget.config @@ -2,6 +2,7 @@ + diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/Nuget.config b/test/SmokeTests/OOProcSmokeTests/durableJS/Nuget.config index c7e0e8535..4656064a6 100644 --- a/test/SmokeTests/OOProcSmokeTests/durableJS/Nuget.config +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/Nuget.config @@ -1,8 +1,9 @@ + + - \ No newline at end of file diff --git a/test/SmokeTests/OOProcSmokeTests/durableJava/Nuget.config b/test/SmokeTests/OOProcSmokeTests/durableJava/Nuget.config index c7e0e8535..4656064a6 100644 --- a/test/SmokeTests/OOProcSmokeTests/durableJava/Nuget.config +++ b/test/SmokeTests/OOProcSmokeTests/durableJava/Nuget.config @@ -1,8 +1,9 @@ + + - \ No newline at end of file diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/Nuget.config b/test/SmokeTests/OOProcSmokeTests/durablePy/Nuget.config index c7e0e8535..4656064a6 100644 --- a/test/SmokeTests/OOProcSmokeTests/durablePy/Nuget.config +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/Nuget.config @@ -1,8 +1,9 @@ + + - \ No newline at end of file diff --git a/test/TimeoutTests/Python/Nuget.config b/test/TimeoutTests/Python/Nuget.config index c7e0e8535..4656064a6 100644 --- a/test/TimeoutTests/Python/Nuget.config +++ b/test/TimeoutTests/Python/Nuget.config @@ -1,8 +1,9 @@ + + - \ No newline at end of file From 68d8ec99df084f92f989479a308a953efd0cc0b1 Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Fri, 14 Jun 2024 10:12:25 -0700 Subject: [PATCH 03/17] Cache TokenCredential (#2845) This PR caches TokenCredential to resolve a memory issue that has been reported when managed identity is enabled. With these changes, we only create the TokenCredential object once instead of every time that GetStorageAccountDetails() is called. This also means that TokenRenewalState is also created once. --- .../AzureStorageAccountProvider.cs | 9 ++++++++- test/Common/AzureStorageAccountProviderTests.cs | 10 +++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs index 1e22e2716..7d42ec413 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs @@ -2,9 +2,11 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using System.Collections.Concurrent; using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Options; using Microsoft.Extensions.Configuration; + #if !FUNCTIONS_V1 using Microsoft.Azure.WebJobs.Extensions.DurableTask.Auth; using Microsoft.WindowsAzure.Storage.Auth; @@ -19,6 +21,9 @@ internal sealed class AzureStorageAccountProvider : IStorageAccountProvider #if !FUNCTIONS_V1 private readonly ITokenCredentialFactory credentialFactory; + private readonly ConcurrentDictionary cachedTokenCredentials = + new ConcurrentDictionary(); + public AzureStorageAccountProvider(IConnectionInfoResolver connectionInfoResolver, ITokenCredentialFactory credentialFactory) { this.connectionInfoResolver = connectionInfoResolver ?? throw new ArgumentNullException(nameof(connectionInfoResolver)); @@ -44,7 +49,9 @@ public StorageAccountDetails GetStorageAccountDetails(string connectionName) AzureStorageAccountOptions account = connectionInfo.Get(); if (account != null) { - TokenCredential credential = this.credentialFactory.Create(connectionInfo); + TokenCredential credential = this.cachedTokenCredentials.GetOrAdd( + connectionName, + attr => this.credentialFactory.Create(connectionInfo)); return new StorageAccountDetails { diff --git a/test/Common/AzureStorageAccountProviderTests.cs b/test/Common/AzureStorageAccountProviderTests.cs index 67774b2ec..886903f37 100644 --- a/test/Common/AzureStorageAccountProviderTests.cs +++ b/test/Common/AzureStorageAccountProviderTests.cs @@ -80,11 +80,11 @@ public void GetStorageAccountDetails_ConfigSection_Endpoints() Assert.Equal(options.TableServiceUri, actual.TableServiceUri); // Get CloudStorageAccount - CloudStorageAccount acount = actual.ToCloudStorageAccount(); - Assert.Same(actual.StorageCredentials, acount.Credentials); - Assert.Equal(options.BlobServiceUri, acount.BlobEndpoint); - Assert.Equal(options.QueueServiceUri, acount.QueueEndpoint); - Assert.Equal(options.TableServiceUri, acount.TableEndpoint); + CloudStorageAccount account = actual.ToCloudStorageAccount(); + Assert.Same(actual.StorageCredentials, account.Credentials); + Assert.Equal(options.BlobServiceUri, account.BlobEndpoint); + Assert.Equal(options.QueueServiceUri, account.QueueEndpoint); + Assert.Equal(options.TableServiceUri, account.TableEndpoint); } [Fact] From 3eebcdee1f93f2a7869486b3d54e7d4d2f5f668d Mon Sep 17 00:00:00 2001 From: Naiyuan Tian <110135109+nytian@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:59:56 -0700 Subject: [PATCH 04/17] Update WebJobs.Extensions.DurableTask.csproj --- .../WebJobs.Extensions.DurableTask.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 256aea537..914aff257 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -75,7 +75,7 @@ $(AssemblyName).xml - + @@ -96,7 +96,7 @@ $(DefineConstants);FUNCTIONS_V2_OR_GREATER;FUNCTIONS_V3_OR_GREATER - + From 9ef79cd9111fab7bf45beb09bc4e666bc4e03923 Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 17 Jun 2024 14:10:52 -0700 Subject: [PATCH 05/17] Provide actionable suggestions for when webhooks are not configured (#2849) --- src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs | 4 +++- test/Common/HttpApiHandlerTests.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs b/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs index 538f473ee..81253c399 100644 --- a/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs +++ b/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs @@ -1241,7 +1241,9 @@ private HttpResponseMessage CreateCheckStatusResponseMessage( internal Uri GetWebhookUri() { - return this.webhookUrlProvider?.Invoke() ?? throw new InvalidOperationException("Webhooks are not configured"); + string errorMessage = "Webhooks are not configured. This may occur if the environment variable `WEBSITE_HOSTNAME` is not set (should be automatically set for Azure Functions). " + + "Try setting it to the appropiate URI to reach your app. For example: the DNS name of the app, or a value of the form :."; + return this.webhookUrlProvider?.Invoke() ?? throw new InvalidOperationException(errorMessage); } internal bool TryGetRpcBaseUrl(out Uri rpcBaseUrl) diff --git a/test/Common/HttpApiHandlerTests.cs b/test/Common/HttpApiHandlerTests.cs index be254329e..8556ae074 100644 --- a/test/Common/HttpApiHandlerTests.cs +++ b/test/Common/HttpApiHandlerTests.cs @@ -41,7 +41,9 @@ public void CreateCheckStatusResponse_Throws_Exception_When_NotificationUrl_Miss var httpApiHandler = new HttpApiHandler(GetTestExtension(options), null); var ex = Assert.Throws(() => httpApiHandler.CreateCheckStatusResponse(new HttpRequestMessage(), string.Empty, null)); - Assert.Equal("Webhooks are not configured", ex.Message); + string errorMessage = "Webhooks are not configured. This may occur if the environment variable `WEBSITE_HOSTNAME` is not set (should be automatically set for Azure Functions). " + + "Try setting it to the appropiate URI to reach your app. For example: the DNS name of the app, or a value of the form :."; + Assert.Equal(errorMessage, ex.Message); } [Fact] From e70ae86758990f27fe536cb37ed768c6641a507c Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Tue, 18 Jun 2024 15:50:01 -0700 Subject: [PATCH 06/17] Migrate to 1ES pipelines (#2854) This PR migrates the pipelines to 1ES pipelines. It adds 2 files - official-build.yml and build.yml. official-build.yml references build.yml and runs on any changes pushed to the main branch. --- eng/ci/official-build.yml | 36 +++++++++++++ eng/templates/build.yml | 105 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 eng/ci/official-build.yml create mode 100644 eng/templates/build.yml diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml new file mode 100644 index 000000000..91fb7ace7 --- /dev/null +++ b/eng/ci/official-build.yml @@ -0,0 +1,36 @@ +variables: + - template: ci/variables/cfs.yml@eng + +trigger: + batch: true + branches: + include: + - main + +# CI only, does not trigger on PRs. +pr: none + +resources: + repositories: + - repository: 1es + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: eng + type: git + name: engineering + ref: refs/tags/release + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1es + parameters: + pool: + name: 1es-pool-azfunc + image: 1es-windows-2022 + os: windows + + stages: + - stage: BuildAndSign + dependsOn: [] + jobs: + - template: /eng/templates/build.yml@self diff --git a/eng/templates/build.yml b/eng/templates/build.yml new file mode 100644 index 000000000..7bd8ee166 --- /dev/null +++ b/eng/templates/build.yml @@ -0,0 +1,105 @@ +jobs: + - job: Build + + templateContext: + outputs: + - output: pipelineArtifact + path: $(build.artifactStagingDirectory) + artifact: drop + sbomBuildDropPath: $(build.artifactStagingDirectory) + sbomPackageName: 'Durable Functions Extension SBOM' + + steps: + + # Configure all the .NET SDK versions we need + - task: UseDotNet@2 + displayName: 'Use the .NET Core 2.1 SDK (required for build signing)' + inputs: + packageType: 'sdk' + version: '2.1.x' + + - task: UseDotNet@2 + displayName: 'Use the .NET Core 3.1 SDK' + inputs: + packageType: 'sdk' + version: '3.1.x' + + - task: UseDotNet@2 + displayName: 'Use the .NET 6 SDK' + inputs: + packageType: 'sdk' + version: '6.0.x' + + # Start by restoring all the dependencies. + - task: DotNetCoreCLI@2 + displayName: 'dotnet restore' + inputs: + command: restore + projects: '**/**/*.csproj' + feedsToUse: config + nugetConfigPath: 'nuget.config' + + # Build durable-extension + - task: VSBuild@1 + displayName: 'Build Durable Extension' + inputs: + solution: '**/WebJobs.Extensions.DurableTask.sln' + vsVersion: "16.0" + configuration: Release + + - template: ci/sign-files.yml@eng + parameters: + displayName: Sign assemblies + folderPath: 'src/WebJobs.Extensions.DurableTask/bin/Release' + pattern: '*DurableTask.dll' + signType: dll + + # dotnet pack + # Packaging needs to be a separate step from build. + # This will automatically pick up the signed DLLs. + - task: DotNetCoreCLI@2 + displayName: 'dotnet pack WebJobs.Extensions.DurableTask.csproj' + inputs: + command: pack + packagesToPack: 'src/**/WebJobs.Extensions.DurableTask.csproj' + configuration: Release + packDirectory: 'azure-functions-durable-extension' + nobuild: true + + # Remove redundant symbol package(s) + - script: | + echo *** Searching for .symbols.nupkg files to delete... + dir /s /b *.symbols.nupkg + + echo *** Deleting .symbols.nupkg files... + del /S /Q *.symbols.nupkg + + echo *** Listing remaining packages + dir /s /b *.nupkg + displayName: 'Remove Redundant Symbols Package(s)' + continueOnError: true + + - template: ci/sign-files.yml@eng + parameters: + displayName: Sign NugetPackages + folderPath: $(build.artifactStagingDirectory) + pattern: '*.nupkg' + signType: nuget + + # zip .NET in-proc perf tests + - task: DotNetCoreCLI@2 + displayName: 'Zip .NET in-proc perf tests' + inputs: + command: 'publish' + publishWebProjects: false + projects: '$(System.DefaultWorkingDirectory)/test/PerfTests/DFPerfTests/**/*.csproj' + arguments: '-o $(System.DefaultWorkingDirectory)/test/PerfTests/DFPerfTests/Output' + zipAfterPublish: true + modifyOutputPath: true + + # Move zip'ed .NET in-proc perf tests to the ADO publishing directory + - task: CopyFiles@2 + inputs: + SourceFolder: '$(System.DefaultWorkingDirectory)/test/PerfTests/DFPerfTests/Output/' + Contents: '**' + TargetFolder: '$(System.DefaultWorkingDirectory)/azure-functions-durable-extension/' From 779d56a9165b44684cbfcb7be51f5311fe740d0f Mon Sep 17 00:00:00 2001 From: David Justo Date: Wed, 19 Jun 2024 10:57:36 -0700 Subject: [PATCH 07/17] Replace ADO tests with GitHub actions (#2855) --- .github/workflows/validate-build-analyzer.yml | 58 ++++++++++++++++++ .github/workflows/validate-build-e2e.yml | 60 ++++++++++++++++++ .github/workflows/validate-build.yml | 61 +++++++++++++++++++ ...bs.Extensions.DurableTask.Analyzers.csproj | 1 + .../WebJobs.Extensions.DurableTask.csproj | 2 +- test/Common/DurableTaskEndToEndTests.cs | 5 +- test/Common/TestDurableClient.cs | 2 +- test/Common/TestHelpers.cs | 2 +- test/FunctionsV2/CorrelationEndToEndTests.cs | 14 +---- test/FunctionsV2/OutOfProcTests.cs | 3 + .../PlatformSpecificHelpers.FunctionsV2.cs | 3 +- .../SmokeTests/SmokeTestsV1/VSSampleV1.csproj | 1 - 12 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/validate-build-analyzer.yml create mode 100644 .github/workflows/validate-build-e2e.yml create mode 100644 .github/workflows/validate-build.yml diff --git a/.github/workflows/validate-build-analyzer.yml b/.github/workflows/validate-build-analyzer.yml new file mode 100644 index 000000000..ee49e3585 --- /dev/null +++ b/.github/workflows/validate-build-analyzer.yml @@ -0,0 +1,58 @@ +name: Validate Build (analyzer) + +on: + push: + branches: + - main + paths-ignore: [ '**.md' ] + pull_request: + branches: + - main + paths-ignore: [ '**.md' ] + +env: + solution: WebJobs.Extensions.DurableTask.sln + config: Release + AzureWebJobsStorage: UseDevelopmentStorage=true + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + + - name: Set up .NET Core 3.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '3.1.x' + + - name: Set up .NET Core 2.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '2.1.x' + + - name: Restore dependencies + run: dotnet restore $solution + + - name: Build + run: dotnet build $solution + + # Install Azurite + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' # Azurite requires at least Node 18 + + - name: Install Azurite + run: npm install -g azurite + + # Run tests + - name: Run Analyzer tests + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/WebJobs.Extensions.DurableTask.Analyzers.Test/WebJobs.Extensions.DurableTask.Analyzers.Test.csproj + diff --git a/.github/workflows/validate-build-e2e.yml b/.github/workflows/validate-build-e2e.yml new file mode 100644 index 000000000..8290d61e1 --- /dev/null +++ b/.github/workflows/validate-build-e2e.yml @@ -0,0 +1,60 @@ +name: Validate Build (E2E tests) + +on: + push: + branches: + - main + paths-ignore: [ '**.md' ] + pull_request: + branches: + - main + paths-ignore: [ '**.md' ] + +env: + solution: WebJobs.Extensions.DurableTask.sln + config: Release + AzureWebJobsStorage: UseDevelopmentStorage=true + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + + - name: Set up .NET Core 3.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '3.1.x' + + - name: Set up .NET Core 2.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '2.1.x' + + - name: Restore dependencies + run: dotnet restore $solution + + - name: Build + run: dotnet build $solution + + # Install Azurite + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' # Azurite requires at least Node 18 + + - name: Install Azurite + run: npm install -g azurite + + # Run tests + - name: Run FunctionsV2 tests (only DurableEntity_CleanEntityStorage test, which is flaky) + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests.DurableEntity_CleanEntityStorage" + + - name: Run FunctionsV2 tests (all other E2E tests) + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests&FullyQualifiedName!~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests.DurableEntity_CleanEntityStorage" \ No newline at end of file diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml new file mode 100644 index 000000000..6c23b1198 --- /dev/null +++ b/.github/workflows/validate-build.yml @@ -0,0 +1,61 @@ +name: Validate Build (except E2E tests) + +on: + push: + branches: + - main + paths-ignore: [ '**.md' ] + pull_request: + branches: + - main + paths-ignore: [ '**.md' ] + +env: + solution: WebJobs.Extensions.DurableTask.sln + config: Release + AzureWebJobsStorage: UseDevelopmentStorage=true + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + + - name: Set up .NET Core 3.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '3.1.x' + + - name: Set up .NET Core 2.1 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '2.1.x' + + - name: Restore dependencies + run: dotnet restore $solution + + - name: Build + run: dotnet build $solution + + # Install Azurite + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' # Azurite requires at least Node 18 + + - name: Install Azurite + run: npm install -g azurite + + # Run tests + - name: Run FunctionsV2 tests (except E2E tests) + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName!~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests" + + - name: Run Worker Extension tests + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/Worker.Extensions.DurableTask.Tests/Worker.Extensions.DurableTask.Tests.csproj + diff --git a/src/WebJobs.Extensions.DurableTask.Analyzers/WebJobs.Extensions.DurableTask.Analyzers.csproj b/src/WebJobs.Extensions.DurableTask.Analyzers/WebJobs.Extensions.DurableTask.Analyzers.csproj index be27d32a9..6c627046e 100644 --- a/src/WebJobs.Extensions.DurableTask.Analyzers/WebJobs.Extensions.DurableTask.Analyzers.csproj +++ b/src/WebJobs.Extensions.DurableTask.Analyzers/WebJobs.Extensions.DurableTask.Analyzers.csproj @@ -4,6 +4,7 @@ netstandard2.0 false true + RS1026 diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 914aff257..0b17fee29 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -57,7 +57,7 @@ - + diff --git a/test/Common/DurableTaskEndToEndTests.cs b/test/Common/DurableTaskEndToEndTests.cs index 1044eddab..57eb8f532 100644 --- a/test/Common/DurableTaskEndToEndTests.cs +++ b/test/Common/DurableTaskEndToEndTests.cs @@ -746,6 +746,9 @@ await TestHelpers.WaitUntilTrue( conditionDescription: "Log file exists", timeout: TimeSpan.FromSeconds(30)); + // add a minute wait to ensure logs are fully written + await Task.Delay(TimeSpan.FromMinutes(1)); + await TestHelpers.WaitUntilTrue( predicate: () => { @@ -4299,7 +4302,7 @@ public async Task DurableEntity_EntityProxy_NameResolve(bool extendedSessions) } /// - /// Test which validates that entity state deserialization + /// Test which validates that entity state deserialization. /// [Theory] [InlineData(true)] diff --git a/test/Common/TestDurableClient.cs b/test/Common/TestDurableClient.cs index d9f41d4c4..6e35e5e49 100644 --- a/test/Common/TestDurableClient.cs +++ b/test/Common/TestDurableClient.cs @@ -182,7 +182,7 @@ public async Task WaitForCompletionAsync( { if (timeout == null) { - timeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(30); + timeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(1); } Stopwatch sw = Stopwatch.StartNew(); diff --git a/test/Common/TestHelpers.cs b/test/Common/TestHelpers.cs index 83259db08..e89c09978 100644 --- a/test/Common/TestHelpers.cs +++ b/test/Common/TestHelpers.cs @@ -12,8 +12,8 @@ using DurableTask.AzureStorage; using Microsoft.ApplicationInsights.Channel; #if !FUNCTIONS_V1 -using Microsoft.Extensions.Hosting; using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Hosting; #endif using Microsoft.Azure.WebJobs.Host.TestCommon; using Microsoft.Extensions.Logging; diff --git a/test/FunctionsV2/CorrelationEndToEndTests.cs b/test/FunctionsV2/CorrelationEndToEndTests.cs index 0b9e31eac..6eb2e9090 100644 --- a/test/FunctionsV2/CorrelationEndToEndTests.cs +++ b/test/FunctionsV2/CorrelationEndToEndTests.cs @@ -234,7 +234,7 @@ internal async Task, List>> [InlineData(false, true, true)] [InlineData(true, true, false)] [InlineData(true, true, true)] - public async void TelemetryClientSetup_AppInsights_Warnings(bool instrumentationKeyIsSet, bool connStringIsSet, bool extendedSessions) + public void TelemetryClientSetup_AppInsights_Warnings(bool instrumentationKeyIsSet, bool connStringIsSet, bool extendedSessions) { TraceOptions traceOptions = new TraceOptions() { @@ -258,11 +258,11 @@ public async void TelemetryClientSetup_AppInsights_Warnings(bool instrumentation } else if (instrumentationKeyIsSet) { - mockNameResolver = GetNameResolverMock(new[] { (instKeyEnvVarName, environmentVariableValue), (connStringEnvVarName, String.Empty) }); + mockNameResolver = GetNameResolverMock(new[] { (instKeyEnvVarName, environmentVariableValue), (connStringEnvVarName, string.Empty) }); } else if (connStringIsSet) { - mockNameResolver = GetNameResolverMock(new[] { (instKeyEnvVarName, String.Empty), (connStringEnvVarName, connStringValue) }); + mockNameResolver = GetNameResolverMock(new[] { (instKeyEnvVarName, string.Empty), (connStringEnvVarName, connStringValue) }); } using (var host = TestHelpers.GetJobHost( @@ -405,14 +405,6 @@ private static List GetCorrelationSortedList(OperationTeleme var result = new List(); if (current.Count != 0) { - foreach (var some in current) - { - if (parent.Id == some.Context.Operation.ParentId) - { - Console.WriteLine("match"); - } - } - IOrderedEnumerable nexts = current.Where(p => p.Context.Operation.ParentId == parent.Id).OrderBy(p => p.Timestamp.Ticks); foreach (OperationTelemetry next in nexts) { diff --git a/test/FunctionsV2/OutOfProcTests.cs b/test/FunctionsV2/OutOfProcTests.cs index 4f95c1a2b..69fa450a5 100644 --- a/test/FunctionsV2/OutOfProcTests.cs +++ b/test/FunctionsV2/OutOfProcTests.cs @@ -342,6 +342,7 @@ public async Task TestLocalRcpEndpointRuntimeVersion(string runtimeVersion, bool // Validate if we opened local RPC endpoint by looking at log statements. var logger = this.loggerProvider.CreatedLoggers.Single(l => l.Category == TestHelpers.LogCategory); var logMessages = logger.LogMessages.ToList(); + bool enabledRpcEndpoint = logMessages.Any(msg => msg.Level == Microsoft.Extensions.Logging.LogLevel.Information && msg.FormattedMessage.StartsWith($"Opened local {expectedProtocol} endpoint:")); Assert.Equal(enabledExpected, enabledRpcEndpoint); @@ -363,6 +364,7 @@ public async Task InvokeLocalRpcEndpoint() { await host.StartAsync(); +#pragma warning disable SYSLIB0014 // Type or member is obsolete using (var client = new WebClient()) { string jsonString = client.DownloadString("http://localhost:17071/durabletask/instances"); @@ -370,6 +372,7 @@ public async Task InvokeLocalRpcEndpoint() // The result is expected to be an empty array JArray array = JArray.Parse(jsonString); } +#pragma warning restore SYSLIB0014 // Type or member is obsolete await host.StopAsync(); } diff --git a/test/FunctionsV2/PlatformSpecificHelpers.FunctionsV2.cs b/test/FunctionsV2/PlatformSpecificHelpers.FunctionsV2.cs index 4b2d46456..428b93c9d 100644 --- a/test/FunctionsV2/PlatformSpecificHelpers.FunctionsV2.cs +++ b/test/FunctionsV2/PlatformSpecificHelpers.FunctionsV2.cs @@ -230,7 +230,6 @@ private static IWebJobsBuilder AddEmulatorDurableTask(this IWebJobsBuilder build internal class FunctionsV2HostWrapper : ITestHost { - internal readonly IHost InnerHost; private readonly JobHost innerWebJobsHost; private readonly DurableTaskOptions options; private readonly INameResolver nameResolver; @@ -255,6 +254,8 @@ internal FunctionsV2HostWrapper( this.options = options.Value; } + internal IHost InnerHost { get; private set; } + public Task CallAsync(string methodName, IDictionary args) => this.innerWebJobsHost.CallAsync(methodName, args); diff --git a/test/SmokeTests/SmokeTestsV1/VSSampleV1.csproj b/test/SmokeTests/SmokeTestsV1/VSSampleV1.csproj index 1d2b30732..304a6fe3b 100644 --- a/test/SmokeTests/SmokeTestsV1/VSSampleV1.csproj +++ b/test/SmokeTests/SmokeTestsV1/VSSampleV1.csproj @@ -10,7 +10,6 @@ - From 781104f3c3182f613e769b81ac7de8875b9e3f50 Mon Sep 17 00:00:00 2001 From: David Justo Date: Wed, 19 Jun 2024 16:15:23 -0700 Subject: [PATCH 08/17] add dev as trigger branch for gh acitons (#2859) --- .github/workflows/validate-build-analyzer.yml | 2 ++ .github/workflows/validate-build-e2e.yml | 2 ++ .github/workflows/validate-build.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/validate-build-analyzer.yml b/.github/workflows/validate-build-analyzer.yml index ee49e3585..4eb275c4e 100644 --- a/.github/workflows/validate-build-analyzer.yml +++ b/.github/workflows/validate-build-analyzer.yml @@ -4,10 +4,12 @@ on: push: branches: - main + - dev paths-ignore: [ '**.md' ] pull_request: branches: - main + - dev paths-ignore: [ '**.md' ] env: diff --git a/.github/workflows/validate-build-e2e.yml b/.github/workflows/validate-build-e2e.yml index 8290d61e1..8056b73f8 100644 --- a/.github/workflows/validate-build-e2e.yml +++ b/.github/workflows/validate-build-e2e.yml @@ -4,10 +4,12 @@ on: push: branches: - main + - dev paths-ignore: [ '**.md' ] pull_request: branches: - main + - dev paths-ignore: [ '**.md' ] env: diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index 6c23b1198..9f740e834 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -4,10 +4,12 @@ on: push: branches: - main + - dev paths-ignore: [ '**.md' ] pull_request: branches: - main + - dev paths-ignore: [ '**.md' ] env: From 6e3f5f27c3285d55aa10fb00236ec0074c14a84e Mon Sep 17 00:00:00 2001 From: Naiyuan Tian <110135109+nytian@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:05:17 -0700 Subject: [PATCH 09/17] Add Injected Durable Client with Identity Based Connection Samples (#2861) * initial commit * version update * reword description * udpate by comment * reword * remove typo --- .../Controllers/TodoController.cs | 130 ++++++++++++++++++ .../aspnetcore-app/Program.cs | 22 +++ .../aspnetcore-app/README.md | 37 +++++ .../aspnetcore-app/Startup.cs | 74 ++++++++++ .../aspnetcore-app/ToDoList.csproj | 20 +++ .../aspnetcore-app/ToDoList.sln | 25 ++++ .../aspnetcore-app/appsettings.json | 17 +++ .../functions-app/ClientFunction.cs | 52 +++++++ .../DurableClientSampleFunctionApp.csproj | 20 +++ .../functions-app/README.md | 34 +++++ .../functions-app/Startup.cs | 17 +++ .../functions-app/host.json | 11 ++ .../functions-app/local.settings.json | 9 ++ 13 files changed, 468 insertions(+) create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/Controllers/TodoController.cs create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/Program.cs create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/README.md create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/Startup.cs create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/ToDoList.csproj create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/ToDoList.sln create mode 100644 samples/durable-client-managed-identity/aspnetcore-app/appsettings.json create mode 100644 samples/durable-client-managed-identity/functions-app/ClientFunction.cs create mode 100644 samples/durable-client-managed-identity/functions-app/DurableClientSampleFunctionApp.csproj create mode 100644 samples/durable-client-managed-identity/functions-app/README.md create mode 100644 samples/durable-client-managed-identity/functions-app/Startup.cs create mode 100644 samples/durable-client-managed-identity/functions-app/host.json create mode 100644 samples/durable-client-managed-identity/functions-app/local.settings.json diff --git a/samples/durable-client-managed-identity/aspnetcore-app/Controllers/TodoController.cs b/samples/durable-client-managed-identity/aspnetcore-app/Controllers/TodoController.cs new file mode 100644 index 000000000..c1c4ba6d8 --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/Controllers/TodoController.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Azure.WebJobs.Extensions.DurableTask; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.ContextImplementations; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using TodoApi.Models; + +namespace TodoApi.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class TodoController : Controller + { + private readonly TodoContext _context; + private readonly IDurableClient _client; + + public TodoController(TodoContext context, IDurableClientFactory clientFactory, IConfiguration configuration) + { + _context = context; + + if (_context.TodoItems.Count() == 0) + { + _context.TodoItems.Add(new TodoItem { Name = "Item1" }); + _context.SaveChanges(); + } + + _client = clientFactory.CreateClient(new DurableClientOptions + { + ConnectionName = configuration["MyStorage"], + TaskHub = configuration["TaskHub"] + }); + } + + // GET: api/Todo + [HttpGet] + public async Task>> GetTodoItem() + { + return await _context.TodoItems.ToListAsync(); + } + + // GET: api/Todo/5 + [HttpGet("{id}")] + public async Task> GetTodoItem(long id) + { + var todoItem = await _context.TodoItems.FindAsync(id); + + if (todoItem == null) + { + return NotFound(); + } + + return todoItem; + } + + // PUT: api/Todo/5 + // To protect from overposting attacks, please enable the specific properties you want to bind to, for + // more details see https://aka.ms/RazorPagesCRUD. + [HttpPut("{id}")] + public async Task PutTodoItem(long id, TodoItem todoItem) + { + if (id != todoItem.Id) + { + return BadRequest(); + } + + _context.Entry(todoItem).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!TodoItemExists(id)) + { + return NotFound(); + } + else + { + throw; + } + } + + return NoContent(); + } + + // POST: api/Todo + // To protect from overposting attacks, please enable the specific properties you want to bind to, for + // more details see https://aka.ms/RazorPagesCRUD. + [HttpPost] + public async Task> PostTodoItem(TodoItem todoItem) + { + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + string instanceId = await _client.StartNewAsync("SetReminder", todoItem.Name); + + return CreatedAtAction("GetTodoItem", new { id = todoItem.Id }, todoItem); + } + + // DELETE: api/Todo/5 + [HttpDelete("{id}")] + public async Task> DeleteTodoItem(long id) + { + var todoItem = await _context.TodoItems.FindAsync(id); + if (todoItem == null) + { + return NotFound(); + } + + _context.TodoItems.Remove(todoItem); + await _context.SaveChangesAsync(); + + return todoItem; + } + + private bool TodoItemExists(long id) + { + return _context.TodoItems.Any(e => e.Id == id); + } + } +} diff --git a/samples/durable-client-managed-identity/aspnetcore-app/Program.cs b/samples/durable-client-managed-identity/aspnetcore-app/Program.cs new file mode 100644 index 000000000..e5f916eef --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace TodoApi +{ + public class Program + { + + static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/samples/durable-client-managed-identity/aspnetcore-app/README.md b/samples/durable-client-managed-identity/aspnetcore-app/README.md new file mode 100644 index 000000000..4c6cb3567 --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/README.md @@ -0,0 +1,37 @@ +# ASP.NET Core API To Do List Sample with Identity-Based Connection + +This example is adapted from the [To Do List sample](https://github.com/Azure-Samples/dotnet-core-api) in the Azure-Samples repository. It demonstrates an ASP.NET Core application with an injected Durable Client and identity-based connections. In this sample, the Durable Client is configured to use a storage connection with a custom name, `MyStorage`, and is set up to utilize a client secret for authentication. + + +## To make the sample run, you need to: + +1. Create an identity for your Function App in the Azure portal. + +2. Grant the following Role-Based Access Control (RBAC) permissions to the identity: + - Storage Queue Data Contributor + - Storage Blob Data Contributor + - Storage Table Data Contributor + +3. Link your storage account to your Function App by adding either of these two details to your configuration, which is appsettings.json file in this sample . + - accountName + - blobServiceUri, queueServiceUri and tableServiceUri + +4. Add the required identity information to your Functions App configuration, which is appsettings.json file in this sample. + - system-assigned identity: nothing needs to be provided. + - user-assigned identity: + - credential: managedidentity + - clientId + - client secret application: + - clientId + - ClientSecret + - tenantId + + +## Notes +- The storage connection information must be provided in the format specified in the appsettings.json file. +- If your storage information is saved in a custom-named JSON file, be sure to add it to your configuration as shown below. +```csharp +this.Configuration = new ConfigurationBuilder() + .AddJsonFile("myjson.json") + .Build(); +``` \ No newline at end of file diff --git a/samples/durable-client-managed-identity/aspnetcore-app/Startup.cs b/samples/durable-client-managed-identity/aspnetcore-app/Startup.cs new file mode 100644 index 000000000..d0dc745bf --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/Startup.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using TodoApi.Models; +using Microsoft.Azure.WebJobs.Extensions.DurableTask; + +namespace TodoApi +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + // AddDurableClientFactory() registers IDurableClientFactory as a service so the application + // can consume it and and call the Durable Client APIs + services.AddDurableClientFactory(); + + services.AddControllers(); + + // Register the Swagger generator, defining 1 or more Swagger documents + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + }); + + services.AddDbContext(options => options.UseInMemoryDatabase("TodoList")); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + // Enable middleware to serve generated Swagger as a JSON endpoint. + app.UseSwagger(); + + // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), + // specifying the Swagger JSON endpoint. + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); + }); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + //app.UseHttpsRedirection(); + + app.UseDefaultFiles(); + + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.csproj b/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.csproj new file mode 100644 index 000000000..fdd756ee8 --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.sln b/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.sln new file mode 100644 index 000000000..e10bb40b2 --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/ToDoList.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30503.244 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ToDoList", "ToDoList.csproj", "{D75105D4-B93A-4A9B-B12E-E8EF0F7E6223}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D75105D4-B93A-4A9B-B12E-E8EF0F7E6223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D75105D4-B93A-4A9B-B12E-E8EF0F7E6223}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D75105D4-B93A-4A9B-B12E-E8EF0F7E6223}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D75105D4-B93A-4A9B-B12E-E8EF0F7E6223}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E7920E27-E4F7-47B7-B1B9-01F8645883CA} + EndGlobalSection +EndGlobal diff --git a/samples/durable-client-managed-identity/aspnetcore-app/appsettings.json b/samples/durable-client-managed-identity/aspnetcore-app/appsettings.json new file mode 100644 index 000000000..06b8d6289 --- /dev/null +++ b/samples/durable-client-managed-identity/aspnetcore-app/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "TaskHub": "MyTestHub", + "MyStorage": { + "accountName": "YourStorageAccountName", + "clientId": "", + "clientsecret": "", + "tenantId": "" + } +} \ No newline at end of file diff --git a/samples/durable-client-managed-identity/functions-app/ClientFunction.cs b/samples/durable-client-managed-identity/functions-app/ClientFunction.cs new file mode 100644 index 000000000..cc19a7ca7 --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/ClientFunction.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Microsoft.Azure.WebJobs.Extensions.DurableTask; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.ContextImplementations; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Options; +using Microsoft.Extensions.Configuration; + +namespace DurableClientSampleFunctionApp +{ + public class ClientFunction + { + private readonly IDurableClient _client; + + public ClientFunction(IDurableClientFactory clientFactory, IConfiguration configuration) + { + _client = clientFactory.CreateClient(new DurableClientOptions + { + ConnectionName = "ClientStorage", + TaskHub = configuration["TaskHub"] + }); + } + + [FunctionName("CallHelloSequence")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, + ILogger log) + { + log.LogInformation("C# HTTP trigger function processed a request."); + + string instanceId = await _client.StartNewAsync("E1_HelloSequence"); + + DurableOrchestrationStatus status = await _client.GetStatusAsync(instanceId); + + while (status.RuntimeStatus == OrchestrationRuntimeStatus.Pending || + status.RuntimeStatus == OrchestrationRuntimeStatus.Running || + status.RuntimeStatus == OrchestrationRuntimeStatus.ContinuedAsNew) + { + await Task.Delay(10000); + status = await _client.GetStatusAsync(instanceId); + } + + return new ObjectResult(status); + } + } +} diff --git a/samples/durable-client-managed-identity/functions-app/DurableClientSampleFunctionApp.csproj b/samples/durable-client-managed-identity/functions-app/DurableClientSampleFunctionApp.csproj new file mode 100644 index 000000000..cb7299f19 --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/DurableClientSampleFunctionApp.csproj @@ -0,0 +1,20 @@ + + + netcoreapp3.1 + v3 + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + diff --git a/samples/durable-client-managed-identity/functions-app/README.md b/samples/durable-client-managed-identity/functions-app/README.md new file mode 100644 index 000000000..870403d19 --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/README.md @@ -0,0 +1,34 @@ +# Azure Function App with Durable Function and Identity-Based Connection + +This project demonstrates an Azure Function App that invokes a Durable Function through a Durable Client using dependency injection and identity-based connection. In the sample, the function is set up to utilize a storage connection named `Storage` by default. Meanwhile, the integrated Durable Client is set to use a storage connection that is specifically named `ClientStorage`. + + +## To make the sample run, you need to: + +1. Create an identity for your Function App in the Azure portal. + +2. Grant the following Role-Based Access Control (RBAC) permissions to the identity: + - Storage Queue Data Contributor + - Storage Blob Data Contributor + - Storage Table Data Contributor + +3. Link your storage account to your Function App by adding either of these two details to your `local.settings.json` file (for local development) or as environment variables in your Function App settings in Azure. + - AzureWebJobsStorage__accountName + - AzureWebJobsStorage__blobServiceUri, AzureWebJobsStorage__queueServiceUri and AzureWebJobsStorage__tableServiceUri + +4. Add the required identity information to your Functions App configuration. + - system-assigned identity: nothing needs to be provided. + - user-assigned identity: + - AzureWebJobsStorage__credential: managedidentity + - AzureWebJobsStorage__clientId + - client secret application: + - AzureWebJobsStorage__clientId + - AzureWebJobsStorage__ClientSecret + - AzureWebJobsStorage__tenantId + + +## Notes + +- The Azure Functions runtime requires a storage account to start, with the default connection name `Storage`. +- The Durable Client injected also requires a storage account, with the same default connection name `Storage`. However, you can use a custom connection name for a separate storage account as runtime for the durable client. For example, in this sample we use custom name `ClientStorage`. + diff --git a/samples/durable-client-managed-identity/functions-app/Startup.cs b/samples/durable-client-managed-identity/functions-app/Startup.cs new file mode 100644 index 000000000..4bb731cfb --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/Startup.cs @@ -0,0 +1,17 @@ +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Azure.WebJobs.Extensions.DurableTask; + +[assembly: FunctionsStartup(typeof(DurableClientSampleFunctionApp.Startup))] + +namespace DurableClientSampleFunctionApp +{ + public class Startup : FunctionsStartup + { + public override void Configure(IFunctionsHostBuilder builder) + { + // AddDurableClientFactory() registers IDurableClientFactory as a service so the application + // can consume it and and call the Durable Client APIs + builder.Services.AddDurableClientFactory(); + } + } +} diff --git a/samples/durable-client-managed-identity/functions-app/host.json b/samples/durable-client-managed-identity/functions-app/host.json new file mode 100644 index 000000000..bb3b8dadd --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/host.json @@ -0,0 +1,11 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingExcludedTypes": "Request", + "samplingSettings": { + "isEnabled": true + } + } + } +} \ No newline at end of file diff --git a/samples/durable-client-managed-identity/functions-app/local.settings.json b/samples/durable-client-managed-identity/functions-app/local.settings.json new file mode 100644 index 000000000..a4173f806 --- /dev/null +++ b/samples/durable-client-managed-identity/functions-app/local.settings.json @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage__accountName": "", + "ClientStorage__accountName": "", + "FUNCTIONS_WORKER_RUNTIME": "dotnet", + "TaskHub": "mytesthub" + } +} From 11e5b76c717a057730cccd9507d81c5db6c3c6be Mon Sep 17 00:00:00 2001 From: Naiyuan Tian <110135109+nytian@users.noreply.github.com> Date: Wed, 26 Jun 2024 17:04:27 -0700 Subject: [PATCH 10/17] Update External Durable Client ReadMe.md (#2865) * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * add \ --- .../functions-app/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/samples/durable-client-managed-identity/functions-app/README.md b/samples/durable-client-managed-identity/functions-app/README.md index 870403d19..cfda39c88 100644 --- a/samples/durable-client-managed-identity/functions-app/README.md +++ b/samples/durable-client-managed-identity/functions-app/README.md @@ -13,22 +13,22 @@ This project demonstrates an Azure Function App that invokes a Durable Function - Storage Table Data Contributor 3. Link your storage account to your Function App by adding either of these two details to your `local.settings.json` file (for local development) or as environment variables in your Function App settings in Azure. - - AzureWebJobsStorage__accountName - - AzureWebJobsStorage__blobServiceUri, AzureWebJobsStorage__queueServiceUri and AzureWebJobsStorage__tableServiceUri + - \__accountName + - \__blobServiceUri, \__queueServiceUri and \__tableServiceUri 4. Add the required identity information to your Functions App configuration. - system-assigned identity: nothing needs to be provided. - user-assigned identity: - - AzureWebJobsStorage__credential: managedidentity - - AzureWebJobsStorage__clientId + - \__credential: managedidentity + - \__clientId - client secret application: - - AzureWebJobsStorage__clientId - - AzureWebJobsStorage__ClientSecret - - AzureWebJobsStorage__tenantId + - \__clientId + - \__ClientSecret + - \__tenantId ## Notes - The Azure Functions runtime requires a storage account to start, with the default connection name `Storage`. - The Durable Client injected also requires a storage account, with the same default connection name `Storage`. However, you can use a custom connection name for a separate storage account as runtime for the durable client. For example, in this sample we use custom name `ClientStorage`. - +- To provide the necessary connection information, use the format `__`, as shown in local.settings.json. For example, if you want to specify the accountName, then add the setting `__accountName`. From 829777ae7efb3dbbbab396f4bd4d0f76bc3c0955 Mon Sep 17 00:00:00 2001 From: Naiyuan Tian <110135109+nytian@users.noreply.github.com> Date: Wed, 26 Jun 2024 19:27:23 -0700 Subject: [PATCH 11/17] Enable External Durable Client Managed Identity Support (#2856) * Update StandardConnectionInfoProvider.cs * Update StandardConnectionInfoProvider.cs * Update StandardConnectionInfoProvider.cs * Update StandardConnectionInfoProvider.cs * add description about the resolve method implementation * remove whitespace * update by comment --- .../StandardConnectionInfoProvider.cs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/StandardConnectionInfoProvider.cs b/src/WebJobs.Extensions.DurableTask/StandardConnectionInfoProvider.cs index 0a9bcca7d..2597877bf 100644 --- a/src/WebJobs.Extensions.DurableTask/StandardConnectionInfoProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/StandardConnectionInfoProvider.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using System.Linq; using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask @@ -23,10 +24,40 @@ public StandardConnectionInfoProvider(IConfiguration configuration) this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } + // This implementation is a clone of `IConfigurationSection.Exists` found here https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Configuration.Abstractions/src/ConfigurationExtensions.cs#L78 + // Functions host v1 (.net462 framework) doesn't support this method so we implement a substitute one here. + private bool IfExists(IConfigurationSection section) + { + if (section == null) + { + return false; + } + + if (section.Value == null) + { + return section.GetChildren().Any(); + } + + return true; + } + /// public IConfigurationSection Resolve(string name) { - return this.configuration.GetSection(name); + // This implementation is a replica of the WebJobsConnectionInfoProvider used for the internal durable client. + // The original code can be found at: + // https://github.com/Azure/azure-functions-durable-extension/blob/dev/src/WebJobs.Extensions.DurableTask/WebJobsConnectionInfoProvider.cs#L37. + // We need to first check the configuration section with the AzureWebJobs prefix, as this is the default name within the Functions app whether it's internal or external. + string prefixedConnectionStringName = "AzureWebJobs" + name; + IConfigurationSection section = this.configuration?.GetSection(prefixedConnectionStringName); + + if (!this.IfExists(section)) + { + // If the section doesn't exist, then look for the configuration section without the prefix, since there is no prefix outside the WebJobs app. + section = this.configuration?.GetSection(name); + } + + return section; } } -} \ No newline at end of file +} From 08c385bd837b8fa1ec07f6a76160e3b5cf2b2236 Mon Sep 17 00:00:00 2001 From: David Justo Date: Tue, 9 Jul 2024 10:53:12 -0700 Subject: [PATCH 12/17] Refactor entity cleanup test to make it less flakey (#2871) --- test/Common/DurableTaskEndToEndTests.cs | 28 ++++++++++++++++++------- test/Common/HttpApiHandlerTests.cs | 4 ++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/test/Common/DurableTaskEndToEndTests.cs b/test/Common/DurableTaskEndToEndTests.cs index 57eb8f532..489379cdd 100644 --- a/test/Common/DurableTaskEndToEndTests.cs +++ b/test/Common/DurableTaskEndToEndTests.cs @@ -5065,6 +5065,8 @@ public async Task DurableEntity_CleanEntityStorage(string storageProvider) var orchestrationA = $"{prefix}-A"; var orchestrationB = $"{prefix}-B"; + // PART 1: Test removal of empty entities + // create an empty entity var client = await host.StartOrchestratorAsync(nameof(TestOrchestrations.CreateEmptyEntities), new EntityId[] { emptyEntityId }, this.output); var status = await client.WaitForCompletionAsync(this.output); @@ -5084,6 +5086,17 @@ public async Task DurableEntity_CleanEntityStorage(string storageProvider) var result = await client.InnerClient.ListEntitiesAsync(query, CancellationToken.None); Assert.Contains(result.Entities, s => s.EntityId.Equals(emptyEntityId)); + // test removal of empty entity + var response = await client.InnerClient.CleanEntityStorageAsync(removeEmptyEntities: true, releaseOrphanedLocks: false, CancellationToken.None); + Assert.Equal(1, response.NumberOfEmptyEntitiesRemoved); + Assert.Equal(0, response.NumberOfOrphanedLocksRemoved); + + // check that the empty entity record has been removed from storage + result = await client.InnerClient.ListEntitiesAsync(query, CancellationToken.None); + Assert.DoesNotContain(result.Entities, s => s.EntityId.Equals(emptyEntityId)); + + // PART 2: Test recovery from orphaned locks + // run an orchestration A that leaves an orphaned lock TestDurableClient clientA = await host.StartOrchestratorAsync(nameof(TestOrchestrations.LockThenFailReplay), (orphanedEntityId, true), this.output, orchestrationA); status = await clientA.WaitForCompletionAsync(this.output); @@ -5091,21 +5104,20 @@ public async Task DurableEntity_CleanEntityStorage(string storageProvider) // run an orchestration B that queues behind A for the lock (and thus gets stuck) TestDurableClient clientB = await host.StartOrchestratorAsync(nameof(TestOrchestrations.LockThenFailReplay), (orphanedEntityId, false), this.output, orchestrationB); - // remove empty entity and release orphaned lock - var response = await client.InnerClient.CleanEntityStorageAsync(true, true, CancellationToken.None); + await Task.Delay(TimeSpan.FromMinutes(1)); // wait for a stable entity executionID, needed until https://github.com/Azure/durabletask/pull/1128 is merged + + // remove release orphaned lock to unblock orchestration B + // Note: do NOT remove empty entities yet: we want to keep the empty entity so it can unblock orchestration B + response = await client.InnerClient.CleanEntityStorageAsync(removeEmptyEntities: false, releaseOrphanedLocks: true, CancellationToken.None); Assert.Equal(1, response.NumberOfOrphanedLocksRemoved); - Assert.Equal(1, response.NumberOfEmptyEntitiesRemoved); + Assert.Equal(0, response.NumberOfEmptyEntitiesRemoved); // wait for orchestration B to complete, now that the lock has been released status = await clientB.WaitForCompletionAsync(this.output); Assert.True(status.RuntimeStatus == OrchestrationRuntimeStatus.Completed); - // check that the empty entity record has been removed from storage - result = await client.InnerClient.ListEntitiesAsync(query, CancellationToken.None); - Assert.DoesNotContain(result.Entities, s => s.EntityId.Equals(emptyEntityId)); - // clean again to remove the orphaned entity which is now empty also - response = await client.InnerClient.CleanEntityStorageAsync(true, true, CancellationToken.None); + response = await client.InnerClient.CleanEntityStorageAsync(removeEmptyEntities: true, releaseOrphanedLocks: true, CancellationToken.None); Assert.Equal(0, response.NumberOfOrphanedLocksRemoved); Assert.Equal(1, response.NumberOfEmptyEntitiesRemoved); diff --git a/test/Common/HttpApiHandlerTests.cs b/test/Common/HttpApiHandlerTests.cs index 8556ae074..615d20874 100644 --- a/test/Common/HttpApiHandlerTests.cs +++ b/test/Common/HttpApiHandlerTests.cs @@ -411,14 +411,14 @@ public async Task WaitForCompletionOrCreateCheckStatusResponseAsync_Returns_HTTP TaskHub = TestConstants.TaskHub, ConnectionName = TestConstants.ConnectionName, }, - TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(3)); stopwatch.Stop(); Assert.Equal(HttpStatusCode.OK, httpResponseMessage.StatusCode); var content = await httpResponseMessage.Content.ReadAsStringAsync(); var value = JsonConvert.DeserializeObject(content); Assert.Equal("Hello Tokyo!", value); - Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10)); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15)); } [Fact] From c88b62c4905e37c08fef3c61c4f027b1eef86c9e Mon Sep 17 00:00:00 2001 From: David Justo Date: Tue, 9 Jul 2024 11:42:24 -0700 Subject: [PATCH 13/17] Minimize telemetry surface area (#2844) --- .../DurableEntityContext.cs | 16 +- .../DurableOrchestrationContext.cs | 8 +- .../RemoteOrchestratorContext.cs | 15 +- .../DurableTaskExtension.cs | 38 +--- .../EndToEndTraceHelper.cs | 198 +++++++++++++----- .../EtwEventSource.cs | 31 ++- .../Listener/TaskActivityShim.cs | 13 +- .../Listener/TaskEntityShim.cs | 18 +- .../Listener/TaskOrchestrationShim.cs | 18 +- .../Listener/WrappedFunctionResult.cs | 5 +- .../OutOfProcMiddleware.cs | 24 +-- test/Common/TestHelpers.cs | 4 +- test/FunctionsV2/EndToEndTraceHelperTests.cs | 108 ++++++++++ 13 files changed, 341 insertions(+), 155 deletions(-) create mode 100644 test/FunctionsV2/EndToEndTraceHelperTests.cs diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableEntityContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableEntityContext.cs index 0b658eca5..228034dd3 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableEntityContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableEntityContext.cs @@ -122,13 +122,12 @@ bool IDurableEntityContext.HasState public void CaptureInternalError(Exception e, TaskEntityShim shim) { // first, try to get a quick ETW message out to help us diagnose what happened - string details = Utils.IsFatal(e) ? e.GetType().Name : e.ToString(); this.Config.TraceHelper.EntityBatchFailed( this.HubName, this.Name, this.InstanceId, shim.TraceFlags, - details); + e); // then, record the error for additional reporting and tracking in other places this.InternalError = ExceptionDispatchInfo.Capture(e); @@ -180,22 +179,27 @@ public void ThrowApplicationExceptionsIfAny() } } - public bool ErrorsPresent(out string description) + public bool ErrorsPresent(out string error, out string sanitizedError) { if (this.InternalError != null) { - description = $"Internal error: {this.InternalError.SourceException}"; + error = $"Internal error: {this.InternalError.SourceException}"; + sanitizedError = $"Internal error: {this.InternalError.SourceException.GetType().FullName} \n {this.InternalError.SourceException.StackTrace}"; return true; } else if (this.ApplicationErrors != null) { var messages = this.ApplicationErrors.Select(i => $"({i.SourceException.Message})"); - description = $"One or more operations failed: {string.Concat(messages)}"; + error = $"One or more operations failed: {string.Concat(messages)}"; + + string errorTypes = string.Join(", ", this.ApplicationErrors.Select(i => i.SourceException.GetType().FullName)); + sanitizedError = $"One or more operations failed: {errorTypes}"; return true; } else { - description = string.Empty; + error = string.Empty; + sanitizedError = string.Empty; return false; } } diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs index 572c1b747..782a4c8b1 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs @@ -781,7 +781,7 @@ internal async Task CallDurableTaskFunctionAsync( operationId, operationName, input: "(replayed)", - exception: "(replayed)", + exception: exception, duration: 0, isReplay: true); } @@ -791,7 +791,7 @@ internal async Task CallDurableTaskFunctionAsync( this.Config.Options.HubName, functionName, this.InstanceId, - reason: $"(replayed {exception.GetType().Name})", + exception: exception, functionType: functionType, isReplay: true); } @@ -933,7 +933,7 @@ internal void RaiseEvent(string name, string input) FunctionType.Orchestrator, this.InstanceId, name, - this.Config.GetIntputOutputTrace(responseMessage.Result), + responseMessage.Result, this.IsReplaying); } else @@ -943,7 +943,7 @@ internal void RaiseEvent(string name, string input) this.Name, this.InstanceId, name, - this.Config.GetIntputOutputTrace(input), + input, this.IsReplaying); } diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs index 5a8a50482..9c4d6a02e 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs @@ -64,18 +64,11 @@ internal OrchestratorExecutionResult GetResult() return this.executionResult ?? throw new InvalidOperationException($"The execution result has not yet been set using {nameof(this.SetResult)}."); } - internal bool TryGetOrchestrationErrorDetails(out string details) + internal bool TryGetOrchestrationErrorDetails(out Exception? failure) { - if (this.failure != null) - { - details = this.failure.Message; - return true; - } - else - { - details = string.Empty; - return false; - } + bool hasError = this.failure != null; + failure = hasError ? this.failure : null; + return hasError; } internal void SetResult(IEnumerable actions, string customStatus) diff --git a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs index 3ae312bef..937b236ea 100644 --- a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs +++ b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs @@ -154,7 +154,7 @@ public DurableTaskExtension( ILogger logger = loggerFactory.CreateLogger(LoggerCategoryName); - this.TraceHelper = new EndToEndTraceHelper(logger, this.Options.Tracing.TraceReplayEvents); + this.TraceHelper = new EndToEndTraceHelper(logger, this.Options.Tracing.TraceReplayEvents, this.Options.Tracing.TraceInputsAndOutputs); this.LifeCycleNotificationHelper = lifeCycleNotificationHelper ?? this.CreateLifeCycleNotificationHelper(); this.durabilityProviderFactory = GetDurabilityProviderFactory(this.Options, logger, orchestrationServiceFactories); this.defaultDurabilityProvider = this.durabilityProviderFactory.GetDurabilityProvider(); @@ -1037,7 +1037,7 @@ private async Task EntityMiddleware(DispatchMiddlewareContext dispatchContext, F entityContext.HubName, entityContext.Name, entityContext.InstanceId, - this.GetIntputOutputTrace(runtimeState.Input), + runtimeState.Input, FunctionType.Entity, isReplay: false); @@ -1063,13 +1063,14 @@ private async Task EntityMiddleware(DispatchMiddlewareContext dispatchContext, F await next(); // 5. If there were internal or application errors, trace them for DF - if (entityContext.ErrorsPresent(out var description)) + if (entityContext.ErrorsPresent(out string description, out string sanitizedError)) { this.TraceHelper.FunctionFailed( entityContext.HubName, entityContext.Name, entityContext.InstanceId, description, + sanitizedReason: sanitizedError, functionType: FunctionType.Entity, isReplay: false); } @@ -1079,7 +1080,7 @@ private async Task EntityMiddleware(DispatchMiddlewareContext dispatchContext, F entityContext.HubName, entityContext.Name, entityContext.InstanceId, - this.GetIntputOutputTrace(entityContext.State.EntityState), + entityContext.State.EntityState, continuedAsNew: true, functionType: FunctionType.Entity, isReplay: false); @@ -1486,35 +1487,6 @@ bool HasActiveListeners(RegisteredFunctionInfo info) return false; } - internal string GetIntputOutputTrace(string rawInputOutputData) - { - if (this.Options.Tracing.TraceInputsAndOutputs) - { - return rawInputOutputData; - } - else if (rawInputOutputData == null) - { - return "(null)"; - } - else - { - // Azure Storage uses UTF-32 encoding for string payloads - return "(" + Encoding.UTF32.GetByteCount(rawInputOutputData) + " bytes)"; - } - } - - internal string GetExceptionTrace(string rawExceptionData) - { - if (rawExceptionData == null) - { - return "(null)"; - } - else - { - return rawExceptionData; - } - } - /// Task IAsyncConverter.ConvertAsync( HttpRequestMessage request, diff --git a/src/WebJobs.Extensions.DurableTask/EndToEndTraceHelper.cs b/src/WebJobs.Extensions.DurableTask/EndToEndTraceHelper.cs index e730d6e71..8d7e2ddd6 100644 --- a/src/WebJobs.Extensions.DurableTask/EndToEndTraceHelper.cs +++ b/src/WebJobs.Extensions.DurableTask/EndToEndTraceHelper.cs @@ -4,26 +4,31 @@ using System; using System.Diagnostics; using System.Net; +using DurableTask.Core.Common; +using DurableTask.Core.Exceptions; using Microsoft.Extensions.Logging; +#nullable enable namespace Microsoft.Azure.WebJobs.Extensions.DurableTask { internal class EndToEndTraceHelper { private static readonly string ExtensionVersion = FileVersionInfo.GetVersionInfo(typeof(DurableTaskExtension).Assembly.Location).FileVersion; - private static string appName; - private static string slotName; + private static string? appName; + private static string? slotName; private readonly ILogger logger; private readonly bool traceReplayEvents; + private readonly bool shouldTraceRawData; private long sequenceNumber; - public EndToEndTraceHelper(ILogger logger, bool traceReplayEvents) + public EndToEndTraceHelper(ILogger logger, bool traceReplayEvents, bool shouldTraceRawData = false) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.traceReplayEvents = traceReplayEvents; + this.shouldTraceRawData = shouldTraceRawData; } public static string LocalAppName @@ -54,6 +59,54 @@ public static string LocalSlotName #pragma warning disable SA1117 // Parameters should be on same line or separate lines + internal void SanitizeString(string? rawPayload, out string iloggerString, out string durableKustoTableString) + { + string payload = rawPayload ?? string.Empty; + int numCharacters = payload.Length; + string sanitizedPayload = $"(Redacted {numCharacters} characters)"; + + // By default, both ilogger and kusto data should use the sanitized data + iloggerString = sanitizedPayload; + durableKustoTableString = sanitizedPayload; + + // IFF users opts into tracing raw data, then their ILogger gets the raw data + if (this.shouldTraceRawData) + { + iloggerString = payload; + } + } + + internal void SanitizeException(Exception? exception, out string iloggerExceptionString, out string durableKustoTableString) + { + // default case: exception is null + string rawError = string.Empty; + string sanitizedError = string.Empty; + + // if exception is not null + if (exception != null) + { + // common case if exception is not null + rawError = exception.ToString(); + sanitizedError = $"{exception.GetType().FullName}\n{exception.StackTrace}"; + + // if exception is an OrchestrationFailureException, we need to unravel the details + if (exception is OrchestrationFailureException orchestrationFailureException) + { + rawError = orchestrationFailureException.Details; + } + } + + // By default, both ilogger and kusto data should use the sanitized string + iloggerExceptionString = sanitizedError; + durableKustoTableString = sanitizedError; + + // IFF users opts into tracing raw data, then their ILogger gets the raw exception string + if (this.shouldTraceRawData) + { + iloggerExceptionString = rawError; + } + } + public void ExtensionInformationalEvent( string hubName, string instanceId, @@ -126,11 +179,13 @@ public void FunctionStarting( string hubName, string functionName, string instanceId, - string input, + string? input, FunctionType functionType, bool isReplay, int taskEventId = -1) { + this.SanitizeString(input, out string loggerInput, out string sanitizedInput); + if (this.ShouldLogEvent(isReplay)) { EtwEventSource.Instance.FunctionStarting( @@ -140,14 +195,14 @@ public void FunctionStarting( functionName, taskEventId, instanceId, - input, + sanitizedInput, functionType.ToString(), ExtensionVersion, isReplay); this.logger.LogInformation( "{instanceId}: Function '{functionName} ({functionType})' started. IsReplay: {isReplay}. Input: {input}. State: {state}. RuntimeStatus: {runtimeStatus}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}. TaskEventId: {taskEventId}", - instanceId, functionName, functionType, isReplay, input, FunctionState.Started, OrchestrationRuntimeStatus.Running, hubName, + instanceId, functionName, functionType, isReplay, loggerInput, FunctionState.Started, OrchestrationRuntimeStatus.Running, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++, taskEventId); } } @@ -211,12 +266,14 @@ public void FunctionCompleted( string hubName, string functionName, string instanceId, - string output, + string? output, bool continuedAsNew, FunctionType functionType, bool isReplay, int taskEventId = -1) { + this.SanitizeString(output, out string loggerOutput, out string sanitizedOutput); + if (this.ShouldLogEvent(isReplay)) { EtwEventSource.Instance.FunctionCompleted( @@ -226,7 +283,7 @@ public void FunctionCompleted( functionName, taskEventId, instanceId, - output, + sanitizedOutput, continuedAsNew, functionType.ToString(), ExtensionVersion, @@ -234,37 +291,19 @@ public void FunctionCompleted( this.logger.LogInformation( "{instanceId}: Function '{functionName} ({functionType})' completed. ContinuedAsNew: {continuedAsNew}. IsReplay: {isReplay}. Output: {output}. State: {state}. RuntimeStatus: {runtimeStatus}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}. TaskEventId: {taskEventId}", - instanceId, functionName, functionType, continuedAsNew, isReplay, output, FunctionState.Completed, OrchestrationRuntimeStatus.Completed, hubName, + instanceId, functionName, functionType, continuedAsNew, isReplay, loggerOutput, FunctionState.Completed, OrchestrationRuntimeStatus.Completed, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++, taskEventId); } } - public void ProcessingOutOfProcPayload( - string functionName, - string taskHub, - string instanceId, - string details) - { - EtwEventSource.Instance.ProcessingOutOfProcPayload( - functionName, - taskHub, - LocalAppName, - LocalSlotName, - instanceId, - details, - ExtensionVersion); - - this.logger.LogDebug( - "{instanceId}: Function '{functionName} ({functionType})' returned the following OOProc orchestration state: {details}. : {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, FunctionType.Orchestrator, details, taskHub, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); - } - public void FunctionTerminated( string hubName, string functionName, string instanceId, string reason) { + this.SanitizeString(reason, out string loggerReason, out string sanitizedReason); + FunctionType functionType = FunctionType.Orchestrator; EtwEventSource.Instance.FunctionTerminated( @@ -273,14 +312,14 @@ public void FunctionTerminated( LocalSlotName, functionName, instanceId, - reason, + sanitizedReason, functionType.ToString(), ExtensionVersion, IsReplay: false); this.logger.LogWarning( "{instanceId}: Function '{functionName} ({functionType})' was terminated. Reason: {reason}. State: {state}. RuntimeStatus: {runtimeStatus}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, functionType, reason, FunctionState.Terminated, OrchestrationRuntimeStatus.Terminated, hubName, + instanceId, functionName, functionType, loggerReason, FunctionState.Terminated, OrchestrationRuntimeStatus.Terminated, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } @@ -290,6 +329,8 @@ public void SuspendingOrchestration( string instanceId, string reason) { + this.SanitizeString(reason, out string loggerReason, out string sanitizedReason); + FunctionType functionType = FunctionType.Orchestrator; EtwEventSource.Instance.SuspendingOrchestration( @@ -298,14 +339,14 @@ public void SuspendingOrchestration( LocalSlotName, functionName, instanceId, - reason, + sanitizedReason, functionType.ToString(), ExtensionVersion, IsReplay: false); this.logger.LogInformation( "{instanceId}: Suspending function '{functionName} ({functionType})'. Reason: {reason}. State: {state}. RuntimeStatus: {runtimeStatus}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, functionType, reason, FunctionState.Suspended, OrchestrationRuntimeStatus.Suspended, hubName, + instanceId, functionName, functionType, loggerReason, FunctionState.Suspended, OrchestrationRuntimeStatus.Suspended, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } @@ -315,6 +356,8 @@ public void ResumingOrchestration( string instanceId, string reason) { + this.SanitizeString(reason, out string loggerReason, out string sanitizedReason); + FunctionType functionType = FunctionType.Orchestrator; EtwEventSource.Instance.ResumingOrchestration( @@ -323,14 +366,14 @@ public void ResumingOrchestration( LocalSlotName, functionName, instanceId, - reason, + sanitizedReason, functionType.ToString(), ExtensionVersion, IsReplay: false); this.logger.LogInformation( "{instanceId}: Resuming function '{functionName} ({functionType})'. Reason: {reason}. State: {state}. RuntimeStatus: {runtimeStatus}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, functionType, reason, FunctionState.Scheduled, OrchestrationRuntimeStatus.Running, hubName, + instanceId, functionName, functionType, loggerReason, FunctionState.Scheduled, OrchestrationRuntimeStatus.Running, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } @@ -340,6 +383,8 @@ public void FunctionRewound( string instanceId, string reason) { + this.SanitizeString(reason, out string loggerReason, out string sanitizedReason); + FunctionType functionType = FunctionType.Orchestrator; EtwEventSource.Instance.FunctionRewound( @@ -348,22 +393,36 @@ public void FunctionRewound( LocalSlotName, functionName, instanceId, - reason, + sanitizedReason, functionType.ToString(), ExtensionVersion, IsReplay: false); this.logger.LogWarning( "{instanceId}: Function '{functionName} ({functionType})' was rewound. Reason: {reason}. State: {state}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, functionType, reason, FunctionState.Rewound, hubName, + instanceId, functionName, functionType, loggerReason, FunctionState.Rewound, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } + public void FunctionFailed( + string hubName, + string functionName, + string instanceId, + Exception? exception, + FunctionType functionType, + bool isReplay, + int taskEventId = -1) + { + this.SanitizeException(exception, out string loggerReason, out string sanitizedReason); + this.FunctionFailed(hubName, functionName, instanceId, loggerReason, sanitizedReason, functionType, isReplay, taskEventId); + } + public void FunctionFailed( string hubName, string functionName, string instanceId, string reason, + string sanitizedReason, FunctionType functionType, bool isReplay, int taskEventId = -1) @@ -377,7 +436,7 @@ public void FunctionFailed( functionName, taskEventId, instanceId, - reason, + sanitizedReason, functionType.ToString(), ExtensionVersion, isReplay); @@ -424,6 +483,9 @@ public void OperationCompleted( double duration, bool isReplay) { + this.SanitizeString(input, out string loggerInput, out string sanitizedInput); + this.SanitizeString(output, out string loggerOutput, out string sanitizedOutput); + if (this.ShouldLogEvent(isReplay)) { EtwEventSource.Instance.OperationCompleted( @@ -434,8 +496,8 @@ public void OperationCompleted( instanceId, operationId, operationName, - input, - output, + sanitizedInput, + sanitizedOutput, duration, FunctionType.Entity.ToString(), ExtensionVersion, @@ -443,11 +505,27 @@ public void OperationCompleted( this.logger.LogInformation( "{instanceId}: Function '{functionName} ({functionType})' completed '{operationName}' operation {operationId} in {duration}ms. IsReplay: {isReplay}. Input: {input}. Output: {output}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, FunctionType.Entity, operationName, operationId, duration, isReplay, input, output, + instanceId, functionName, FunctionType.Entity, operationName, operationId, duration, isReplay, loggerInput, loggerOutput, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } } + public void OperationFailed( + string hubName, + string functionName, + string instanceId, + string operationId, + string operationName, + string input, + Exception exception, + double duration, + bool isReplay) + { + this.SanitizeString(input, out string loggerInput, out string sanitizedInput); + this.SanitizeException(exception, out string loggerException, out string sanitizedException); + this.OperationFailed(hubName, functionName, instanceId, operationId, operationName, sanitizedInput, loggerInput, sanitizedException, loggerException, duration, isReplay); + } + public void OperationFailed( string hubName, string functionName, @@ -458,6 +536,24 @@ public void OperationFailed( string exception, double duration, bool isReplay) + { + this.SanitizeString(input, out string loggerInput, out string sanitizedInput); + this.SanitizeString(exception, out string loggerException, out string sanitizedException); + this.OperationFailed(hubName, functionName, instanceId, operationId, operationName, sanitizedInput, loggerInput, sanitizedException, loggerException, duration, isReplay); + } + + private void OperationFailed( + string hubName, + string functionName, + string instanceId, + string operationId, + string operationName, + string sanitizedInput, + string loggerInput, + string sanitizedException, + string loggerException, + double duration, + bool isReplay) { if (this.ShouldLogEvent(isReplay)) { @@ -469,8 +565,8 @@ public void OperationFailed( instanceId, operationId, operationName, - input, - exception, + sanitizedInput, + sanitizedException, duration, FunctionType.Entity.ToString(), ExtensionVersion, @@ -478,7 +574,7 @@ public void OperationFailed( this.logger.LogError( "{instanceId}: Function '{functionName} ({functionType})' failed '{operationName}' operation {operationId} after {duration}ms with exception {exception}. Input: {input}. IsReplay: {isReplay}. HubName: {hubName}. AppName: {appName}. SlotName: {slotName}. ExtensionVersion: {extensionVersion}. SequenceNumber: {sequenceNumber}.", - instanceId, functionName, FunctionType.Entity, operationName, operationId, duration, exception, input, isReplay, hubName, + instanceId, functionName, FunctionType.Entity, operationName, operationId, duration, loggerException, loggerInput, isReplay, hubName, LocalAppName, LocalSlotName, ExtensionVersion, this.sequenceNumber++); } } @@ -491,6 +587,8 @@ public void ExternalEventRaised( string input, bool isReplay) { + this.SanitizeString(input, out string _, out string sanitizedInput); + if (this.ShouldLogEvent(isReplay)) { FunctionType functionType = FunctionType.Orchestrator; @@ -502,7 +600,7 @@ public void ExternalEventRaised( functionName, instanceId, eventName, - input, + sanitizedInput, functionType.ToString(), ExtensionVersion, isReplay); @@ -608,6 +706,8 @@ public void EntityResponseReceived( string result, bool isReplay) { + this.SanitizeString(result, out string _, out string sanitizedResult); + if (this.ShouldLogEvent(isReplay)) { EtwEventSource.Instance.EntityResponseReceived( @@ -617,7 +717,7 @@ public void EntityResponseReceived( functionName, instanceId, operationId, - result, + sanitizedResult, functionType.ToString(), ExtensionVersion, isReplay); @@ -806,9 +906,11 @@ public void EntityBatchFailed( string functionName, string instanceId, string traceFlags, - string details) + Exception error) { FunctionType functionType = FunctionType.Entity; + string details = Utils.IsFatal(error) ? error.GetType().Name : error.ToString(); + string sanitizedDetails = $"{error.GetType().FullName}\n{error.StackTrace}"; EtwEventSource.Instance.EntityBatchFailed( hubName, @@ -817,7 +919,7 @@ public void EntityBatchFailed( functionName, instanceId, traceFlags, - details, + sanitizedDetails, functionType.ToString(), ExtensionVersion); diff --git a/src/WebJobs.Extensions.DurableTask/EtwEventSource.cs b/src/WebJobs.Extensions.DurableTask/EtwEventSource.cs index 77f9bb0e4..019e27a2f 100644 --- a/src/WebJobs.Extensions.DurableTask/EtwEventSource.cs +++ b/src/WebJobs.Extensions.DurableTask/EtwEventSource.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; +#nullable enable using System.Diagnostics.Tracing; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask @@ -43,7 +42,7 @@ public void FunctionStarting( string FunctionName, int TaskEventId, string InstanceId, - string Input, + string? Input, string FunctionType, string ExtensionVersion, bool IsReplay) @@ -88,7 +87,7 @@ public void ExternalEventRaised( string FunctionName, string InstanceId, string EventName, - string Input, + string? Input, string FunctionType, string ExtensionVersion, bool IsReplay) @@ -104,7 +103,7 @@ public void FunctionCompleted( string FunctionName, int TaskEventId, string InstanceId, - string Output, + string? Output, bool ContinuedAsNew, string FunctionType, string ExtensionVersion, @@ -204,7 +203,7 @@ public void EventGridNotificationException( string SlotName, string FunctionName, FunctionState FunctionState, - string Version, + string? Version, string InstanceId, string Details, string Reason, @@ -222,8 +221,8 @@ public void ExtensionInformationalEvent( string TaskHub, string AppName, string SlotName, - string FunctionName, - string InstanceId, + string? FunctionName, + string? InstanceId, string Details, string ExtensionVersion) { @@ -235,8 +234,8 @@ public void ExtensionWarningEvent( string TaskHub, string AppName, string SlotName, - string FunctionName, - string InstanceId, + string? FunctionName, + string? InstanceId, string Details, string ExtensionVersion) { @@ -265,7 +264,7 @@ public void FunctionRewound( string SlotName, string FunctionName, string InstanceId, - string Reason, + string? Reason, string FunctionType, string ExtensionVersion, bool IsReplay) @@ -297,7 +296,7 @@ public void EntityResponseReceived( string FunctionName, string InstanceId, string OperationId, - string Result, + string? Result, string FunctionType, string ExtensionVersion, bool IsReplay) @@ -313,7 +312,7 @@ public void EntityLockAcquired( string FunctionName, string InstanceId, string RequestingInstanceId, - string RequestingExecutionId, + string? RequestingExecutionId, string RequestId, string FunctionType, string ExtensionVersion, @@ -347,8 +346,8 @@ public void OperationCompleted( string InstanceId, string OperationId, string OperationName, - string Input, - string Output, + string? Input, + string? Output, double Duration, string FunctionType, string ExtensionVersion, @@ -366,7 +365,7 @@ public void OperationFailed( string InstanceId, string OperationId, string OperationName, - string Input, + string? Input, string Exception, double Duration, string FunctionType, diff --git a/src/WebJobs.Extensions.DurableTask/Listener/TaskActivityShim.cs b/src/WebJobs.Extensions.DurableTask/Listener/TaskActivityShim.cs index 9481a0be0..128ed4c44 100644 --- a/src/WebJobs.Extensions.DurableTask/Listener/TaskActivityShim.cs +++ b/src/WebJobs.Extensions.DurableTask/Listener/TaskActivityShim.cs @@ -10,6 +10,7 @@ using Microsoft.Azure.WebJobs.Host; using Microsoft.Azure.WebJobs.Host.Executors; +#nullable enable namespace Microsoft.Azure.WebJobs.Extensions.DurableTask { /// @@ -58,7 +59,7 @@ public override async Task RunAsync(TaskContext context, string rawInput this.config.Options.HubName, this.activityName, instanceId, - this.config.GetIntputOutputTrace(rawInput), + rawInput, functionType: FunctionType.Activity, isReplay: false, taskEventId: this.taskEventId); @@ -76,7 +77,7 @@ public override async Task RunAsync(TaskContext context, string rawInput this.config.Options.HubName, this.activityName, instanceId, - this.config.GetIntputOutputTrace(serializedOutput), + serializedOutput, continuedAsNew: false, functionType: FunctionType.Activity, isReplay: false, @@ -100,7 +101,7 @@ public override async Task RunAsync(TaskContext context, string rawInput case WrappedFunctionResult.FunctionResultStatus.FunctionTimeoutError: // Flow the original activity function exception to the orchestration // without the outer FunctionInvocationException. - Exception exceptionToReport = StripFunctionInvocationException(result.Exception); + Exception? exceptionToReport = StripFunctionInvocationException(result.Exception); if (OutOfProcExceptionHelpers.TryGetExceptionWithFriendlyMessage( exceptionToReport, @@ -113,13 +114,13 @@ public override async Task RunAsync(TaskContext context, string rawInput this.config.Options.HubName, this.activityName, instanceId, - exceptionToReport?.ToString() ?? string.Empty, + exceptionToReport, functionType: FunctionType.Activity, isReplay: false, taskEventId: this.taskEventId); throw new TaskFailureException( - $"Activity function '{this.activityName}' failed: {exceptionToReport.Message}", + $"Activity function '{this.activityName}' failed: {exceptionToReport!.Message}", Utils.SerializeCause(exceptionToReport, this.config.ErrorDataConverter)); default: // we throw a TaskFailureException to ensure deserialization is possible. @@ -143,7 +144,7 @@ internal void SetTaskEventId(int taskEventId) this.taskEventId = taskEventId; } - private static Exception StripFunctionInvocationException(Exception e) + private static Exception? StripFunctionInvocationException(Exception? e) { var infrastructureException = e as FunctionInvocationException; if (infrastructureException?.InnerException != null) diff --git a/src/WebJobs.Extensions.DurableTask/Listener/TaskEntityShim.cs b/src/WebJobs.Extensions.DurableTask/Listener/TaskEntityShim.cs index 1e0998ce4..648a86731 100644 --- a/src/WebJobs.Extensions.DurableTask/Listener/TaskEntityShim.cs +++ b/src/WebJobs.Extensions.DurableTask/Listener/TaskEntityShim.cs @@ -237,7 +237,7 @@ public override async Task Execute(OrchestrationContext innerContext, st this.context.Name, this.context.InstanceId, this.entityTraceInfo.TraceFlags, - this.context.InternalError.ToString()); + this.context.InternalError.SourceException); } else { @@ -533,8 +533,8 @@ private async Task ProcessOperationRequestAsync(RequestMessage request) this.context.InstanceId, request.Id.ToString(), request.Operation, - this.Config.GetIntputOutputTrace(this.context.RawInput), - this.Config.GetIntputOutputTrace(response.Result), + this.context.RawInput, + response.Result, stopwatch.Elapsed.TotalMilliseconds, isReplay: false); } @@ -546,8 +546,8 @@ private async Task ProcessOperationRequestAsync(RequestMessage request) this.context.InstanceId, request.Id.ToString(), request.Operation, - this.Config.GetIntputOutputTrace(this.context.RawInput), - exception.ToString(), + this.context.RawInput, + exception, stopwatch.Elapsed.TotalMilliseconds, isReplay: false); } @@ -638,8 +638,8 @@ private async Task ExecuteOutOfProcBatch() this.context.InstanceId, request.Id.ToString(), request.Operation, - this.Config.GetIntputOutputTrace(request.Input), - this.Config.GetIntputOutputTrace(result.Result), + request.Input, + result.Result, result.DurationInMilliseconds, isReplay: false); } @@ -654,8 +654,8 @@ private async Task ExecuteOutOfProcBatch() this.context.InstanceId, request.Id.ToString(), request.Operation, - this.Config.GetIntputOutputTrace(request.Input), - this.Config.GetIntputOutputTrace(result.Result), + request.Input, + result.Result, result.DurationInMilliseconds, isReplay: false); } diff --git a/src/WebJobs.Extensions.DurableTask/Listener/TaskOrchestrationShim.cs b/src/WebJobs.Extensions.DurableTask/Listener/TaskOrchestrationShim.cs index 8cb6a33ff..3f127dca5 100644 --- a/src/WebJobs.Extensions.DurableTask/Listener/TaskOrchestrationShim.cs +++ b/src/WebJobs.Extensions.DurableTask/Listener/TaskOrchestrationShim.cs @@ -75,7 +75,7 @@ public override async Task Execute(OrchestrationContext innerContext, st this.context.HubName, this.context.Name, this.context.InstanceId, - this.Config.GetIntputOutputTrace(serializedInput), + serializedInput, FunctionType.Orchestrator, this.context.IsReplaying); status = OrchestrationRuntimeStatus.Running; @@ -113,7 +113,7 @@ public override async Task Execute(OrchestrationContext innerContext, st this.context.HubName, this.context.Name, this.context.InstanceId, - this.Config.GetIntputOutputTrace(serializedOutput), + serializedOutput, this.context.ContinuedAsNew, FunctionType.Orchestrator, this.context.IsReplaying); @@ -184,14 +184,14 @@ private async Task InvokeUserCodeAndHandleResults( } catch (OrchestrationFailureException ex) { - this.TraceAndSendExceptionNotification(ex.Details); + this.TraceAndSendExceptionNotification(ex); this.context.OrchestrationException = ExceptionDispatchInfo.Capture(ex); throw ex; } } else { - this.TraceAndSendExceptionNotification(e.ToString()); + this.TraceAndSendExceptionNotification(e); var orchestrationException = new OrchestrationFailureException( $"Orchestrator function '{this.context.Name}' failed: {e.Message}", Utils.SerializeCause(e, innerContext.ErrorDataConverter)); @@ -212,13 +212,19 @@ private async Task InvokeUserCodeAndHandleResults( } } - private void TraceAndSendExceptionNotification(string exceptionDetails) + private void TraceAndSendExceptionNotification(Exception exception) { + string exceptionDetails = exception.Message; + if (exception is OrchestrationFailureException orchestrationFailureException) + { + exceptionDetails = orchestrationFailureException.Details; + } + this.config.TraceHelper.FunctionFailed( this.context.HubName, this.context.Name, this.context.InstanceId, - exceptionDetails, + exception: exception, FunctionType.Orchestrator, this.context.IsReplaying); diff --git a/src/WebJobs.Extensions.DurableTask/Listener/WrappedFunctionResult.cs b/src/WebJobs.Extensions.DurableTask/Listener/WrappedFunctionResult.cs index fade92f13..f758179d2 100644 --- a/src/WebJobs.Extensions.DurableTask/Listener/WrappedFunctionResult.cs +++ b/src/WebJobs.Extensions.DurableTask/Listener/WrappedFunctionResult.cs @@ -3,13 +3,14 @@ using System; +#nullable enable namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Listener { internal class WrappedFunctionResult { private WrappedFunctionResult( FunctionResultStatus status, - Exception ex) + Exception? ex) { this.Exception = ex; this.ExecutionStatus = status; @@ -24,7 +25,7 @@ internal enum FunctionResultStatus FunctionsHostStoppingError = 4, // host was shutting down; treated as a functions runtime error } - internal Exception Exception { get; } + internal Exception? Exception { get; } internal FunctionResultStatus ExecutionStatus { get; } diff --git a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs index 8a7983cba..4d514c670 100644 --- a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs +++ b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs @@ -95,7 +95,7 @@ public async Task CallOrchestratorAsync(DispatchMiddlewareContext dispatchContex this.Options.HubName, functionName.Name, instance.InstanceId, - isReplaying ? "(replay)" : this.extension.GetIntputOutputTrace(startEvent.Input), + startEvent.Input, FunctionType.Orchestrator, isReplaying); @@ -188,7 +188,7 @@ await this.LifeCycleNotificationHelper.OrchestratorStartingAsync( this.Options.HubName, functionName.Name, instance.InstanceId, - this.extension.GetIntputOutputTrace(context.SerializedOutput), + context.SerializedOutput, context.ContinuedAsNew, FunctionType.Orchestrator, isReplay: false); @@ -214,7 +214,7 @@ await this.LifeCycleNotificationHelper.OrchestratorCompletedAsync( isReplay: false); } } - else if (context.TryGetOrchestrationErrorDetails(out string details)) + else if (context.TryGetOrchestrationErrorDetails(out Exception? exception)) { // the function failed because the orchestrator failed. @@ -224,7 +224,7 @@ await this.LifeCycleNotificationHelper.OrchestratorCompletedAsync( this.Options.HubName, functionName.Name, instance.InstanceId, - details, + exception, FunctionType.Orchestrator, isReplay: false); @@ -232,7 +232,7 @@ await this.LifeCycleNotificationHelper.OrchestratorFailedAsync( this.Options.HubName, functionName.Name, instance.InstanceId, - details, + exception?.Message ?? string.Empty, isReplay: false); } else @@ -245,7 +245,7 @@ await this.LifeCycleNotificationHelper.OrchestratorFailedAsync( this.Options.HubName, functionName.Name, instance.InstanceId, - exceptionDetails, + functionResult.Exception, FunctionType.Orchestrator, isReplay: false); @@ -320,7 +320,7 @@ void SetErrorResult(FailureDetails failureDetails) this.Options.HubName, functionName.Name, batchRequest.InstanceId, - this.extension.GetIntputOutputTrace(batchRequest.EntityState), + batchRequest.EntityState, functionType: FunctionType.Entity, isReplay: false); @@ -396,7 +396,7 @@ void SetErrorResult(FailureDetails failureDetails) this.Options.HubName, functionName.Name, batchRequest.InstanceId, - functionResult.Exception.ToString(), + functionResult.Exception, FunctionType.Entity, isReplay: false); @@ -429,7 +429,7 @@ void SetErrorResult(FailureDetails failureDetails) this.Options.HubName, functionName.Name, batchRequest.InstanceId, - this.extension.GetIntputOutputTrace(batchRequest.EntityState), + batchRequest.EntityState, batchResult.EntityState != null, FunctionType.Entity, isReplay: false); @@ -496,7 +496,7 @@ public async Task CallActivityAsync(DispatchMiddlewareContext dispatchContext, F this.Options.HubName, functionName.Name, instance.InstanceId, - this.extension.GetIntputOutputTrace(rawInput), + rawInput, functionType: FunctionType.Activity, isReplay: false, taskEventId: scheduledEvent.EventId); @@ -542,7 +542,7 @@ public async Task CallActivityAsync(DispatchMiddlewareContext dispatchContext, F this.Options.HubName, functionName.Name, instance.InstanceId, - this.extension.GetIntputOutputTrace(serializedOutput), + serializedOutput, continuedAsNew: false, FunctionType.Activity, isReplay: false, @@ -562,7 +562,7 @@ public async Task CallActivityAsync(DispatchMiddlewareContext dispatchContext, F this.Options.HubName, functionName.Name, instance.InstanceId, - result.Exception.ToString(), + result.Exception, FunctionType.Activity, isReplay: false, scheduledEvent.EventId); diff --git a/test/Common/TestHelpers.cs b/test/Common/TestHelpers.cs index e89c09978..a6c455e3a 100644 --- a/test/Common/TestHelpers.cs +++ b/test/Common/TestHelpers.cs @@ -703,7 +703,7 @@ private static List GetLogs_UnhandledOrchestrationException(string messa var list = new List() { $"{messageId}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' scheduled. Reason: NewInstance. IsReplay: False.", - $"{messageId}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' started. IsReplay: False. Input: (null)", + $"{messageId}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' started. IsReplay: False. Input: ", $"{messageId}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' failed with an error. Reason: System.ArgumentNullException: Value cannot be null.", }; @@ -831,7 +831,7 @@ private static List GetLogs_Orchestration_Activity(string[] messageIds, $"{messageIds[1]}:0: Function '{orchestratorFunctionNames[1]} ({FunctionType.Orchestrator})' completed. ContinuedAsNew: False. IsReplay: False. Output: \"Hello,", $"{messageIds[0]}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' started. IsReplay: True.", $"{messageIds[0]}: Function '{orchestratorFunctionNames[1]} ({FunctionType.Orchestrator})' scheduled. Reason: OrchestratorGreeting. IsReplay: True.", - $"{messageIds[0]}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' completed. ContinuedAsNew: False. IsReplay: False. Output: (null)", + $"{messageIds[0]}: Function '{orchestratorFunctionNames[0]} ({FunctionType.Orchestrator})' completed. ContinuedAsNew: False. IsReplay: False. Output: ", }; return list; diff --git a/test/FunctionsV2/EndToEndTraceHelperTests.cs b/test/FunctionsV2/EndToEndTraceHelperTests.cs new file mode 100644 index 000000000..4ea1b4e06 --- /dev/null +++ b/test/FunctionsV2/EndToEndTraceHelperTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#nullable enable +using System; +using Microsoft.Azure.WebJobs.Extensions.DurableTask; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace WebJobs.Extensions.DurableTask.Tests.V2 +{ + public class EndToEndTraceHelperTests + { + [Theory] + [InlineData(true, "DO NOT LOG ME")] + [InlineData(false, "DO NOT LOG ME")] + [InlineData(true, null)] + [InlineData(false, null)] + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public void StringSanitizerTest( + bool shouldTraceRawData, + string? possiblySensitiveData) + { + // set up trace helper + var nullLogger = new NullLogger(); + var traceHelper = new EndToEndTraceHelper( + logger: nullLogger, + traceReplayEvents: false, // has not effect on sanitizer + shouldTraceRawData: shouldTraceRawData); + + // run sanitizer + traceHelper.SanitizeString( + rawPayload: possiblySensitiveData, + out string iLoggerString, + out string kustoTableString); + + // expected: sanitized string should not contain the sensitive data + // skip this check if data is null + if (possiblySensitiveData != null) + { + Assert.DoesNotContain(possiblySensitiveData, kustoTableString); + } + + if (shouldTraceRawData) + { + string expectedString = possiblySensitiveData ?? string.Empty; + Assert.Equal(expectedString, iLoggerString); + } + else + { + // If raw data is not being traced, + // kusto and the ilogger should get the same data + Assert.Equal(iLoggerString, kustoTableString); + } + } + + [Theory] + [InlineData(true, "DO NOT LOG ME")] + [InlineData(false, "DO NOT LOG ME")] + [InlineData(true, null)] + [InlineData(false, null)] + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public void ExceptionSanitizerTest( + bool shouldTraceRawData, + string? possiblySensitiveData) + { + // set up trace helper + var nullLogger = new NullLogger(); + var traceHelper = new EndToEndTraceHelper( + logger: nullLogger, + traceReplayEvents: false, // has not effect on sanitizer + shouldTraceRawData: shouldTraceRawData); + + // exception to sanitize + Exception? exception = null; + if (possiblySensitiveData != null) + { + exception = new Exception(possiblySensitiveData); + } + + // run sanitizer + traceHelper.SanitizeException( + exception: exception, + out string iLoggerString, + out string kustoTableString); + + // exception message should not be part of the sanitized strings + // skip this check if data is null + if (possiblySensitiveData != null) + { + Assert.DoesNotContain(possiblySensitiveData, kustoTableString); + } + + if (shouldTraceRawData) + { + var expectedString = exception?.ToString() ?? string.Empty; + Assert.Equal(expectedString, iLoggerString); + } + else + { + // If raw data is not being traced, + // kusto and the ilogger should get the same data + Assert.Equal(iLoggerString, kustoTableString); + } + } + } +} From b1db0f5de4bb5bb6333fe1f5ae8b3f1e15aa8580 Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Wed, 17 Jul 2024 11:14:48 -0700 Subject: [PATCH 14/17] Revert "Cache TokenCredential (#2845)" This reverts commit 68d8ec99df084f92f989479a308a953efd0cc0b1. --- .../AzureStorageAccountProvider.cs | 9 +-------- test/Common/AzureStorageAccountProviderTests.cs | 10 +++++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs index 7d42ec413..1e22e2716 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageAccountProvider.cs @@ -2,11 +2,9 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Collections.Concurrent; using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Options; using Microsoft.Extensions.Configuration; - #if !FUNCTIONS_V1 using Microsoft.Azure.WebJobs.Extensions.DurableTask.Auth; using Microsoft.WindowsAzure.Storage.Auth; @@ -21,9 +19,6 @@ internal sealed class AzureStorageAccountProvider : IStorageAccountProvider #if !FUNCTIONS_V1 private readonly ITokenCredentialFactory credentialFactory; - private readonly ConcurrentDictionary cachedTokenCredentials = - new ConcurrentDictionary(); - public AzureStorageAccountProvider(IConnectionInfoResolver connectionInfoResolver, ITokenCredentialFactory credentialFactory) { this.connectionInfoResolver = connectionInfoResolver ?? throw new ArgumentNullException(nameof(connectionInfoResolver)); @@ -49,9 +44,7 @@ public StorageAccountDetails GetStorageAccountDetails(string connectionName) AzureStorageAccountOptions account = connectionInfo.Get(); if (account != null) { - TokenCredential credential = this.cachedTokenCredentials.GetOrAdd( - connectionName, - attr => this.credentialFactory.Create(connectionInfo)); + TokenCredential credential = this.credentialFactory.Create(connectionInfo); return new StorageAccountDetails { diff --git a/test/Common/AzureStorageAccountProviderTests.cs b/test/Common/AzureStorageAccountProviderTests.cs index 886903f37..67774b2ec 100644 --- a/test/Common/AzureStorageAccountProviderTests.cs +++ b/test/Common/AzureStorageAccountProviderTests.cs @@ -80,11 +80,11 @@ public void GetStorageAccountDetails_ConfigSection_Endpoints() Assert.Equal(options.TableServiceUri, actual.TableServiceUri); // Get CloudStorageAccount - CloudStorageAccount account = actual.ToCloudStorageAccount(); - Assert.Same(actual.StorageCredentials, account.Credentials); - Assert.Equal(options.BlobServiceUri, account.BlobEndpoint); - Assert.Equal(options.QueueServiceUri, account.QueueEndpoint); - Assert.Equal(options.TableServiceUri, account.TableEndpoint); + CloudStorageAccount acount = actual.ToCloudStorageAccount(); + Assert.Same(actual.StorageCredentials, acount.Credentials); + Assert.Equal(options.BlobServiceUri, acount.BlobEndpoint); + Assert.Equal(options.QueueServiceUri, acount.QueueEndpoint); + Assert.Equal(options.TableServiceUri, acount.TableEndpoint); } [Fact] From 45d7bb4d811a08e02e461074226e26d5eca95936 Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Mon, 22 Jul 2024 15:40:23 -0700 Subject: [PATCH 15/17] Update validate-build.yml to run tests --- .github/workflows/validate-build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index 9f740e834..de6cf0360 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -8,8 +8,7 @@ on: paths-ignore: [ '**.md' ] pull_request: branches: - - main - - dev + - '*' paths-ignore: [ '**.md' ] env: From 3a23c8120373b5cec3407bfb27dfbde8e1ea6473 Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Mon, 22 Jul 2024 15:48:11 -0700 Subject: [PATCH 16/17] Update validate-build-e2e.yml to run tests --- .github/workflows/validate-build-e2e.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate-build-e2e.yml b/.github/workflows/validate-build-e2e.yml index 8056b73f8..935a33072 100644 --- a/.github/workflows/validate-build-e2e.yml +++ b/.github/workflows/validate-build-e2e.yml @@ -8,8 +8,7 @@ on: paths-ignore: [ '**.md' ] pull_request: branches: - - main - - dev + - '*' paths-ignore: [ '**.md' ] env: @@ -59,4 +58,4 @@ jobs: run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests.DurableEntity_CleanEntityStorage" - name: Run FunctionsV2 tests (all other E2E tests) - run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests&FullyQualifiedName!~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests.DurableEntity_CleanEntityStorage" \ No newline at end of file + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & dotnet test ./test/FunctionsV2/WebJobs.Extensions.DurableTask.Tests.V2.csproj --filter "FullyQualifiedName~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests&FullyQualifiedName!~Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests.DurableTaskEndToEndTests.DurableEntity_CleanEntityStorage" From 87f7fbaedea64ccd6ae3070a99817c1fc19055dd Mon Sep 17 00:00:00 2001 From: Varshitha Bachu Date: Mon, 22 Jul 2024 15:49:19 -0700 Subject: [PATCH 17/17] Update validate-build-analyzer.yml to run tests --- .github/workflows/validate-build-analyzer.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/validate-build-analyzer.yml b/.github/workflows/validate-build-analyzer.yml index 4eb275c4e..22afaf09d 100644 --- a/.github/workflows/validate-build-analyzer.yml +++ b/.github/workflows/validate-build-analyzer.yml @@ -8,8 +8,7 @@ on: paths-ignore: [ '**.md' ] pull_request: branches: - - main - - dev + - '*' paths-ignore: [ '**.md' ] env: