fix(aspire): preserve user-supplied OTLP endpoint (#4818)#5665
fix(aspire): preserve user-supplied OTLP endpoint (#4818)#5665
Conversation
Capture any user-supplied OTEL_EXPORTER_OTLP_ENDPOINT (set via WithEnvironment in ConfigureBuilder, e.g. for a self-hosted Aspire dashboard) as the OtlpReceiver's upstream forward target before overriding it with the local receiver URL. Without this, SUT spans were silently dropped from the user's dashboard whenever Aspire's built-in dashboard was disabled. OtlpReceiver.UpstreamEndpoint is now settable post-construction via Volatile read/write, with a single static HttpClient for forwarding.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 5 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review: fix(aspire): preserve user-supplied OTLP endpoint (#4818)
This is a well-structured fix for a genuinely tricky timing problem. The root cause diagnosis is correct — environment callbacks resolve at resource startup rather than receiver construction time — and the capture-then-override pattern is the right architectural response. The Volatile.Read/Write usage and the static readonly HttpClient refactor are both sound.
A few things worth discussing before this ships:
1. Architectural: CaptureAndOverride couples env-var logic to receiver configuration
public static string? CaptureAndOverride(
IDictionary<string, object> environmentVariables,
OtlpReceiver receiver, // ← receiver as param
string overrideEndpoint)The method mixes two concerns: (a) capturing/overriding an env-var entry, and (b) wiring up the receiver's upstream. The PR description justifies the helper as "unit-testable without spinning up a full Aspire host" — but the helper still takes OtlpReceiver directly, which means a test still needs a live OtlpReceiver.
A cleaner split would be to return the captured value and let the caller configure the receiver:
// OtlpEndpointEnvironment — pure env-var manipulation
internal static string? CaptureAndOverride(
IDictionary<string, object> environmentVariables,
string overrideEndpoint)
{
string? userEndpoint = null;
if (environmentVariables.TryGetValue(OtelExporterEndpointEnvVar, out var existing)
&& existing is string s && !string.IsNullOrWhiteSpace(s))
{
userEndpoint = s;
}
environmentVariables[OtelExporterEndpointEnvVar] = overrideEndpoint;
return userEndpoint;
}
// AspireFixture callback
var userEndpoint = OtlpEndpointEnvironment.CaptureAndOverride(
context.EnvironmentVariables, otlpEndpoint);
if (userEndpoint is not null)
receiver.UpstreamEndpoint = userEndpoint;This makes OtlpEndpointEnvironment a true pure-logic helper (testable with just a plain Dictionary), and keeps receiver wiring in the one place that owns the receiver.
2. Test coverage gap: non-string case doesn't assert the env-var override still happens
[Test]
public async Task Capture_IgnoresNonStringExistingValue()
{
// ...
await Assert.That(captured).IsNull();
await Assert.That(receiver.UpstreamEndpoint).IsNull();
// Missing: env["OTEL_EXPORTER_OTLP_ENDPOINT"] is still overridden to overrideEndpoint
}The code does unconditionally override the env var (the assignment is outside the if), but the test doesn't verify it. Given that "TUnit silently drops the user's non-string endpoint reference" is exactly the scenario that could bite someone, the assertion is worth adding:
await Assert.That(env["OTEL_EXPORTER_OTLP_ENDPOINT"] as string).IsEqualTo("http://127.0.0.1:5000");Also worth calling out in a comment: EndpointReference/ParameterResource-typed values are forwarded by Aspire at process launch — they resolve to real URLs before the SUT sees them. By replacing them with a static string, TUnit loses the ability to capture that resolved URL. The current behavior (silently not-forwarding from non-string entries) should be documented with a // TODO or a known-limitation comment so a future contributor doesn't wonder why.
3. Minor: public method on an internal class is misleading
internal static class OtlpEndpointEnvironment
{
public static string? CaptureAndOverride(...) // effectively internalSince the class is internal, public on its members has no effect. This should be internal static for clarity. Same note applies to OtlpReceiver.UpstreamEndpoint (though that one needs to remain accessible from the test project, so is probably internal or uses InternalsVisibleTo already).
4. Minor: read-check-write in UpstreamEndpoint setter adds complexity without a real benefit
set
{
var normalized = value?.TrimEnd('/');
if (normalized == Volatile.Read(ref _upstreamEndpoint)) // TOCTOU window
{
return;
}
Volatile.Write(ref _upstreamEndpoint, normalized);
}The check is a micro-optimisation but introduces a non-atomic read-check-write. In the real scenario (AspireFixture setup, single writer), this is harmless. But a Volatile.Write on a string field is cheap enough that the check isn't justified. Removing it makes the setter simpler and correct by inspection:
set => Volatile.Write(ref _upstreamEndpoint, value?.TrimEnd('/'));What's good
- Capture-then-override is exactly the right pattern for the timing constraint described in the PR.
Volatile.Read/Writeis the correct concurrency primitive for this single-writer, many-reader string field.- Moving from per-instance
HttpClienttostatic readonlyfollows .NET best practices and avoids socket exhaustion under load. - The new tests are well-described and cover the three meaningful cases.
- The
ContainerResourceasymmetry explanation in the PR description is helpful for future readers.
The end-to-end verification item (redoz's repro repo) is the right call — recommend completing that before release.
- Decouple OtlpEndpointEnvironment from OtlpReceiver: helper now returns the captured user endpoint instead of mutating the receiver, and the AspireFixture callback wires receiver.UpstreamEndpoint itself. Tests no longer need a live OtlpReceiver. - Add the missing override-still-happens assertion in the non-string case, and document the EndpointReference / ParameterResource limitation. - Tighten internal-class member visibility (public -> internal). - Simplify UpstreamEndpoint setter to a single Volatile.Write expression (the previous read-check-write was a non-atomic micro-optimisation that added more complexity than it saved).
There was a problem hiding this comment.
Follow-up Review: refactor(aspire): address PR review feedback
All four issues raised in the previous review have been resolved in the follow-up commit. Tracking them here for completeness:
| # | Issue | Status |
|---|---|---|
| 1 | CaptureAndOverride coupled receiver wiring to env-var logic |
✅ Fixed — method is now a pure string?-returning helper; caller owns receiver wiring |
| 2 | Non-string test missing assertion that env var is still overridden | ✅ Fixed — Capture_IgnoresNonStringExistingValue_ButStillOverrides now asserts env[EnvVar] equals OverrideEndpoint |
| 3 | public method on internal class |
✅ Fixed — OtlpEndpointEnvironment.CaptureAndOverride is now internal |
| 4 | Redundant read-check-write in UpstreamEndpoint setter |
✅ Fixed — setter is now set => Volatile.Write(ref _upstreamEndpoint, value?.TrimEnd('/')) |
One remaining minor point
OtlpReceiver.UpstreamEndpoint is still declared public on an internal sealed class. As noted before, this is effectively internal given the class visibility, so it's not a correctness issue — but internal would more clearly communicate intent. Not a blocker.
One new edge case to be aware of (not a blocker)
In ConfigureOtlpEndpoints, the loop registers a callback for every ProjectResource. If a multi-project Aspire setup has two project resources each independently configured with different OTEL_EXPORTER_OTLP_ENDPOINT values, the last callback to fire wins and silently overwrites the first. In practice this is unlikely (users set one upstream dashboard for all resources), but it's worth a // TODO comment or a note in the known-limitations doc if anyone ever reports oddities with multi-project configurations.
What's good about the refactor
var receiver = _otlpReceiverlocal capture inConfigureOtlpEndpointsis the correct pattern — it pins the specific receiver instance into the closure rather than closing over the mutable field, preventing any potential issue if_otlpReceiverwere ever reassigned.OtlpEndpointEnvironmentis now a true pure-logic helper, testable with a plainDictionaryand no live infrastructure.ForwardAsyncbeingstaticafter the refactor is the right consequence of removing all instance state from it.
The PR is in good shape. The end-to-end verification against the repro repo remains the right gate before release.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to 1.39.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 Commits viewable in [compare view](thomhurst/TUnit@v1.37.10...v1.39.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
Fixes #4818 — TUnit.Aspire was silently dropping SUT traces from a user-hosted Aspire dashboard whenever
OTEL_EXPORTER_OTLP_ENDPOINTwas set viaWithEnvironmentinConfigureBuilder.AspireFixture.ConfigureOtlpEndpointsnow captures any pre-existing userOTEL_EXPORTER_OTLP_ENDPOINTvalue as the receiver's upstream forwarding target before overriding it with the local TUnit receiver URL. Project resources still export to TUnit (for log/test correlation), and the receiver now forwards everything to the user's dashboard.OtlpReceiver.UpstreamEndpointmade settable post-construction (via lock-freeVolatile.Read/Write); a singlestatic readonly HttpClienthandles forwarding so there's no instance lock on the per-export hot path.OtlpEndpointEnvironment.CaptureAndOverridehelper isolates the capture-then-override logic so it's unit-testable without spinning up a full Aspire host.Why it broke
Environment callbacks resolve at resource startup, not at
StartOtlpReceivertime. Before this fix, the receiver only knew aboutDOTNET_DASHBOARD_OTLP_ENDPOINT_URL(set when Aspire's built-in dashboard is enabled). Users disabling the built-in dashboard (options.DisableDashboard = true) and pointing project resources at their own dashboard had no way to inform the receiver — so spans went into the receiver and never came out the other side.ContainerResource-typed resources (e.g. WireMock) kept working because TUnit only annotatesProjectResources, leaving container env vars untouched. That asymmetry is what made the regression so confusing in the issue report.Test plan
OtlpEndpointEnvironment.CaptureAndOverride(capture / no-key / non-string-value cases) —TUnit.Aspire.Tests/OtlpEndpointEnvironmentTests.csOtlpReceivertest that setsUpstreamEndpointpost-construction and verifies forwarding still works —TUnit.Aspire.Tests/OtlpReceiverTests.csOtlpReceiverTestsstill pass (14/14)