OTel Is Wobbling at the Top of Hacker News. What Does That Mean for Your .NET Telemetry?
A spreadsheet on OpenTelemetry's project health hit the HN front page. The governance problems are real, but for .NET the blast radius is small. Here is how to build your telemetry so upstream trouble never becomes your incident, with working C#.
Jean-Pierre Broeders
Freelance .NET Developer
This week a post by Mat Duggan climbed high on Hacker News under a title that leaves little room for spin: "OTel isn't going well and I made a spreadsheet about it". Duggan pulled two years of GitHub activity out of the OpenTelemetry repos and put numbers on it. The verdict is grim. OpenTelemetry, the project half the industry rests its observability on, runs on too few people carrying too much.
I read it because I have run OTel in production at .NET clients for years. My first reaction was not panic. It was: this is right, and it also lands on me softer than on the average Python or Ruby shop. That gap is the part worth talking about, because it says something about how you should build your telemetry in the first place.
What the spreadsheet shows
Duggan measures something simple but telling: who merges the pull requests. For the Python SDK, by his figures, 61.4 percent of merges go through one person. For PHP and Ruby the distribution is worse still. Set that next to Prometheus with 31 distinct mergers, or Envoy with 28, and the shape of the problem is clear. A healthy open source project spreads the load. OTel concentrates it.
The line that stuck with me: "Your authors shouldn't also be your mergers and your issue closers." When the same handful of people write features, review them, merge them and close the issues, there is no buffer left. One person goes on holiday and the review queue stalls. One person burns out and a whole language loses its maintenance.
On top of that sits the process. A new feature travels through OTEP, then the spec, then semantic conventions, then the SDK, then the contrib packages, then the collector. Duggan points at semantic-convention PRs that stayed open for 277 days with 115 reviews underneath them. That is not collaboration anymore. That is a committee nobody dares to gavel shut, partly because the stability promise turns every change into a risk.
And then the point that matters most to me: language support is not equal. Go and .NET get serious attention. PHP and Ruby limp along behind. Pretending parity exists when it does not, Duggan writes, only creates confusion. He is right about that. I have watched teams try to use an OTel feature from a blog post and only discover a day later that it simply does not exist in their language.
Why .NET has a head start here
Now the part the HN thread mostly skipped. The reason OTel is less scary for .NET has nothing to do with OTel. It comes from the BCL.
In .NET, your instrumentation API lives in the framework itself, not in a separate OpenTelemetry package. You create traces with System.Diagnostics.ActivitySource and Activity. You create metrics with System.Diagnostics.Metrics.Meter. Those types are part of .NET, maintained by Microsoft on the .NET release cadence, and designed to be vendor neutral. OpenTelemetry, on the other side of that API, is only the layer that exports your Activities and Meters.
That distinction is fundamental. A Python app using OTel depends on the OTel SDK to create spans at all. If that SDK falls behind, you stall. A .NET app built cleanly creates spans with a type from the standard library. Whether you export to OTLP, to Application Insights, or to nothing at all, your domain code does not change.
Here is what that separation looks like in code. First a single home for your ActivitySource:
using System.Diagnostics;
public static class Telemetry
{
public const string ServiceName = "orders-api";
// Belongs to System.Diagnostics, not to the OTel SDK.
public static readonly ActivitySource Source = new(ServiceName);
}
And then using it, somewhere deep in your checkout flow:
using var activity = Telemetry.Source.StartActivity("CheckoutOrder");
activity?.SetTag("order.id", orderId);
activity?.SetTag("order.item_count", items.Count);
// ... your actual work ...
if (paymentFailed)
{
activity?.SetStatus(ActivityStatusCode.Error, "payment declined");
}
Notice the activity?. When there is no listener, StartActivity is cheap and returns null. Your code pays almost nothing when telemetry is off. And nowhere in this snippet does the word OpenTelemetry appear. That is the whole point.
The OTel layer as a thin edge
You bring the OTel SDK in only where it belongs: at the edge, in your startup code. That is where you say which sources to pick up and where they should go.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(Telemetry.ServiceName))
.WithTracing(t => t
.AddSource(Telemetry.ServiceName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddMeter(Telemetry.ServiceName)
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
Everything OTel touches sits in this one block. Want to swap exporters tomorrow? You touch these lines and nothing else. The hundreds of StartActivity calls scattered through your codebase stay exactly as they are. That is the architecture that neutralises Duggan's concerns for you: not because OTel is healthy, but because you depend on it in exactly one place.
Metrics work the same way through IMeterFactory, which since .NET 8 is the tidy route because it plays along with dependency injection:
using System.Diagnostics.Metrics;
public sealed class CheckoutMetrics
{
private readonly Counter<long> _completed;
public CheckoutMetrics(IMeterFactory factory)
{
var meter = factory.Create(Telemetry.ServiceName);
_completed = meter.CreateCounter<long>("checkout.completed");
}
public void Completed(string channel) =>
_completed.Add(1, new KeyValuePair<string, object?>("channel", channel));
}
Again, Counter<long> comes from the BCL. The OTel SDK picks it up through AddMeter(Telemetry.ServiceName), and that is the only coupling.
Semantic conventions: where the churn does reach you
There is one thing I will not dress up. The semantic conventions do reach you. Those are the agreed names for attributes, like http.request.method or server.address. They have been renamed a few times over the years, and that is precisely the slow, committee-driven decision making Duggan writes about. Anyone who once used http.method and later had to move to http.request.method knows the sting.
The fix is small and boring. Do not sprinkle those string literals across your codebase. Put them in one place:
public static class OtelAttributes
{
// The semantic-convention keys, pinned in one spot.
public const string HttpRequestMethod = "http.request.method";
public const string ServerAddress = "server.address";
public const string OrderId = "order.id";
}
When a name shifts upstream, you change one constant instead of forty call sites. It is the same discipline you apply to any magic string. The difference with OTel is that you know for a fact the string will move eventually.
Pin your versions and let the collector do the dirty work
Two practical things I enforce at every .NET client.
Pin your OTel packages to exact versions. No floating ranges. The SDK is stable enough, but the instrumentation packages and exporters occasionally carry behaviour you do not want to discover on a Tuesday morning through an automatic bump.
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
And put an OpenTelemetry Collector between your apps and your backend. Your apps export to the collector over OTLP, and the collector handles the rewriting, filtering, sampling and forwarding. When a semantic convention changes or you switch vendors, you edit the collector config instead of redeploying every service. The collector becomes your churn boundary. Everything that wobbles about OTel, you keep on that one spot, outside your binaries.
A minimal pipeline in otel-collector-config.yaml:
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
# Catch an old attribute name and rename it, without touching your code.
transform:
trace_statements:
- context: span
statements:
- set(attributes["http.request.method"], attributes["http.method"]) where attributes["http.method"] != nil
exporters:
otlphttp:
endpoint: https://backend.example.com
service:
pipelines:
traces:
receivers: [otlp]
processors: [transform, batch]
exporters: [otlphttp]
That transform rule is cheap insurance. When the conventions shift upstream, you fix it here, centrally, while keeping half an eye on your services.
What I take from the HN discussion
The thread split two ways, as it always does. One half defended OTel with "it is an enormous project, of course it creaks". The other half recognised Duggan's numbers from their own frustration with slow reviews and half-finished language support. Both are true. A standard shared by the whole industry is by definition too big for a handful of hobbyists, and that is exactly the argument for not depending on it blindly in the spots where you have an alternative.
For .NET you have that alternative, and it is not even an alternative so much as the default path: instrument with the BCL, keep OTel at the edge, pin your versions, centralise your attributes and let the collector absorb the churn. Do that, and a struggling upstream is a news item, not an incident.
Frequently asked questions about OpenTelemetry in .NET
Am I dependent on the OTel SDK when I create spans in .NET?
No, not if you do it cleanly. You create spans with ActivitySource and Activity from System.Diagnostics, which are part of the standard library and maintained by Microsoft. The OpenTelemetry SDK is only the export layer that picks up your Activities, and you bring it in at a single place in your startup code.
Do OTel's governance problems affect the .NET SDK too? The .NET SDK is one of the better-maintained implementations, so you feel it less than a PHP or Ruby shop does. Where you do feel it is in the semantic conventions, the shared attribute names that occasionally get renamed, and you contain that by pinning those names in one place and running a collector as an intermediate layer.
What is the role of an OpenTelemetry Collector in this story? The collector sits between your apps and your observability backend and handles filtering, sampling, renaming and forwarding. When a convention changes upstream or you switch vendors, you edit the collector config instead of redeploying every service, which keeps the churn outside your binaries.
Should I pin my OTel package versions? Yes, use exact versions instead of floating ranges. The core SDK is stable, but the instrumentation packages and exporters can carry behaviour changes you would rather adopt deliberately than discover through an unexpected bump.
Can I switch from OTLP to Application Insights or something else later? Yes, and that is exactly the payoff of instrumenting with the BCL. Your domain code creates Activities and Meters that know nothing about your export choice, so switching backend or exporter touches only the configuration block in your startup code and not a single call site.
Further reading
- Monitoring on a Budget: Cost Control Without Blind Spots
- Postgres for Everything: What the 'Just Use Postgres' Hype Means for Your .NET Stack
- Code was never the easy part: why programming craftsmanship still matters in the AI age
Conclusion: The worries about OpenTelemetry's health are fair, but treat it as an upstream risk you shield against, not as a reason to avoid observability. In .NET your instrumentation lives in the BCL, so keep OTel at the edge, pin your versions, and let the collector absorb the churn. Then a wobbling project is a news item and not a production incident.
Sources: OTel isn't going well and I made a spreadsheet about it · Hacker News discussion.
