diff --git a/.github/workflows/bootstrap/action.yml b/.github/workflows/bootstrap/action.yml index 1ee0518..de57e69 100644 --- a/.github/workflows/bootstrap/action.yml +++ b/.github/workflows/bootstrap/action.yml @@ -32,7 +32,6 @@ runs: dotnet-version: | 6.0.x 8.0.x - 6.0.x - id: dotnet shell: bash run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07e912c..7da3e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: id: bootstrap uses: ./.github/workflows/bootstrap - - run: build.bat test + - run: build.bat test --test-suite=skip-e2e shell: cmd name: Test @@ -48,5 +48,5 @@ jobs: uses: ./.github/workflows/bootstrap # We still run the full release build on pull-requests this ensures packages are validated ahead of time - - run: ./build.sh release + - run: ./build.sh release --test-suite=skip-e2e name: Release diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 64021e8..e4bc5f4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,16 +24,17 @@ concurrency: env: # (keep_serverless-staging-oblt, keep_serverless-qa-oblt or serverless-production-oblt) SERVERLESS_PROJECT: serverless-production-oblt - # (staging, qa or production) - VAULT_SECRET_SUFFIX: production + # (staging, qa or pro) + VAULT_SECRET_SUFFIX: pro # NOTE: if you add a new job and it's a mandatory check then # update e2e-docs.yml jobs: test: - if: | - github.event_name != 'pull_request' || - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) + if: false() + #if: | + # github.event_name != 'pull_request' || + # (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -55,10 +56,6 @@ jobs: vault-role-id: ${{ secrets.VAULT_ROLE_ID }} vault-secret-id: ${{ secrets.VAULT_SECRET_ID }} - # TODO: run the e2e targeting the required endpoint. - # those values can be found in https://github.com/elastic/apm-pipeline-library/tree/main/.github/actions/oblt-cli-cluster-credentials#outputs - - run: curl -X GET "${ELASTICSEARCH_HOST}/_cat/indices?v" -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} - - name: Get the browser email and password from Vault uses: hashicorp/vault-action@v2.8.0 with: @@ -67,5 +64,10 @@ jobs: secretId: ${{ secrets.VAULT_SECRET_ID }} method: approle secrets: | - secret/observability-team/ci/elastic-cloud/observability-team-${{ env.VAULT_SECRET_SUFFIX }} username | E2E__BROWSEREMAIL + secret/observability-team/ci/elastic-cloud/observability-team-${{ env.VAULT_SECRET_SUFFIX }} username | E2E__BROWSEREMAIL ; secret/observability-team/ci/elastic-cloud/observability-team-${{ env.VAULT_SECRET_SUFFIX }} password | E2E__BROWSERPASSWORD + + - run: ./build.sh test --test-suite=e2e + env: + E2E__ENDPOINT: "${{env.ELASTIC_APM_SERVER_URL}}" + E2E__AUTHORIZATION: "Authentication=ApiKey ${{env.ELASTIC_APM_API_KEY}}" \ No newline at end of file diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f73cc0e..5211a00 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -21,7 +21,7 @@ jobs: id: bootstrap uses: ./.github/workflows/bootstrap - - run: ./build.sh release + - run: ./build.sh release --test-suite=skip-e2e name: Release - name: publish canary packages github package repository diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc6db8b..a81be4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: id: bootstrap uses: ./.github/workflows/bootstrap - - run: ./build.sh release --skiptests + - run: ./build.sh release --test-suite=skip-all name: Release - name: Prepare Nuget diff --git a/Elastic.OpenTelemetry.sln b/Elastic.OpenTelemetry.sln index 7a2adb9..d9a22f7 100644 --- a/Elastic.OpenTelemetry.sln +++ b/Elastic.OpenTelemetry.sln @@ -22,6 +22,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AAD39891 ProjectSection(SolutionItems) = preProject tests\Directory.Build.props = tests\Directory.Build.props tests\xunit.runner.json = tests\xunit.runner.json + tests\.runsettings = tests\.runsettings EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elastic.OpenTelemetry.Tests", "tests\Elastic.OpenTelemetry.Tests\Elastic.OpenTelemetry.Tests.csproj", "{22BF9223-3A6D-4197-8527-3E4E43A98A81}" @@ -34,6 +35,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Elastic.OpenTelemet EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elastic.OpenTelemetry.AspNetCore", "src\Elastic.OpenTelemetry.AspNetCore\Elastic.OpenTelemetry.AspNetCore.csproj", "{2139F902-B10D-475D-8A38-F78962CEBFD3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.OpenTelemetry.EndToEndTests", "tests\Elastic.OpenTelemetry.EndToEndTests\Elastic.OpenTelemetry.EndToEndTests.csproj", "{B970DBE1-6A04-4014-A285-6A9F36421025}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -76,6 +79,10 @@ Global {2139F902-B10D-475D-8A38-F78962CEBFD3}.Debug|Any CPU.Build.0 = Debug|Any CPU {2139F902-B10D-475D-8A38-F78962CEBFD3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2139F902-B10D-475D-8A38-F78962CEBFD3}.Release|Any CPU.Build.0 = Release|Any CPU + {B970DBE1-6A04-4014-A285-6A9F36421025}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B970DBE1-6A04-4014-A285-6A9F36421025}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B970DBE1-6A04-4014-A285-6A9F36421025}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B970DBE1-6A04-4014-A285-6A9F36421025}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -87,6 +94,7 @@ Global {EC81FA30-C765-4F04-8679-86F16DA3CC65} = {4E95C87B-655B-4BC3-8F2A-DF06B7AAB7E9} {4377A059-16E0-4D5D-AC03-44C09BCE5BC4} = {4E95C87B-655B-4BC3-8F2A-DF06B7AAB7E9} {2139F902-B10D-475D-8A38-F78962CEBFD3} = {E622CFF2-C6C4-40FB-BE42-7C4F2B38B75A} + {B970DBE1-6A04-4014-A285-6A9F36421025} = {AAD39891-0B70-47FA-A212-43E1AAE5DF56} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {573B2B5F-8CBB-4D52-A55A-4E65E282AAFB} diff --git a/Elastic.OpenTelemetry.sln.DotSettings b/Elastic.OpenTelemetry.sln.DotSettings index 14fff26..ac2fd04 100644 --- a/Elastic.OpenTelemetry.sln.DotSettings +++ b/Elastic.OpenTelemetry.sln.DotSettings @@ -84,8 +84,7 @@ &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;/Language&gt; &lt;/profile&gt;</RIDER_SETTINGS></Profile> - Licensed to Elasticsearch B.V under -one or more agreements. + Licensed to Elasticsearch B.V under one or more agreements. Elasticsearch B.V licenses this file to you under the Apache 2.0 License. See the LICENSE file in the project root for more information True @@ -564,6 +563,7 @@ See the LICENSE file in the project root for more information True False KM + UI False True True diff --git a/build/build.fsproj b/build/build.fsproj index 6a4e88d..c4d18d0 100644 --- a/build/build.fsproj +++ b/build/build.fsproj @@ -1,31 +1,33 @@ - - net8.0 - Exe - $(NoWarn);NU1701 - false - - - - - - - + + net8.0 + Exe + $(NoWarn);NU1701 + false + + + + + + + + + - - - + + + - - - - - - - - - - - + + + + + + + + + + + diff --git a/build/scripts/CommandLine.fs b/build/scripts/CommandLine.fs index 4998afa..4bf27b4 100644 --- a/build/scripts/CommandLine.fs +++ b/build/scripts/CommandLine.fs @@ -9,12 +9,21 @@ open Microsoft.FSharp.Reflection open System open Bullseye +type TestSuite = All | Unit | Integration | E2E | Skip_All | Skip_E2E + with + member this.SuitName = + match FSharpValue.GetUnionFields(this, typeof) with + | case, _ -> case.Name.ToLowerInvariant() + type Build = | [] Clean | [] Version | [] Build | [] Test + | [] Unit_Test + | [] End_To_End + | [] PristineCheck | [] GeneratePackages | [] ValidateLicenses @@ -23,10 +32,10 @@ type Build = | [] GenerateApiChanges | [] Release - | [] SingleTarget + | [] Single_Target | [] Token of string - | [] SkipDirtyCheck - | [] SkipTests + | [] Skip_Dirty_Check + | [] Test_Suite of TestSuite with interface IArgParserTemplate with member this.Usage = @@ -35,7 +44,10 @@ with | Clean -> "clean known output locations" | Version -> "print version information" | Build -> "Run build" - | Test -> "Runs build then tests" + + | Unit_Test -> "alias to providing: test --test-suite=unit" + | End_To_End -> "alias to providing: test --test-suite=e2e" + | Test -> "runs a clean build and then runs all the tests unless --test-suite is provided" | Release -> "runs build, tests, and create and validates the packages shy of publishing them" // steps @@ -47,10 +59,11 @@ with | GenerateApiChanges -> "Undocumented, dependent target" // flags - | SingleTarget -> "Runs the provided sub command without running their dependencies" + | Single_Target -> "Runs the provided sub command without running their dependencies" | Token _ -> "Token to be used to authenticate with github" - | SkipDirtyCheck -> "Skip the clean checkout check that guards the release/publish targets" - | SkipTests -> "Skips running tests" + | Skip_Dirty_Check -> "Skip the clean checkout check that guards the release/publish targets" + | Test_Suite _ -> "Specify the test suite to run, defaults to all" + member this.StepName = match FSharpValue.GetUnionFields(this, typeof) with @@ -70,7 +83,7 @@ with Targets.Target(target.StepName, Action(fun _ -> action(parsed))) static member Cmd (dependsOn: Build list) (composedOf: Build list) action (target: Build) (parsed: ParseResults) = - let singleTarget = parsed.TryGetResult SingleTarget |> Option.isSome + let singleTarget = parsed.TryGetResult Single_Target |> Option.isSome let dependsOn = if singleTarget then [] else dependsOn let steps = dependsOn @ composedOf |> List.map (_.StepName) diff --git a/build/scripts/Targets.fs b/build/scripts/Targets.fs index da94794..cf6e665 100644 --- a/build/scripts/Targets.fs +++ b/build/scripts/Targets.fs @@ -34,31 +34,53 @@ let private version _ = let private generatePackages _ = exec { run "dotnet" "pack" } let private pristineCheck (arguments:ParseResults) = - let skipCheck = arguments.TryGetResult SkipDirtyCheck |> Option.isSome + let skipCheck = arguments.TryGetResult Skip_Dirty_Check |> Option.isSome match skipCheck, Information.isCleanWorkingCopy "." with | true, _ -> printfn "Checkout is dirty but -c was specified to ignore this" | _, true -> printfn "The checkout folder does not have pending changes, proceeding" | _ -> failwithf "The checkout folder has pending changes, aborting. Specify -c to ./build.sh to skip this check" -let private runTests _ = - let testOutputPath = Paths.ArtifactPath "tests" - let junitOutput = Path.Combine(testOutputPath.FullName, "junit-{assembly}-{framework}-test-results.xml") - let loggerPathArgs = $"LogFilePath=%s{junitOutput}" - let loggerArg = $"--logger:\"junit;%s{loggerPathArgs}\"" - let githubActionsLogger = $"--logger:\"GitHubActions;summary.includePassedTests=true\"" +let private runTests suite _ = + let logger = + // use junit xml logging locally, github actions logs using console out formats + match BuildServer.isGitHubActionsBuild with + | true -> "--logger:\"GitHubActions;summary.includePassedTests=false\"" + | false -> + let testOutputPath = Paths.ArtifactPath "tests" + let junitOutput = Path.Combine(testOutputPath.FullName, "junit-{assembly}-{framework}-test-results.xml") + let loggerPathArgs = $"LogFilePath=%s{junitOutput}" + $"--logger:\"junit;%s{loggerPathArgs}\"" + let filterArgs = + match suite with + | All -> [] + | Skip_All -> ["--filter"; "FullyQualifiedName~.SKIPPING.ALL.TESTS"] + | Unit -> [ "--filter"; "FullyQualifiedName~.Tests" ] + | Integration -> [ "--filter"; "FullyQualifiedName~.IntegrationTests" ] + | E2E -> [ "--filter"; "FullyQualifiedName~.EndToEndTests" ] + | Skip_E2E -> [ "--filter"; "FullyQualifiedName!~.EndToEndTests" ] + + + let settingsArg = ["-s"; "tests/.runsettings"] let tfmArgs = if OS.Current = OS.Windows then [] else ["-f"; "net8.0"] exec { + env (Map ["TEST_SUITE", suite.SuitName]) run "dotnet" ( - ["test"; "-c"; "release"; loggerArg; githubActionsLogger] + ["test"; "-c"; "release"; "--no-restore"; "--no-build"; logger] + @ settingsArg + @ filterArgs @ tfmArgs @ ["--"; "RunConfiguration.CollectSourceInformation=true"] ) } let private test (arguments:ParseResults) = - match arguments.TryGetResult SkipTests with - | Some _ -> runTests arguments - | None -> printfn "Skipping tests because --skiptests was provided" + let arg = arguments.TryGetResult Test_Suite + match arg with + | None -> runTests TestSuite.All arguments + | Some suite -> + match suite with + | Skip_All -> printfn "Skipping tests because --test-suite skip was provided" + | _ -> runTests suite arguments let private validateLicenses _ = let args = ["-u"; "-t"; "-i"; "Elastic.OpenTelemetry.sln"; "--use-project-assets-json" @@ -131,7 +153,11 @@ let Setup (parsed:ParseResults) = | Version -> Build.Step version | Clean -> Build.Cmd [Version] [] clean | Build -> Build.Cmd [Clean] [] build - | Test -> Build.Cmd [Build] [] test + + | End_To_End -> Build.Cmd [] [Build] <| runTests E2E + | Unit_Test -> Build.Cmd [] [Build] <| runTests Unit + | Test -> Build.Cmd [] [Build] test + | Release -> Build.Cmd [PristineCheck; Test] @@ -147,10 +173,10 @@ let Setup (parsed:ParseResults) = | GenerateApiChanges -> Build.Step generateApiChanges // flags - | SingleTarget - | SkipTests + | Single_Target + | Test_Suite _ | Token _ - | SkipDirtyCheck -> Build.Ignore + | Skip_Dirty_Check -> Build.Ignore for target in Build.Targets do let setup = wireCommandLine target diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Controllers/E2EController.cs b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Controllers/E2EController.cs new file mode 100644 index 0000000..07f5bb9 --- /dev/null +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Controllers/E2EController.cs @@ -0,0 +1,42 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; + +namespace Example.Elastic.OpenTelemetry.AspNetCore.Controllers; + +public class E2EController : Controller +{ + public async Task Index() + { + var activityFeature = HttpContext.Features.Get(); + var activity = activityFeature?.Activity; + activity?.AddBaggage("operation.success", true.ToString()); + activity?.SetTag("CustomTag", "TagValue"); + + await Task.Delay(100); + + using var childActivity = activity?.Source.StartActivity("childActivity", ActivityKind.Internal); + await Task.Delay(200); + + return View(); + } + + public async Task Fail() + { + var activityFeature = HttpContext.Features.Get(); + var activity = activityFeature?.Activity; + activity?.AddBaggage("operation.success", false.ToString()); + activity?.SetTag("CustomTag", "TagValue"); + + await Task.Delay(100); + + using var childActivity = activity?.Source.StartActivity("childActivity", ActivityKind.Internal); + await Task.Delay(200); + + throw new Exception("Random failure"); + } +} diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Example.Elastic.OpenTelemetry.AspNetCore.csproj b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Example.Elastic.OpenTelemetry.AspNetCore.csproj index 1bc1253..6ef668b 100644 --- a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Example.Elastic.OpenTelemetry.AspNetCore.csproj +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Example.Elastic.OpenTelemetry.AspNetCore.csproj @@ -1,4 +1,4 @@ - + net8.0 diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Program.cs b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Program.cs index 803560c..406fa44 100644 --- a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Program.cs +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Program.cs @@ -1,6 +1,7 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information + using Example.Elastic.OpenTelemetry.AspNetCore.Controllers; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +14,8 @@ var app = builder.Build(); +app.Logger.LogInformation("Process Id {ProcesId}", Environment.ProcessId); + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -29,7 +32,7 @@ app.UseAuthorization(); app.MapControllerRoute( - name: "default", - pattern: "{controller=Home}/{action=Index}/{id?}"); + "default", + "{controller=Home}/{action=Index}/{id?}"); app.Run(); diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Properties/launchSettings.json b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Properties/launchSettings.json index dfcd958..96dfc62 100644 --- a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Properties/launchSettings.json +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:5245", + "applicationUrl": "http://localhost:5247", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -22,7 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7295;http://localhost:5245", + "applicationUrl": "https://localhost:7295;http://localhost:5247", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "OTEL_RESOURCE_ATTRIBUTES": "service.name=AspNetCoreApp,service.version=1.0.0,deployment.environment=development" diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/Views/E2E/Index.cshtml b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Views/E2E/Index.cshtml new file mode 100644 index 0000000..bcfd79a --- /dev/null +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/Views/E2E/Index.cshtml @@ -0,0 +1,8 @@ +@{ + ViewData["Title"] = "Home Page"; +} + +
+

Welcome

+

Learn about building Web apps with ASP.NET Core.

+
diff --git a/examples/Example.Elastic.OpenTelemetry.AspNetCore/appsettings.json b/examples/Example.Elastic.OpenTelemetry.AspNetCore/appsettings.json index f3af4af..6314868 100644 --- a/examples/Example.Elastic.OpenTelemetry.AspNetCore/appsettings.json +++ b/examples/Example.Elastic.OpenTelemetry.AspNetCore/appsettings.json @@ -12,5 +12,5 @@ } }, "AllowedHosts": "*", - "ServiceName": "elastic-otel-test-aspnetcore" + "ServiceName": "elastic-otel-example-aspnetcore" } diff --git a/examples/Example.Elastic.OpenTelemetry.Worker/Worker.cs b/examples/Example.Elastic.OpenTelemetry.Worker/Worker.cs index 9eee4f2..d36f378 100644 --- a/examples/Example.Elastic.OpenTelemetry.Worker/Worker.cs +++ b/examples/Example.Elastic.OpenTelemetry.Worker/Worker.cs @@ -3,40 +3,39 @@ // See the LICENSE file in the project root for more information using System.Diagnostics; -namespace Example.Elastic.OpenTelemetry.Worker +namespace Example.Elastic.OpenTelemetry.Worker; + +public class Worker : BackgroundService { - public class Worker : BackgroundService - { - private readonly ILogger _logger; + private readonly ILogger _logger; - private static readonly HttpClient HttpClient = new(); - private const string ActivitySourceName = "CustomActivitySource"; - private static readonly ActivitySource ActivitySource = new(ActivitySourceName, "1.0.0"); + private static readonly HttpClient HttpClient = new(); + private const string ActivitySourceName = "CustomActivitySource"; + private static readonly ActivitySource ActivitySource = new(ActivitySourceName, "1.0.0"); - public Worker(ILogger logger) => _logger = logger; + public Worker(ILogger logger) => _logger = logger; - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) { - while (!stoppingToken.IsCancellationRequested) - { - _logger.LogInformation("Sending request... "); + _logger.LogInformation("Sending request... "); - using (var activity = ActivitySource.StartActivity("DoingStuff", ActivityKind.Internal)) - { - activity?.SetTag("CustomTag", "TagValue"); - - await Task.Delay(100, stoppingToken); - var response = await HttpClient.GetAsync("http://elastic.co", stoppingToken); - await Task.Delay(50, stoppingToken); + using (var activity = ActivitySource.StartActivity("DoingStuff", ActivityKind.Internal)) + { + activity?.SetTag("CustomTag", "TagValue"); - if (response.StatusCode == System.Net.HttpStatusCode.OK) - activity?.SetStatus(ActivityStatusCode.Ok); - else - activity?.SetStatus(ActivityStatusCode.Error); - } + await Task.Delay(100, stoppingToken); + var response = await HttpClient.GetAsync("http://elastic.co", stoppingToken); + await Task.Delay(50, stoppingToken); - await Task.Delay(5000, stoppingToken); + if (response.StatusCode == System.Net.HttpStatusCode.OK) + activity?.SetStatus(ActivityStatusCode.Ok); + else + activity?.SetStatus(ActivityStatusCode.Error); } + + await Task.Delay(5000, stoppingToken); } } } diff --git a/tests/.runsettings b/tests/.runsettings new file mode 100644 index 0000000..25f85e2 --- /dev/null +++ b/tests/.runsettings @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 79246d4..5d1eeb0 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -26,12 +26,12 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ApmUIBrowserContext.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ApmUIBrowserContext.cs new file mode 100644 index 0000000..57c1ebb --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ApmUIBrowserContext.cs @@ -0,0 +1,145 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Microsoft.Extensions.Configuration; +using Microsoft.Playwright; +using Xunit; + +namespace Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; + +public class ApmUIBrowserContext : IAsyncLifetime +{ + private readonly IConfigurationRoot _configuration; + private readonly string _serviceName; + + public ApmUIBrowserContext(IConfigurationRoot configuration, string serviceName) + { + _configuration = configuration; + _serviceName = serviceName; + //"https://{instance}.apm.us-east-1.aws.elastic.cloud:443" + // https://{instance}.kb.us-east-1.aws.elastic.cloud/app/apm/services?comparisonEnabled=true&environment=ENVIRONMENT_ALL&rangeFrom=now-15m&rangeTo=now&offset=1d + var endpoint = configuration["E2E:Endpoint"]?.Trim() ?? string.Empty; + var newBase = endpoint.Replace(".apm.", ".kb."); + KibanaAppUri = new Uri(new Uri(newBase), "app/apm"); + } + + public Uri KibanaAppUri { get; } + + public IBrowser Browser { get; private set; } = null!; + public IPlaywright HeadlessTester { get; private set; } = null!; + + public async Task InitializeAsync() + { + var username = _configuration["E2E:BrowserEmail"]?.Trim() ?? string.Empty; + var password = _configuration["E2E:BrowserPassword"]?.Trim() ?? string.Empty; + Program.Main(["install", "chromium"]); + HeadlessTester = await Playwright.CreateAsync(); + Browser = await HeadlessTester.Chromium.LaunchAsync(); + var page = await OpenApmLandingPage("test_bootstrap"); + try + { + await page.GetByRole(AriaRole.Textbox, new () { Name = "email" }).FillAsync(username); + await page.GetByRole(AriaRole.Textbox, new () { Name = "password" }).FillAsync(password); + await page.GetByRole(AriaRole.Button, new() { Name = "Log in" }).ClickAsync(); + + await WaitForServiceOnOverview(page); + + StorageState = await page.Context.StorageStateAsync(); + + await StopTrace(page); + } + catch (Exception e) + { + await StopTrace(page, "test_bootstrap"); + Console.WriteLine(e); + throw; + } + } + + public string? StorageState { get; set; } + + + public async Task NewProfiledPage(string testName) + { + var page = await Browser.NewPageAsync(new () { StorageState = StorageState }); + await page.Context.Tracing.StartAsync(new() + { + Title = testName, + Screenshots = true, + Snapshots = true, + Sources = true + }); + + return page; + } + + + public async Task OpenApmLandingPage(string testName) + { + var page = await NewProfiledPage(testName); + await page.GotoAsync(KibanaAppUri.ToString()); + return page; + } + + public async Task WaitForServiceOnOverview(IPage page) + { + page.SetDefaultTimeout((float)TimeSpan.FromSeconds(30).TotalMilliseconds); + + var servicesHeader = page.GetByRole(AriaRole.Heading, new() { Name = "Services" }); + await servicesHeader.WaitForAsync(new () { State = WaitForSelectorState.Visible }); + + page.SetDefaultTimeout((float)TimeSpan.FromSeconds(10).TotalMilliseconds); + + Exception? observed = null; + for (var i = 0; i < 10;i++) + { + try + { + var serviceLink = page.GetByRole(AriaRole.Link, new() { Name = _serviceName }); + await serviceLink.WaitForAsync(new() { State = WaitForSelectorState.Visible }); + observed = null; + break; + } + catch (Exception e) + { + observed ??= e; + await page.ReloadAsync(); + } + finally + { + page.SetDefaultTimeout((float)TimeSpan.FromSeconds(5).TotalMilliseconds); + } + } + if (observed != null) + throw observed; //TODO proper rethrow with stack + + } + + public async Task StopTrace(IPage page, string? testName = null) + { + + if (string.IsNullOrWhiteSpace(testName)) + await page.Context.Tracing.StopAsync(new ()); + else + { + var root = DotNetRunApplication.GetSolutionRoot(); + await page.Context.Tracing.StopAsync(new() + { + Path = Path.Combine( + Path.Combine(root.FullName, ".artifacts"), + "playwright-traces", + $"{testName}.zip" + ) + }); + } + await page.CloseAsync(); + } + + + public async Task DisposeAsync() + { + await Browser.DisposeAsync(); + HeadlessTester.Dispose(); + } +} diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DistributedApplicationFixture.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DistributedApplicationFixture.cs new file mode 100644 index 0000000..64297b3 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DistributedApplicationFixture.cs @@ -0,0 +1,80 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using Nullean.Xunit.Partitions.Sdk; + +namespace Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; + +public class DistributedApplicationFixture : IPartitionLifetime +{ + private readonly ITrafficSimulator[] _trafficSimulators = [ new DefaultTrafficSimulator() ]; + + public string ServiceName { get; } = $"dotnet-e2e-{ShaForCurrentTicks()}"; + + public bool Started => AspNetApplication.ProcessId.HasValue; + + public int? MaxConcurrency => null; + + public ApmUIBrowserContext ApmUI { get; private set; } = null!; + + public AspNetCoreExampleApplication AspNetApplication { get; private set; } = null!; + + private static string ShaForCurrentTicks() + { + var buffer = Encoding.UTF8.GetBytes(DateTime.UtcNow.Ticks.ToString(DateTimeFormatInfo.InvariantInfo)); + + return BitConverter.ToString(SHA1.Create().ComputeHash(buffer)) + .Replace("-", "") + .ToLowerInvariant() + .Substring(0, 12); + } + + public async Task DisposeAsync() + { + AspNetApplication.Dispose(); + await ApmUI.DisposeAsync(); + } + + public async Task InitializeAsync() + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + AspNetApplication = new AspNetCoreExampleApplication(ServiceName, configuration); + ApmUI = new ApmUIBrowserContext(configuration, ServiceName); + + foreach (var trafficSimulator in _trafficSimulators) + await trafficSimulator.Start(this); + + // TODO query OTEL_BSP_SCHEDULE_DELAY? + await Task.Delay(5000); + + // Stateless refresh + //https://github.com/elastic/elasticsearch/blob/main/server/src/main/java/org/elasticsearch/index/IndexSettings.java#L286 + await Task.Delay(TimeSpan.FromSeconds(15)); + await ApmUI.InitializeAsync(); + } + +} + +public class AspNetCoreExampleApplication : DotNetRunApplication +{ + public AspNetCoreExampleApplication(string serviceName, IConfiguration configuration) + : base(serviceName, configuration, "Example.Elastic.OpenTelemetry.AspNetCore") => + HttpClient = new HttpClient { BaseAddress = new Uri("http://localhost:5247") }; + + public HttpClient HttpClient { get; } + + public override void Dispose() + { + base.Dispose(); + HttpClient.Dispose(); + } +}; diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DotNetRunApplication.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DotNetRunApplication.cs new file mode 100644 index 0000000..5793840 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/DotNetRunApplication.cs @@ -0,0 +1,90 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using ProcNet; + +namespace Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; + +public abstract class DotNetRunApplication +{ + private static readonly DirectoryInfo CurrentDirectory = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory!; + private static readonly Regex ProcessIdMatch = new(@"^\s*Process Id (?\d+)"); + private readonly LongRunningApplicationSubscription _app; + private readonly string _applicationName; + private readonly string _authorization; + private readonly string _endpoint; + private readonly string _serviceName; + + public DotNetRunApplication(string serviceName, IConfiguration configuration, string applicationName) + { + _serviceName = serviceName; + _applicationName = applicationName; + _endpoint = configuration["E2E:Endpoint"]?.Trim() ?? string.Empty; + _authorization = configuration["E2E:Authorization"]?.Trim() ?? string.Empty; + + var args = CreateStartArgs(); + _app = Proc.StartLongRunning(args, TimeSpan.FromSeconds(10)); + } + + public int? ProcessId { get; private set; } + + protected virtual string[] GetArguments() => Array.Empty(); + + public static DirectoryInfo GetSolutionRoot() + { + var root = CurrentDirectory; + while (root != null && root.GetFiles("*.sln").Length == 0) + root = root.Parent; + + if (root == null) + throw new Exception($"Could not locate root starting from {CurrentDirectory}"); + + return root; + } + + private LongRunningArguments CreateStartArgs() + { + var root = GetSolutionRoot(); + var project = Path.Combine(root.FullName, "examples", _applicationName); + + var arguments = new[] { "run", "--project", project }; + var applicationArguments = GetArguments(); + if (applicationArguments.Length > 0) + arguments = [..arguments, "--", ..applicationArguments]; + + return new("dotnet", arguments) + { + Environment = new Dictionary + { + { "OTEL_EXPORTER_OTLP_ENDPOINT", _endpoint }, + { "OTEL_EXPORTER_OTLP_HEADERS", _authorization }, + { "OTEL_METRICS_EXPORTER", "otlp" }, + { "OTEL_LOGS_EXPORTER", "otlp" }, + { "OTEL_BSP_SCHEDULE_DELAY", "1000" }, + { "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", "5" }, + { "OTEL_RESOURCE_ATTRIBUTES", $"service.name={_serviceName},service.version=1.0,1,deployment.environment=e2e" }, + }, + StartedConfirmationHandler = l => + { + //Grab actual process id to send SIGINT to. + if (l.Line == null) return false; + var processIdMatch = ProcessIdMatch.Match(l.Line); + if (processIdMatch.Success) + ProcessId = int.Parse(processIdMatch.Groups["processid"].Value); + + return l.Line.StartsWith(" Application started."); + } + }; + } + + public virtual void Dispose() + { + if (ProcessId.HasValue) + _app.SendControlC(ProcessId.Value); + _app.Dispose(); + } +} diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ITrafficSimulator.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ITrafficSimulator.cs new file mode 100644 index 0000000..118a0c4 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/DistributedFixture/ITrafficSimulator.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using FluentAssertions; + +namespace Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; + +public interface ITrafficSimulator +{ + Task Start(DistributedApplicationFixture distributedInfra); +} + +public class DefaultTrafficSimulator : ITrafficSimulator +{ + public async Task Start(DistributedApplicationFixture distributedInfra) + { + for (var i = 0; i < 10; i++) + { + var get = await distributedInfra.AspNetApplication.HttpClient.GetAsync("e2e"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + var response = await get.Content.ReadAsStringAsync(); + } + } +} diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/Elastic.OpenTelemetry.EndToEndTests.csproj b/tests/Elastic.OpenTelemetry.EndToEndTests/Elastic.OpenTelemetry.EndToEndTests.csproj new file mode 100644 index 0000000..41dfa91 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/Elastic.OpenTelemetry.EndToEndTests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + false + true + xUnit1041 + 588f828e-db42-4b45-9783-023f03243753 + + + + + + + + + + + + diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/EndToEndOptions.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/EndToEndOptions.cs new file mode 100644 index 0000000..46113ca --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/EndToEndOptions.cs @@ -0,0 +1,54 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.OpenTelemetry.EndToEndTests; +using Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Nullean.Xunit.Partitions; +using Xunit; + +[assembly: TestFramework(Partition.TestFramework, Partition.Assembly)] +[assembly: PartitionOptions(typeof(EndToEndOptions))] + +namespace Elastic.OpenTelemetry.EndToEndTests; + +public class EndToEndOptions : PartitionOptions +{ + public override void OnBeforeTestsRun() + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + var testSuite = Environment.GetEnvironmentVariable("TEST_SUITE"); + + //only validate credentials if we are actually running the e2e suite + if (testSuite == null || ( + !testSuite.Equals("e2e", StringComparison.InvariantCultureIgnoreCase) + && !testSuite.Equals("all", StringComparison.InvariantCultureIgnoreCase)) + ) + return; + + try + { + configuration["E2E:Endpoint"].Should() + .NotBeNullOrWhiteSpace("Missing E2E:Endpoint configuration"); + configuration["E2E:Authorization"].Should() + .NotBeNullOrWhiteSpace("Missing E2E:Authorization configuration"); + configuration["E2E:BrowserEmail"].Should() + .NotBeNullOrWhiteSpace("Missing E2E:BrowserEmail configuration"); + configuration["E2E:BrowserPassword"].Should() + .NotBeNullOrWhiteSpace("Missing E2E:BrowserPassword configuration"); + } + catch (Exception e) + { + Console.WriteLine(); + Console.WriteLine(e.Message); + Console.WriteLine(); + throw; + } + } +} diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/README.md b/tests/Elastic.OpenTelemetry.EndToEndTests/README.md new file mode 100644 index 0000000..a29a992 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/README.md @@ -0,0 +1,45 @@ +# E2E Tests: Elastic's .NET OpenTelemetry Distribution + + +## Target Environment + +Requires an already running serverless observability project on cloud. + +The configuration can be provided either as asp.net secrets or environment variables. + +```bash +dotnet user-secrets set "E2E:Endpoint" "" --project tests/Elastic.OpenTelemetry.IntegrationTests +dotnet user-secrets set "E2E:Authorization" "
" --project tests/Elastic.OpenTelemetry.IntegrationTests +``` + +The equivalent environment variables are `E2E__ENDPOINT` and `E2E__AUTHORIZATION`. For local development setting +secrets is preferred. + +This ensures the instrumented applications will send OTLP data. + +## Browser authentication + +The tests require a headless browser to login. This requires a non OAuth login to be setup on your serverless +observability project. + +To do this is to invite an email address you own to your organization: + +https://cloud.elastic.co/account/members + +This user only needs instance access to the `Target Environment`. + +**NOTE:** since you can only be part of a single organization on cloud be sure that the organization you are part of is +not used for any production usecases and you have clearance from the organization owner. + +By default accounts on cloud are part of their own personal organization. + +Once invited and accepted the invited email can be used to login during the automated tests. + +These can be provided again as user secrets: + +```bash +dotnet user-secrets set "E2E:BrowserEmail" "" --project tests/Elastic.OpenTelemetry.IntegrationTests +dotnet user-secrets set "E2E:BrowserPassword" "" --project tests/Elastic.OpenTelemetry.IntegrationTests +``` + +or environment variables (`E2E__BROWSEREMAIL` and `E2E__BROWSERPASSWORD`). diff --git a/tests/Elastic.OpenTelemetry.EndToEndTests/ServiceTests.cs b/tests/Elastic.OpenTelemetry.EndToEndTests/ServiceTests.cs new file mode 100644 index 0000000..2e0b432 --- /dev/null +++ b/tests/Elastic.OpenTelemetry.EndToEndTests/ServiceTests.cs @@ -0,0 +1,39 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.OpenTelemetry.EndToEndTests.DistributedFixture; +using FluentAssertions; +using Microsoft.Playwright; +using Nullean.Xunit.Partitions.Sdk; +using Xunit; +using Xunit.Abstractions; +using static Microsoft.Playwright.Assertions; + +namespace Elastic.OpenTelemetry.EndToEndTests; + +public class EndToEndTests(ITestOutputHelper output, DistributedApplicationFixture fixture) + : IPartitionFixture, IAsyncLifetime +{ + public ITestOutputHelper Output { get; } = output; + private string _testName = string.Empty; + private IPage _page = null!; + + [Fact] + public void EnsureApplicationWasStarted() => fixture.Started.Should().BeTrue(); + + [Fact] + public async Task LatencyShowsAGraph() + { + // click on service in service overview page. + _page.SetDefaultTimeout((float)TimeSpan.FromSeconds(30).TotalMilliseconds); + var uri = new Uri(fixture.ApmUI.KibanaAppUri, $"/app/apm/services/{fixture.ServiceName}/overview").ToString(); + await _page.GotoAsync(uri); + await Expect(_page.GetByRole(AriaRole.Heading, new() { Name = "Latency", Exact = true })).ToBeVisibleAsync(); + } + + + public async Task InitializeAsync() => _page = await fixture.ApmUI.NewProfiledPage(_testName); + + public async Task DisposeAsync() => await fixture.ApmUI.StopTrace(_page, PartitionContext.TestException == null ? null : _testName); +}