From 4a4e44a2f886620e9127a84ae07f10b8c971392e Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sat, 8 Aug 2026 15:23:17 +0000 Subject: [PATCH 1/4] Add lightweight OpenTelemetry signals sample --- .../OpenTelemetrySignalsMinimal.slnx | 4 + .../observability-opentelemetry/README.md | 124 +++++++++ .../Models/CheckoutModels.cs | 21 ++ .../OpenTelemetrySignalsMinimal.csproj | 16 ++ .../OpenTelemetrySignalsMinimal/Program.cs | 175 ++++++++++++ .../Telemetry/CheckoutLog.cs | 21 ++ .../Telemetry/CheckoutTelemetry.cs | 78 ++++++ .../OpenTelemetrySignalsMinimal.Tests.csproj | 24 ++ .../OpenTelemetrySignalsTests.cs | 263 ++++++++++++++++++ 9 files changed, 726 insertions(+) create mode 100644 dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx create mode 100644 dotnet-8-essentials/observability-opentelemetry/README.md create mode 100644 dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Models/CheckoutModels.cs create mode 100644 dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj create mode 100644 dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Program.cs create mode 100644 dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutLog.cs create mode 100644 dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutTelemetry.cs create mode 100644 dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsMinimal.Tests.csproj create mode 100644 dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs diff --git a/dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx b/dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx new file mode 100644 index 0000000..9e9de43 --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/README.md b/dotnet-8-essentials/observability-opentelemetry/README.md new file mode 100644 index 0000000..7dedc03 --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/README.md @@ -0,0 +1,124 @@ +# OpenTelemetry Correlated Signals — Minimal .NET 10 Companion + +> Full tutorial: [.NET 8 Observability with OpenTelemetry: Tracing, Metrics & Structured Logging](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/observability-opentelemetry/) + +## What this sample demonstrates + +- ASP.NET Core Minimal API with OpenTelemetry 1.17.0 +- Custom `ActivitySource` for manual tracing via `Checkout.Process` spans +- Custom `Meter` with a `counter` (`checkout.requests`) and a `histogram` (`checkout.duration`) +- Low-cardinality metric tags (`checkout.channel`, `checkout.outcome`) +- Source-generated structured `ILogger` events via `LoggerMessage` +- Automatic log-to-trace correlation from the active `Activity` +- Learning-only OpenTelemetry Console Exporter output +- Input validation with rejection telemetry +- `ActivityListener` and `MeterListener` unit tests +- Deterministic API smoke tests without external backends + +## Architecture + +``` +POST /checkout + ↓ +ASP.NET Core request Activity + ↓ +custom Checkout.Process Activity + ↓ +structured ILogger event + ↓ +checkout.requests counter + ↓ +checkout.duration histogram + ↓ +OpenTelemetry SDK + ↓ +Console Exporter +``` + +## File structure + +``` +dotnet-8-essentials/observability-opentelemetry/ +├── OpenTelemetrySignalsMinimal.slnx +├── README.md +├── src/OpenTelemetrySignalsMinimal/ +│ ├── OpenTelemetrySignalsMinimal.csproj +│ ├── Program.cs +│ ├── Models/ +│ │ └── CheckoutModels.cs +│ └── Telemetry/ +│ ├── CheckoutLog.cs +│ └── CheckoutTelemetry.cs +└── tests/OpenTelemetrySignalsMinimal.Tests/ + ├── OpenTelemetrySignalsMinimal.Tests.csproj + └── OpenTelemetrySignalsTests.cs +``` + +## Prerequisites + +- .NET 10.0 SDK or later +- No Docker, collector, or external backend required + +## Run + +```bash +cd dotnet-8-essentials/observability-opentelemetry +dotnet restore OpenTelemetrySignalsMinimal.slnx +dotnet build --configuration Release +dotnet run --project src/OpenTelemetrySignalsMinimal --configuration Release --urls http://127.0.0.1:5099 +``` + +## Verify + +```bash +# Root endpoint — fixed metadata +curl --silent http://127.0.0.1:5099/ +# {"sample":"correlated-opentelemetry-signals","exporter":"console","activitySource":"DotNetGuide.Observability.Checkout","meter":"DotNetGuide.Observability.Checkout"} + +# Successful checkout — returns trace ID +curl --silent --request POST \ + --header "Content-Type: application/json" \ + --data '{"channel":"WEB","itemCount":2}' \ + http://127.0.0.1:5099/checkout +# {"status":"accepted","channel":"web","itemCount":2,"traceId":"<32 hex chars>"} + +# Invalid channel — returns 400 +curl --silent --request POST \ + --header "Content-Type: application/json" \ + --data '{"channel":"customer-92823","itemCount":2}' \ + http://127.0.0.1:5099/checkout + +# Out-of-range item count — returns 400 +curl --silent --request POST \ + --header "Content-Type: application/json" \ + --data '{"channel":"mobile","itemCount":11}' \ + http://127.0.0.1:5099/checkout +``` + +## Tests + +```bash +dotnet test --configuration Release --no-build +``` + +## Important boundary + +- **Console Exporter** is for learning and debugging only. It is **not** recommended for production use, and its text output is not a standardized transport contract. +- OTLP, Collector, Docker Compose, Jaeger, Prometheus, Grafana, and cloud exporters are intentionally excluded. +- EF Core, HttpClient instrumentation, external service calls, queues, sampling, baggage, and health checks are not included. +- The full tutorial at [dotnet-guide.com](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/observability-opentelemetry/) covers production observability architecture with OTLP → Collector → backends. + +## Verification table + +| Item | Value | +|------|-------| +| Target framework | `net10.0` | +| OpenTelemetry.Extensions.Hosting | 1.17.0 | +| OpenTelemetry.Exporter.Console | 1.17.0 | +| OpenTelemetry.Instrumentation.AspNetCore | 1.17.0 | +| External services | None | +| Last reviewed | 2026-08-08 | + +## License + +This sample is provided for educational purposes as part of the [DOTNET GUIDE](https://www.dotnet-guide.com) tutorial series. \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Models/CheckoutModels.cs b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Models/CheckoutModels.cs new file mode 100644 index 0000000..9bfe5d4 --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Models/CheckoutModels.cs @@ -0,0 +1,21 @@ +namespace OpenTelemetrySignalsMinimal.Models; + +public sealed record SampleInfo( + string Sample, + string Exporter, + string ActivitySource, + string Meter); + +public sealed record CheckoutRequest( + string? Channel, + int ItemCount); + +public sealed record CheckoutResponse( + string Status, + string Channel, + int ItemCount, + string TraceId); + +public sealed record ApiError( + string Code, + string Message); \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj new file mode 100644 index 0000000..6e6281d --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Program.cs b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Program.cs new file mode 100644 index 0000000..66ed4b2 --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Program.cs @@ -0,0 +1,175 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Http.HttpResults; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetrySignalsMinimal.Models; +using OpenTelemetrySignalsMinimal.Telemetry; + +const string serviceName = + "DotNetGuide.ObservabilityLab"; + +const string serviceVersion = + "1.0.0"; + +WebApplicationBuilder builder = + WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); + +builder.Services + .AddOpenTelemetry() + .ConfigureResource(resource => + resource.AddService( + serviceName: serviceName, + serviceVersion: serviceVersion)) + .WithTracing(tracing => + tracing + .AddSource( + CheckoutTelemetry.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddConsoleExporter()) + .WithMetrics(metrics => + metrics + .AddMeter( + CheckoutTelemetry.MeterName) + .AddAspNetCoreInstrumentation() + .AddConsoleExporter((_, readerOptions) => + { + readerOptions + .PeriodicExportingMetricReaderOptions + .ExportIntervalMilliseconds = 1_000; + })); + +builder.Logging.ClearProviders(); + +builder.Logging.AddOpenTelemetry(logging => +{ + logging.SetResourceBuilder( + ResourceBuilder + .CreateDefault() + .AddService( + serviceName: serviceName, + serviceVersion: serviceVersion)); + + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.AddConsoleExporter(); +}); + +WebApplication app = + builder.Build(); + +app.MapGet("/", () => + TypedResults.Ok( + new SampleInfo( + Sample: "correlated-opentelemetry-signals", + Exporter: "console", + ActivitySource: CheckoutTelemetry.ActivitySourceName, + Meter: CheckoutTelemetry.MeterName))); + +app.MapPost( + "/checkout", + ( + CheckoutRequest request, + CheckoutTelemetry telemetry, + ILogger logger) => + HandleCheckout( + request, + telemetry, + logger)); + +app.Run(); + +static Results, BadRequest> + HandleCheckout( + CheckoutRequest request, + CheckoutTelemetry telemetry, + ILogger logger) +{ + string channel = + request.Channel? + .Trim() + .ToLowerInvariant() + ?? ""; + + if (channel is not ("web" or "mobile")) + { + telemetry.Record( + "unknown", + "rejected", + 0); + + logger.CheckoutRejected( + "invalid_channel"); + + return TypedResults.BadRequest( + new ApiError( + "CHANNEL_INVALID", + "Channel must be 'web' or 'mobile'.")); + } + + if (request.ItemCount is < 1 or > 10) + { + telemetry.Record( + channel, + "rejected", + 0); + + logger.CheckoutRejected( + "invalid_item_count"); + + return TypedResults.BadRequest( + new ApiError( + "ITEM_COUNT_INVALID", + "ItemCount must be between 1 and 10.")); + } + + long started = + Stopwatch.GetTimestamp(); + + using Activity? activity = + telemetry.StartCheckout( + channel, + request.ItemCount); + + activity?.AddEvent( + new ActivityEvent( + "Checkout validated")); + + logger.CheckoutAccepted( + channel, + request.ItemCount); + + double elapsedMilliseconds = + Stopwatch + .GetElapsedTime(started) + .TotalMilliseconds; + + telemetry.Record( + channel, + "accepted", + elapsedMilliseconds); + + activity?.SetTag( + "checkout.outcome", + "accepted"); + + activity?.SetStatus( + ActivityStatusCode.Ok); + + string traceId = + activity?.TraceId.ToString() + ?? Activity.Current?.TraceId.ToString() + ?? ""; + + return TypedResults.Ok( + new CheckoutResponse( + "accepted", + channel, + request.ItemCount, + traceId)); +} + +public partial class Program; \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutLog.cs b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutLog.cs new file mode 100644 index 0000000..015025a --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutLog.cs @@ -0,0 +1,21 @@ +namespace OpenTelemetrySignalsMinimal.Telemetry; + +internal static partial class CheckoutLog +{ + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "Checkout accepted for channel {Channel} with {ItemCount} item(s).")] + public static partial void CheckoutAccepted( + this ILogger logger, + string channel, + int itemCount); + + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Checkout rejected with reason {Reason}.")] + public static partial void CheckoutRejected( + this ILogger logger, + string reason); +} \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutTelemetry.cs b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutTelemetry.cs new file mode 100644 index 0000000..7708abe --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/Telemetry/CheckoutTelemetry.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace OpenTelemetrySignalsMinimal.Telemetry; + +public sealed class CheckoutTelemetry : IDisposable +{ + public const string ActivitySourceName = + "DotNetGuide.Observability.Checkout"; + + public const string MeterName = + "DotNetGuide.Observability.Checkout"; + + private readonly ActivitySource _activitySource = + new(ActivitySourceName, "1.0.0"); + + private readonly Meter _meter = + new(MeterName, "1.0.0"); + + private readonly Counter _requests; + private readonly Histogram _duration; + + public CheckoutTelemetry() + { + _requests = _meter.CreateCounter( + "checkout.requests", + unit: "{request}", + description: "Checkout requests processed by outcome."); + + _duration = _meter.CreateHistogram( + "checkout.duration", + unit: "ms", + description: "Checkout processing duration."); + } + + public Activity? StartCheckout( + string channel, + int itemCount) + { + Activity? activity = + _activitySource.StartActivity( + "Checkout.Process", + ActivityKind.Internal); + + activity?.SetTag( + "checkout.channel", + channel); + + activity?.SetTag( + "checkout.item_count", + itemCount); + + return activity; + } + + public void Record( + string channel, + string outcome, + double durationMilliseconds) + { + TagList tags = new() + { + { "checkout.channel", channel }, + { "checkout.outcome", outcome } + }; + + _requests.Add(1, tags); + _duration.Record( + durationMilliseconds, + tags); + } + + public void Dispose() + { + _activitySource.Dispose(); + _meter.Dispose(); + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsMinimal.Tests.csproj b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsMinimal.Tests.csproj new file mode 100644 index 0000000..a7e22f9 --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsMinimal.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs new file mode 100644 index 0000000..71c016e --- /dev/null +++ b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using OpenTelemetrySignalsMinimal.Models; +using OpenTelemetrySignalsMinimal.Telemetry; +using Xunit; + +namespace OpenTelemetrySignalsMinimal.Tests; + +public sealed class OpenTelemetrySignalsTests + : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public OpenTelemetrySignalsTests( + WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void Custom_activity_uses_stable_name_and_attributes() + { + Activity? captured = null; + + using ActivityListener listener = new() + { + ShouldListenTo = source => + source.Name == CheckoutTelemetry.ActivitySourceName, + SampleUsingParentId = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + Sample = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => + { + if (activity.OperationName == "Checkout.Process") + captured = activity; + } + }; + + ActivitySource.AddActivityListener(listener); + + using ActivitySource source = new( + CheckoutTelemetry.ActivitySourceName); + + using Activity? activity = source.StartActivity( + "Checkout.Process", + ActivityKind.Internal); + + if (activity is not null) + { + activity.SetTag("checkout.channel", "web"); + activity.SetTag("checkout.item_count", 2); + activity.SetTag("checkout.outcome", "accepted"); + activity.SetStatus(ActivityStatusCode.Ok); + } + + Assert.NotNull(captured); + Assert.Equal("Checkout.Process", captured.DisplayName); + Assert.Equal("web", captured.GetTagItem("checkout.channel")); + Assert.Equal(2, captured.GetTagItem("checkout.item_count")); + Assert.Equal("accepted", captured.GetTagItem("checkout.outcome")); + Assert.Equal(ActivityStatusCode.Ok, captured.Status); + } + + [Fact] + public void Counter_uses_only_bounded_metric_tag_keys() + { + List> capturedTags = new(); + + using MeterListener listener = new(); + listener.InstrumentPublished = + (instrument, meterListener) => + { + if (instrument.Meter.Name == + CheckoutTelemetry.MeterName) + { + meterListener.EnableMeasurementEvents(instrument); + } + }; + + listener.SetMeasurementEventCallback( + (instrument, measurement, tags, state) => + { + capturedTags.AddRange(tags.ToArray()); + }); + + using Meter meter = new( + CheckoutTelemetry.MeterName, "1.0.0"); + + Counter counter = meter.CreateCounter( + "checkout.requests"); + + listener.Start(); + + counter.Add(1, + new("checkout.channel", "web"), + new("checkout.outcome", "accepted")); + + Assert.NotEmpty(capturedTags); + + HashSet keys = new( + capturedTags.Select(kvp => kvp.Key)); + + Assert.Equal(2, keys.Count); + Assert.Contains("checkout.channel", keys); + Assert.Contains("checkout.outcome", keys); + + foreach (string key in keys) + { + Assert.DoesNotContain("id", key, + StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Root_returns_fixed_sample_metadata() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + SampleInfo? result = + await client.GetFromJsonAsync( + "/", ct); + + Assert.NotNull(result); + Assert.Equal( + "correlated-opentelemetry-signals", + result.Sample); + Assert.Equal("console", result.Exporter); + Assert.Equal( + CheckoutTelemetry.ActivitySourceName, + result.ActivitySource); + Assert.Equal( + CheckoutTelemetry.MeterName, + result.Meter); + } + + [Fact] + public async Task Checkout_returns_trace_id_for_valid_request() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + using HttpResponseMessage response = + await client.PostAsJsonAsync( + "/checkout", + new CheckoutRequest( + Channel: "WEB", + ItemCount: 2), + ct); + + CheckoutResponse? result = + await response.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + Assert.NotNull(result); + Assert.Equal("accepted", result.Status); + Assert.Equal("web", result.Channel); + Assert.Equal(2, result.ItemCount); + Assert.Matches( + "^[0-9a-fA-F]{32}$", + result.TraceId); + } + + [Fact] + public async Task Checkout_rejects_missing_channel() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/checkout", + new CheckoutRequest( + Channel: null, + ItemCount: 2), + ct); + + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + ApiError? error = + await response.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + Assert.NotNull(error); + Assert.Equal("CHANNEL_INVALID", error.Code); + } + + [Fact] + public async Task Checkout_rejects_unsupported_channel() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/checkout", + new CheckoutRequest( + Channel: "customer-92823", + ItemCount: 2), + ct); + + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + ApiError? error = + await response.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + Assert.NotNull(error); + Assert.Equal("CHANNEL_INVALID", error.Code); + } + + [Fact] + public async Task Checkout_rejects_out_of_range_item_count() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/checkout", + new CheckoutRequest( + Channel: "mobile", + ItemCount: 11), + ct); + + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + ApiError? error = + await response.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + Assert.NotNull(error); + Assert.Equal("ITEM_COUNT_INVALID", error.Code); + } + + [Fact] + public async Task Unknown_route_returns_not_found() + { + CancellationToken ct = TestContext.Current.CancellationToken; + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = + await client.GetAsync("/nonexistent", ct); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + } +} \ No newline at end of file From 957852b84f3ad034c75265922fcd7acdf5275b0a Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sat, 8 Aug 2026 15:23:28 +0000 Subject: [PATCH 2/4] Add OpenTelemetry sample to repository README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b29b9bd..e762b00 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-8-essentials/background-jobs-hostedservice-queues`](dotnet-8-essentials/background-jobs-hostedservice-queues/) | Focused ASP.NET Core Minimal API demonstrating a bounded Channel queue, asynchronous backpressure, a BackgroundService consumer, fresh scoped handlers, safe in-memory job status, exception isolation, cancellation, and integration tests | [.NET 8 Background Jobs: IBackgroundTaskQueue, BackgroundService & Production-Ready Patterns](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/background-jobs-hostedservice-queues/) | | [`dotnet-8-essentials/configuration-secrets-environments`](dotnet-8-essentials/configuration-secrets-environments/) | Focused .NET 10 Minimal API demonstrating layered configuration precedence, prefixed environment variables, command-line overrides, strongly typed options, startup validation, User Secrets metadata, a lightweight feature flag, and safe Development-only diagnostics | [.NET 8 Configuration & Secrets Management: Typed Options, User Secrets & Feature Flags](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/configuration-secrets-environments/) | | [`dotnet-8-essentials/core-features-get-started`](dotnet-8-essentials/core-features-get-started/) | Focused .NET 10 Native AOT Minimal API demonstrating CreateSlimBuilder, source-generated JSON, typed DI, AOT-safe endpoints, analyzer-aware publishing, and direct native-binary smoke testing | [.NET 8 Essentials: Core Features & Getting Started](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/core-features-get-started/) | +| [`dotnet-8-essentials/observability-opentelemetry`](dotnet-8-essentials/observability-opentelemetry/) | Focused .NET 10 OpenTelemetry companion demonstrating ASP.NET Core request instrumentation, custom ActivitySource spans, low-cardinality Meter metrics, structured ILogger events, automatic log-to-trace correlation, console export, and deterministic tests without external observability infrastructure | [.NET 8 Observability with OpenTelemetry: Tracing, Metrics & Structured Logging](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/observability-opentelemetry/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -177,6 +178,7 @@ tutorials/ | |-- ConfigPrecedenceMinimal.Tests.csproj | `-- ConfigurationTests.cs | `-- core-features-get-started/ +| `-- observability-opentelemetry/ | |-- NativeAotEssentialsMinimal.slnx | |-- README.md | |-- src/ From fdd59dff87d11501533921dba3660c702b3eee86 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sat, 8 Aug 2026 15:23:35 +0000 Subject: [PATCH 3/4] Test correlated OpenTelemetry signals in GitHub Actions --- .github/workflows/build-samples.yml | 265 ++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index fcbe88e..8da5289 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -26,6 +26,7 @@ on: - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" - "dotnet-8-essentials/configuration-secrets-environments/**" - "dotnet-8-essentials/core-features-get-started/**" + - "dotnet-8-essentials/observability-opentelemetry/**" - ".github/workflows/build-samples.yml" pull_request: @@ -52,6 +53,7 @@ on: - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" - "dotnet-8-essentials/configuration-secrets-environments/**" - "dotnet-8-essentials/core-features-get-started/**" + - "dotnet-8-essentials/observability-opentelemetry/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1428,3 +1430,266 @@ jobs: )" test "${invalid_status}" = "400" + test-opentelemetry-signals: + name: Test correlated OpenTelemetry signals sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + + - name: Build + run: > + dotnet build + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + --configuration Release + --no-build + + - name: Verify OpenTelemetry package baseline + shell: bash + run: | + project="dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj" + + test "$(grep --count '"${app_log}" 2>&1 & + + app_pid="$!" + + cleanup() { + kill "${app_pid}" 2>/dev/null || true + wait "${app_pid}" 2>/dev/null || true + } + + trap cleanup EXIT + + for attempt in $(seq 1 40); do + if curl --fail --silent \ + http://127.0.0.1:5099/ \ + >"${RUNNER_TEMP}/otel-root.json"; then + break + fi + + if ! kill -0 "${app_pid}" 2>/dev/null; then + cat "${app_log}" + exit 1 + fi + + sleep 0.25 + done + + root="$(cat "${RUNNER_TEMP}/otel-root.json")" + + expected_root='{"sample":"correlated-opentelemetry-signals","exporter":"console","activitySource":"DotNetGuide.Observability.Checkout","meter":"DotNetGuide.Observability.Checkout"}' + + test "${root}" = "${expected_root}" + + checkout="$( + curl --fail --silent \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"WEB","itemCount":2}' \ + http://127.0.0.1:5099/checkout + )" + + printf '%s\n' "${checkout}" | + grep --extended-regexp --quiet \ + '^\{"status":"accepted","channel":"web","itemCount":2,"traceId":"[0-9a-fA-F]{32}"\}$' + + invalid_channel="$( + curl --silent \ + --output "${RUNNER_TEMP}/invalid-channel.json" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"customer-92823","itemCount":2}' \ + http://127.0.0.1:5099/checkout + )" + + test "${invalid_channel}" = "400" + + invalid_count="$( + curl --silent \ + --output "${RUNNER_TEMP}/invalid-count.json" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"mobile","itemCount":11}' \ + http://127.0.0.1:5099/checkout + )" + + test "${invalid_count}" = "400" + + sleep 1.5 + + grep --fixed-strings --quiet 'Checkout.Process' "${app_log}" + grep --fixed-strings --quiet 'checkout.requests' "${app_log}" + grep --fixed-strings --quiet 'Checkout accepted' "${app_log}" + test-opentelemetry-signals: + name: Test correlated OpenTelemetry signals sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + + - name: Build + run: > + dotnet build + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx + --configuration Release + --no-build + + - name: Verify OpenTelemetry package baseline + shell: bash + run: | + project="dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj" + + test "$(grep --count '"${app_log}" 2>&1 & + + app_pid="$!" + + cleanup() { + kill "${app_pid}" 2>/dev/null || true + wait "${app_pid}" 2>/dev/null || true + } + + trap cleanup EXIT + + for attempt in $(seq 1 40); do + if curl --fail --silent \ + http://127.0.0.1:5099/ \ + >"${RUNNER_TEMP}/otel-root.json"; then + break + fi + + if ! kill -0 "${app_pid}" 2>/dev/null; then + cat "${app_log}" + exit 1 + fi + + sleep 0.25 + done + + root="$(cat "${RUNNER_TEMP}/otel-root.json")" + + expected_root='{"sample":"correlated-opentelemetry-signals","exporter":"console","activitySource":"DotNetGuide.Observability.Checkout","meter":"DotNetGuide.Observability.Checkout"}' + + test "${root}" = "${expected_root}" + + checkout="$( + curl --fail --silent \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"WEB","itemCount":2}' \ + http://127.0.0.1:5099/checkout + )" + + printf '%s +' "${checkout}" | + grep --extended-regexp --quiet \ + '^\{"status":"accepted","channel":"web","itemCount":2,"traceId":"[0-9a-fA-F]{32}"\}$' + + invalid_channel="$( + curl --silent \ + --output "${RUNNER_TEMP}/invalid-channel.json" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"customer-92823","itemCount":2}' \ + http://127.0.0.1:5099/checkout + )" + + test "${invalid_channel}" = "400" + + invalid_count="$( + curl --silent \ + --output "${RUNNER_TEMP}/invalid-count.json" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"channel":"mobile","itemCount":11}' \ + http://127.0.0.1:5099/checkout + )" + + test "${invalid_count}" = "400" + + sleep 1.5 + + grep --fixed-strings --quiet 'Checkout.Process' "${app_log}" + grep --fixed-strings --quiet 'checkout.requests' "${app_log}" + grep --fixed-strings --quiet 'Checkout accepted' "${app_log}" From 10289823860640e4251a2d797bb9b34a75a7437f Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sat, 8 Aug 2026 15:30:29 +0000 Subject: [PATCH 4/4] Fix OpenTelemetry CI and test real telemetry sources --- .github/workflows/build-samples.yml | 132 ------------------ .../OpenTelemetrySignalsTests.cs | 50 ++++--- 2 files changed, 27 insertions(+), 155 deletions(-) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 8da5289..9a4836c 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -1558,138 +1558,6 @@ jobs: sleep 1.5 - grep --fixed-strings --quiet 'Checkout.Process' "${app_log}" - grep --fixed-strings --quiet 'checkout.requests' "${app_log}" - grep --fixed-strings --quiet 'Checkout accepted' "${app_log}" - test-opentelemetry-signals: - name: Test correlated OpenTelemetry signals sample - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Check out repository - uses: actions/checkout@v5 - - - name: Install .NET 10 SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: "10.0.x" - - - name: Restore - run: > - dotnet restore - dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx - - - name: Build - run: > - dotnet build - dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx - --configuration Release - --no-restore - - - name: Test - run: > - dotnet test - dotnet-8-essentials/observability-opentelemetry/OpenTelemetrySignalsMinimal.slnx - --configuration Release - --no-build - - - name: Verify OpenTelemetry package baseline - shell: bash - run: | - project="dotnet-8-essentials/observability-opentelemetry/src/OpenTelemetrySignalsMinimal/OpenTelemetrySignalsMinimal.csproj" - - test "$(grep --count '"${app_log}" 2>&1 & - - app_pid="$!" - - cleanup() { - kill "${app_pid}" 2>/dev/null || true - wait "${app_pid}" 2>/dev/null || true - } - - trap cleanup EXIT - - for attempt in $(seq 1 40); do - if curl --fail --silent \ - http://127.0.0.1:5099/ \ - >"${RUNNER_TEMP}/otel-root.json"; then - break - fi - - if ! kill -0 "${app_pid}" 2>/dev/null; then - cat "${app_log}" - exit 1 - fi - - sleep 0.25 - done - - root="$(cat "${RUNNER_TEMP}/otel-root.json")" - - expected_root='{"sample":"correlated-opentelemetry-signals","exporter":"console","activitySource":"DotNetGuide.Observability.Checkout","meter":"DotNetGuide.Observability.Checkout"}' - - test "${root}" = "${expected_root}" - - checkout="$( - curl --fail --silent \ - --request POST \ - --header 'Content-Type: application/json' \ - --data '{"channel":"WEB","itemCount":2}' \ - http://127.0.0.1:5099/checkout - )" - - printf '%s -' "${checkout}" | - grep --extended-regexp --quiet \ - '^\{"status":"accepted","channel":"web","itemCount":2,"traceId":"[0-9a-fA-F]{32}"\}$' - - invalid_channel="$( - curl --silent \ - --output "${RUNNER_TEMP}/invalid-channel.json" \ - --write-out '%{http_code}' \ - --request POST \ - --header 'Content-Type: application/json' \ - --data '{"channel":"customer-92823","itemCount":2}' \ - http://127.0.0.1:5099/checkout - )" - - test "${invalid_channel}" = "400" - - invalid_count="$( - curl --silent \ - --output "${RUNNER_TEMP}/invalid-count.json" \ - --write-out '%{http_code}' \ - --request POST \ - --header 'Content-Type: application/json' \ - --data '{"channel":"mobile","itemCount":11}' \ - http://127.0.0.1:5099/checkout - )" - - test "${invalid_count}" = "400" - - sleep 1.5 - grep --fixed-strings --quiet 'Checkout.Process' "${app_log}" grep --fixed-strings --quiet 'checkout.requests' "${app_log}" grep --fixed-strings --quiet 'Checkout accepted' "${app_log}" diff --git a/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs index 71c016e..5adc8b3 100644 --- a/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs +++ b/dotnet-8-essentials/observability-opentelemetry/tests/OpenTelemetrySignalsMinimal.Tests/OpenTelemetrySignalsTests.cs @@ -42,20 +42,21 @@ public void Custom_activity_uses_stable_name_and_attributes() ActivitySource.AddActivityListener(listener); - using ActivitySource source = new( - CheckoutTelemetry.ActivitySourceName); + using var telemetry = + new CheckoutTelemetry(); - using Activity? activity = source.StartActivity( - "Checkout.Process", - ActivityKind.Internal); + using Activity? activity = + telemetry.StartCheckout( + "web", + 2); - if (activity is not null) - { - activity.SetTag("checkout.channel", "web"); - activity.SetTag("checkout.item_count", 2); - activity.SetTag("checkout.outcome", "accepted"); - activity.SetStatus(ActivityStatusCode.Ok); - } + Assert.NotNull(activity); + + activity.SetTag( + "checkout.outcome", + "accepted"); + activity.SetStatus( + ActivityStatusCode.Ok); Assert.NotNull(captured); Assert.Equal("Checkout.Process", captured.DisplayName); @@ -75,7 +76,9 @@ public void Counter_uses_only_bounded_metric_tag_keys() (instrument, meterListener) => { if (instrument.Meter.Name == - CheckoutTelemetry.MeterName) + CheckoutTelemetry.MeterName && + instrument.Name == + "checkout.requests") { meterListener.EnableMeasurementEvents(instrument); } @@ -84,20 +87,21 @@ public void Counter_uses_only_bounded_metric_tag_keys() listener.SetMeasurementEventCallback( (instrument, measurement, tags, state) => { - capturedTags.AddRange(tags.ToArray()); + if (measurement == 1) + { + capturedTags.AddRange(tags.ToArray()); + } }); - using Meter meter = new( - CheckoutTelemetry.MeterName, "1.0.0"); - - Counter counter = meter.CreateCounter( - "checkout.requests"); - listener.Start(); - counter.Add(1, - new("checkout.channel", "web"), - new("checkout.outcome", "accepted")); + using var telemetry = + new CheckoutTelemetry(); + + telemetry.Record( + channel: "web", + outcome: "accepted", + durationMilliseconds: 12.5); Assert.NotEmpty(capturedTags);