# ASP.NET Core (.NET 6+)

> Instrument ASP.NET Core applications with OpenTelemetry auto-instrumentation and send traces and metrics to Last9

Source: https://last9.io/docs/integrations/frameworks/dotnet/aspnet-core/

Use the [OpenTelemetry .NET Automatic Instrumentation](https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation) agent to instrument ASP.NET Core applications without adding any OTel SDK packages to your project. The CLR profiler intercepts incoming requests, outbound HTTP calls, and SQL queries at the process level.

:::note[.NET Framework vs .NET (Core)]
This guide is for **.NET 6/7/8/9** applications (ASP.NET Core controllers, Minimal API, Kestrel, or IIS-hosted). For legacy **.NET Framework 4.x** applications, see the [ASP.NET on IIS guide](/docs/integrations/frameworks/dotnet/aspnet-iis/).
:::

## What Gets Auto-Instrumented

| Library                         | What's captured                                                           |
| ------------------------------- | ------------------------------------------------------------------------- |
| ASP.NET Core                    | Incoming request spans — controllers and Minimal API endpoints            |
| IHttpClientFactory / HttpClient | Outbound HTTP call spans with W3C `traceparent` header injection          |
| ADO.NET (DbCommand)             | SQL query spans with `db.statement` (SqlClient, SQLite, Npgsql, etc.)     |
| .NET CLR                        | GC, heap, thread pool, exception rate metrics (via Windows perf counters) |

## Prerequisites

- **.NET 6.0 or later** runtime installed
- **For IIS mode**: Windows Server 2016+ with IIS 8.5+
- **Administrator access** on the server
- **Windows PowerShell 5.1** (Desktop edition — not PowerShell 7/pwsh) — required for setup only
- **Last9 Account** with [OpenTelemetry integration credentials](https://app.last9.io/integrations?integration=OpenTelemetry)

## Installation

**IIS-hosted (recommended)**

1. **Download the auto-instrumentation package**

   Open an elevated **Windows PowerShell 5.1** session (not PowerShell 7):

   :::caution[Use Windows PowerShell 5.1]
   The IIS registration command requires **Windows PowerShell 5.1** (`powershell.exe`). It does not work in PowerShell 7 (`pwsh`). If your default shell is PS7, use:

   ```powershell
   powershell.exe -Version 5.1 -File setup-otel.ps1 ...
   ```

   :::

   ```powershell
   [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

   $version = "v1.14.1"
   $otelHome = "C:\Program Files\OpenTelemetry .NET AutoInstrumentation"

   Invoke-WebRequest `
     -Uri "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/$version/opentelemetry-dotnet-instrumentation-windows.zip" `
     -OutFile "$env:TEMP\otel-dotnet.zip" -UseBasicParsing

   Invoke-WebRequest `
     -Uri "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/$version/OpenTelemetry.DotNet.Auto.psm1" `
     -OutFile "$env:TEMP\OpenTelemetry.DotNet.Auto.psm1" -UseBasicParsing

   New-Item -Path $otelHome -ItemType Directory -Force | Out-Null
   Expand-Archive -Path "$env:TEMP\otel-dotnet.zip" -DestinationPath $otelHome -Force
   Copy-Item "$env:TEMP\OpenTelemetry.DotNet.Auto.psm1" "$otelHome\" -Force
   ```

2. **Register the profiler for IIS**

   ```powershell
   $env:OTEL_DOTNET_AUTO_HOME = "C:\Program Files\OpenTelemetry .NET AutoInstrumentation"
   Import-Module "$env:OTEL_DOTNET_AUTO_HOME\OpenTelemetry.DotNet.Auto.psm1"

   Install-OpenTelemetryCore
   Register-OpenTelemetryForIIS
   ```

   This restarts IIS. The CLR profiler is now enabled for all app pools.

3. **Set the app pool CLR mode to "No Managed Code"**

   ASP.NET Core runs the .NET runtime in-process — the app pool must **not** be configured as a Classic .NET 4.x pool. If `managedRuntimeVersion` is set to `v4.0`, the profiler will fail silently.

   ```powershell
   Import-Module WebAdministration

   $poolName = "YourCoreAppPool"  # Replace with your app pool name

   # Must be empty string ("") — not "v4.0"
   Set-ItemProperty "IIS:\AppPools\$poolName" -Name managedRuntimeVersion -Value ""

   # Verify
   (Get-ItemProperty "IIS:\AppPools\$poolName").managedRuntimeVersion
   # Should return: "" (empty)
   ```

4. **Configure environment variables on the app pool**

**Send via OTel Collector**

   ```powershell
   Import-Module WebAdministration

   $poolName = "YourCoreAppPool"

   Set-ItemProperty "IIS:\AppPools\$poolName" -Name environmentVariables -Value @(
       @{ name = 'OTEL_SERVICE_NAME';           value = 'your-service-name' },
       @{ name = 'OTEL_RESOURCE_ATTRIBUTES';    value = "deployment.environment=production,host.name=$env:COMPUTERNAME" },
       @{ name = 'OTEL_TRACES_EXPORTER';        value = 'otlp' },
       @{ name = 'OTEL_METRICS_EXPORTER';       value = 'otlp' },
       @{ name = 'OTEL_LOGS_EXPORTER';          value = 'none' },
       @{ name = 'OTEL_EXPORTER_OTLP_ENDPOINT'; value = 'http://localhost:4317' },
       @{ name = 'OTEL_EXPORTER_OTLP_PROTOCOL'; value = 'grpc' },
       @{ name = 'OTEL_PROPAGATORS';            value = 'tracecontext,baggage' },
       @{ name = 'OTEL_TRACES_SAMPLER';         value = 'parentbased_traceidratio' },
       @{ name = 'OTEL_TRACES_SAMPLER_ARG';     value = '1.0' }
   )
   ```

**Send to Last9 directly**

   ```powershell
   Import-Module WebAdministration

   $poolName = "YourCoreAppPool"

   Set-ItemProperty "IIS:\AppPools\$poolName" -Name environmentVariables -Value @(
       @{ name = 'OTEL_SERVICE_NAME';           value = 'your-service-name' },
       @{ name = 'OTEL_RESOURCE_ATTRIBUTES';    value = "deployment.environment=production,host.name=$env:COMPUTERNAME" },
       @{ name = 'OTEL_TRACES_EXPORTER';        value = 'otlp' },
       @{ name = 'OTEL_METRICS_EXPORTER';       value = 'otlp' },
       @{ name = 'OTEL_LOGS_EXPORTER';          value = 'none' },
       @{ name = 'OTEL_EXPORTER_OTLP_ENDPOINT'; value = '$last9_otlp_endpoint' },
       @{ name = 'OTEL_EXPORTER_OTLP_PROTOCOL'; value = 'http/protobuf' },
       @{ name = 'OTEL_EXPORTER_OTLP_HEADERS';  value = 'Authorization=$last9_otlp_auth_header' },
       @{ name = 'OTEL_PROPAGATORS';            value = 'tracecontext,baggage' },
       @{ name = 'OTEL_TRACES_SAMPLER';         value = 'parentbased_traceidratio' },
       @{ name = 'OTEL_TRACES_SAMPLER_ARG';     value = '1.0' }
   )
   ```

5. **Restart the app pool**

   ```powershell
   Restart-WebAppPool -Name "YourCoreAppPool"
   ```

6. **Verify instrumentation**

   Make a request to your application, then check the profiler loaded:

   ```powershell
   Get-Process w3wp | ForEach-Object {
       $modules = $_.Modules | Where-Object { $_.ModuleName -like "*OpenTelemetry*" }
       if ($modules) {
           Write-Host "PID $($_.Id): OTel profiler loaded"
           $modules | Select ModuleName
       }
   }
   ```

   You should see `OpenTelemetry.AutoInstrumentation.Native`. If the process list is empty, the app pool has not received a request yet — make one first.

**Kestrel (self-hosted)**

For development or self-hosted deployments where the process runs directly (not through IIS), set the environment variables before starting `dotnet run` or `dotnet YourApp.dll`.

1. **Download and install the profiler**

   ```powershell
   [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

   $version = "v1.14.1"
   $otelHome = "C:\Program Files\OpenTelemetry .NET AutoInstrumentation"

   Invoke-WebRequest `
     -Uri "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/$version/opentelemetry-dotnet-instrumentation-windows.zip" `
     -OutFile "$env:TEMP\otel-dotnet.zip" -UseBasicParsing

   Invoke-WebRequest `
     -Uri "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/$version/OpenTelemetry.DotNet.Auto.psm1" `
     -OutFile "$env:TEMP\OpenTelemetry.DotNet.Auto.psm1" -UseBasicParsing

   New-Item -Path $otelHome -ItemType Directory -Force | Out-Null
   Expand-Archive -Path "$env:TEMP\otel-dotnet.zip" -DestinationPath $otelHome -Force
   Copy-Item "$env:TEMP\OpenTelemetry.DotNet.Auto.psm1" "$otelHome\" -Force

   $env:OTEL_DOTNET_AUTO_HOME = $otelHome
   Import-Module "$otelHome\OpenTelemetry.DotNet.Auto.psm1"
   Install-OpenTelemetryCore
   ```

2. **Set environment variables and run**

   All `CORECLR_*`, `DOTNET_*`, and `OTEL_*` variables must be set **in the same shell session** that runs `dotnet`. Setting them in a separate window has no effect.

   ```powershell
   $otelHome = "C:\Program Files\OpenTelemetry .NET AutoInstrumentation"

   # Profiler registration
   $env:CORECLR_ENABLE_PROFILING        = "1"
   $env:CORECLR_PROFILER                = "{918728DD-259F-4A6A-AC2B-B85E1B658318}"
   $env:CORECLR_PROFILER_PATH_64        = "$otelHome\win-x64\OpenTelemetry.AutoInstrumentation.Native.dll"
   $env:DOTNET_ADDITIONAL_DEPS          = "$otelHome\AdditionalDeps"
   $env:DOTNET_SHARED_STORE             = "$otelHome\store"
   $env:DOTNET_STARTUP_HOOKS            = "$otelHome\net\OpenTelemetry.AutoInstrumentation.StartupHook.dll"
   $env:OTEL_DOTNET_AUTO_HOME          = $otelHome

   # Service identity
   $env:OTEL_SERVICE_NAME               = "your-service-name"
   $env:OTEL_RESOURCE_ATTRIBUTES        = "deployment.environment=production,host.name=$env:COMPUTERNAME"

   # Exporter — via OTel Collector (recommended)
   $env:OTEL_EXPORTER_OTLP_ENDPOINT    = "http://localhost:4317"
   $env:OTEL_EXPORTER_OTLP_PROTOCOL    = "grpc"

   # Signals
   $env:OTEL_TRACES_EXPORTER           = "otlp"
   $env:OTEL_METRICS_EXPORTER          = "otlp"
   $env:OTEL_LOGS_EXPORTER             = "none"

   # Propagation + sampling
   $env:OTEL_PROPAGATORS               = "tracecontext,baggage"
   $env:OTEL_TRACES_SAMPLER            = "parentbased_traceidratio"
   $env:OTEL_TRACES_SAMPLER_ARG        = "1.0"

   # Run the app
   dotnet run
   ```

## Distributed Trace Propagation

The profiler automatically reads and writes W3C `traceparent` headers on all HTTP traffic. No code changes are needed:

- **Incoming requests** with a `traceparent` header continue the upstream trace
- **Outgoing requests** via `HttpClient` or `IHttpClientFactory` carry the `traceparent` to downstream services

This works the same for both controller-based routes and Minimal API endpoints.

## Sampling Configuration

| Sampler                        | `OTEL_TRACES_SAMPLER` value | Use case                                               |
| ------------------------------ | --------------------------- | ------------------------------------------------------ |
| Always on                      | `always_on`                 | Development, debugging                                 |
| Always off                     | `always_off`                | Disable temporarily                                    |
| Fixed percentage               | `traceidratio`              | Production — set `OTEL_TRACES_SAMPLER_ARG=0.1` for 10% |
| Respect parent, ratio for root | `parentbased_traceidratio`  | Production with distributed tracing                    |

For production with a gateway collector, use `parentbased_traceidratio`. This respects tail-sampling decisions made upstream and applies the percentage only to root traces.

## PII and SQL Statement Redaction

On .NET Core, the CLR profiler captures `db.query.text` (new OTel semantic conventions name) by default for every SQL query executed via ADO.NET. Unlike .NET Framework — where SQL text capture requires explicit opt-in via `OTEL_DOTNET_AUTO_SQLCLIENT_NETFX_ILREWRITE_ENABLED` — there is no env var to disable it on .NET Core.

The correct control point is the **OTel Collector**. Add a `transform/redact_pii` processor to strip both the old and new attribute names before spans leave your network:

```yaml
processors:
  transform/redact_pii:
    trace_statements:
      - context: span
        statements:
          - delete_key(attributes, "db.statement") # SDK < 1.6 (old semconv)
          - delete_key(attributes, "db.query.text") # SDK ≥ 1.6 (new semconv)
```

Wire it into the traces pipeline before the exporter:

```yaml
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, transform/redact_pii, batch]
      exporters: [otlp]
```

The `otelcol-dotnet.yaml` in the sample app already includes this processor.

:::caution[.NET Framework behaves differently]
On .NET Framework 4.6.2+, SQL text is **not captured by default** — `OTEL_DOTNET_AUTO_SQLCLIENT_NETFX_ILREWRITE_ENABLED` defaults to `false`. The collector-level redaction is still recommended as defence-in-depth but is less critical on Framework apps. See the [ASP.NET on IIS guide](/docs/integrations/frameworks/dotnet/aspnet-iis/) for details.
:::

## IIS + CLR Metrics

To collect IIS request metrics and .NET CLR runtime metrics (GC, heap, threads), deploy an OTel Collector with the `iis` and `windowsperfcounters` receivers on the same host.

```yaml
# otelcol-dotnet.yaml — deploy alongside your app
receivers:
  iis:
    collection_interval: 60s
  windowsperfcounters/dotnet:
    collection_interval: 60s
    perfcounters:
      - object: ".NET CLR Memory"
        instances: ["_Global_"]
        counters:
          - name: "# Bytes in all Heaps"
            metric: dotnet.clr.heap_bytes
          - name: "% Time in GC"
            metric: dotnet.clr.gc.time_percent
          - name: "# Gen 0 Collections"
            metric: dotnet.clr.gc.gen0_collections
          - name: "# Gen 1 Collections"
            metric: dotnet.clr.gc.gen1_collections
          - name: "# Gen 2 Collections"
            metric: dotnet.clr.gc.gen2_collections
      - object: ".NET CLR Exceptions"
        instances: ["_Global_"]
        counters:
          - name: "# of Exceps Thrown / sec"
            metric: dotnet.clr.exceptions.per_second
      - object: ".NET CLR LocksAndThreads"
        instances: ["_Global_"]
        counters:
          - name: "# of current logical Threads"
            metric: dotnet.clr.threads.logical

processors:
  batch:
    timeout: 10s
  memory_limiter:
    limit_mib: 256
    check_interval: 5s
  resourcedetection:
    detectors: [system]

exporters:
  otlp:
    endpoint: "$last9_otlp_endpoint"
    headers:
      Authorization: "$last9_otlp_auth_header"

service:
  pipelines:
    metrics/dotnet:
      receivers: [iis, windowsperfcounters/dotnet]
      processors: [memory_limiter, resourcedetection, batch]
      exporters: [otlp]
```

:::note
The `iis` receiver produces metrics only when the process has IIS counters available. In Kestrel mode, omit the `iis` receiver — CLR counters still work.
:::

---

## Troubleshooting

### No spans — IIS mode

1. **Check `managedRuntimeVersion`** — must be empty, not `v4.0`:

   ```powershell
   (Get-ItemProperty "IIS:\AppPools\YourCoreAppPool").managedRuntimeVersion
   # Must return: "" (empty string)
   # If it returns "v4.0", run:
   Set-ItemProperty "IIS:\AppPools\YourCoreAppPool" -Name managedRuntimeVersion -Value ""
   Restart-WebAppPool -Name "YourCoreAppPool"
   ```

2. **Verify the profiler loaded** in the w3wp process:

   ```powershell
   Get-Process w3wp | % { $_.Modules | Where ModuleName -like "*OpenTelemetry*" }
   ```

3. **Check the auto-instrumentation log**:

   ```powershell
   Get-ChildItem $env:TEMP -Filter "otel-dotnet-auto-*" |
       Sort LastWriteTime -Desc | Select -First 1 | Get-Content | Select -Last 30
   ```

### No spans — Kestrel mode

All `CORECLR_*` and `DOTNET_*` environment variables must be set in the **same PowerShell session** that runs `dotnet`. If you set them in one window and run the app in another, the profiler does not load.

Verify they are present before running:

```powershell
$env:CORECLR_ENABLE_PROFILING  # Should be: 1
$env:DOTNET_STARTUP_HOOKS      # Should be a path ending in .dll
```

### Two services showing the same `service.name`

:::caution[Each service needs its own app pool]
When multiple ASP.NET Core apps share an IIS app pool, the first app's `OTEL_SERVICE_NAME` applies to all of them. Dedicate one app pool per service:

```powershell
Import-Module WebAdministration

New-WebAppPool -Name "ServiceAPool"
New-WebAppPool -Name "ServiceBPool"

Set-ItemProperty "IIS:\Sites\ServiceA" -Name applicationPool -Value "ServiceAPool"
Set-ItemProperty "IIS:\Sites\ServiceB" -Name applicationPool -Value "ServiceBPool"
```

Then configure `OTEL_SERVICE_NAME` separately on each pool.
:::

### Missing Visual C++ Redistributable

If setup logs show `HRESULT: 0x80004005`, install the Visual C++ Redistributable:

```powershell
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile "$env:TEMP\vc_redist.x64.exe"
Start-Process "$env:TEMP\vc_redist.x64.exe" -ArgumentList "/install /quiet /norestart" -Wait
Register-OpenTelemetryForIIS
```

## Sample Application

A complete working example is available in the [Last9 OTel examples repository](https://github.com/last9/opentelemetry-examples/tree/main/dotnet/aspnet-core). It includes:

- Controller-based and Minimal API endpoints
- ADO.NET SQL calls and outbound HTTP calls (all auto-instrumented)
- `setup-otel.ps1` for both IIS and Kestrel modes (handles `managedRuntimeVersion` automatically)
- OTel Collector config with CLR + IIS metrics

Please get in touch with us on [Discord](https://discord.com/channels/652153247672729619/652153247672729621) or [Email](mailto:support@last9.io) if you have any questions.
