Shining a light on .NET versions across our organisation with OpenTelemetry – The Azure Monitor edition
In a previous post I showed how to add the .NET runtime version as an OpenTelemetry resource attribute:
ResourceBuilder.CreateEmpty()
.AddService($"{_sofaSettings.ApplicationName}-{_sofaSettings.EnvironmentName}")
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = _sofaSettings.EnvironmentName,
["service.name"] = _sofaSettings.ApplicationName,
["runtime.dotnet.version"] = Environment.Version.ToString()
})
.Build();
The idea was clean: attach facts about what is running directly to the resource, and let OpenTelemetry carry them along with every trace, metric, and log automatically.
Unfortunately, there is a catch.
The problem
Azure Monitor's OpenTelemetry exporter only maps a fixed set of well-known resource attributes onto Application Insights fields. service.name and service.namespace become Cloud Role Name, service.instance.id becomes Cloud Role Instance — and that's about it. Anything else you attach to the resource, including custom attributes like runtime.dotnet.version, is silently dropped on the way in.
This turns out to be a known limitation, tracked across multiple GitHub issues for the .NET, Python, and OTel Collector contrib exporters. There is no configuration flag to change this behaviour. It's just not supported (yet).
The workaround
The fix is to stop relying on the resource for this kind of metadata and instead push the attributes onto the telemetry itself using a custom BaseProcessor<Activity>.
Span attributes — called activity tags in .NET — do get exported to Application Insights, where they show up as customDimensions on your requests, dependencies, traces, and exceptions.
First, create the processor:
public class RuntimeVersionEnrichingProcessor : BaseProcessor<Activity>
{
private readonly string _runtimeVersion;
public RuntimeVersionEnrichingProcessor()
{
_runtimeVersion = Environment.Version.ToString();
}
public override void OnEnd(Activity activity)
{
activity.SetTag("runtime.dotnet.version", _runtimeVersion);
}
}
Then register it before UseAzureMonitor() — order matters here, the processor needs to be in the pipeline before the exporter picks up the activity:
builder.Services.ConfigureOpenTelemetryTracerProvider((sp, b) =>
b.AddProcessor(new RuntimeVersionEnrichingProcessor()));
builder.Services.AddOpenTelemetry().UseAzureMonitor();
That's it. Every span your service emits will now carry runtime.dotnet.version as a tag, and it will land in customDimensions in Application Insights.
Querying it
The KQL query from the original post still works, just pointing at the right place:
AppRequests
| extend runtimeVersion = tostring(customDimensions["runtime.dotnet.version"])
| summarize count() by runtimeVersion, AppRoleName
| order by AppRoleName asc
You get the same live view of your runtime landscape — which services are on .NET 10, which are lagging behind — without any spreadsheets or manual tracking.
Remark:The processor approach only covers traces (requests, dependencies, custom spans). If you also want the version on logs, attach it there too using the message template:
logger.LogInformation("Application started on {RuntimeVersion}", Environment.Version);
Logs from ILogger go through a separate pipeline; the activity processor doesn't touch them.
The broader picture
The resource approach was conceptually the right one — that's exactly what resource attributes are designed for. The limitation is on the Azure Monitor exporter side, not in OpenTelemetry itself. The processor workaround gets you the same outcome with a bit more plumbing, and it's what Microsoft's own documentation recommends in the meantime.