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)
{