Skip to content

StartupHook isolation makes GetCustomAttribute<T>() return null for shared-framework attributes in application code (.NET 10, 1.15.0+) #5311

Description

@asilva-cmgx

Symptom

Describe the bug

With the managed StartupHook active and the CLR profiler disabled, reflective materialization of a
custom attribute defined in a shared-framework assembly silently returns null in application
code, while the underlying IL metadata stays intact.

Concretely, FieldInfo.GetCustomAttribute<TAttribute>() returns null for an attribute that is present, while
FieldInfo.GetCustomAttributesData() still reports the same attribute with its named arguments intact.

EnumMemberAttribute lives in System.Runtime.Serialization.Primitives, which ships with the runtime.
The auto-instrumentation distribution contains no copy of that assembly, so this is not a version
conflict — it is the same bits resolved into a different load context. Queried from inside the
application:

AssemblyLoadContext.GetLoadContext(typeof(EnumMemberAttribute).Assembly)

System.Runtime.Serialization.Primitives, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  -> OpenTelemetry.AutoInstrumentation.StartupHook.IsolatedAssemblyLoadContext

That is a shared-framework assembly owned by the isolated ALC rather than delegated to
AssemblyLoadContext.Default.

The failure is silent and affects application correctness, not just telemetry:

  • GetCustomAttribute<T>() returns null instead of throwing, so attribute-driven code takes its
    "no attribute present" branch.
  • In our case an ASP.NET Core TypeConverter fell through to intrinsic Enum.Parse, which rejects the
    wire format, producing HTTP 400 on valid input.
  • The reverse direction is worse: our converter had a ?? memberName fallback, so responses silently emitted the CLR member name instead of the wire value — wrong data, no error anywhere.

Any attribute-driven library (serializers, validators, model binders, DI conventions) that materializes
attributes from shared-framework assemblies is exposed.

Expected behavior

Enabling the StartupHook should not change the observable behaviour of reflection in the instrumented application: GetCustomAttribute<TAttribute>() should return an attribute that is present, and type identity should stay consistent for both framework and first-party types.

For the reproduction below that means 200 rather than a validation failure — as on 1.14.1, and as on 1.15.0+ whenever CORECLR_ENABLE_PROFILING=1.

Screenshots

Not applicable — HTTP status codes and log excerpts are included inline below.

Runtime environment (please complete the following information):

Additional context

Version bisect

Version Result StartupHook log line
1.14.1 works Initialization.
1.15.0 fails Isolation Initialization.
1.16.0 (latest) fails Isolation Initialization.

The 1.15.0+ stack trace references IsolatedSetup.Initialize. This lines up with the StartupHook
isolation strategy introduced in #4783.

CORECLR_ENABLE_PROFILING is the switch

The hook only enters IsolatedSetup when profiling is off. Five controlled runs — same published
application, same instrumentation payload, same image, and the first HTTP request is the test request:

# Config CORECLR_ENABLE_PROFILING Init path Result
A StartupHook only 0 Isolation Initialization. 400
B StartupHook + third-party native profiler loaded 1 (other CLSID) Normal Initialization. 200
C Third-party native profiler only, no hook 1 (other CLSID) 200
D StartupHook + this project's native profiler 1 (OTel CLSID) Normal Initialization. 200
E StartupHook, =1, no profiler binary loaded at all 1 Normal Initialization. 200

Row E is the important one: absence of any loaded profiler was verified via /proc/1/maps, yet the
application works. Nothing needs to actually instrument anything — the hook merely reads the variable
and takes the non-isolated path. With a foreign CLSID it logs
The CLR profiler is enabled, but a different profiler ID was provided '{...}' and proceeds normally.

Practical consequence: deployments that inject a different APM agent (which sets
CORECLR_ENABLE_PROFILING=1) are unaffected, while StartupHook-only deployments break. That made this
look environment-specific for some time.

Relationship to #4924

#4924 covers System.Diagnostics.DiagnosticSource being pulled into the Default ALC via
UnsafeAccessorType, breaking isolation from the other direction and causing version drift for the
instrumentation itself.

This report looks like the mirror image, and we believe it is distinct:

  • A different assembly (System.Runtime.Serialization.Primitives) moving in the opposite
    direction
    — claimed by the isolated ALC instead of leaking to Default.
  • The victim is application code, not the instrumentation. The impact is silent wrong behaviour in
    the host application rather than degraded telemetry.
  • No UnsafeAccessorType is involved anywhere in this repro.
  • It identifies the CORECLR_ENABLE_PROFILING switch, which UnsafeAccessorType loads DiagnosticSource to Default ALC breaking StartupHook isolation on .NET 10+ #4924 does not mention.

If the fix for #4924 is the general one hinted at there — excluding shared-framework assemblies from
isolation rather than special-casing DiagnosticSource — this is plausibly the same root cause and
would be fixed alongside it. We are filing separately because the failure mode, the assembly, and the
blast radius all differ, and because a DiagnosticSource-only exclusion would not fix this.

Suggested direction

Delegating shared-framework assemblies to AssemblyLoadContext.Default would fix this symptom, but is likely not sufficient — see this comment: the application's entry assembly itself executes inside the isolated ALC under 1.15.0 while a shadow copy remains in Default, which breaks identity for first-party types too, outside the scope of any framework exclusion. That also explains why our own diagnostic saw a single consistent copy — it was running isolated.

The more useful question: is running the application's entry assembly in the isolated ALC intended in 1.15.0, and are the resulting type-identity mismatches in scope upstream or expected to be handled application-side?

Workaround, for others hitting this

Read the attribute from metadata instead of materializing it, matched by name, which makes it
identity-agnostic:

foreach (var data in field.GetCustomAttributesData())
{
    if (data.AttributeType.FullName != typeof(EnumMemberAttribute).FullName) continue;
    foreach (var arg in data.NamedArguments)
        if (arg.MemberName == nameof(EnumMemberAttribute.Value)) return arg.TypedValue.Value as string;
}

Verified to return 200 under 1.15.0 on a cold first request — metadata is unaffected by the ALC split.
Alternatives: pin to 1.14.1, or set CORECLR_ENABLE_PROFILING=1 (works, but relies on the branch above
rather than on anything supported, so not recommended for production).

Reproduce

Steps to reproduce the behavior:

  1. Create a net10.0 ASP.NET Core application with the following Program.cs:

    using System.ComponentModel;
    using System.Globalization;
    using System.Reflection;
    using System.Runtime.Serialization;
    using Microsoft.AspNetCore.Mvc;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddControllers();
    var app = builder.Build();
    app.MapControllers();
    app.Run();
    
    [TypeConverter(typeof(EnumMemberTypeConverter))]
    public enum LinkedStatus
    {
        [EnumMember(Value = "LINKED")] Linked = 0,
        [EnumMember(Value = "NOT_LINKED")] NotLinked = 1,
    }
    
    public class QueryParameters
    {
        public LinkedStatus? LinkedStatus { get; set; }
    }
    
    [ApiController]
    [Route("test")]
    public class TestController : ControllerBase
    {
        [HttpGet]
        public IActionResult Get([FromQuery] QueryParameters q)   // complex model, resolved lazily
            => Ok(new { status = q.LinkedStatus?.ToString() });
    }
    
    public class EnumMemberTypeConverter(Type type) : EnumConverter(type)
    {
        public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
        {
            if (value is not string s) return base.ConvertFrom(context, culture, value);
    
            foreach (var name in Enum.GetNames(EnumType))
            {
                var attr = EnumType.GetField(name)!.GetCustomAttribute<EnumMemberAttribute>(inherit: true);
                if (string.Equals(attr?.Value, s, StringComparison.OrdinalIgnoreCase))
                    return Enum.Parse(EnumType, name);
            }
    
            return base.ConvertFrom(context, culture, value);
        }
    }
  2. Publish it: dotnet publish -c Release -o out

  3. Download and extract opentelemetry-dotnet-instrumentation-linux-glibc-arm64.zip for v1.16.0
    (or v1.15.0) into ./otel, so that ./otel/net/OpenTelemetry.AutoInstrumentation.StartupHook.dll
    exists.

  4. Run it on a stock runtime image with only the StartupHook configured and the profiler off:

    docker run -d --rm --name repro -v "$PWD/out:/app" -v "$PWD/otel:/otel" -w /app \
      -e ASPNETCORE_URLS=http://0.0.0.0:5000 \
      -e OTEL_DOTNET_AUTO_HOME=/otel \
      -e DOTNET_STARTUP_HOOKS=/otel/net/OpenTelemetry.AutoInstrumentation.StartupHook.dll \
      -e CORECLR_ENABLE_PROFILING=0 \
      -e OTEL_TRACES_EXPORTER=none -e OTEL_METRICS_EXPORTER=none -e OTEL_LOGS_EXPORTER=none \
      -p 5000:5000 \
      mcr.microsoft.com/dotnet/aspnet:10.0 dotnet <App>.dll
  5. Confirm the hook initialized and took the isolated path (see the log excerpt below). This step
    matters — a hook that fails to initialize produces a false negative.

  6. Issue the test request. This must be the first request the process serves (see note 1):

    curl "http://localhost:5000/test?linkedStatus=NOT_LINKED"
    • Expected: 200 {"status":"NotLinked"}
    • Actual on 1.15.0 / 1.16.0: 400 The value 'NOT_LINKED' is not valid for LinkedStatus.
  7. Repeat step 4 with -e CORECLR_ENABLE_PROFILING=1 (no profiler binary needed) and observe 200.

Two reproduction gotchas

  1. The attribute must first be materialized on a request thread. Any startup-time or main-thread
    read primes it and masks the bug — including merely declaring an unrelated action with a top-level
    LinkedStatus? parameter elsewhere in the application. A console-application repro does not
    reproduce. The complex [FromQuery] model matters because MVC resolves that converter lazily, per
    request.

  2. Verify the hook actually initialized. Setting DOTNET_STARTUP_HOOKS without
    OTEL_DOTNET_AUTO_HOME makes the hook throw and the application returns 200 — a false negative:

    [Error] Error in StartupHook initialization
    Exception: Could not load file or assembly 'OpenTelemetry.AutoInstrumentation.Loader, Culture=neutral, PublicKeyToken=null'.
       at OpenTelemetry.AutoInstrumentation.IsolatedSetup.Initialize(String instrumentationHomePath) in /_/src/OpenTelemetry.AutoInstrumentation.StartupHook/IsolatedSetup.cs:line 37
    

Log file

/var/log/opentelemetry/dotnet/otel-dotnet-auto-<pid>-<app>-StartupHook-<date>.log

Failing run, 1.15.0 (CORECLR_ENABLE_PROFILING=0):

[Information] Isolation Initialization.
[Information] Rule Engine: MinSupportedFrameworkRule evaluation success.
[Information] Rule Engine: OpenTelemetrySdkMinimumVersionRule evaluation success.
[Warning] CORECLR_ENABLE_PROFILING environment variable is not set to '1'. The CLR Profiler is disabled and no bytecode instrumentations are going to be injected.
[Information] StartupHook initialized successfully!

Working run, 1.14.1, same configuration — note Initialization. rather than Isolation Initialization.:

[Information] Rule Engine: MinSupportedFrameworkRule evaluation success.
[Information] Rule Engine: OpenTelemetrySdkMinimumVersionRule evaluation success.
[Warning] CORECLR_ENABLE_PROFILING environment variable is not set to '1'. The CLR Profiler is disabled and no bytecode instrumentations are going to be injected.
[Information] Initialization.
[Information] StartupHook initialized successfully!

Tip: React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it. Learn more here.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions