This is a demo application that shows how to instrument an existing .NET Framework 4.6.2 application with OpenTelemetry to collect traces and metrics. The focus is on the exact changes required to go from a standard ASP.NET WebAPI/MVC application to a fully observable one exporting telemetry via OTLP.
The base application is a Loyalty Platform API built on ASP.NET WebAPI (MVC 5) targeting .NET Framework 4.6.2. It exposes a REST API for managing loyalty clubs and includes a legacy Web Forms project with SOAP web services.
Projects in the solution:
| Project | Framework | Type | Description |
|---|---|---|---|
LoyaltyPlatform.Api |
.NET Framework 4.6.2 | ASP.NET WebAPI/MVC | REST API — instrumented with OpenTelemetry |
Base14Scout |
.NET Framework 4.6.2 | ASP.NET Web Forms | Legacy SOAP web services (Member, Offer) |
API Endpoints:
GET /api/clubs— Returns all loyalty clubsGET /api/clubs/{id}— Returns a single club by ID
The instrumentation was done via manual SDK integration (not the CLR Profiler auto-instrumentation agent). Below is every change made to the base application, file by file.
The following OpenTelemetry packages were added:
Core SDK:
| Package | Version |
|---|---|
OpenTelemetry |
1.9.0 |
OpenTelemetry.Api |
1.9.0 |
OpenTelemetry.Api.ProviderBuilderExtensions |
1.9.0 |
OpenTelemetry.Extensions.Hosting |
1.9.0 |
Exporter:
| Package | Version |
|---|---|
OpenTelemetry.Exporter.OpenTelemetryProtocol |
1.9.0 |
Trace Instrumentation Libraries:
| Package | Version | What It Captures |
|---|---|---|
OpenTelemetry.Instrumentation.AspNet |
1.9.0-beta.1 | Incoming HTTP requests to the API |
OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule |
1.9.0-beta.1 | ASP.NET pipeline integration (HTTP module) |
OpenTelemetry.Instrumentation.Http |
1.9.0 | Outgoing HTTP calls made by HttpClient / HttpWebRequest |
OpenTelemetry.Instrumentation.SqlClient |
1.9.0-beta.1 | SQL database queries |
Metric Instrumentation Libraries:
| Package | Version | What It Captures |
|---|---|---|
OpenTelemetry.Instrumentation.Runtime |
1.9.0 | GC collections, assemblies loaded, exception count |
OpenTelemetry.Instrumentation.Process |
0.5.0-beta.6 | CPU time, memory usage, thread count |
Transitive Dependencies (required by the above):
Google.Protobuf, Grpc.Core.Api, Microsoft.Bcl.AsyncInterfaces, Microsoft.Extensions.Configuration, Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Logging.Abstractions, Microsoft.Extensions.Options, System.Diagnostics.DiagnosticSource, System.Memory, System.Buffers, System.Runtime.CompilerServices.Unsafe, System.Threading.Tasks.Extensions
File: LoyaltyPlatform.Api/App_Start/OpenTelemetryConfig.cs
This is the central instrumentation configuration. It sets up both a TracerProvider (for distributed traces) and a MeterProvider (for metrics), and exports them via OTLP over HTTP/Protobuf.
public static class OpenTelemetryConfig
{
private static TracerProvider _tracerProvider;
private static MeterProvider _meterProvider;
public static void Initialize()
{
var serviceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? "Base14Scout";
var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4318";
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: serviceName, serviceVersion: "1.0.0")
.AddAttributes(new[] {
new KeyValuePair<string, object>("environment",
Environment.GetEnvironmentVariable("OTEL_ENVIRONMENT") ?? "development")
});
// Traces: ASP.NET incoming requests + HttpClient outgoing + SQL queries → OTLP
_tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(resourceBuilder)
.AddAspNetInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(options => {
options.SetDbStatementForText = true;
options.RecordException = true;
})
.AddOtlpExporter(options => {
options.Endpoint = new Uri(otlpEndpoint + "/v1/traces");
options.Protocol = OtlpExportProtocol.HttpProtobuf;
})
.Build();
// Metrics: ASP.NET + HttpClient + runtime + process → OTLP
_meterProvider = Sdk.CreateMeterProviderBuilder()
.SetResourceBuilder(resourceBuilder)
.AddAspNetInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddOtlpExporter(options => {
options.Endpoint = new Uri(otlpEndpoint + "/v1/metrics");
options.Protocol = OtlpExportProtocol.HttpProtobuf;
})
.Build();
}
public static void Shutdown()
{
_tracerProvider?.Dispose();
_meterProvider?.Dispose();
}
}Key design decisions:
- Environment variables (
OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_ENVIRONMENT) control all configuration — no hardcoded endpoints. - Resource attributes tag every trace and metric with the service name, version, and deployment environment, so telemetry can be filtered and correlated in backends.
- SqlClient is configured with
SetDbStatementForText = trueso the actual SQL query text appears in traces, andRecordException = trueto capture SQL errors as span events. - Uses HTTP/Protobuf (port 4318) rather than gRPC (port 4317), which is simpler to configure through proxies and load balancers.
File: LoyaltyPlatform.Api/Global.asax.cs
Two calls were added to the application lifecycle:
protected void Application_Start()
{
+ // Initialize OpenTelemetry first
+ OpenTelemetryConfig.Initialize();
+
AreaRegistration.RegisterAllAreas();
GlobalConfiguration.Configure(WebApiConfig.Register);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
+protected void Application_End()
+{
+ // Gracefully shutdown OpenTelemetry
+ OpenTelemetryConfig.Shutdown();
+}Initialize()is called first — before route registration — so the telemetry pipeline is ready to capture everything from the first request.Shutdown()inApplication_Endflushes any buffered telemetry and disposes providers cleanly.
File: LoyaltyPlatform.Api/Web.config
The ASP.NET TelemetryHttpModule must be registered in Web.config to capture incoming HTTP request traces. On .NET Framework, the auto-registration via CLR Profiler often fails due to timing issues, so explicit registration is required.
<system.web>
<compilation debug="true" targetFramework="4.6.2" />
<httpRuntime targetFramework="4.6.2" />
+ <httpModules>
+ <add name="TelemetryHttpModule"
+ type="OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule,
+ OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule" />
+ </httpModules>
</system.web>
<system.webServer>
+ <validation validateIntegratedModeConfiguration="false" />
+ <modules>
+ <add name="TelemetryHttpModule"
+ type="OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule,
+ OpenTelemetry.Instrumentation.AspNet.TelemetryHttpModule"
+ preCondition="integratedMode,managedHandler" />
+ </modules>The module is registered in both <system.web><httpModules> (Classic mode) and <system.webServer><modules> (Integrated mode / IIS Express) to cover all hosting scenarios.
Binding redirects were also added for OpenTelemetry's transitive dependencies (System.Diagnostics.DiagnosticSource, Microsoft.Extensions.*, Google.Protobuf, etc.) to resolve version conflicts.
All OpenTelemetry DLL references and their dependency DLLs were added to the project file so the compiler and runtime can find them. The OpenTelemetryConfig.cs file was also added to the <Compile> item group.
With these changes, the application automatically emits:
| Signal | Source | Example |
|---|---|---|
| Incoming HTTP requests | AddAspNetInstrumentation() + TelemetryHttpModule |
GET /api/clubs with status, duration, route |
| Outgoing HTTP calls | AddHttpClientInstrumentation() |
Any HttpClient / HttpWebRequest call |
| SQL queries | AddSqlClientInstrumentation() |
Query text, duration, exceptions |
Each trace includes resource attributes: service.name, service.version, and environment.
| Metric | Source | Examples |
|---|---|---|
| HTTP server request duration | AddAspNetInstrumentation() |
http.server.request.duration |
| HTTP client request duration | AddHttpClientInstrumentation() |
http.client.request.duration |
| Runtime stats | AddRuntimeInstrumentation() |
GC collection count, assemblies loaded, exception count |
| Process stats | AddProcessInstrumentation() |
process.memory.usage, process.cpu.time, thread count |
| Variable | Default | Description |
|---|---|---|
OTEL_SERVICE_NAME |
Base14Scout |
Service name in telemetry |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4318 |
OTLP collector endpoint (HTTP) |
OTEL_ENVIRONMENT |
development |
Deployment environment tag |
- Run an OpenTelemetry Collector listening on port 4318 (HTTP/Protobuf), or point
OTEL_EXPORTER_OTLP_ENDPOINTto your collector/backend. - Open the solution in Visual Studio and run
LoyaltyPlatform.Api. - Hit
GET /api/clubs— traces and metrics will flow to the collector.
Set OTEL_EXPORTER_OTLP_ENDPOINT to your backend's OTLP ingestion URL. For example:
set OTEL_SERVICE_NAME=Base14Scout
set OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend.example.com:4318
set OTEL_ENVIRONMENT=production| File | Change | Purpose |
|---|---|---|
LoyaltyPlatform.Api/App_Start/OpenTelemetryConfig.cs |
New | TracerProvider + MeterProvider configuration |
LoyaltyPlatform.Api/Global.asax.cs |
Modified | Call Initialize() on start, Shutdown() on end |
LoyaltyPlatform.Api/Web.config |
Modified | Register TelemetryHttpModule + binding redirects |
LoyaltyPlatform.Api/packages.config |
Modified | Add 12 OpenTelemetry NuGet packages + dependencies |
LoyaltyPlatform.Api/LoyaltyPlatform.Api.csproj |
Modified | Assembly references for all new DLLs |
No application code (controllers, models, business logic) was modified. The instrumentation is entirely additive — the existing API behavior is unchanged.
Base14Scout.sln
├── LoyaltyPlatform.Api/ # ASP.NET WebAPI/MVC (C#) — instrumented
│ ├── App_Start/
│ │ ├── OpenTelemetryConfig.cs # ← NEW: OTel setup
│ │ ├── WebApiConfig.cs
│ │ ├── RouteConfig.cs
│ │ ├── BundleConfig.cs
│ │ └── FilterConfig.cs
│ ├── Controllers/
│ │ ├── ClubsController.cs # REST API for clubs
│ │ └── HomeController.cs
│ ├── Models/
│ │ ├── ClubItem.cs
│ │ └── ClubsReturn.cs
│ ├── Configuration/
│ │ ├── ExternalConfig.cs
│ │ └── ExternalConfigLoader.cs
│ ├── SampleData/
│ │ └── ClubsReturn_SampleData.json
│ ├── Views/ # MVC views
│ ├── Global.asax.cs # ← MODIFIED: OTel lifecycle hooks
│ ├── Web.config # ← MODIFIED: HttpModule + binding redirects
│ └── packages.config # ← MODIFIED: OTel NuGet packages
│
├── Base14Scout/ # ASP.NET Web Forms (VB.NET) — not instrumented
│ ├── App_Code/
│ │ ├── Member.vb # SOAP service: FetchMemberState
│ │ └── Offer.vb # SOAP service stub
│ ├── Member.asmx
│ ├── Offer.asmx
│ └── SampleData/
│
└── docs/
└── OTEL_AUTO_INSTRUMENTATION.md # Reference: CLR Profiler auto-instrumentation approach