# ASP.NET on IIS (.NET Framework)

> Instrument ASP.NET applications on IIS with OpenTelemetry to send traces, metrics, and logs to Last9

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

Use the [OpenTelemetry .NET Automatic Instrumentation](https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation) agent to instrument ASP.NET applications running on IIS and send telemetry data to Last9. The agent uses the .NET CLR profiler to capture traces from incoming HTTP requests, outbound HTTP calls, SQL queries, and WCF services.

:::note[.NET Framework vs .NET (Core)]
This guide is for **.NET Framework 4.6.2–4.8** applications hosted on IIS (ASP.NET WebForms, MVC, Web API). For **.NET 6+** applications, see the [.NET integration guide](/docs/integrations/languages/dotnet/) or the [ASP.NET Core guide](/docs/integrations/frameworks/dotnet/aspnet-core/) for IIS/Kestrel specifics.
:::

## What Gets Auto-Instrumented

| Library                     | What's captured                                                     |
| --------------------------- | ------------------------------------------------------------------- |
| ASP.NET / ASP.NET MVC       | Incoming HTTP request spans with method, URL, status code, duration |
| System.Net.Http / WebClient | Outbound HTTP call spans                                            |
| ADO.NET (System.Data)       | SQL query spans with `db.statement`                                 |
| WCF (client)                | Service call spans                                                  |

## Prerequisites

- **Windows Server** 2016 or later with IIS 8.5+
- **.NET Framework 4.6.2–4.8** installed — versions 4.5.x and below are not supported by the CLR profiler
- **Administrator access** on the server
- **IIS app pool name** — find it with: `Get-WebApplication | Select ApplicationPool`
- **Last9 Account** with [OpenTelemetry integration credentials](https://app.last9.io/integrations?integration=OpenTelemetry)

## Installation

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

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

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

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

   # Download the instrumentation package
   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

   # Download the PowerShell management module
   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

   # Extract
   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
   ```

   Verify the installation:

   ```powershell
   Test-Path "$otelHome\win-x64\OpenTelemetry.AutoInstrumentation.Native.dll"
   # Should return: True
   ```

2. **Register the profiler for IIS**

   The official PowerShell module registers the CLR profiler and configures IIS globally:

   ```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 automatically. The profiler is now enabled for all app pools.

   :::caution[Use Windows PowerShell 5.1]
   The `Register-OpenTelemetryForIIS` command requires **Windows PowerShell 5.1** (not PowerShell 7/pwsh). The IIS management module does not work in PowerShell 7.
   :::

3. **Configure your app pool**

   Set environment variables on the specific app pool to control service name, exporter, and sampling:

**Send to Last9 directly**

   ```powershell
   Import-Module WebAdministration

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

   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 = 'otlp' },
       @{ 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 = 'always_on' }
   )
   ```

**Send via OTel Collector**

   ```powershell
   Import-Module WebAdministration

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

   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 = 'otlp' },
       @{ name = 'OTEL_EXPORTER_OTLP_ENDPOINT'; value = 'http://localhost:4318' },
       @{ name = 'OTEL_EXPORTER_OTLP_PROTOCOL'; value = 'http/protobuf' },
       @{ name = 'OTEL_PROPAGATORS';            value = 'tracecontext,baggage' },
       @{ name = 'OTEL_TRACES_SAMPLER';         value = 'always_on' }
   )
   ```

4. **Restart the app pool**

   ```powershell
   Restart-WebAppPool -Name "YourAppPool"
   ```

5. **Verify instrumentation**

   Make a request to your application, then verify 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` in the module list.

   Check Last9 Traces and filter by your `service.name` to see incoming and outgoing HTTP spans.

## Distributed Trace Propagation

The auto-instrumentation automatically reads and writes W3C `traceparent` headers on all HTTP requests. This means:

- **Incoming requests** with a `traceparent` header (from upstream services, mobile apps, or load balancers) will be continued as part of the same trace
- **Outgoing requests** (via `HttpClient`, `WebClient`, or `HttpWebRequest`) will automatically include the `traceparent` header, propagating the trace to downstream services

No configuration needed — this works by default when `OTEL_PROPAGATORS=tracecontext,baggage` is set.

## Sampling Configuration

| Sampler                    | Value                                                    | Use case                                               |
| -------------------------- | -------------------------------------------------------- | ------------------------------------------------------ |
| `always_on`                | Capture all traces                                       | Development, debugging                                 |
| `always_off`               | Capture no traces                                        | Disable temporarily                                    |
| `traceidratio`             | Sample by percentage                                     | Production (set `OTEL_TRACES_SAMPLER_ARG=0.1` for 10%) |
| `parentbased_traceidratio` | Respect parent's sampling decision, ratio for new traces | Production with distributed tracing                    |

For production, use `parentbased_traceidratio` to respect upstream sampling decisions while controlling the sampling rate for traces originating at this service.

## Multi-App Pool Configuration

:::caution[Each service needs its own app pool]
When multiple applications share an app pool, the first application's `OTEL_SERVICE_NAME` wins for all apps in that pool. All requests from every app appear under the same service name in Last9 — there is no per-app override once the profiler is loaded.

**Fix:** Give each service its own dedicated app pool.

```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 run the configuration step above once per pool with its own `-ServiceName`.
:::

To disable instrumentation for a specific app pool without affecting others:

```powershell
Import-Module "$env:OTEL_DOTNET_AUTO_HOME\OpenTelemetry.DotNet.Auto.psm1"
Disable-OpenTelemetryForIISAppPool -AppPoolName "PoolToDisable"
```

## PII and SQL Statement Redaction

Even with `OTEL_DOTNET_AUTO_SQLCLIENT_NETFX_ILREWRITE_ENABLED=false`, add a `transform/redact_pii` processor to your OTel Collector as a defence-in-depth layer. This removes `db.statement` and `db.query.text` from all spans before they leave your network:

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

Wire it into the traces pipeline:

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

The `delete_key` OTTL function is a no-op when the key is absent, so this is safe to deploy unconditionally.

## Sample Application

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

- Web API and MVC controllers with ADO.NET SQL calls
- Outbound HTTP calls via `HttpClient`
- A one-shot `setup-otel.ps1` script that runs all four steps above
- An OTel Collector config with IIS + CLR Windows perf counter metrics

---

## Troubleshooting

### Profiler not loading

**Check the .NET Runtime event log:**

```powershell
Get-WinEvent -LogName Application -MaxEvents 20 | Where-Object {
    $_.ProviderName -eq '.NET Runtime' -and $_.Message -like '*profiler*'
} | Format-List TimeCreated, Message
```

Common errors:

- `HRESULT: 0x80004005` — Missing Visual C++ Redistributable. Install from [Microsoft](https://aka.ms/vs/17/release/vc_redist.x64.exe)
- `CoCreateInstance failed` — Run `Install-OpenTelemetryCore` and `Register-OpenTelemetryForIIS` again

### No spans appearing

1. Verify the app pool has the correct environment variables:

   ```powershell
   (Get-ItemProperty "IIS:\AppPools\YourAppPool").environmentVariables.Collection | Select name, value
   ```

2. Check the auto-instrumentation log:

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

3. Ensure the app pool is running in **64-bit mode** (32-bit mode uses a different profiler path):

   ```powershell
   (Get-ItemProperty "IIS:\AppPools\YourAppPool").enable32BitAppOnWin64
   # Should be False for 64-bit
   ```

### SQL query spans missing

ADO.NET instrumentation works automatically for `System.Data.SqlClient`. If using a third-party data access layer, ensure it ultimately uses ADO.NET under the hood.

:::note[`db.statement` is off by default on .NET Framework]
On .NET Framework, capturing the SQL query text requires IL rewriting and is **disabled by default**:

```powershell
# Default — db.statement NOT captured (safe for PHI environments)
OTEL_DOTNET_AUTO_SQLCLIENT_NETFX_ILREWRITE_ENABLED=false

# Enable only if you need query text and have confirmed no PHI in SQL literals
OTEL_DOTNET_AUTO_SQLCLIENT_NETFX_ILREWRITE_ENABLED=true
```

Leave this at the default `false` if your SQL queries may contain patient data or other sensitive values.
:::

### Still stuck?

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.
