diff --git a/Directory.Packages.props b/Directory.Packages.props index c4089ef5..446cb86e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,60 +4,85 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - + + + + + + + - - - - + + + + - + - + - + - - + + - - + + - - - + + + - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elastic.OpenTelemetry.slnx b/Elastic.OpenTelemetry.slnx index 12fb706d..726c1091 100644 --- a/Elastic.OpenTelemetry.slnx +++ b/Elastic.OpenTelemetry.slnx @@ -50,7 +50,9 @@ + + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 00000000..367c920c --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "examples/AppHost/AppHost.csproj" + } +} \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json index 1c4f5623..35ca53a8 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "minver-cli": { - "version": "6.0.0", + "version": "7.0.0", "commands": [ "minver" ] diff --git a/examples/AppHost/AppHost.csproj b/examples/AppHost/AppHost.csproj index 4d75b4de..b45a7f7f 100644 --- a/examples/AppHost/AppHost.csproj +++ b/examples/AppHost/AppHost.csproj @@ -1,6 +1,4 @@ - - - + Exe @@ -11,10 +9,6 @@ true - - - - diff --git a/src/Elastic.OpenTelemetry.AutoInstrumentation/Elastic.OpenTelemetry.AutoInstrumentation.csproj b/src/Elastic.OpenTelemetry.AutoInstrumentation/Elastic.OpenTelemetry.AutoInstrumentation.csproj index 8568de3b..753ba3dd 100644 --- a/src/Elastic.OpenTelemetry.AutoInstrumentation/Elastic.OpenTelemetry.AutoInstrumentation.csproj +++ b/src/Elastic.OpenTelemetry.AutoInstrumentation/Elastic.OpenTelemetry.AutoInstrumentation.csproj @@ -30,7 +30,9 @@ - + + compile; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.cmd b/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.cmd index d91a563b..3c7c716c 100755 --- a/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.cmd +++ b/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.cmd @@ -40,12 +40,12 @@ Attempting to use the native profiler from runtimes\win-x64\native and runtimes\ set COR_ENABLE_PROFILING=1 set COR_PROFILER={918728DD-259F-4A6A-AC2B-B85E1B658318} -:: On .NET Framework automatic assembly redirection MUST be disabled. This setting -:: is ignored on .NET. This is necessary because the NuGet package doesn't bring -:: the pre-defined versions of the transitive dependencies used in the automatic -:: redirection. Instead the transitive dependencies versions are determined by +:: Automatic assembly redirection MUST be disabled. This is necessary +:: because the NuGet package doesn't bring the pre-defined versions +:: of the transitive dependencies used in the automatic redirection. +:: Instead the transitive dependencies versions are determined by :: the NuGet version resolution algorithm when building the application. -set OTEL_DOTNET_AUTO_NETFX_REDIRECT_ENABLED=false +set OTEL_DOTNET_AUTO_REDIRECT_ENABLED=false :: Settings for .NET set ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=OpenTelemetry.AutoInstrumentation.AspNetCoreBootstrapper diff --git a/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.sh b/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.sh index 10937afa..9f06c899 100755 --- a/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.sh +++ b/src/Elastic.OpenTelemetry.AutoInstrumentation/_instrument.sh @@ -55,6 +55,13 @@ fi export CORECLR_PROFILER_PATH +# Automatic assembly redirection MUST be disabled. This is necessary +# because the NuGet package doesn't bring the pre-defined versions +# of the transitive dependencies used in the automatic redirection. +# Instead the transitive dependencies versions are determined by +# the NuGet version resolution algorithm when building the application. +export OTEL_DOTNET_AUTO_REDIRECT_ENABLED=false + # Settings for .NET export ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=OpenTelemetry.AutoInstrumentation.AspNetCoreBootstrapper export CORECLR_ENABLE_PROFILING=1 diff --git a/src/Elastic.OpenTelemetry.Core/Elastic.OpenTelemetry.Core.csproj b/src/Elastic.OpenTelemetry.Core/Elastic.OpenTelemetry.Core.csproj index ff07252e..d6f782c0 100644 --- a/src/Elastic.OpenTelemetry.Core/Elastic.OpenTelemetry.Core.csproj +++ b/src/Elastic.OpenTelemetry.Core/Elastic.OpenTelemetry.Core.csproj @@ -1,4 +1,4 @@ - + Library @@ -20,7 +20,9 @@ - + + compile; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Elastic.OpenTelemetry/Elastic.OpenTelemetry.csproj b/src/Elastic.OpenTelemetry/Elastic.OpenTelemetry.csproj index cbf9bc59..99b9008b 100644 --- a/src/Elastic.OpenTelemetry/Elastic.OpenTelemetry.csproj +++ b/src/Elastic.OpenTelemetry/Elastic.OpenTelemetry.csproj @@ -45,7 +45,9 @@ - + + compile; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test-applications/AutoInstr.Console.Net10/AutoInstr.Console.Net10.csproj b/test-applications/AutoInstr.Console.Net10/AutoInstr.Console.Net10.csproj new file mode 100644 index 00000000..49ca30ea --- /dev/null +++ b/test-applications/AutoInstr.Console.Net10/AutoInstr.Console.Net10.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/test-applications/AutoInstr.Console.Net10/Program.cs b/test-applications/AutoInstr.Console.Net10/Program.cs new file mode 100644 index 00000000..1edec317 --- /dev/null +++ b/test-applications/AutoInstr.Console.Net10/Program.cs @@ -0,0 +1,11 @@ +// 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 + +// Plain console app — no Elastic or OpenTelemetry references. +// The auto-instrumentation profiler injects EDOT at runtime via env vars +// (CORECLR_ENABLE_PROFILING, CORECLR_PROFILER, etc.). + +Console.WriteLine("APP_STARTED"); +await Task.Delay(30_000).ConfigureAwait(false); +Console.WriteLine("APP_COMPLETE"); diff --git a/test-applications/AutoInstr.Console.Net9/AutoInstr.Console.Net9.csproj b/test-applications/AutoInstr.Console.Net9/AutoInstr.Console.Net9.csproj new file mode 100644 index 00000000..8351b7cd --- /dev/null +++ b/test-applications/AutoInstr.Console.Net9/AutoInstr.Console.Net9.csproj @@ -0,0 +1,18 @@ + + + + Exe + net9.0 + enable + enable + true + + + + + + + + + + diff --git a/test-applications/AutoInstr.Console.Net9/Program.cs b/test-applications/AutoInstr.Console.Net9/Program.cs new file mode 100644 index 00000000..1edec317 --- /dev/null +++ b/test-applications/AutoInstr.Console.Net9/Program.cs @@ -0,0 +1,11 @@ +// 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 + +// Plain console app — no Elastic or OpenTelemetry references. +// The auto-instrumentation profiler injects EDOT at runtime via env vars +// (CORECLR_ENABLE_PROFILING, CORECLR_PROFILER, etc.). + +Console.WriteLine("APP_STARTED"); +await Task.Delay(30_000).ConfigureAwait(false); +Console.WriteLine("APP_COMPLETE"); diff --git a/tests/AutoInstrumentation.IntegrationTests/PluginLoaderTests.cs b/tests/AutoInstrumentation.IntegrationTests/PluginLoaderTests.cs index 98677919..22dacfb2 100644 --- a/tests/AutoInstrumentation.IntegrationTests/PluginLoaderTests.cs +++ b/tests/AutoInstrumentation.IntegrationTests/PluginLoaderTests.cs @@ -39,8 +39,7 @@ public PluginLoaderTests() .Build(); _output = Consume.RedirectStdoutAndStderrToStream(new MemoryStream(), new MemoryStream()); - _container = new ContainerBuilder() - .WithImage(_image) + _container = new ContainerBuilder(_image) .WithPortBinding(5000, 8080) .WithLogger(ConsoleLogger.Instance) .WithOutputConsumer(_output) diff --git a/tests/Elastic.OpenTelemetry.IntegrationTests/AutoInstrDistributionTests.cs b/tests/Elastic.OpenTelemetry.IntegrationTests/AutoInstrDistributionTests.cs index 6b70f83f..a5c97880 100644 --- a/tests/Elastic.OpenTelemetry.IntegrationTests/AutoInstrDistributionTests.cs +++ b/tests/Elastic.OpenTelemetry.IntegrationTests/AutoInstrDistributionTests.cs @@ -12,6 +12,11 @@ namespace Elastic.OpenTelemetry.IntegrationTests; /// The test app has no Elastic references — the profiler injects EDOT at runtime. /// On net8.0+, OpAmp assemblies load via AssemblyLoadContext isolation. /// +/// +/// Parameterized by TFM so a dep-version regression (e.g. pinning Microsoft.Extensions.* +/// to a higher major than the host's shared framework supplies) surfaces as an +/// asymmetric failure across TFMs rather than going unnoticed on the one we happen to test. +/// [Collection("Redistributable")] public class AutoInstrDistributionTests { @@ -23,8 +28,19 @@ private void AssertFixtureReady() => Assert.True(_fixture.IsReady, $"Redistributable fixture failed to initialize — test cannot run.\n{_fixture.InitializationError}"); - [Fact(Timeout = 90_000)] - public async Task AutoInstr_Net8_AlcPath_OpAmpWorks() + private string GetAppPath(string tfm) => tfm switch + { + "net8.0" => _fixture.Net8AppPath, + "net9.0" => _fixture.Net9AppPath, + "net10.0" => _fixture.Net10AppPath, + _ => throw new ArgumentOutOfRangeException(nameof(tfm), tfm, "No published app for this TFM.") + }; + + [Theory(Timeout = 90_000)] + [InlineData("net8.0")] + [InlineData("net9.0")] + [InlineData("net10.0")] + public async Task AutoInstr_AlcPath_OpAmpWorks(string tfm) { AssertFixtureReady(); @@ -33,9 +49,9 @@ public async Task AutoInstr_Net8_AlcPath_OpAmpWorks() var envVars = ProfilerEnvironment.ForCoreCLR(_fixture.InstallationDirectory); envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = server.Endpoint; - envVars["OTEL_SERVICE_NAME"] = "autoinstr-net8-opamp-test"; + envVars["OTEL_SERVICE_NAME"] = $"autoinstr-{tfm}-opamp-test"; - await using var runner = new TestAppRunner(_fixture.Net8AppPath, envVars); + await using var runner = new TestAppRunner(GetAppPath(tfm), envVars); await runner.RunToCompletionAsync(); runner.AssertExitCodeZero(); @@ -53,8 +69,11 @@ public async Task AutoInstr_Net8_AlcPath_OpAmpWorks() Assert.True(server.RequestCount >= 1, "Server should have received at least one request."); } - [SkipOnCiFact("Times out on CI; needs investigation.", Timeout = 90_000)] - public async Task AutoInstr_Net8_AlcPath_CentralConfigReceived() + [Theory(Timeout = 90_000)] + [InlineData("net8.0")] + [InlineData("net9.0")] + [InlineData("net10.0")] + public async Task AutoInstr_AlcPath_CentralConfigReceived(string tfm) { AssertFixtureReady(); @@ -63,9 +82,9 @@ public async Task AutoInstr_Net8_AlcPath_CentralConfigReceived() var envVars = ProfilerEnvironment.ForCoreCLR(_fixture.InstallationDirectory); envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = server.Endpoint; - envVars["OTEL_SERVICE_NAME"] = "autoinstr-net8-config-test"; + envVars["OTEL_SERVICE_NAME"] = $"autoinstr-{tfm}-config-test"; - await using var runner = new TestAppRunner(_fixture.Net8AppPath, envVars); + await using var runner = new TestAppRunner(GetAppPath(tfm), envVars); await runner.RunToCompletionAsync(); runner.AssertExitCodeZero(); @@ -81,16 +100,98 @@ public async Task AutoInstr_Net8_AlcPath_CentralConfigReceived() analyzer.AssertContainsEventId(205, "ExtractedLogLevel"); } - [SkipOnCiFact("Times out on CI; needs investigation.", Timeout = 90_000)] - public async Task AutoInstr_Net8_AlcPath_NoOpAmpServer_GracefulFallback() + [WindowsOnlyFact(Timeout = 90_000)] + public async Task AutoInstr_NetFramework_OpAmpWorks() + { + AssertFixtureReady(); + + await using var server = new OpAmpTestServer.OpAmpTestServer("""{"log_level":"debug"}"""); + await server.StartAsync(); + + var envVars = ProfilerEnvironment.ForNetFramework(_fixture.InstallationDirectory); + envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = server.Endpoint; + envVars["OTEL_SERVICE_NAME"] = "autoinstr-net462-opamp-test"; + + await using var runner = new TestAppRunner(_fixture.Net462AppPath, envVars); + await runner.RunToCompletionAsync(); + + runner.AssertExitCodeZero(); + Assert.Contains("APP_COMPLETE", runner.StandardOutput); + Assert.NotNull(runner.EdotLogFilePath); + + var analyzer = new EdotLogAnalyzer(runner.EdotLogFilePath); + analyzer.AssertNoErrors(); + // net462 uses the direct path — no ALC isolation + analyzer.AssertDoesNotContainEventId(102, "net462 should not use ALC isolation"); + analyzer.AssertContainsEventId(101, "InitializingCentralConfig"); + analyzer.AssertContainsEventId(106, "OpAmpClientCreated"); + analyzer.AssertContainsEventId(107, "OpAmpClientStarted"); + Assert.True(server.RequestCount >= 1, "Server should have received at least one request."); + } + + [WindowsOnlyFact(Timeout = 90_000)] + public async Task AutoInstr_NetFramework_CentralConfigReceived() + { + AssertFixtureReady(); + + await using var server = new OpAmpTestServer.OpAmpTestServer("""{"log_level":"debug"}"""); + await server.StartAsync(); + + var envVars = ProfilerEnvironment.ForNetFramework(_fixture.InstallationDirectory); + envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = server.Endpoint; + envVars["OTEL_SERVICE_NAME"] = "autoinstr-net462-config-test"; + + await using var runner = new TestAppRunner(_fixture.Net462AppPath, envVars); + await runner.RunToCompletionAsync(); + + runner.AssertExitCodeZero(); + Assert.Contains("APP_COMPLETE", runner.StandardOutput); + Assert.NotNull(runner.EdotLogFilePath); + + var analyzer = new EdotLogAnalyzer(runner.EdotLogFilePath); + analyzer.AssertNoErrors(); + analyzer.AssertDoesNotContainEventId(102, "net462 should not use ALC isolation"); + analyzer.AssertContainsEventId(131, "ReceivedInitialCentralConfig"); + analyzer.AssertContainsEventId(200, "ReceivedRemoteConfig"); + analyzer.AssertContainsEventId(205, "ExtractedLogLevel"); + } + + [WindowsOnlyFact(Timeout = 90_000)] + public async Task AutoInstr_NetFramework_NoOpAmpServer_GracefulFallback() + { + AssertFixtureReady(); + + var envVars = ProfilerEnvironment.ForNetFramework(_fixture.InstallationDirectory); + envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = "http://127.0.0.1:1"; + envVars["OTEL_SERVICE_NAME"] = "autoinstr-net462-fallback-test"; + + await using var runner = new TestAppRunner(_fixture.Net462AppPath, envVars); + await runner.RunToCompletionAsync(); + + runner.AssertExitCodeZero(); + Assert.Contains("APP_COMPLETE", runner.StandardOutput); + Assert.NotNull(runner.EdotLogFilePath); + + var analyzer = new EdotLogAnalyzer(runner.EdotLogFilePath); + analyzer.AssertNoErrors( + allowedErrorEventIds: [116], + allowedMessageSubstrings: ["Failed to send heartbeat"]); + analyzer.AssertDoesNotContainEventId(131, "Should not receive initial config from unreachable server"); + } + + [Theory(Timeout = 90_000)] + [InlineData("net8.0")] + [InlineData("net9.0")] + [InlineData("net10.0")] + public async Task AutoInstr_AlcPath_NoOpAmpServer_GracefulFallback(string tfm) { AssertFixtureReady(); var envVars = ProfilerEnvironment.ForCoreCLR(_fixture.InstallationDirectory); envVars["ELASTIC_OTEL_OPAMP_ENDPOINT"] = "http://127.0.0.1:1"; - envVars["OTEL_SERVICE_NAME"] = "autoinstr-net8-fallback-test"; + envVars["OTEL_SERVICE_NAME"] = $"autoinstr-{tfm}-fallback-test"; - await using var runner = new TestAppRunner(_fixture.Net8AppPath, envVars); + await using var runner = new TestAppRunner(GetAppPath(tfm), envVars); await runner.RunToCompletionAsync(); runner.AssertExitCodeZero(); diff --git a/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetAutoInstrFixture.cs b/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetAutoInstrFixture.cs index e0483b0f..47c89dfd 100644 --- a/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetAutoInstrFixture.cs +++ b/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetAutoInstrFixture.cs @@ -2,7 +2,6 @@ // 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 System.IO.Compression; using System.Runtime.InteropServices; using Elastic.OpenTelemetry.IntegrationTests.Helpers; @@ -103,6 +102,8 @@ public async Task InitializeAsync() ?? throw new InvalidOperationException( "Failed to determine package version after packing. " + $"Feed directory: {_feed.FeedPath}"); + NuGetPackageFixture.InvalidateGlobalCacheEntry( + "Elastic.OpenTelemetry.AutoInstrumentation", PackageVersion, WriteFixtureLog); // 3. Write nuget.config for the consumer apps var nugetConfigDir = Path.Combine(Path.GetTempPath(), $"edot-nuget-autoinstr-config-{Guid.NewGuid():N}"); @@ -135,9 +136,18 @@ public async Task InitializeAsync() var aotProjectPath = Path.Combine(solutionRoot, "test-applications", "NuGetAutoInstr.Aot.Net10", "NuGetAutoInstr.Aot.Net10.csproj"); + // AOT is the scenario that most visibly surfaced the stale-project.assets.json issue: + // ilc reads the full transitive graph at publish time, so a cache-hit restore that + // inherited ProjectReference-era deps from a prior solution build would feed ilc an + // older Google.Protobuf (with AOT analysis warnings) instead of the centrally-pinned + // version. Isolate obj/bin per consumer app to keep .artifacts/ out of the picture. + var (aotBaseOutputArg, aotBaseIntermediateOutputArg, _) = + NuGetPackageFixture.CreateIsolatedBuildPaths("NuGetAutoInstr.Aot.Net10", _tempDirectories); + await RunDotnetAsync( $"restore \"{aotProjectPath}\" " + $"--configfile \"{configFilePath}\" " + + $"{aotBaseOutputArg} {aotBaseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", aotSetupCts.Token).ConfigureAwait(false); var aotPublishDir = Path.Combine(Path.GetTempPath(), @@ -148,6 +158,7 @@ await RunDotnetAsync( await RunDotnetAsync( $"publish \"{aotProjectPath}\" --no-restore " + $"-c Release -o \"{aotPublishDir}\" " + + $"{aotBaseOutputArg} {aotBaseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", aotSetupCts.Token).ConfigureAwait(false); // AOT produces a native exe (no .dll) @@ -177,9 +188,16 @@ private async Task PublishConsumerAppAsync( var projectPath = Path.Combine(solutionRoot, "test-applications", projectName, $"{projectName}.csproj"); + // Isolate obj/bin from .artifacts/ so a prior solution-wide build's project.assets.json + // can't cache-hit this restore and override the PackageReference path we're exercising. + // See NuGetPackageFixture.CreateIsolatedBuildPaths for the detailed rationale. + var (baseOutputArg, baseIntermediateOutputArg, _) = + NuGetPackageFixture.CreateIsolatedBuildPaths(projectName, _tempDirectories); + await RunDotnetAsync( $"restore \"{projectPath}\" " + $"--configfile \"{configFilePath}\" " + + $"{baseOutputArg} {baseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", ct).ConfigureAwait(false); var publishDir = Path.Combine(Path.GetTempPath(), @@ -196,6 +214,7 @@ await RunDotnetAsync( await RunDotnetAsync( $"publish \"{projectPath}\" --no-restore " + $"-c Release -o \"{publishDir}\" " + + $"{baseOutputArg} {baseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", ct).ConfigureAwait(false); var appPath = Path.Combine(publishDir, outputFileName); diff --git a/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetPackageFixture.cs b/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetPackageFixture.cs index fe0ece09..0f0ba1ce 100644 --- a/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetPackageFixture.cs +++ b/tests/Elastic.OpenTelemetry.IntegrationTests/NuGetPackageFixture.cs @@ -35,7 +35,7 @@ public class NuGetPackageFixture : IAsyncLifetime private readonly Stopwatch _sw = Stopwatch.StartNew(); private readonly LocalNuGetFeed _feed = new(); - private readonly List _publishDirectories = []; + private readonly List _tempDirectories = []; private readonly IMessageSink _diagnosticMessageSink; private string? _nugetConfigDirectory; @@ -89,9 +89,11 @@ public async Task InitializeAsync() "Failed to determine package version after packing. " + $"Feed directory: {_feed.FeedPath}"); WriteFixtureLog($"[Stage 1/4] Pack completed. PackageVersion='{PackageVersion}'. FeedPath='{_feed.FeedPath}'."); + InvalidateGlobalCacheEntry("Elastic.OpenTelemetry", PackageVersion, WriteFixtureLog); // 2. Write a temp nuget.config for the consumer apps' restore _nugetConfigDirectory = Path.Combine(Path.GetTempPath(), $"edot-nuget-config-{Guid.NewGuid():N}"); + _tempDirectories.Add(_nugetConfigDirectory); _feed.WriteNuGetConfig(_nugetConfigDirectory); configFilePath = Path.Combine(_nugetConfigDirectory, "nuget.config"); WriteFixtureLog($"[Stage 2/4] nuget.config written to '{configFilePath}'."); @@ -143,10 +145,6 @@ public async Task InitializeAsync() } } - private void WriteFixtureLog(string message) => - _diagnosticMessageSink.OnMessage( - new DiagnosticMessage($"[{DateTimeOffset.UtcNow:O}] [+{_sw.Elapsed.TotalSeconds:F1}s] [NuGetPackageFixture] {message}")); - private async Task PublishConsumerAppAsync( string solutionRoot, string projectName, string outputFileName, string configFilePath, CancellationToken ct) @@ -154,20 +152,27 @@ private async Task PublishConsumerAppAsync( var consumerProjectPath = Path.Combine(solutionRoot, "test-applications", projectName, $"{projectName}.csproj"); + // Isolate obj/bin from .artifacts/ so a prior solution-wide build's project.assets.json + // can't cache-hit this restore and override the PackageReference path we're exercising. + // See CreateIsolatedBuildPaths for the detailed rationale. + var (baseOutputArg, baseIntermediateOutputArg, _) = CreateIsolatedBuildPaths(projectName, _tempDirectories); + WriteFixtureLog($" Restoring '{projectName}' (elapsed {_sw.Elapsed.TotalSeconds:F1}s). ConfigFile='{configFilePath}'."); await RunDotnetAsync( $"restore \"{consumerProjectPath}\" " + $"--configfile \"{configFilePath}\" " + + $"{baseOutputArg} {baseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", ct).ConfigureAwait(false); WriteFixtureLog($" Restore complete for '{projectName}' (elapsed {_sw.Elapsed.TotalSeconds:F1}s)."); var publishDir = Path.Combine(Path.GetTempPath(), $"edot-{projectName.ToLowerInvariant()}-{Guid.NewGuid():N}"); - _publishDirectories.Add(publishDir); + _tempDirectories.Add(publishDir); WriteFixtureLog($" Publishing '{projectName}' to '{publishDir}' (elapsed {_sw.Elapsed.TotalSeconds:F1}s)."); await RunDotnetAsync( $"publish \"{consumerProjectPath}\" --no-restore " + $"-c Release -o \"{publishDir}\" " + + $"{baseOutputArg} {baseIntermediateOutputArg} " + $"-p:ElasticOtelVersion={PackageVersion}", ct).ConfigureAwait(false); WriteFixtureLog($" Publish complete for '{projectName}' (elapsed {_sw.Elapsed.TotalSeconds:F1}s)."); @@ -180,6 +185,70 @@ await RunDotnetAsync( return appPath; } + private void WriteFixtureLog(string message) => + _diagnosticMessageSink.OnMessage( + new DiagnosticMessage($"[{DateTimeOffset.UtcNow:O}] [+{_sw.Elapsed.TotalSeconds:F1}s] [NuGetPackageFixture] {message}")); + + /// + /// Builds -p:BaseOutputPath / -p:BaseIntermediateOutputPath arguments pointing + /// at a fresh scratch directory so restore/publish doesn't read or write the shared + /// .artifacts/ tree. Without this, a project.assets.json left behind by an + /// earlier build (for example a solution-wide ./build.sh build, which compiles the + /// test-apps via ProjectReference) would be treated as cache-compatible by + /// dotnet restore, and the fixture's -p:ElasticOtelVersion=... PackageReference + /// path would silently be ignored — producing wrong transitive versions at publish time + /// (the concrete failure we hit was ilc picking up an older Google.Protobuf with + /// AOT analysis warnings, instead of the centrally-pinned version). + /// + /// + /// Shared between and ; + /// the returned entry is tracked for later + /// best-effort cleanup in the caller's DisposeAsync. + /// + internal static (string BaseOutputArg, string BaseIntermediateOutputArg, string ScratchDir) CreateIsolatedBuildPaths( + string projectName, List tempDirectoriesForCleanup) + { + var scratchDir = Path.Combine(Path.GetTempPath(), $"edot-scratch-{projectName.ToLowerInvariant()}-{Guid.NewGuid():N}"); + tempDirectoriesForCleanup.Add(scratchDir); + + // Trailing separator is required by MSBuild for Base*Path properties. Use forward slashes + // on Windows to avoid the \"- escaping issue where a trailing backslash before a closing + // quote is interpreted as an escaped literal quote, merging adjacent arguments. + var binPath = Path.Combine(scratchDir, "bin").Replace('\\', '/') + "/"; + var objPath = Path.Combine(scratchDir, "obj").Replace('\\', '/') + "/"; + + return ( + $"\"-p:BaseOutputPath={binPath}\"", + $"\"-p:BaseIntermediateOutputPath={objPath}\"", + scratchDir); + } + + /// + /// Deletes the global NuGet package cache entry for a freshly packed package so that + /// the restore step uses the new nupkg from the local feed rather than a cached copy + /// with potentially stale dependencies. Canary versions reuse the same version string + /// across runs (MinVer produces the same version while the git state is unchanged), + /// so NuGet would otherwise trust the cached nuspec and resolve old transitive versions. + /// + internal static void InvalidateGlobalCacheEntry(string packageId, string version, Action log) + { + var globalPackagesPath = + Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages"); + + var cacheDir = Path.Combine(globalPackagesPath, packageId.ToLowerInvariant(), version.ToLowerInvariant()); + if (!Directory.Exists(cacheDir)) + return; + + log($"Deleting stale global NuGet cache entry '{cacheDir}' so the freshly packed nupkg is used."); + try + { Directory.Delete(cacheDir, recursive: true); } + catch (Exception ex) + { log($"Warning: could not delete stale cache dir '{cacheDir}': {ex.Message}"); } + } + /// /// Shared helper used by all integration test fixtures to run dotnet commands /// with full diagnostic logging via xUnit's . @@ -294,7 +363,7 @@ public Task DisposeAsync() { _feed.Dispose(); - foreach (var dir in _publishDirectories) + foreach (var dir in _tempDirectories) { if (Directory.Exists(dir)) { @@ -307,18 +376,6 @@ public Task DisposeAsync() } } - if (_nugetConfigDirectory is not null && Directory.Exists(_nugetConfigDirectory)) - { - try - { - Directory.Delete(_nugetConfigDirectory, true); - } - catch - { - // Best-effort cleanup - } - } - return Task.CompletedTask; } diff --git a/tests/Elastic.OpenTelemetry.IntegrationTests/OpAmpBootstrapDockerTests.cs b/tests/Elastic.OpenTelemetry.IntegrationTests/OpAmpBootstrapDockerTests.cs index a27b59b3..bf9048bc 100644 --- a/tests/Elastic.OpenTelemetry.IntegrationTests/OpAmpBootstrapDockerTests.cs +++ b/tests/Elastic.OpenTelemetry.IntegrationTests/OpAmpBootstrapDockerTests.cs @@ -45,8 +45,7 @@ public async Task InitializeAsync() await _image.CreateAsync(); _output = Consume.RedirectStdoutAndStderrToStream(new MemoryStream(), new MemoryStream()); - _container = new ContainerBuilder() - .WithImage(_image) + _container = new ContainerBuilder(_image) .WithLogger(ContainerLogger) .WithOutputConsumer(_output) .WithCreateParameterModifier(p => diff --git a/tests/Elastic.OpenTelemetry.IntegrationTests/RedistributableFixture.cs b/tests/Elastic.OpenTelemetry.IntegrationTests/RedistributableFixture.cs index 52f801d1..e29f8253 100644 --- a/tests/Elastic.OpenTelemetry.IntegrationTests/RedistributableFixture.cs +++ b/tests/Elastic.OpenTelemetry.IntegrationTests/RedistributableFixture.cs @@ -23,9 +23,24 @@ namespace Elastic.OpenTelemetry.IntegrationTests; /// public class RedistributableFixture : IAsyncLifetime { + private static readonly (string Tfm, string ProjectName)[] TestApps = GetTestApps(); + + private static (string Tfm, string ProjectName)[] GetTestApps() + { + var apps = new List<(string Tfm, string ProjectName)> + { + ("net8.0", "AutoInstr.Console.Net8"), + ("net9.0", "AutoInstr.Console.Net9"), + ("net10.0", "AutoInstr.Console.Net10"), + }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + apps.Add(("net462", "AutoInstr.Console.Net462")); + return [.. apps]; + } + private readonly IMessageSink _diagnosticMessageSink; + private readonly List _publishDirectories = []; private string? _extractionDirectory; - private string? _publishDirectory; public RedistributableFixture(IMessageSink diagnosticMessageSink) => _diagnosticMessageSink = diagnosticMessageSink; @@ -35,11 +50,18 @@ public class RedistributableFixture : IAsyncLifetime /// public string InstallationDirectory { get; private set; } = string.Empty; - /// - /// Path to the published AutoInstr.Console.Net8.dll, ready to run under the profiler. - /// + /// Path to the published AutoInstr.Console.Net8.dll, ready to run under the profiler. public string Net8AppPath { get; private set; } = string.Empty; + /// Path to the published AutoInstr.Console.Net9.dll, ready to run under the profiler. + public string Net9AppPath { get; private set; } = string.Empty; + + /// Path to the published AutoInstr.Console.Net10.dll, ready to run under the profiler. + public string Net10AppPath { get; private set; } = string.Empty; + + /// Path to the published AutoInstr.Console.Net462.exe, ready to run under the .NET Framework profiler. Windows only. + public string Net462AppPath { get; private set; } = string.Empty; + /// Whether the fixture initialized successfully. public bool IsReady { get; private set; } @@ -71,21 +93,40 @@ public async Task InitializeAsync() ZipFile.ExtractToDirectory(zipPath, _extractionDirectory); InstallationDirectory = _extractionDirectory; - // 3. Build + publish the auto-instrumentation test app - var appProjectPath = Path.Combine(solutionRoot, - "test-applications", "AutoInstr.Console.Net8", "AutoInstr.Console.Net8.csproj"); - _publishDirectory = Path.Combine( - Path.GetTempPath(), $"edot-autoinstr-net8-{Guid.NewGuid():N}"); - var publishDir = _publishDirectory; - - await RunDotnetAsync( - $"publish \"{appProjectPath}\" -c Release -o \"{publishDir}\"", ct).ConfigureAwait(false); - - Net8AppPath = Path.Combine(publishDir, "AutoInstr.Console.Net8.dll"); - - if (!File.Exists(Net8AppPath)) - throw new FileNotFoundException( - $"Published app not found at expected path: {Net8AppPath}"); + // 3. Build + publish one auto-instrumentation test app per supported TFM + foreach (var (tfm, projectName) in TestApps) + { + var appProjectPath = Path.Combine(solutionRoot, + "test-applications", projectName, $"{projectName}.csproj"); + var publishDir = Path.Combine( + Path.GetTempPath(), $"edot-autoinstr-{tfm}-{Guid.NewGuid():N}"); + _publishDirectories.Add(publishDir); + + await RunDotnetAsync( + $"publish \"{appProjectPath}\" -c Release -o \"{publishDir}\"", ct).ConfigureAwait(false); + + var appExtension = tfm == "net462" ? ".exe" : ".dll"; + var appPath = Path.Combine(publishDir, $"{projectName}{appExtension}"); + if (!File.Exists(appPath)) + throw new FileNotFoundException( + $"Published app not found at expected path: {appPath}"); + + switch (tfm) + { + case "net8.0": + Net8AppPath = appPath; + break; + case "net9.0": + Net9AppPath = appPath; + break; + case "net10.0": + Net10AppPath = appPath; + break; + case "net462": + Net462AppPath = appPath; + break; + } + } IsReady = true; } @@ -98,7 +139,8 @@ await RunDotnetAsync( public Task DisposeAsync() { - string?[] dirs = [_extractionDirectory, _publishDirectory]; + var dirs = new List(_publishDirectories.Count + 1) { _extractionDirectory }; + dirs.AddRange(_publishDirectories); foreach (var dir in dirs) {