diff --git a/.claude/skills/auto-release/scripts/check-otel-updates.sh b/.claude/skills/auto-release/scripts/check-otel-updates.sh deleted file mode 100644 index 4e96609..0000000 --- a/.claude/skills/auto-release/scripts/check-otel-updates.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Lists outdated OpenTelemetry NuGet packages in the solution. -# Exit code 0 = updates available, 3 = nothing to release, 1 = error. -set -euo pipefail - -SLN="OpenTelemetryExtension.slnx" - -echo "Checking for outdated OpenTelemetry packages in $SLN ..." -outdated="$(dotnet list "$SLN" package --outdated 2>/dev/null | grep -i 'OpenTelemetry' || true)" - -if [[ -z "$outdated" ]]; then - echo "No OpenTelemetry package updates available — nothing to release." - exit 3 -fi - -echo "OpenTelemetry updates available:" -echo "$outdated" -exit 0 diff --git a/.claude/skills/auto-release/SKILL.md b/.claude/skills/prepare-release/SKILL.md similarity index 56% rename from .claude/skills/auto-release/SKILL.md rename to .claude/skills/prepare-release/SKILL.md index 150f189..1100cde 100644 --- a/.claude/skills/auto-release/SKILL.md +++ b/.claude/skills/prepare-release/SKILL.md @@ -1,9 +1,9 @@ --- -name: auto-release -description: Prepare a new NuGet release of this repository. Use when the user wants to cut/prepare a release, bump the package version, update dependencies for a release, or open a release PR to master. Decides the next SemVer version automatically and only releases when OpenTelemetry packages have updates. +name: prepare-release +description: Prepare a new NuGet release of this repository (version bump, release notes, release PR to master). Use this whenever the user mentions releasing, publishing, shipping, cutting a release, bumping the version, updating OpenTelemetry dependencies, or preparing release notes — even if they don't say "release" explicitly. Decides the next SemVer version automatically and proceeds when the shipped library project has dependency updates or there are any new commits since the last tag. --- -# Auto-release +# Prepare release Prepare a new release of this NuGet package. You decide the next version yourself. The NuGet publish itself is a **manual** GitHub Actions trigger @@ -12,10 +12,11 @@ prepare the repository and open the PR. ## Workflow -1. **Check for OpenTelemetry NuGet updates first** - - Run the helper: `bash scripts/check-otel-updates.sh` - - Exit code **3** = no OpenTelemetry updates → **stop and do not release**. - - Exit code **0** = updates available → continue. +1. **Check whether there is anything to release** + - Run the helper: `bash .claude/skills/prepare-release/scripts/check-otel-updates.sh` + - The helper only inspects the **shipped library project** (`src/OpenTelemetryExtension.Configuration/...csproj`). Dependency updates in the Sample or Tests projects are ignored — they are never published and must not trigger a new version. + - Exit code **3** = nothing to release (no library dependency updates *and* no new commits since the last tag) → **stop**. + - Exit code **0** = library dependency updates and/or new commits exist → continue. 2. **Determine current version & last tag** - Read `` in `src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj`. @@ -29,18 +30,18 @@ prepare the repository and open the PR. 5. **Branch** — `git checkout -b release/v`. -6. **Update all NuGet packages** to latest, then `dotnet restore OpenTelemetryExtension.slnx`. +6. **Update the library project's NuGet packages** to latest (`src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj` only — leave Sample/Tests packages alone), then `dotnet restore OpenTelemetryExtension.slnx`. 7. **Build & test (green required)** - `dotnet build OpenTelemetryExtension.slnx -c Release` - - `dotnet test OpenTelemetryExtension.slnx -c Release` + - `dotnet test OpenTelemetryExtension.slnx -c Release --filter "Category=Unit"` (unit tests only; integration tests need the live OpenObserve/SQL stack) 8. **End-to-end smoke test** — prove telemetry actually reaches a backend. Requires a local Kubernetes cluster (k3s in WSL2) with Helm + kubectl. OpenObserve is used because it has a real query API, so the test can positively confirm ingested data. The helper starts OpenObserve via its Helm chart, runs the sample, generates traffic and queries the API for records: - - `bash scripts/smoke-test.sh` + - `bash .claude/skills/prepare-release/scripts/smoke-test.sh` - Exit 0 = telemetry confirmed → continue. Non-zero = stop and report; do not release if telemetry does not arrive. @@ -48,7 +49,7 @@ prepare the repository and open the PR. 10. **Extend docs** — update `README.md` etc. for the changes/new dep versions. -11. **Release notes** — copy `templates/release-notes.md` to +11. **Release notes** — copy `.claude/skills/prepare-release/assets/release-notes.md` to `release-notes/v.md`, fill in the `{{VERSION}}`/`{{DATE}}` placeholders and the **Added / Changed / Fixed / Removed** sections (omit empty ones). diff --git a/.claude/skills/auto-release/templates/release-notes.md b/.claude/skills/prepare-release/assets/release-notes.md similarity index 100% rename from .claude/skills/auto-release/templates/release-notes.md rename to .claude/skills/prepare-release/assets/release-notes.md diff --git a/.claude/skills/prepare-release/scripts/check-otel-updates.sh b/.claude/skills/prepare-release/scripts/check-otel-updates.sh new file mode 100644 index 0000000..5957be5 --- /dev/null +++ b/.claude/skills/prepare-release/scripts/check-otel-updates.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Decides whether there is anything worth releasing. +# +# A release is warranted when EITHER a dependency of the shipped library project +# has an update OR there are new commits since the last tag (features, fixes, +# breaking changes). Only the library project's packages matter — updates in the +# Sample or Tests projects are never published and must not drive a release. +# +# Exit 0 = something to release, 3 = nothing to release, 1 = error. +set -euo pipefail + +# Run from the repository root so the relative paths below resolve regardless +# of where the script was invoked from. +cd "$(git rev-parse --show-toplevel)" + +PROJ="src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj" + +echo "Checking for outdated packages in $PROJ ..." +# Only count real package rows (they start with "> "); skip status lines such as +# "The given project `OpenTelemetry...` has no updates", which otherwise match. +outdated="$(dotnet list "$PROJ" package --outdated 2>/dev/null | grep -E '^[[:space:]]*>' | grep -i 'OpenTelemetry' || true)" + +last_tag="$(git tag --sort=-v:refname | head -1)" +if [[ -n "$last_tag" ]]; then + commits="$(git log "$last_tag"..HEAD --oneline 2>/dev/null || true)" +else + commits="$(git log --oneline 2>/dev/null || true)" +fi + +if [[ -z "$outdated" && -z "$commits" ]]; then + echo "No OpenTelemetry updates and no new commits since ${last_tag:-the start} — nothing to release." + exit 3 +fi + +if [[ -n "$outdated" ]]; then + echo "OpenTelemetry updates available:" + echo "$outdated" +fi +if [[ -n "$commits" ]]; then + echo "Releasable changes since ${last_tag:-the start}:" + echo "$commits" +fi +exit 0 diff --git a/.claude/skills/auto-release/scripts/smoke-test.sh b/.claude/skills/prepare-release/scripts/smoke-test.sh similarity index 91% rename from .claude/skills/auto-release/scripts/smoke-test.sh rename to .claude/skills/prepare-release/scripts/smoke-test.sh index a2ed51e..7aaa60d 100644 --- a/.claude/skills/auto-release/scripts/smoke-test.sh +++ b/.claude/skills/prepare-release/scripts/smoke-test.sh @@ -11,7 +11,11 @@ # Exit 0 = telemetry confirmed, non-zero = failed. set -euo pipefail -SAMPLE_DIR="src/OpenTelemetryExtension.Configuration.Sample" +# Run from the repository root so the relative paths below resolve regardless +# of where the script was invoked from. +cd "$(git rev-parse --show-toplevel)" + +SAMPLE_DIR="src/OpenTelemetryExtension.Configuration.Sample.WebApi" SWAGGER_URL="http://localhost:5021" # http app url from launchSettings # 1. Start OpenObserve via its Helm chart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00aaa9..e877eed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: --collect:"XPlat Code Coverage" ` --results-directory TestResults/Tests ` --configuration Release ` + --filter "Category=Unit" ` --logger "console;verbosity=detailed" dotnet tool run reportgenerator ` diff --git a/.github/workflows/deploy-nuget.yml b/.github/workflows/deploy-nuget.yml index 54bef7d..0d1f97a 100644 --- a/.github/workflows/deploy-nuget.yml +++ b/.github/workflows/deploy-nuget.yml @@ -30,14 +30,21 @@ jobs: with: dotnet-version: 10.x + # Build only the published library and the unit tests. The solution also + # contains a WPF sample (net10.0-windows) that cannot build on Linux and + # integration tests that need a live backend — neither belongs in the deploy. - name: Restore - run: dotnet restore "${{ env.Solution_Name }}" + run: | + dotnet restore "${{ env.Nuget_Project_Path }}" + dotnet restore "${{ env.Test_Project_Path }}" - name: Build - run: dotnet build "${{ env.Solution_Name }}" --configuration Release --no-restore + run: | + dotnet build "${{ env.Nuget_Project_Path }}" --configuration Release --no-restore + dotnet build "${{ env.Test_Project_Path }}" --configuration Release --no-restore - - name: Run tests - run: dotnet test "${{ env.Test_Project_Path }}" --configuration Release --logger "console;verbosity=detailed" --logger "console;verbosity=detailed" + - name: Run unit tests + run: dotnet test "${{ env.Test_Project_Path }}" --configuration Release --no-build --filter "Category=Unit" --logger "console;verbosity=detailed" - name: Pack NuGet Package run: dotnet pack "${{ env.Nuget_Project_Path }}" --configuration Release --no-build --output ./nupkg diff --git a/AGENTS.md b/AGENTS.md index 39c6ce5..5bcc7b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,11 @@ ASP.NET Core via a single `AddTelemetry()` call and `appsettings.json`. ``` src/ - OpenTelemetryExtension.Configuration/ # Library (netstandard2.0 + net10.0) - OpenTelemetryExtension.Configuration.Tests/ # xUnit tests (net10.0) - OpenTelemetryExtension.Configuration.Sample/ # ASP.NET Core sample app (net10.0) + OpenTelemetryExtension.Configuration/ # Library (netstandard2.0 + net10.0) + OpenTelemetryExtension.Configuration.Tests/ # xUnit unit tests (net10.0, in-process) + OpenTelemetryExtension.Configuration.IntegrationTests/ # Integration tests (net10.0) — query a live OpenObserve + OpenTelemetryExtension.Configuration.Sample.WebApi/ # ASP.NET Core sample app (net10.0) + OpenTelemetryExtension.Configuration.Sample.Wpf/ # WPF desktop sample app (net10.0-windows) OpenTelemetryExtension.slnx # Solution file .github/workflows/ ci.yml # Build + test + coverage on push @@ -26,24 +28,46 @@ release-notes/ # v{VERSION}.md per release ```csharp // IServiceCollection extensions -services.AddTelemetry(configuration); // binds "Telemetry" section -services.AddTelemetry(configuration, o => { ... }); // bind + code callback (combined) -services.AddTelemetry(o => { o.Enabled = true; o.Endpoint = new Uri("..."); }); +services.AddTelemetry(configuration); // binds "Telemetry" section +services.AddTelemetry(configuration, "CustomSection"); // custom section name +services.AddTelemetry(configuration, o => { ... }); // bind + code callback (combined) +services.AddTelemetry(configuration, o => { ... }, "Sec"); // combined + custom section +services.AddTelemetry(o => { o.Endpoint = new Uri("..."); }); ``` -`TelemetryOptions` is the single configuration model. `Enabled = false` is the -safe default; `AddTelemetry()` is a no-op when disabled. `Endpoint` is -`[Required]` and validated at registration time when `Enabled = true`. +`TelemetryOptions` is the single configuration model. `Enabled` defaults to +`true`; set it to `false` to make `AddTelemetry()` a no-op. `Endpoint` is +`[Required]` and validated at registration time when `Enabled = true`. The +configuration section name (`Telemetry`) is overridable via the `sectionName` +parameter on the `IConfiguration` overloads. ## Build & test ```bash dotnet build OpenTelemetryExtension.slnx -c Release -dotnet test OpenTelemetryExtension.slnx -c Release" +dotnet test src/OpenTelemetryExtension.Configuration.Tests -c Release # unit tests ``` -Tests use **xUnit + Moq**. No integration test infrastructure needed — all -tests run in-process via `ServiceCollection`. +Unit tests use **xUnit + Moq** and run in-process via `ServiceCollection` — no +infrastructure required. + +**Whenever you add or change a feature, run the unit tests. When the telemetry +stack is running, also run the integration tests** (see below). **CI runs the +unit tests only** (the workflows filter on `Category=Unit`). + +### Integration tests + +`OpenTelemetryExtension.Configuration.IntegrationTests` exercises the real export +path: it emits logs, metrics and traces (and a SQL Server span) through +`AddTelemetry()` to a running **OpenObserve** instance and queries its `_search` +API to confirm the data was ingested. + +- Needs the OpenObserve Helm chart (`infrastructure/helm/helm-install-openobserve.cmd`); + the SQL Server chart (`helm-install-sqlserver.cmd`) is required only for the SQL test. +- Every test is `[Trait("Category", "Integration")]` and **auto-skips** when the + backend (or SQL Server) is unreachable, so the suite stays green without the stack. +- Endpoints/credentials default to the Helm chart values; override via `OTEL_IT_*` env vars. +- Run: `dotnet test src/OpenTelemetryExtension.Configuration.IntegrationTests -c Release`. ## Language & framework @@ -78,7 +102,11 @@ tests run in-process via `ServiceCollection`. no reflection hacks - Use `Record.Exception` (not `Assert.Throws`) when asserting that no exception is thrown -- Do not use `Thread.Sleep` or `Task.Delay` in tests +- Do not use `Thread.Sleep` or `Task.Delay` in **unit** tests (integration tests + may poll the backend until telemetry is queryable) +- Integration tests live in the `*.IntegrationTests` project, are marked + `[Trait("Category", "Integration")]` and assert against a live OpenObserve via + its `_search` API — see [Integration tests](#integration-tests) ## Versioning & release @@ -88,13 +116,18 @@ tests run in-process via `ServiceCollection`. - Do not change `` without also creating `release-notes/v{VERSION}.md` - NuGet publish is **manual** (`workflow_dispatch`) — never triggered automatically +- The full release-prep workflow (decide SemVer, bump, update deps, build/test, + end-to-end smoke test, release notes, PR to `master`) is encoded in the + **`prepare-release`** skill at `.claude/skills/prepare-release/`. Run it via + Claude Code (`/prepare-release`) when cutting a release; it only prepares the + PR — publishing stays the manual `deploy-nuget.yml` trigger. ## What NOT to do - Do not add `using` directives already covered by global/implicit usings - Do not add `// TODO` comments — raise an issue instead -- Do not modify the `*.Sample` project for library behaviour changes (it is - excluded from code coverage) +- Do not modify the `*.Sample.*` projects for library behaviour changes (they + are excluded from code coverage) - Do not add new public API surface without a corresponding test in `TelemetryOptionsTests.cs` or `TelemetryServiceCollectionExtensionsTests.cs` @@ -105,7 +138,9 @@ tests run in-process via `ServiceCollection`. 2. Wire it up in `TelemetryServiceCollectionExtensions` under the appropriate signal block 3. Add default-value test in `TelemetryOptionsTests.cs` -4. Add enabled/disabled integration tests in +4. Add enabled/disabled unit tests in `TelemetryServiceCollectionExtensionsTests.cs` 5. Add the option to the `` block in the XML doc on `TelemetryOptions` 6. Update `README.md` configuration reference table +7. Run the unit tests; when the telemetry stack is running, run the integration + tests too (see [Integration tests](#integration-tests)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cec8cd8..2e98140 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,16 +11,40 @@ For anything beyond a small fix, please [open an issue](https://github.com/thors 1. Fork the repository and create a branch from `develop`. 2. Make your changes. 3. Add or update tests — every public API change needs test coverage. -4. Run the build and tests locally. +4. When you add or change a feature, run the **unit tests**; if you have the + telemetry stack running, run the **integration tests** too (see + [Integration tests](#integration-tests)). 5. Open a pull request against `develop`. ## Build & Test ```bash dotnet build OpenTelemetryExtension.slnx -c Release -dotnet test OpenTelemetryExtension.slnx -c Release +dotnet test src/OpenTelemetryExtension.Configuration.Tests -c Release # unit tests ``` +### Integration tests + +The separate `OpenTelemetryExtension.Configuration.IntegrationTests` project +verifies that telemetry is actually exported: it sends logs, metrics, traces and +a SQL Server span through `AddTelemetry()` to a live **OpenObserve** instance and +queries its API to confirm the data arrived. + +```bash +# 1. Start the backends (local Kubernetes + Helm required) +infrastructure/helm/helm-install-openobserve.cmd +infrastructure/helm/helm-install-sqlserver.cmd # only needed for the SQL Server test + +# 2. Run the integration tests +dotnet test src/OpenTelemetryExtension.Configuration.IntegrationTests -c Release +``` + +The tests are tagged `[Trait("Category", "Integration")]` and **skip +automatically** when OpenObserve (or SQL Server) is unreachable, so a normal +`dotnet test` run never fails just because the stack is down. Endpoints and +credentials default to the Helm chart values and can be overridden via the +`OTEL_IT_*` environment variables. **CI runs the unit tests only.** + ## Code Style - File-scoped namespaces (`namespace Foo;`) @@ -43,6 +67,8 @@ If your PR changes the public API or behaviour, please: PRs without a version bump are fine for documentation or refactoring that has no user-visible impact. +> **Maintainers:** the release-prep steps (version decision, dependency updates, build/test, smoke test, release notes, release PR) are automated by the `prepare-release` Claude Code skill in [`.claude/skills/prepare-release/`](./.claude/skills/prepare-release/). It prepares the PR only — the actual NuGet publish remains the manual **Deploy Nuget** workflow. + ## Adding a New Instrumentation Option Follow the checklist in [CLAUDE.md](./CLAUDE.md#adding-a-new-instrumentation-option). diff --git a/OpenTelemetryExtension.slnx b/OpenTelemetryExtension.slnx index 0c90d06..27bdeab 100644 --- a/OpenTelemetryExtension.slnx +++ b/OpenTelemetryExtension.slnx @@ -8,14 +8,15 @@ - - + + + - - + + - - + + @@ -72,7 +73,9 @@ - + + + diff --git a/README.md b/README.md index e4a829a..86cc5c3 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,23 @@ Configurable OpenTelemetry setup for .NET applications providing **tracing, metr - **One-call setup** — tracing, metrics and logging via a single `AddTelemetry()`, configured from `appsettings.json` or code - **All three signals over OTLP** — HTTP/protobuf or gRPC, to any OTLP-compatible backend -- **Built-in instrumentation** — ASP.NET Core, `HttpClient`, SQL Client and .NET runtime metrics, each toggleable -- **Sensible defaults** — sampling, health-check path exclusion and exception recording work out of the box +- **Built-in instrumentation** — `HttpClient` and .NET runtime metrics everywhere; ASP.NET Core instrumentation on web targets — each toggleable. Database instrumentation is opt-in via a one-liner (see [Databases](#databases)) +- **Sensible defaults** — configurable sampling, health-check path exclusion and exception recording work out of the box - **Startup validation** — misconfiguration fails fast with a clear error - **Extensible** — `ConfigureTracing`/`ConfigureMetrics`/`ConfigureLogging` hooks for custom sources, meters and providers -- **Broad target support** — `netstandard2.0` and `net10.0` +- **Works on any .NET** — ASP.NET Core, WPF, console and more; the `netstandard2.0` build pulls **no** ASP.NET Core dependencies --- +## ✅ Requirements + +- A .NET target compatible with **`netstandard2.0`** — i.e. .NET Framework 4.6.1+, .NET 6/8/9/10, or .NET 10 directly. +- An **OTLP-compatible backend** to receive the telemetry (collector, Jaeger, OpenObserve, the .NET Aspire Dashboard, …). See [Running Locally with a Backend](#-running-locally-with-a-backend). +- ASP.NET Core instrumentation requires a **web target** (`net10.0` build); it is not included in the `netstandard2.0` build used by WPF/console apps. + +--- + ## 📦 Installation ```bash @@ -44,7 +52,6 @@ builder.Services.AddTelemetry(builder.Configuration); ```json { "Telemetry": { - "Enabled": true, "Endpoint": "http://localhost:4318", "ServiceName": "my-api" } @@ -53,6 +60,9 @@ builder.Services.AddTelemetry(builder.Configuration); That's it — tracing, metrics and logging are exported via OTLP. +> You need an **OTLP-compatible backend** listening at `Endpoint`. No backend yet? +> See [Running Locally with a Backend](#-running-locally-with-a-backend) for one-command setups. + --- ## ⚙️ Configuration @@ -61,19 +71,20 @@ All options live under the `Telemetry` key in `appsettings.json`. | Property | Type | Default | Description | |---|---|---|---| -| `Enabled` | `bool` | `false` | Must be `true` to activate telemetry. | +| `Enabled` | `bool` | `true` | Set to `false` to disable telemetry (no OpenTelemetry services are registered). | | `Endpoint` | `Uri` | *(required)* | OTLP collector endpoint, e.g. `http://localhost:4318`. | | `Headers` | `string` | `""` | Exporter headers. Format: `key1=value1,key2=value2`. | | `Protocol` | `string` | `HttpProtobuf` | `HttpProtobuf` (port 4318) or `Grpc` (port 4317). | | `ServiceName` | `string?` | `null` | Service name shown in the backend. | | `ResourceAttributes` | `object` | `{}` | Extra resource attributes, e.g. `{ "deployment.environment": "production", "team": "backend" }`. | +| `AdditionalTracingSources` | `string[]` | `[]` | Extra `ActivitySource` names to collect (e.g. `"Npgsql"`, your own app sources) — registered via `AddSource`. | +| `AdditionalMeters` | `string[]` | `[]` | Extra `Meter` names to collect (e.g. `"MyApp.Orders"`) — registered via `AddMeter`. | | `SampleRatio` | `double` | `1.0` | Fraction of traces to sample. `0.1` = 10%, `1.0` = all. | | `EnableTracing` | `bool` | `true` | Enables distributed tracing. | | `EnableMetrics` | `bool` | `true` | Enables metrics collection. | | `EnableLogging` | `bool` | `true` | Enables log export via OTLP. | | `EnableAspNetCoreInstrumentation` | `bool` | `true` | Instruments incoming HTTP requests. | | `EnableHttpClientInstrumentation` | `bool` | `true` | Instruments outgoing `HttpClient` requests. | -| `EnableSqlClientInstrumentation` | `bool` | `false` | Instruments SQL calls. Opt-in — not all apps use SQL. | | `EnableRuntimeInstrumentation` | `bool` | `true` | Collects GC, memory and thread pool metrics. | | `RecordExceptions` | `bool` | `true` | Records exception stack traces on spans. | | `ExcludedPaths` | `string[]` | `["/health"]` | Paths excluded from tracing. | @@ -84,6 +95,25 @@ All options live under the `Telemetry` key in `appsettings.json`. > > For every key with its default value, see the [Full configuration reference](#-full-configuration-reference) below. +### Custom section name + +The section defaults to `Telemetry`, but you can bind any section by passing its name: + +```csharp +builder.Services.AddTelemetry(builder.Configuration, "MyTelemetry"); +// or together with a code callback: +builder.Services.AddTelemetry(builder.Configuration, o => { /* ... */ }, "MyTelemetry"); +``` + +```json +{ + "MyTelemetry": { + "Endpoint": "http://localhost:4318", + "ServiceName": "my-api" + } +} +``` + --- ## 🧩 Code Configuration @@ -140,8 +170,7 @@ only collects metrics from meters you have explicitly registered with ```csharp // 1. Create a Meter and an instrument somewhere in your app private static readonly Meter Meter = new("MyApp.Orders"); -private static readonly Counter OrdersPlaced = - Meter.CreateCounter("orders.placed"); +private static readonly Counter OrdersPlaced = Meter.CreateCounter("orders.placed"); // ... later OrdersPlaced.Add(1); @@ -168,28 +197,149 @@ o.ConfigureTracing = tracing => tracing.AddSource("MyApp"); > gave the `Meter`/`ActivitySource` — that name is how OpenTelemetry routes the > data. +### Databases + +Database instrumentation is **not** built in — it depends entirely on your +driver, so it is added through the `ConfigureTracing` hook. This keeps the +package free of database-specific dependencies; you only pull in what you use. + +```csharp +// SQL Server — install the package, then register it: +// dotnet add package OpenTelemetry.Instrumentation.SqlClient +o.ConfigureTracing = t => t.AddSqlClientInstrumentation(); + +// EF Core — dedicated instrumentation package: +// dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore +o.ConfigureTracing = t => t.AddEntityFrameworkCoreInstrumentation(); + +// Drivers with a built-in ActivitySource — just register its name: +o.ConfigureTracing = t => t.AddSource("Npgsql"); // PostgreSQL (Npgsql) +o.ConfigureTracing = t => t.AddSource("MySqlConnector"); // MySQL (MySqlConnector) +``` + +Oracle (`Oracle.ManagedDataAccess.Core`) emits an `ActivitySource` in recent +versions and is wired up the same way via `AddSource(...)`. + +**No code for source-based drivers:** if the driver only needs an `ActivitySource` +name (Npgsql, MySqlConnector, Oracle, your own app sources), you can enable it +purely from `appsettings.json` — no `ConfigureTracing` call required: + +```json +{ + "Telemetry": { + "Endpoint": "http://localhost:4318", + "AdditionalTracingSources": [ "Npgsql", "MyApp" ], + "AdditionalMeters": [ "MyApp.Orders" ] + } +} +``` + +> Package-based instrumentation (SQL Server, EF Core) still needs the one-line +> `ConfigureTracing` call above, because it requires its NuGet package — a config +> string alone can't pull in a dependency. + +**Toggling SQL instrumentation from `appsettings.json`** + +Because `EnableSqlClientInstrumentation` is not part of `TelemetryOptions` (the +package is optional), you can add it as a custom key and read it in the callback: + +`appsettings.json`: + +```json +{ + "Telemetry": { + "Endpoint": "http://localhost:4318", + "ServiceName": "my-api", + "EnableSqlClientInstrumentation": true + } +} +``` + +`Program.cs`: + +```csharp +builder.Services.AddTelemetry(builder.Configuration, opt => + opt.ConfigureTracing = tracing => + { + if (builder.Configuration.GetValue("Telemetry:EnableSqlClientInstrumentation")) + { + // Microsoft SQL Server / System.Data.SqlClient + // NuGet: OpenTelemetry.Instrumentation.SqlClient + tracing.AddSqlClientInstrumentation(sql => sql.RecordException = opt.RecordExceptions); + + // PostgreSQL (Npgsql) + // NuGet: OpenTelemetry.Instrumentation.Npgsql + tracing.AddNpgsql(); + + // MySQL (MySqlConnector) + // NuGet: OpenTelemetry.Instrumentation.MySqlData + tracing.AddMySqlDataInstrumentation(); + } + }); +``` + +This keeps the on/off switch in config while the package dependency stays explicit in code. + +--- + +## 🖥️ Using outside the Generic Host + +`AddTelemetry()` works with **any** `IServiceCollection` — ASP.NET Core, WPF, +WinForms, console, MAUI/WinUI, UWP, worker services, etc. + +**With the Generic Host** (recommended for desktop/console — `Host.CreateApplicationBuilder()`), +the providers start and flush automatically: + +```csharp +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTelemetry(builder.Configuration); +using var host = builder.Build(); +await host.RunAsync(); // telemetry starts here and flushes on shutdown +``` + +**Without a host** (e.g. a bare `ServiceCollection` in UWP or a minimal app), +build the provider and **dispose it on exit** so buffered telemetry is flushed: + +```csharp +var services = new ServiceCollection(); +services.AddTelemetry(o => +{ + o.Endpoint = new Uri("http://localhost:4318"); + o.ServiceName = "my-desktop-app"; +}); + +var provider = services.BuildServiceProvider(); +// ... app runs ... +provider.Dispose(); // flushes traces, metrics and logs +``` + +> ASP.NET Core instrumentation is only in the `net10.0` build. On the +> `netstandard2.0` build (WPF/WinForms/console/UWP) it is simply absent — +> setting `EnableAspNetCoreInstrumentation` there is a harmless no-op. + --- -## 📋 Full configuration reference +## 📋 Full Configuration Reference -Every key with its **default** value (only `Enabled` and `Endpoint` are required to get started): +Every key with its **default** value (only `Endpoint` is required to get started — telemetry is enabled by default): ```jsonc { "Telemetry": { - "Enabled": false, // master switch — set true to activate + "Enabled": true, // master switch — set false to disable "Endpoint": "http://localhost:4318", // OTLP collector endpoint (required) "Headers": "", // exporter headers: "key1=value1,key2=value2" "Protocol": "HttpProtobuf", // "HttpProtobuf" (4318) or "Grpc" (4317) "ServiceName": null, // service name shown in the backend "ResourceAttributes": {}, // extra attributes, e.g. { "deployment.environment": "production" } + "AdditionalTracingSources": [], // extra ActivitySource names, e.g. [ "Npgsql", "MyApp" ] + "AdditionalMeters": [], // extra Meter names, e.g. [ "MyApp.Orders" ] "SampleRatio": 1.0, // 0.1 = 10% of traces, 1.0 = all "EnableTracing": true, // distributed tracing "EnableMetrics": true, // metrics collection "EnableLogging": true, // log export via OTLP "EnableAspNetCoreInstrumentation": true, // incoming HTTP requests "EnableHttpClientInstrumentation": true, // outgoing HttpClient requests - "EnableSqlClientInstrumentation": false, // SQL calls (opt-in) "EnableRuntimeInstrumentation": true, // GC, memory, thread pool metrics "RecordExceptions": true, // exception stack traces on spans "ExcludedPaths": [ "/health" ], // paths excluded from tracing @@ -201,10 +351,25 @@ Every key with its **default** value (only `Enabled` and `Endpoint` are required --- +## 🧪 Samples + +Two runnable samples live under [`src/`](./src): + +| Sample | Project | Demonstrates | +|---|---|---| +| **Web API** | [`…Sample.WebApi`](./src/OpenTelemetryExtension.Configuration.Sample.WebApi) | ASP.NET Core minimal API configured from `appsettings.json`, ready-to-run backend profiles, EF Core and opt-in SQL instrumentation. | +| **WPF** | [`…Sample.Wpf`](./src/OpenTelemetryExtension.Configuration.Sample.Wpf) | Desktop app wiring `AddTelemetry()` through the **Generic Host**, emitting a custom `ActivitySource`/`Meter` and an `HttpClient` span on a button click. | + +The Web API sample drives the backend walkthrough below; the WPF sample exports +to `http://localhost:4318` by default — point it at any of the backends here. + +--- + ## 🔌 Running Locally with a Backend -The [sample project](./src/OpenTelemetryExtension.Configuration.Sample) ships a -ready-to-run configuration for every supported backend. Each backend has: +The [Web API sample](./src/OpenTelemetryExtension.Configuration.Sample.WebApi) ships +ready-to-run configurations for several popular backends (the three below are +documented in full; more start scripts live in [`infrastructure/`](./infrastructure)). Each backend has: 1. an **infrastructure start script** (Docker Compose or Helm) in [`infrastructure/`](./infrastructure), 2. a **launch profile** that selects the matching `appsettings..json`, @@ -217,7 +382,7 @@ ready-to-run configuration for every supported backend. Each backend has: - Helm scripts live in [`infrastructure/helm`](./infrastructure/helm) and need a local Kubernetes cluster (e.g. k3s in WSL2). 2. **Run the sample** with the matching profile: ```bash - cd src/OpenTelemetryExtension.Configuration.Sample + cd src/OpenTelemetryExtension.Configuration.Sample.WebApi dotnet run --launch-profile "Start Aspire" ``` Or pick the profile from the run dropdown in Visual Studio / Rider. @@ -239,7 +404,7 @@ ready-to-run configuration for every supported backend. Each backend has: --- -## ⚙️ Backend Configurations +## 📝 Sample Backend Configurations These are the exact `appsettings..json` files used by the sample's launch profiles. @@ -260,7 +425,7 @@ gRPC endpoint is exposed on NodePort `31889` (Helm) or host port `31889` (Docker Traces, metrics and logs from the sample app shown live in the Aspire Dashboard UI: -![.NET Aspire Dashboard](./assets/Aspire-Dashboard.webp) +![](./assets/Aspire-Dashboard.webp) ### Jaeger — `appsettings.jaeger.json` @@ -275,7 +440,7 @@ Traces, metrics and logs from the sample app shown live in the Aspire Dashboard Traces from the sample app shown in the Jaeger UI: -![Jaeger](./assets/Jaeger.webp) +![](./assets/Jaeger.webp) ### OpenObserve — HTTP/protobuf — `appsettings.openobserve-http.json` @@ -291,7 +456,7 @@ Traces from the sample app shown in the Jaeger UI: The same telemetry explored in the OpenObserve UI: -![OpenObserve](./assets/OpenObserve.webp) +![](./assets/OpenObserve.webp) --- diff --git a/release-notes/v2.0.0.md b/release-notes/v2.0.0.md new file mode 100644 index 0000000..658d04a --- /dev/null +++ b/release-notes/v2.0.0.md @@ -0,0 +1,20 @@ +# v2.0.0 + +_Released: 2026-06-10_ + +## Added + +- **`AdditionalTracingSources`** — extra `ActivitySource` names to collect traces from, registered via `AddSource`. Enables source-based instrumentation (e.g. `Npgsql`, `MySqlConnector`, your own app sources) purely from `appsettings.json` — no code required. +- **`AdditionalMeters`** — extra `Meter` names to collect metrics from, registered via `AddMeter`. +- **Custom configuration section** — the `AddTelemetry(IConfiguration)` overloads accept an optional `sectionName` parameter to bind a section other than the default `"Telemetry"`. + +## Changed + +- **`Enabled` now defaults to `true`** — telemetry is on out of the box; set `"Enabled": false` to turn it off. +- **The `netstandard2.0` build no longer pulls in ASP.NET Core dependencies** — `OpenTelemetry.Instrumentation.AspNetCore` is only referenced by the `net10.0` target, so WPF and console consumers stay lean. +- The local OpenObserve backend setups (Helm chart, Docker Compose) ship with a fixed Basic Auth header so the README examples work copy-paste. +- Updated all NuGet dependencies to their latest versions. + +## Removed + +- **`EnableSqlClientInstrumentation`** and the built-in `OpenTelemetry.Instrumentation.SqlClient` dependency. Database instrumentation is driver-specific and is now added via the `ConfigureTracing` hook (e.g. `AddSqlClientInstrumentation()`, `AddEntityFrameworkCoreInstrumentation()`) or, for drivers with a built-in `ActivitySource`, via `AdditionalTracingSources` — see the README's *Databases* section for migration examples. diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/OpenTelemetryExtension.Configuration.IntegrationTests.csproj b/src/OpenTelemetryExtension.Configuration.IntegrationTests/OpenTelemetryExtension.Configuration.IntegrationTests.csproj new file mode 100644 index 0000000..710f722 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/OpenTelemetryExtension.Configuration.IntegrationTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/SqlServerInstrumentationTests.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/SqlServerInstrumentationTests.cs new file mode 100644 index 0000000..4027beb --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/SqlServerInstrumentationTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Data.SqlClient; +using OpenTelemetry.Trace; +using OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +namespace OpenTelemetryExtension.Configuration.IntegrationTests; + +[Trait("Category", "Integration")] +public sealed class SqlServerInstrumentationTests +{ + private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(40); + + [SqlIntegrationFact] + public async Task SqlServer_CommandSpans_AreExported_AndQueryableInOpenObserve() + { + var runId = Guid.NewGuid().ToString("N"); + var serviceName = $"itest-sql-{runId}"; + + using (var host = new OtelTestHost(o => + { + o.ServiceName = serviceName; + o.EnableMetrics = false; + o.EnableLogging = false; + o.ConfigureTracing = tracing => tracing.AddSqlClientInstrumentation(); + })) + { + await using var connection = new SqlConnection(IntegrationConfig.SqlConnectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT '{runId}' AS run_id"; + _ = await command.ExecuteScalarAsync(); + + host.Flush(); + } + + using var client = new OpenObserveClient(); + + var spans = await client.PollUntilAsync( + "traces", + $"SELECT COUNT(*) AS c FROM \"default\" WHERE service_name = '{serviceName}'", + QueryTimeout); + Assert.True(spans > 0, $"Expected a SQL Server span for service '{serviceName}' in OpenObserve."); + + // Instrumentation emits the current database semantic conventions: + // db.system.name = "microsoft.sql_server" (flattened to db_system_name in OpenObserve). + var sqlServerSpans = await client.PollUntilAsync( + "traces", + $"SELECT COUNT(*) AS c FROM \"default\" WHERE service_name = '{serviceName}' AND db_system_name = 'microsoft.sql_server'", + TimeSpan.FromSeconds(10)); + Assert.True(sqlServerSpans > 0, $"Expected the SQL Server span to carry db_system_name = 'microsoft.sql_server' for service '{serviceName}'."); + } +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/TelemetryIntegrationTests.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/TelemetryIntegrationTests.cs new file mode 100644 index 0000000..710dd35 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/TelemetryIntegrationTests.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +namespace OpenTelemetryExtension.Configuration.IntegrationTests; + +[Trait("Category", "Integration")] +public sealed class TelemetryIntegrationTests +{ + private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(40); + + [IntegrationFact] + public async Task Traces_AreExported_AndQueryableInOpenObserve() + { + var runId = Guid.NewGuid().ToString("N"); + var serviceName = $"itest-traces-{runId}"; + var sourceName = $"Itest.Traces.{runId}"; + + using (var host = new OtelTestHost(o => + { + o.ServiceName = serviceName; + o.EnableMetrics = false; + o.EnableLogging = false; + o.AdditionalTracingSources = [sourceName]; + })) + { + using var source = new ActivitySource(sourceName); + using (var activity = source.StartActivity("integration-span")) + { + activity?.SetTag("itest.run", runId); + } + + host.Flush(); + } + + using var client = new OpenObserveClient(); + var count = await client.PollUntilAsync( + "traces", + $"SELECT COUNT(*) AS c FROM \"default\" WHERE service_name = '{serviceName}'", + QueryTimeout); + + Assert.True(count > 0, $"Expected at least one trace for service '{serviceName}' in OpenObserve."); + } + + [IntegrationFact] + public async Task Metrics_AreExported_AndQueryableInOpenObserve() + { + var runId = Guid.NewGuid().ToString("N"); + var serviceName = $"itest-metrics-{runId}"; + var meterName = $"Itest.Metrics.{runId}"; + var counterName = $"itest_counter_{runId}"; // becomes the OpenObserve metrics stream name + + using (var host = new OtelTestHost(o => + { + o.ServiceName = serviceName; + o.EnableTracing = false; + o.EnableLogging = false; + o.AdditionalMeters = [meterName]; + })) + { + using var meter = new Meter(meterName); + var counter = meter.CreateCounter(counterName); + counter.Add(3); + + host.Flush(); + } + + using var client = new OpenObserveClient(); + var count = await client.PollUntilAsync( + "metrics", + $"SELECT COUNT(*) AS c FROM \"{counterName}\"", + QueryTimeout); + + Assert.True(count > 0, $"Expected the metrics stream '{counterName}' to contain data points in OpenObserve."); + } + + [IntegrationFact] + public async Task Logs_AreExported_AndQueryableInOpenObserve() + { + var runId = Guid.NewGuid().ToString("N"); + var serviceName = $"itest-logs-{runId}"; + + using (var host = new OtelTestHost(o => + { + o.ServiceName = serviceName; + o.EnableTracing = false; + o.EnableMetrics = false; + })) + { + var logger = host.CreateLogger(); + logger.LogInformation("integration log {RunId}", runId); + + host.Flush(); + } + + using var client = new OpenObserveClient(); + var count = await client.PollUntilAsync( + "logs", + $"SELECT COUNT(*) AS c FROM \"default\" WHERE service_name = '{serviceName}'", + QueryTimeout); + + Assert.True(count > 0, $"Expected at least one log record for service '{serviceName}' in OpenObserve."); + } +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationConfig.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationConfig.cs new file mode 100644 index 0000000..87f43a4 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationConfig.cs @@ -0,0 +1,28 @@ +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +internal static class IntegrationConfig +{ + public static string OpenObserveBaseUrl => + Environment.GetEnvironmentVariable("OTEL_IT_OPENOBSERVE_URL") ?? "http://localhost:30117/api/default"; + + public static string OpenObserveUser => + Environment.GetEnvironmentVariable("OTEL_IT_OPENOBSERVE_USER") ?? "admin@web.de"; + + public static string OpenObservePassword => + Environment.GetEnvironmentVariable("OTEL_IT_OPENOBSERVE_PASSWORD") ?? "admin"; + + // OTLP exporter auth + log stream routing. Base64 decodes to "admin@web.de:admin". + public static string OtlpHeaders => + Environment.GetEnvironmentVariable("OTEL_IT_OTLP_HEADERS") + ?? "Authorization=Basic YWRtaW5Ad2ViLmRlOmFkbWlu,stream-name=default"; + + public static Uri OtlpEndpoint => new(OpenObserveBaseUrl); + + public static string SqlConnectionString => + Environment.GetEnvironmentVariable("OTEL_IT_SQL_CONNECTION") + ?? "Server=localhost,31433;Database=master;User Id=sa;Password=YourStrongPassword123!;TrustServerCertificate=True;Encrypt=False"; + + public static (string Host, int Port) OpenObserveEndpoint => ("localhost", 30117); + + public static (string Host, int Port) SqlServerEndpoint => ("localhost", 31433); +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs new file mode 100644 index 0000000..cd78a67 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs @@ -0,0 +1,30 @@ +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +// A Fact that skips itself when the OpenObserve backend is not reachable, so the +// suite stays green on machines without the telemetry stack running. +public sealed class IntegrationFactAttribute : FactAttribute +{ + public IntegrationFactAttribute() + { + if (!Reachability.OpenObserveAvailable) + { + Skip = "OpenObserve is not reachable on localhost:30117 — start it via infrastructure/helm/helm-install-openobserve.cmd."; + } + } +} + +// Like IntegrationFact, but additionally requires a reachable SQL Server. +public sealed class SqlIntegrationFactAttribute : FactAttribute +{ + public SqlIntegrationFactAttribute() + { + if (!Reachability.OpenObserveAvailable) + { + Skip = "OpenObserve is not reachable on localhost:30117 — start it via infrastructure/helm/helm-install-openobserve.cmd."; + } + else if (!Reachability.SqlServerAvailable) + { + Skip = "SQL Server is not reachable on localhost:31433 — start it via infrastructure/helm/helm-install-sqlserver.cmd."; + } + } +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OpenObserveClient.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OpenObserveClient.cs new file mode 100644 index 0000000..9dbd7fe --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OpenObserveClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +// Thin wrapper around the OpenObserve _search API used to assert that emitted +// telemetry actually arrived. The API needs the root user credentials, not the +// OTLP ingestion passcode. +internal sealed class OpenObserveClient : IDisposable +{ + private const long OneHourMicros = 3_600_000_000L; + + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(20) }; + + public OpenObserveClient() + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{IntegrationConfig.OpenObserveUser}:{IntegrationConfig.OpenObservePassword}")); + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + public async Task CountAsync(string streamType, string sql, CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000; + var payload = new { query = new { sql, start_time = now - OneHourMicros, end_time = now, size = 1 } }; + + using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var response = await _http.PostAsync( + $"{IntegrationConfig.OpenObserveBaseUrl}/_search?type={streamType}", content, ct); + + // A missing stream (no data yet) answers non-2xx; treat it as "nothing there yet". + if (!response.IsSuccessStatusCode) + { + return 0; + } + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + if (!doc.RootElement.TryGetProperty("hits", out var hits) + || hits.ValueKind != JsonValueKind.Array + || hits.GetArrayLength() == 0) + { + return 0; + } + + return hits[0].TryGetProperty("c", out var c) && c.TryGetInt64(out var value) ? value : 0; + } + + public async Task PollUntilAsync(string streamType, string sql, TimeSpan timeout, CancellationToken ct = default) + { + var deadline = DateTime.UtcNow + timeout; + long count = 0; + + while (DateTime.UtcNow < deadline) + { + count = await CountAsync(streamType, sql, ct); + if (count > 0) + { + return count; + } + + await Task.Delay(2000, ct); + } + + return count; + } + + public void Dispose() => _http.Dispose(); +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OtelTestHost.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OtelTestHost.cs new file mode 100644 index 0000000..d015317 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/OtelTestHost.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +// Builds a service provider wired up through the library's AddTelemetry() against +// the OpenObserve OTLP endpoint, and exposes a force-flush so a test can assert +// straight afterwards instead of waiting for the batch interval. +internal sealed class OtelTestHost : IDisposable +{ + private readonly ServiceProvider _provider; + + public OtelTestHost(Action configure) + { + var services = new ServiceCollection(); + services.AddTelemetry(o => + { + o.Protocol = OtlpExportProtocol.HttpProtobuf; + o.Endpoint = IntegrationConfig.OtlpEndpoint; + o.Headers = IntegrationConfig.OtlpHeaders; + o.EnableAspNetCoreInstrumentation = false; + o.EnableHttpClientInstrumentation = false; + o.EnableRuntimeInstrumentation = false; + configure(o); + }); + + _provider = services.BuildServiceProvider(); + + // Resolve the providers so the SDK starts listening before telemetry is emitted. + _ = _provider.GetService(); + _ = _provider.GetService(); + } + + public ILogger CreateLogger() => _provider.GetRequiredService>(); + + public void Flush() + { + _provider.GetService()?.ForceFlush(15_000); + _provider.GetService()?.ForceFlush(15_000); + _provider.GetService()?.ForceFlush(15_000); + } + + public void Dispose() + { + Flush(); + _provider.Dispose(); + } +} diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/Reachability.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/Reachability.cs new file mode 100644 index 0000000..a5c3768 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/Reachability.cs @@ -0,0 +1,28 @@ +using System.Net.Sockets; + +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +internal static class Reachability +{ + public static bool IsTcpOpen(string host, int port, int timeoutMs = 1500) + { + try + { + using var client = new TcpClient(); + return client.ConnectAsync(host, port).Wait(timeoutMs) && client.Connected; + } + catch + { + return false; + } + } + + private static bool? _openObserve; + private static bool? _sqlServer; + + public static bool OpenObserveAvailable => + _openObserve ??= IsTcpOpen(IntegrationConfig.OpenObserveEndpoint.Host, IntegrationConfig.OpenObserveEndpoint.Port); + + public static bool SqlServerAvailable => + _sqlServer ??= IsTcpOpen(IntegrationConfig.SqlServerEndpoint.Host, IntegrationConfig.SqlServerEndpoint.Port); +} diff --git a/src/OpenTelemetryExtension.Configuration.Sample/AppDbContext.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/AppDbContext.cs similarity index 80% rename from src/OpenTelemetryExtension.Configuration.Sample/AppDbContext.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/AppDbContext.cs index fa17968..2cd9ade 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/AppDbContext.cs +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/AppDbContext.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -namespace OpenTelemetryExtension.Configuration.Sample; +namespace OpenTelemetryExtension.Configuration.Sample.WebApi; public class AppDbContext : DbContext { diff --git a/src/OpenTelemetryExtension.Configuration.Sample/Migrations/20260526114252_Initial.Designer.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/20260526114252_Initial.Designer.cs similarity index 96% rename from src/OpenTelemetryExtension.Configuration.Sample/Migrations/20260526114252_Initial.Designer.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/20260526114252_Initial.Designer.cs index 7c63d78..8b1a258 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/Migrations/20260526114252_Initial.Designer.cs +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/20260526114252_Initial.Designer.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OpenTelemetryExtension.Configuration.Sample; +using OpenTelemetryExtension.Configuration.Sample.WebApi; diff --git a/src/OpenTelemetryExtension.Configuration.Sample/Migrations/20260526114252_Initial.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/20260526114252_Initial.cs similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/Migrations/20260526114252_Initial.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/20260526114252_Initial.cs diff --git a/src/OpenTelemetryExtension.Configuration.Sample/Migrations/AppDbContextModelSnapshot.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/AppDbContextModelSnapshot.cs similarity index 96% rename from src/OpenTelemetryExtension.Configuration.Sample/Migrations/AppDbContextModelSnapshot.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/AppDbContextModelSnapshot.cs index 8bacae4..ba2a6b3 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/Migrations/AppDbContextModelSnapshot.cs +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Migrations/AppDbContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OpenTelemetryExtension.Configuration.Sample; +using OpenTelemetryExtension.Configuration.Sample.WebApi; diff --git a/src/OpenTelemetryExtension.Configuration.Sample/OpenTelemetryExtension.Configuration.Sample.csproj b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj similarity index 92% rename from src/OpenTelemetryExtension.Configuration.Sample/OpenTelemetryExtension.Configuration.Sample.csproj rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj index a6ed828..cbb2135 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/OpenTelemetryExtension.Configuration.Sample.csproj +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj @@ -10,15 +10,15 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/OpenTelemetryExtension.Configuration.Sample/Program.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Program.cs similarity index 78% rename from src/OpenTelemetryExtension.Configuration.Sample/Program.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/Program.cs index de22d03..3ee86ab 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/Program.cs +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Program.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; +using OpenTelemetry.Trace; -namespace OpenTelemetryExtension.Configuration.Sample; +namespace OpenTelemetryExtension.Configuration.Sample.WebApi; public class Program { @@ -9,7 +10,16 @@ public static void Main(string[] args) var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); - builder.Services.AddTelemetry(builder.Configuration); + + // EnableSqlClientInstrumentation is a custom key — SqlClient is optional and not part of TelemetryOptions. + builder.Services.AddTelemetry(builder.Configuration, o => + o.ConfigureTracing = tracing => + { + if (builder.Configuration.GetValue("Telemetry:EnableSqlClientInstrumentation")) + { + tracing.AddSqlClientInstrumentation(sql => sql.RecordException = o.RecordExceptions); + } + }); builder.Services.AddHealthChecks(); builder.Services.AddAuthorization(); @@ -46,6 +56,7 @@ public static void Main(string[] args) logger.LogWarning("Get Weatherforecast endpoint called"); await Task.Delay(TimeSpan.FromSeconds(10)); + logger.LogWarning("Demo response delay of 10 seconds completed"); var entities = await db.WeatherForecasts.ToListAsync(); return Results.Ok(entities); diff --git a/src/OpenTelemetryExtension.Configuration.Sample/Properties/launchSettings.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/Properties/launchSettings.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/Properties/launchSettings.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/Properties/launchSettings.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/WeatherForecast.cs b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/WeatherForecast.cs similarity index 80% rename from src/OpenTelemetryExtension.Configuration.Sample/WeatherForecast.cs rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/WeatherForecast.cs index ea57614..3b5a92c 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/WeatherForecast.cs +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/WeatherForecast.cs @@ -1,4 +1,4 @@ -namespace OpenTelemetryExtension.Configuration.Sample; +namespace OpenTelemetryExtension.Configuration.Sample.WebApi; public class WeatherForecast { diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.aspire.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.aspire.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.aspire.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.aspire.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.jaeger.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.jaeger.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.jaeger.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.jaeger.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.json similarity index 88% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.json index b466d98..c44926b 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.json +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.json @@ -10,8 +10,8 @@ "EnableLogging": true, "EnableAspNetCoreInstrumentation": true, "EnableHttpClientInstrumentation": true, - "EnableSqlClientInstrumentation": true, - "EnableRuntimeInstrumentation": true + "EnableRuntimeInstrumentation": true, + "EnableSqlClientInstrumentation": true }, "Logging": { "LogLevel": { diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.loki.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.loki.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.loki.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.loki.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.openobserve-grpc.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.openobserve-grpc.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.openobserve-grpc.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.openobserve-grpc.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.openobserve-http.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.openobserve-http.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.openobserve-http.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.openobserve-http.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.opensearch.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.opensearch.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.opensearch.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.opensearch.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample/appsettings.signoz.json b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.signoz.json similarity index 100% rename from src/OpenTelemetryExtension.Configuration.Sample/appsettings.signoz.json rename to src/OpenTelemetryExtension.Configuration.Sample.WebApi/appsettings.signoz.json diff --git a/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml new file mode 100644 index 0000000..49da2a2 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml @@ -0,0 +1,5 @@ + + + diff --git a/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml.cs b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml.cs new file mode 100644 index 0000000..df620cb --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/App.xaml.cs @@ -0,0 +1,36 @@ +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenTelemetryExtension.Configuration; + +namespace OpenTelemetryExtension.Configuration.Sample.Wpf; + +public partial class App : Application +{ + private IHost? _host; + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddTelemetry(builder.Configuration); + builder.Services.AddSingleton(); + + _host = builder.Build(); + await _host.StartAsync(); + + _host.Services.GetRequiredService().Show(); + } + + protected override async void OnExit(ExitEventArgs e) + { + if (_host is not null) + { + await _host.StopAsync(); + _host.Dispose(); + } + + base.OnExit(e); + } +} diff --git a/src/OpenTelemetryExtension.Configuration.Sample.Wpf/MainWindow.xaml b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/MainWindow.xaml new file mode 100644 index 0000000..abd36a2 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.Sample.Wpf/MainWindow.xaml @@ -0,0 +1,15 @@ + + + + +