From fcc587c20a976495fb283c7a964c34918a1784c4 Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 18:51:39 +0200 Subject: [PATCH 1/6] fix: build OTLP signal URLs without double slash, report entry-assembly service version, throw ArgumentNullException on null configure --- .../TelemetryOptions.cs | 5 +- .../TelemetryServiceCollectionExtensions.cs | 58 +++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/OpenTelemetryExtension.Configuration/TelemetryOptions.cs b/src/OpenTelemetryExtension.Configuration/TelemetryOptions.cs index d747d0f..44e75e8 100644 --- a/src/OpenTelemetryExtension.Configuration/TelemetryOptions.cs +++ b/src/OpenTelemetryExtension.Configuration/TelemetryOptions.cs @@ -1,12 +1,11 @@ -namespace OpenTelemetryExtension.Configuration; - -using System; using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Logging; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; +namespace OpenTelemetryExtension.Configuration; + /// /// Configuration options for OpenTelemetry logging, tracing and metrics. /// diff --git a/src/OpenTelemetryExtension.Configuration/TelemetryServiceCollectionExtensions.cs b/src/OpenTelemetryExtension.Configuration/TelemetryServiceCollectionExtensions.cs index 2c12786..4113728 100644 --- a/src/OpenTelemetryExtension.Configuration/TelemetryServiceCollectionExtensions.cs +++ b/src/OpenTelemetryExtension.Configuration/TelemetryServiceCollectionExtensions.cs @@ -1,8 +1,4 @@ -namespace OpenTelemetryExtension.Configuration; - -using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Reflection; #if !NETSTANDARD2_0 using Microsoft.AspNetCore.Http; @@ -16,6 +12,8 @@ namespace OpenTelemetryExtension.Configuration; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +namespace OpenTelemetryExtension.Configuration; + /// /// Extension methods for registering OpenTelemetry services on . /// @@ -92,6 +90,7 @@ public static IServiceCollection AddTelemetry(this IServiceCollection services, /// public static IServiceCollection AddTelemetry(this IServiceCollection services, IConfiguration configuration, Action? configure, string? sectionName = null) { + // ! is safe: netstandard2.0's IsNullOrWhiteSpace lacks the [NotNullWhen] annotation var name = string.IsNullOrWhiteSpace(sectionName) ? TelemetryOptions.SectionName : sectionName!; var section = configuration.GetSection(name); if (!section.Exists()) @@ -140,6 +139,11 @@ public static IServiceCollection AddTelemetry(this IServiceCollection services, /// public static IServiceCollection AddTelemetry(this IServiceCollection services, Action configure) { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + var options = new TelemetryOptions(); configure(options); @@ -156,14 +160,14 @@ private static void ConfigureTelemetry(IServiceCollection services, TelemetryOpt return; } - var endpoint = options.Endpoint!; - var serviceVersion = Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion; + var serviceVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion; var builder = services.AddOpenTelemetry() .ConfigureResource(resource => { if (!string.IsNullOrWhiteSpace(options.ServiceName)) { + // ! is safe: netstandard2.0's IsNullOrWhiteSpace lacks the [NotNullWhen] annotation resource.AddService(serviceName: options.ServiceName!, serviceVersion: serviceVersion); } @@ -205,14 +209,7 @@ private static void ConfigureTelemetry(IServiceCollection services, TelemetryOpt options.ConfigureTracing?.Invoke(tracing); - tracing.AddOtlpExporter(exp => - { -#pragma warning disable CS0618 // OtlpExportProtocol.Grpc is intentionally supported; warning only applies to netstandard2.0 without HttpClientFactory - exp.Endpoint = options.Protocol == OtlpExportProtocol.Grpc ? endpoint : new Uri($"{endpoint}/v1/traces"); -#pragma warning restore CS0618 - exp.Protocol = options.Protocol; - exp.Headers = options.Headers; - }); + tracing.AddOtlpExporter(exp => ConfigureOtlpExporter(exp, options, "v1/traces")); }); } @@ -244,14 +241,7 @@ private static void ConfigureTelemetry(IServiceCollection services, TelemetryOpt options.ConfigureMetrics?.Invoke(metrics); - metrics.AddOtlpExporter(exp => - { -#pragma warning disable CS0618 // OtlpExportProtocol.Grpc is intentionally supported; warning only applies to netstandard2.0 without HttpClientFactory - exp.Endpoint = options.Protocol == OtlpExportProtocol.Grpc ? endpoint : new Uri($"{endpoint}/v1/metrics"); -#pragma warning restore CS0618 - exp.Protocol = options.Protocol; - exp.Headers = options.Headers; - }); + metrics.AddOtlpExporter(exp => ConfigureOtlpExporter(exp, options, "v1/metrics")); }); } @@ -266,19 +256,27 @@ private static void ConfigureTelemetry(IServiceCollection services, TelemetryOpt otel.IncludeScopes = options.IncludeScopes; otel.IncludeFormattedMessage = options.IncludeFormattedMessage; - otel.AddOtlpExporter(exp => - { -#pragma warning disable CS0618 // OtlpExportProtocol.Grpc is intentionally supported; warning only applies to netstandard2.0 without HttpClientFactory - exp.Endpoint = options.Protocol == OtlpExportProtocol.Grpc ? endpoint : new Uri($"{endpoint}/v1/logs"); -#pragma warning restore CS0618 - exp.Protocol = options.Protocol; - exp.Headers = options.Headers; - }); + otel.AddOtlpExporter(exp => ConfigureOtlpExporter(exp, options, "v1/logs")); }); }); } } + private static void ConfigureOtlpExporter(OtlpExporterOptions exporter, TelemetryOptions options, string signalPath) + { + // ! is safe: Endpoint is [Required]-validated before ConfigureTelemetry runs +#pragma warning disable CS0618 // OtlpExportProtocol.Grpc is intentionally supported; warning only applies to netstandard2.0 without HttpClientFactory + exporter.Endpoint = options.Protocol == OtlpExportProtocol.Grpc + ? options.Endpoint! + : BuildSignalEndpoint(options.Endpoint!, signalPath); +#pragma warning restore CS0618 + exporter.Protocol = options.Protocol; + exporter.Headers = options.Headers; + } + + internal static Uri BuildSignalEndpoint(Uri endpoint, string signalPath) + => new($"{endpoint.ToString().TrimEnd('/')}/{signalPath}"); + #if !NETSTANDARD2_0 internal static bool ShouldInstrument(PathString path, string[] excludedPaths) { From c5e136bd7bc4ca784847a8f9e176a2105c910dc9 Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 18:52:18 +0200 Subject: [PATCH 2/6] test: strengthen unit tests, drop unused Moq, add exporter URL and validation tests, split SqlIntegrationFactAttribute into own file --- .../Utils/IntegrationFactAttribute.cs | 16 --- .../Utils/SqlIntegrationFactAttribute.cs | 17 +++ ...emetryExtension.Configuration.Tests.csproj | 1 - .../TelemetryOptionsTests.cs | 79 +++--------- ...lemetryServiceCollectionExtensionsTests.cs | 114 +++++++++++++----- 5 files changed, 119 insertions(+), 108 deletions(-) create mode 100644 src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/SqlIntegrationFactAttribute.cs diff --git a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs index cd78a67..b1e2bfb 100644 --- a/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/IntegrationFactAttribute.cs @@ -12,19 +12,3 @@ public IntegrationFactAttribute() } } } - -// 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/SqlIntegrationFactAttribute.cs b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/SqlIntegrationFactAttribute.cs new file mode 100644 index 0000000..dd02918 --- /dev/null +++ b/src/OpenTelemetryExtension.Configuration.IntegrationTests/Utils/SqlIntegrationFactAttribute.cs @@ -0,0 +1,17 @@ +namespace OpenTelemetryExtension.Configuration.IntegrationTests.Utils; + +// 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.Tests/OpenTelemetryExtension.Configuration.Tests.csproj b/src/OpenTelemetryExtension.Configuration.Tests/OpenTelemetryExtension.Configuration.Tests.csproj index 4d03ca6..6020094 100644 --- a/src/OpenTelemetryExtension.Configuration.Tests/OpenTelemetryExtension.Configuration.Tests.csproj +++ b/src/OpenTelemetryExtension.Configuration.Tests/OpenTelemetryExtension.Configuration.Tests.csproj @@ -20,7 +20,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - all diff --git a/src/OpenTelemetryExtension.Configuration.Tests/TelemetryOptionsTests.cs b/src/OpenTelemetryExtension.Configuration.Tests/TelemetryOptionsTests.cs index 72fa1ef..c20aac0 100644 --- a/src/OpenTelemetryExtension.Configuration.Tests/TelemetryOptionsTests.cs +++ b/src/OpenTelemetryExtension.Configuration.Tests/TelemetryOptionsTests.cs @@ -3,7 +3,7 @@ namespace OpenTelemetryExtension.Configuration.Tests; [Trait("Category", "Unit")] -public class TelemetryOptionsTests +public sealed class TelemetryOptionsTests { // ── Default values ──────────────────────────────────────────────────── @@ -99,68 +99,6 @@ public void Defaults_ConfigureMetrics_IsNull() public void Defaults_ConfigureLogging_IsNull() => Assert.Null(new TelemetryOptions().ConfigureLogging); - // ── Property setters ───────────────────────────────────────────────── - - [Fact] - public void Property_Enabled_CanBeSetToFalse() - { - var o = new TelemetryOptions { Enabled = false }; - Assert.False(o.Enabled); - } - - [Fact] - public void Property_Endpoint_CanBeSet() - { - var uri = new Uri("http://localhost:4318"); - var o = new TelemetryOptions { Endpoint = uri }; - Assert.Equal(uri, o.Endpoint); - } - - [Fact] - public void Property_Headers_CanBeSet() - { - var o = new TelemetryOptions { Headers = "x-api-key=abc" }; - Assert.Equal("x-api-key=abc", o.Headers); - } - - [Fact] - public void Property_Protocol_CanBeSetToGrpc() - { - var o = new TelemetryOptions { Protocol = OtlpExportProtocol.Grpc }; - Assert.Equal(OtlpExportProtocol.Grpc, o.Protocol); - } - - [Fact] - public void Property_ServiceName_CanBeSet() - { - var o = new TelemetryOptions { ServiceName = "my-service" }; - Assert.Equal("my-service", o.ServiceName); - } - - [Fact] - public void Property_ConfigureTracing_CanBeSet() - { - Action cb = _ => { }; - var o = new TelemetryOptions { ConfigureTracing = cb }; - Assert.Same(cb, o.ConfigureTracing); - } - - [Fact] - public void Property_ConfigureMetrics_CanBeSet() - { - Action cb = _ => { }; - var o = new TelemetryOptions { ConfigureMetrics = cb }; - Assert.Same(cb, o.ConfigureMetrics); - } - - [Fact] - public void Property_ConfigureLogging_CanBeSet() - { - Action cb = _ => { }; - var o = new TelemetryOptions { ConfigureLogging = cb }; - Assert.Same(cb, o.ConfigureLogging); - } - // ── DataAnnotations validation ──────────────────────────────────────── [Fact] @@ -177,4 +115,19 @@ public void Validation_Fails_WhenEndpointIsNull() Assert.Throws(() => Validator.ValidateObject(o, new ValidationContext(o), validateAllProperties: true)); } + + [Theory] + [InlineData(-0.1)] + [InlineData(1.5)] + public void Validation_Fails_WhenSampleRatioOutOfRange(double sampleRatio) + { + var o = new TelemetryOptions + { + Endpoint = new Uri("http://localhost:4318"), + SampleRatio = sampleRatio, + }; + + Assert.Throws(() => + Validator.ValidateObject(o, new ValidationContext(o), validateAllProperties: true)); + } } diff --git a/src/OpenTelemetryExtension.Configuration.Tests/TelemetryServiceCollectionExtensionsTests.cs b/src/OpenTelemetryExtension.Configuration.Tests/TelemetryServiceCollectionExtensionsTests.cs index 01e174f..bc20895 100644 --- a/src/OpenTelemetryExtension.Configuration.Tests/TelemetryServiceCollectionExtensionsTests.cs +++ b/src/OpenTelemetryExtension.Configuration.Tests/TelemetryServiceCollectionExtensionsTests.cs @@ -5,7 +5,7 @@ namespace OpenTelemetryExtension.Configuration.Tests; [Trait("Category", "Unit")] -public class TelemetryServiceCollectionExtensionsTests +public sealed class TelemetryServiceCollectionExtensionsTests { // ── Helpers ─────────────────────────────────────────────────────────── @@ -111,7 +111,11 @@ public void AddTelemetry_IConfiguration_MapsAllScalarProperties() var services = NewServices(); var result = services.AddTelemetry(config); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Same(services, result); + Assert.Null(ex); } [Fact] @@ -148,6 +152,16 @@ public void AddTelemetry_Action_ThrowsWhenEndpointIsNull() services.AddTelemetry(o => { })); } + [Fact] + public void AddTelemetry_Action_NullConfigure_ThrowsArgumentNullException() + { + var services = NewServices(); + + // ! is deliberate: the null path is exactly what is under test + Assert.Throws(() => + services.AddTelemetry((Action)null!)); + } + [Fact] public void AddTelemetry_Action_Disabled_DoesNotRegisterOtel() { @@ -257,8 +271,10 @@ public void AddTelemetry_AllInstrumentationFlagsOn_DoesNotThrow() public void AddTelemetry_RecordExceptionsFalse_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.RecordExceptions = false))); + services.AddTelemetry(MinimalConfigure(o => o.RecordExceptions = false)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -277,8 +293,10 @@ public void AddTelemetry_ExcludedPathsEmpty_DoesNotThrow() public void AddTelemetry_ExcludedPathsCustom_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.ExcludedPaths = ["/health", "/metrics"]))); + services.AddTelemetry(MinimalConfigure(o => o.ExcludedPaths = ["/health", "/metrics"])); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -298,8 +316,10 @@ public void AddTelemetry_ResourceAttributes_DoesNotThrow() public void AddTelemetry_SampleRatioHalf_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.SampleRatio = 0.5))); + services.AddTelemetry(MinimalConfigure(o => o.SampleRatio = 0.5)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -307,8 +327,10 @@ public void AddTelemetry_SampleRatioHalf_DoesNotThrow() public void AddTelemetry_SampleRatioZero_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.SampleRatio = 0.0))); + services.AddTelemetry(MinimalConfigure(o => o.SampleRatio = 0.0)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -316,8 +338,10 @@ public void AddTelemetry_SampleRatioZero_DoesNotThrow() public void AddTelemetry_IncludeScopesFalse_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.IncludeScopes = false))); + services.AddTelemetry(MinimalConfigure(o => o.IncludeScopes = false)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -325,8 +349,10 @@ public void AddTelemetry_IncludeScopesFalse_DoesNotThrow() public void AddTelemetry_IncludeFormattedMessageFalse_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.IncludeFormattedMessage = false))); + services.AddTelemetry(MinimalConfigure(o => o.IncludeFormattedMessage = false)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -336,8 +362,10 @@ public void AddTelemetry_IncludeFormattedMessageFalse_DoesNotThrow() public void AddTelemetry_WithServiceName_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.ServiceName = "my-api"))); + services.AddTelemetry(MinimalConfigure(o => o.ServiceName = "my-api")); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -423,12 +451,15 @@ public void AddTelemetry_ConfigureLogging_CallbackIsInvoked() public void AddTelemetry_NullCallbacks_DoNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => + services.AddTelemetry(MinimalConfigure(o => { o.ConfigureTracing = null; o.ConfigureMetrics = null; o.ConfigureLogging = null; - }))); + })); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -438,8 +469,10 @@ public void AddTelemetry_NullCallbacks_DoNotThrow() public void AddTelemetry_WithHeaders_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(MinimalConfigure(o => - o.Headers = "Authorization=Bearer token123"))); + services.AddTelemetry(MinimalConfigure(o => o.Headers = "Authorization=Bearer token123")); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -453,7 +486,10 @@ public void AddTelemetry_IConfiguration_GrpcProtocol_DoesNotThrow() ["Telemetry:Protocol"] = "Grpc", }); var services = NewServices(); - var ex = Record.Exception(() => services.AddTelemetry(config)); + services.AddTelemetry(config); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -499,8 +535,9 @@ public void AddTelemetry_IConfiguration_BindsAdditionalSourcesAndMeters() ["Telemetry:AdditionalMeters:0"] = "MyApp.Orders", }); var services = NewServices(); + services.AddTelemetry(config); - var ex = Record.Exception(() => services.AddTelemetry(config)); + var ex = Record.Exception(() => BuildAndResolveProviders(services)); Assert.Null(ex); } @@ -567,8 +604,9 @@ public void AddTelemetry_IConfiguration_CustomSectionName_Binds() ["MyTelemetry:Endpoint"] = "http://localhost:4318", }); var services = NewServices(); + services.AddTelemetry(config, "MyTelemetry"); - var ex = Record.Exception(() => services.AddTelemetry(config, "MyTelemetry")); + var ex = Record.Exception(() => BuildAndResolveProviders(services)); Assert.Null(ex); } @@ -673,8 +711,10 @@ public void AddTelemetry_WithResourceAttributes_RegistersTracerProvider() public void AddTelemetry_NullServiceName_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => - services.AddTelemetry(MinimalConfigure(o => o.ServiceName = null))); + services.AddTelemetry(MinimalConfigure(o => o.ServiceName = null)); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -682,8 +722,10 @@ public void AddTelemetry_NullServiceName_DoesNotThrow() public void AddTelemetry_WhitespaceServiceName_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => - services.AddTelemetry(MinimalConfigure(o => o.ServiceName = " "))); + services.AddTelemetry(MinimalConfigure(o => o.ServiceName = " ")); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } @@ -691,8 +733,24 @@ public void AddTelemetry_WhitespaceServiceName_DoesNotThrow() public void AddTelemetry_EmptyResourceAttributes_DoesNotThrow() { var services = NewServices(); - var ex = Record.Exception(() => - services.AddTelemetry(MinimalConfigure(o => o.ResourceAttributes = []))); + services.AddTelemetry(MinimalConfigure(o => o.ResourceAttributes = [])); + + var ex = Record.Exception(() => BuildAndResolveProviders(services)); + Assert.Null(ex); } + + // ── BuildSignalEndpoint (exporter URL construction) ─────────────────── + + [Theory] + [InlineData("http://localhost:4318", "http://localhost:4318/v1/traces")] + [InlineData("http://localhost:4318/", "http://localhost:4318/v1/traces")] + [InlineData("http://localhost:30117/api/default", "http://localhost:30117/api/default/v1/traces")] + [InlineData("http://localhost:30117/api/default/", "http://localhost:30117/api/default/v1/traces")] + public void BuildSignalEndpoint_AppendsSignalPathWithoutDoubleSlash(string endpoint, string expected) + { + var result = TelemetryServiceCollectionExtensions.BuildSignalEndpoint(new Uri(endpoint), "v1/traces"); + + Assert.Equal(new Uri(expected), result); + } } From 9405fcd2985d26580bebd3c0eb3773b612e6ddc8 Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 18:52:25 +0200 Subject: [PATCH 3/6] docs: overhaul AGENTS.md with verified facts, slim CLAUDE.md, fix release branch to main in prepare-release skill --- .claude/skills/prepare-release/SKILL.md | 6 +- AGENTS.md | 109 +++++++++++++++++------- CLAUDE.md | 14 ++- 3 files changed, 94 insertions(+), 35 deletions(-) diff --git a/.claude/skills/prepare-release/SKILL.md b/.claude/skills/prepare-release/SKILL.md index 1100cde..caf0409 100644 --- a/.claude/skills/prepare-release/SKILL.md +++ b/.claude/skills/prepare-release/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Prepare a new NuGet release of this repository (version bump, release notes, release PR to main). 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. --- # Prepare release @@ -55,9 +55,9 @@ prepare the repository and open the PR. 12. **Commit** (no tag) — stage csproj(s), docs, release notes; message `release: v`. -13. **Push & PR to master** +13. **Push & PR to main** - `git push -u origin release/v` - - `gh pr create --base master --head release/v --title "release: v" --body ` + - `gh pr create --base main --head release/v --title "release: v" --body ` 14. **Report** the PR link and remind: after merge, trigger **Deploy Nuget** manually (Actions → Deploy Nuget → Run workflow) — it tags `v` and publishes. diff --git a/AGENTS.md b/AGENTS.md index 5bcc7b6..bd43530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,24 +6,38 @@ Tool-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here. ## Project NuGet package that wires up OpenTelemetry (tracing, metrics, logging) for -ASP.NET Core via a single `AddTelemetry()` call and `appsettings.json`. +.NET applications via a single `AddTelemetry()` call and `appsettings.json`. ## Repository layout ``` src/ - 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 + Directory.Build.props # Shared props: nullable, implicit usings, warnings-as-errors, style enforced in build + OpenTelemetryExtension.Configuration/ # Library (netstandard2.0 + net10.0) — the shipped NuGet package + OpenTelemetryExtension.Configuration.Tests/ # xUnit unit tests (net10.0, in-process, Category=Unit) + OpenTelemetryExtension.Configuration.IntegrationTests/ # Integration tests (net10.0, Category=Integration) — 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 +infrastructure/helm/ # Helm charts + install scripts (OpenObserve, SQL Server, Aspire Dashboard, SigNoz) .github/workflows/ - ci.yml # Build + test + coverage on push - deploy-nuget.yml # Manual NuGet publish + GitHub Release + ci.yml # Build + unit tests (Category=Unit) + coverage → Coveralls + deploy-nuget.yml # Manual NuGet publish + git tag + GitHub Release release-notes/ # v{VERSION}.md per release +OpenTelemetryExtension.slnx # Solution file (repo root) ``` +## Branches & CI + +- `develop` is the default working branch; `main` receives release PRs only. +- `ci.yml` runs on push/PR to `develop` and `main` (Windows runner): build, + unit tests with `--filter "Category=Unit"`, coverage via Coverlet + + ReportGenerator, upload to Coveralls. +- `deploy-nuget.yml` is **manual only** (`workflow_dispatch`, Linux runner): it + builds *only* the library and unit-test projects (the WPF sample cannot build + on Linux), runs the unit tests, packs, publishes to NuGet.org and GitHub + Packages, tags `v{VERSION}` and creates a GitHub Release from + `release-notes/v{VERSION}.md`. + ## Public API (2 classes, minimal surface) ```csharp @@ -35,25 +49,49 @@ services.AddTelemetry(configuration, o => { ... }, "Sec"); // combined + custom services.AddTelemetry(o => { o.Endpoint = new Uri("..."); }); ``` -`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. +`TelemetryOptions` is the single configuration model: + +- **Connection**: `Endpoint` (`[Required]`, validated at registration when + enabled), `Headers`, `Protocol` (default `HttpProtobuf`) +- **Identity**: `ServiceName`, `ResourceAttributes` +- **Signal switches**: `Enabled` (default `true`; `false` makes `AddTelemetry()` + a no-op), `EnableTracing`, `EnableMetrics`, `EnableLogging` +- **Instrumentation switches** (all default `true`): + `EnableAspNetCoreInstrumentation`, `EnableHttpClientInstrumentation`, + `EnableRuntimeInstrumentation`, plus `RecordExceptions`, `ExcludedPaths` + (default `["/health"]`) +- **Tracing/metrics extension points**: `AdditionalTracingSources`, + `AdditionalMeters`, `SampleRatio`, and `ConfigureTracing` / + `ConfigureMetrics` / `ConfigureLogging` callbacks +- **Logging**: `IncludeScopes`, `IncludeFormattedMessage` + +The configuration section name (`Telemetry`) is overridable via the +`sectionName` parameter on the `IConfiguration` overloads. + +## Dependencies + +- Main library: only OpenTelemetry SDK packages + (`OpenTelemetry.Exporter.OpenTelemetryProtocol`, + `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.Http`, + `OpenTelemetry.Instrumentation.Runtime`; + `OpenTelemetry.Instrumentation.AspNetCore` is conditional — **not** + referenced for `netstandard2.0` so non-web clients stay lean). Do not add + other third-party packages. +- Unit tests: xUnit + coverlet. The library has `InternalsVisibleTo` + for the unit-test project. ## Build & test ```bash dotnet build OpenTelemetryExtension.slnx -c Release -dotnet test src/OpenTelemetryExtension.Configuration.Tests -c Release # unit tests +dotnet test src/OpenTelemetryExtension.Configuration.Tests -c Release --filter "Category=Unit" # unit tests ``` -Unit tests use **xUnit + Moq** and run in-process via `ServiceCollection` — no -infrastructure required. +Unit tests 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`). +unit tests only.** ### Integration tests @@ -64,9 +102,15 @@ 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. +- Tests use `[IntegrationFact]` / `[SqlIntegrationFact]` (in `Utils/`) instead + of `[Fact]` — they **auto-skip** when OpenObserve (`localhost:30117`) or SQL + Server (`localhost:31433`) is unreachable, so the suite stays green without + the stack. +- Shared helpers live in `Utils/`: `IntegrationConfig` (endpoints/credentials), + `OpenObserveClient` (`_search` queries), `OtelTestHost`, `Reachability`. +- Endpoints/credentials default to the Helm chart values; override via env vars: + `OTEL_IT_OPENOBSERVE_URL`, `OTEL_IT_OPENOBSERVE_USER`, + `OTEL_IT_OPENOBSERVE_PASSWORD`, `OTEL_IT_OTLP_HEADERS`, `OTEL_IT_SQL_CONNECTION`. - Run: `dotnet test src/OpenTelemetryExtension.Configuration.IntegrationTests -c Release`. ## Language & framework @@ -76,14 +120,14 @@ API to confirm the data was ingested. - Target frameworks: `netstandard2.0` and `net10.0` — guard net5.0+ APIs with `#if NET5_0_OR_GREATER`. Do not use APIs unavailable on `netstandard2.0` without the guard. -- No third-party packages in the main library beyond the OpenTelemetry SDK - packages already referenced +- `src/Directory.Build.props` applies to every project: `Nullable`, + `ImplicitUsings`, `LangVersion=latest`, `TreatWarningsAsErrors=true`, + `EnforceCodeStyleInBuild=true` — a style violation fails the build ## Code conventions - **File-scoped namespaces** required (`namespace Foo;` not `namespace Foo { }`) -- **EditorConfig** is enforced at build time (`EnforceCodeStyleInBuild=True`) — - do not bypass it +- **EditorConfig** is enforced at build time — do not bypass it - Private fields: `_camelCase`, static fields: `s_camelCase`, interfaces: `IFoo`, type params: `TFoo` - `var` for local variables when the type is obvious from the right-hand side @@ -95,18 +139,21 @@ API to confirm the data was ingested. ## Tests +- **Every unit test class must carry `[Trait("Category", "Unit")]`** (class + level). CI and the deploy workflow filter on `Category=Unit` — an untagged + test is silently never run in CI. +- Integration test classes carry `[Trait("Category", "Integration")]` and use + `[IntegrationFact]` / `[SqlIntegrationFact]` instead of `[Fact]` - xUnit `[Fact]` for single cases, `[Theory]` + `[InlineData]` for parameterised - Method name pattern: `MethodOrProperty_Condition_ExpectedResult` -- Arrange / Act / Assert with a blank line between each section +- Arrange / Act / Assert with a blank line between each section; trivial + single-expression tests (e.g. default-value checks) may stay compact - Use `ServiceCollection` + `BuildServiceProvider()` to verify DI registrations — 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 **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 @@ -114,10 +161,11 @@ API to confirm the data was ingested. `src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj` (``) - Do not change `` without also creating `release-notes/v{VERSION}.md` -- NuGet publish is **manual** (`workflow_dispatch`) — never triggered - automatically + — the GitHub Release body is taken from that file +- NuGet publish is **manual** (`workflow_dispatch` on `deploy-nuget.yml`) — + never triggered automatically; it also creates the `v{VERSION}` git tag - 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 + end-to-end smoke test, release notes, PR to `main`) 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. @@ -130,6 +178,7 @@ API to confirm the data was ingested. are excluded from code coverage) - Do not add new public API surface without a corresponding test in `TelemetryOptionsTests.cs` or `TelemetryServiceCollectionExtensionsTests.cs` +- Do not add test classes without a `Category` trait (see [Tests](#tests)) ## Adding a new instrumentation option diff --git a/CLAUDE.md b/CLAUDE.md index b2028bf..3574985 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,14 @@ # CLAUDE.md -See [AGENTS.md](./AGENTS.md) for project conventions, build instructions, and -contribution rules. That file is the single source of truth for all AI agents. +[AGENTS.md](./AGENTS.md) is the single source of truth for this repository: +project layout, build & test commands, code conventions, test rules, and the +release process all live there. Read it before making changes — do not +duplicate its content here. + +## Claude-specific notes + +- Quick start: `dotnet build OpenTelemetryExtension.slnx -c Release`, then + `dotnet test src/OpenTelemetryExtension.Configuration.Tests -c Release --filter "Category=Unit"`. +- Day-to-day work happens on `develop`; release PRs target `main`. +- Use the `/prepare-release` skill (`.claude/skills/prepare-release/`) for the + entire release workflow — never bump `` or publish manually. From 5345db4a2c697ee92ba368de21ccc1f7364cdadc Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 18:52:29 +0200 Subject: [PATCH 4/6] chore: rename aspire-dashboard helm files, drop empty openobserve nodeport template, use absolute README image URLs --- README.md | 7 +++---- ...pire-dashboard.yaml => aspire-dashboard.nodeports.yaml} | 0 ....aspire-dashboard.yaml => aspire-dashboard.values.yaml} | 0 .../helm/chart-openobserve/templates/nodeport.yaml | 0 infrastructure/helm/helm-install-aspire-dashboard.cmd | 6 +++--- 5 files changed, 6 insertions(+), 7 deletions(-) rename infrastructure/helm/{nodeports.aspire-dashboard.yaml => aspire-dashboard.nodeports.yaml} (100%) rename infrastructure/helm/{values.aspire-dashboard.yaml => aspire-dashboard.values.yaml} (100%) delete mode 100644 infrastructure/helm/chart-openobserve/templates/nodeport.yaml diff --git a/README.md b/README.md index 86cc5c3..3041104 100644 --- a/README.md +++ b/README.md @@ -425,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: -![](./assets/Aspire-Dashboard.webp) +![Aspire Dashboard demo](https://raw.githubusercontent.com/thorstenalpers/OpenTelemetryExtension.Configuration/main/assets/Aspire-Dashboard.webp) ### Jaeger — `appsettings.jaeger.json` @@ -440,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: -![](./assets/Jaeger.webp) +![Jaeger demo](https://raw.githubusercontent.com/thorstenalpers/OpenTelemetryExtension.Configuration/main/assets/Jaeger.webp) ### OpenObserve — HTTP/protobuf — `appsettings.openobserve-http.json` @@ -456,8 +456,7 @@ Traces from the sample app shown in the Jaeger UI: The same telemetry explored in the OpenObserve UI: -![](./assets/OpenObserve.webp) - +![OpenObserve demo](https://raw.githubusercontent.com/thorstenalpers/OpenTelemetryExtension.Configuration/main/assets/OpenObserve.webp) --- diff --git a/infrastructure/helm/nodeports.aspire-dashboard.yaml b/infrastructure/helm/aspire-dashboard.nodeports.yaml similarity index 100% rename from infrastructure/helm/nodeports.aspire-dashboard.yaml rename to infrastructure/helm/aspire-dashboard.nodeports.yaml diff --git a/infrastructure/helm/values.aspire-dashboard.yaml b/infrastructure/helm/aspire-dashboard.values.yaml similarity index 100% rename from infrastructure/helm/values.aspire-dashboard.yaml rename to infrastructure/helm/aspire-dashboard.values.yaml diff --git a/infrastructure/helm/chart-openobserve/templates/nodeport.yaml b/infrastructure/helm/chart-openobserve/templates/nodeport.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/infrastructure/helm/helm-install-aspire-dashboard.cmd b/infrastructure/helm/helm-install-aspire-dashboard.cmd index fff5171..c5d3ae8 100644 --- a/infrastructure/helm/helm-install-aspire-dashboard.cmd +++ b/infrastructure/helm/helm-install-aspire-dashboard.cmd @@ -3,10 +3,10 @@ helm repo add aspire-dashboard https://kube-the-home.github.io/aspire-dashboard- helm repo update helm uninstall aspire-dashboard -helm install aspire-dashboard aspire-dashboard/aspire-dashboard --version 1.28.3 -f values.aspire-dashboard.yaml +helm install aspire-dashboard aspire-dashboard/aspire-dashboard --version 1.28.3 -f aspire-dashboard.values.yaml -kubectl delete -f nodeports.aspire-dashboard.yaml -kubectl apply -f nodeports.aspire-dashboard.yaml +kubectl delete -f aspire-dashboard.nodeports.yaml +kubectl apply -f aspire-dashboard.nodeports.yaml REM helm show values aspire-dashboard/aspire-dashboard > values.aspire-dashboard.yaml From be264c56a67ca5a1da9ab117ae3614310210a97f Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 18:57:50 +0200 Subject: [PATCH 5/6] release: v2.0.1 --- release-notes/v2.0.1.md | 17 +++++++++++++++++ ...Extension.Configuration.Sample.WebApi.csproj | 4 ++-- .../OpenTelemetryExtension.Configuration.csproj | 6 +++--- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 release-notes/v2.0.1.md diff --git a/release-notes/v2.0.1.md b/release-notes/v2.0.1.md new file mode 100644 index 0000000..f75703a --- /dev/null +++ b/release-notes/v2.0.1.md @@ -0,0 +1,17 @@ +# v2.0.1 + +_Released: 2026-06-12_ + +## Changed +- Updated `OpenTelemetry.Exporter.OpenTelemetryProtocol` and + `OpenTelemetry.Extensions.Hosting` to 1.16.0. + +## Fixed +- OTLP signal URLs no longer contain a double slash when the endpoint has no + path: `http://localhost:4318` now exports to `http://localhost:4318/v1/traces` + instead of `http://localhost:4318//v1/traces`. +- The `service.version` resource attribute now reports the host application's + version (entry assembly) instead of the library's own version. +- `AddTelemetry(Action)` now throws the documented + `ArgumentNullException` instead of a `NullReferenceException` when the + configure delegate is `null`. diff --git a/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj index cbb2135..71d6344 100644 --- a/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj +++ b/src/OpenTelemetryExtension.Configuration.Sample.WebApi/OpenTelemetryExtension.Configuration.Sample.WebApi.csproj @@ -20,8 +20,8 @@ - - + + diff --git a/src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj b/src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj index a0779e7..d765970 100644 --- a/src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj +++ b/src/OpenTelemetryExtension.Configuration/OpenTelemetryExtension.Configuration.csproj @@ -2,7 +2,7 @@ netstandard2.0;net10.0 - 2.0.0 + 2.0.1 false true @@ -26,8 +26,8 @@ - - + + From 7815a92142fd162efebdb85cea69e3f3ab609547 Mon Sep 17 00:00:00 2001 From: thorsten Date: Fri, 12 Jun 2026 19:00:31 +0200 Subject: [PATCH 6/6] chore: add release-notes folder to solution and fix renamed helm file references --- OpenTelemetryExtension.slnx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/OpenTelemetryExtension.slnx b/OpenTelemetryExtension.slnx index 27bdeab..e40e992 100644 --- a/OpenTelemetryExtension.slnx +++ b/OpenTelemetryExtension.slnx @@ -21,6 +21,15 @@ + + + + + + + + + @@ -43,8 +52,8 @@ - - + + @@ -58,7 +67,6 @@ -