diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5ddca9a..04b0a19 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,13 +1,84 @@ { "version": "2.0.0", "tasks": [ + { + "label": "dotnet: restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": "$msCompile", + "presentation": { + "reveal": "silent", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + } + }, + { + "label": "dotnet: build", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore" + ], + "problemMatcher": "$msCompile", + "dependsOn": "dotnet: restore", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + }, + "group": "build" + }, + { + "label": "dotnet: test", + "type": "shell", + "command": "dotnet", + "args": [ + "test", + "--no-build", + "--nologo" + ], + "problemMatcher": "$msCompile", + "dependsOn": "dotnet: build", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + } + }, { "label": "test", "type": "shell", "command": "dotnet test --nologo", "args": [], - "problemMatcher": [ - "$msCompile" - ], - "group": "build" + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 599a556..e868aae 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Parsing HTTP User Agents with .NET | NuGet | |-| | [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser) | -| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCach.MemoryCache` | +| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache` | | [![MyCSharp.HttpUserAgentParser.AspNetCore](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.AspNetCore.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.AspNetCore)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.AspNetCore) | `dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore` | @@ -110,6 +110,183 @@ public void MyMethod(IHttpUserAgentParserAccessor parserAccessor) } ``` +## Telemetry (EventCounters) + +Telemetry is **opt-in** and **modular per package**. + +- Opt-in: no telemetry overhead unless you explicitly enable it. +- Modular: each package has its own `EventSource` name, so you can monitor only what you use. + +### Enable telemetry (Fluent API) + +Core parser telemetry: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentParser() + .WithTelemetry(); +} +``` + +MemoryCache telemetry (in addition to core, optional): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() // core counters (optional) + .WithMemoryCacheTelemetry(); +} +``` + +ASP.NET Core telemetry (header present/missing): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +} +``` + +### EventSource names and counters + +Core (`MyCSharp.HttpUserAgentParser`) + +- `parse-requests` +- `parse-duration` (ms) +- `cache-concurrentdictionary-hit` +- `cache-concurrentdictionary-miss` +- `cache-concurrentdictionary-size` + +MemoryCache (`MyCSharp.HttpUserAgentParser.MemoryCache`) + +- `cache-hit` +- `cache-miss` +- `cache-size` + +ASP.NET Core (`MyCSharp.HttpUserAgentParser.AspNetCore`) + +- `useragent-present` +- `useragent-missing` + +### Monitor counters + +Using `dotnet-counters`: + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +### Export to OpenTelemetry + +You can collect these EventCounters via OpenTelemetry metrics. + +Packages you typically need: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- an exporter (e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol`) + +Example (minimal): + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +### Export to Application Insights + +Two common approaches: + +1) OpenTelemetry → Application Insights (recommended) + - Collect counters with OpenTelemetry (see above) + - Export using an Azure Monitor / Application Insights exporter (API varies by package/version) + +2) Custom `EventListener` → `TelemetryClient` + - Attach an `EventListener` + - Parse the `EventCounters` payload + - Forward values as custom metrics + +### OpenTelemetry listener (recommended) + +You can collect EventCounters as OpenTelemetry metrics. + +Typical packages: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- An exporter, e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol` + +Example: + +```csharp +using OpenTelemetry.Metrics; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +From there you can route metrics to: + +- OpenTelemetry Collector +- Prometheus +- Azure Monitor / Application Insights (via an Azure Monitor exporter) + +### Application Insights listener (custom) + +If you want a direct listener, you can attach an `EventListener` and forward counter values into Application Insights custom metrics. + +High-level steps: + +1) Enable telemetry via `.WithTelemetry()` / `.WithMemoryCacheTelemetry()` / `.WithAspNetCoreTelemetry()` +2) Register an `EventListener` that enables the corresponding EventSources +3) On `EventCounters` payload, forward values to `TelemetryClient.GetMetric(...).TrackValue(...)` + +Notes: + +- This is best-effort telemetry. +- Prefer longer polling intervals (e.g. 10s) to reduce noise. + +> Notes +> +> - Counters are only emitted when telemetry is enabled and a listener is attached. +> - Values are best-effort and may include cache races. + ## Benchmark ```shell diff --git a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..60cb10a --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,34 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry for the AspNetCore package. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables EventCounter telemetry for the AspNetCore package. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserAspNetCoreTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserAspNetCoreTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs index 9da3b29..d6f2462 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; namespace MyCSharp.HttpUserAgentParser.AspNetCore; @@ -16,7 +17,19 @@ public static class HttpContextExtensions public static string? GetUserAgentString(this HttpContext httpContext) { if (httpContext.Request.Headers.TryGetValue("User-Agent", out StringValues value)) + { + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentPresent(); + } + return value; + } + + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentMissing(); + } return null; } diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs index c5f87ed..d2f8fd2 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs @@ -14,6 +14,11 @@ namespace MyCSharp.HttpUserAgentParser.AspNetCore; public class HttpUserAgentParserAccessor(IHttpUserAgentParserProvider httpUserAgentParser) : IHttpUserAgentParserAccessor { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + private readonly IHttpUserAgentParserProvider _httpUserAgentParser = httpUserAgentParser; /// diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs new file mode 100644 index 0000000..7a1fcab --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -0,0 +1,96 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.AspNetCore. +/// +/// +/// Provides EventCounter-based telemetry for User-Agent presence detection. +/// Counters are incremented only when the EventSource is enabled to minimize +/// overhead on hot paths. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + + /// + /// Singleton instance of the EventSource. + /// + internal static HttpUserAgentParserAspNetCoreEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter _userAgentPresent; + private readonly IncrementingEventCounter _userAgentMissing; + + /// + /// Initializes the EventCounters used by this EventSource. + /// + private HttpUserAgentParserAspNetCoreEventSource() + { + _userAgentPresent = new IncrementingEventCounter("useragent-present", this) + { + DisplayName = "User-Agent header present", + DisplayUnits = "calls", + }; + + _userAgentMissing = new IncrementingEventCounter("useragent-missing", this) + { + DisplayName = "User-Agent header missing", + DisplayUnits = "calls", + }; + } + + /// + /// Increments the EventCounter for requests with a present User-Agent header. + /// + [NonEvent] + internal void UserAgentPresent() + { + if (!IsEnabled()) + { + return; + } + + _userAgentPresent?.Increment(); + } + + /// + /// Increments the EventCounter for requests with a missing User-Agent header. + /// + [NonEvent] + internal void UserAgentMissing() + { + if (!IsEnabled()) + { + return; + } + + _userAgentMissing?.Increment(); + } + + /// + /// Releases all EventCounter resources used by this EventSource. + /// + /// + /// when called from Dispose; + /// when called from a finalizer. + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _userAgentPresent?.Dispose(); + _userAgentMissing?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs new file mode 100644 index 0000000..3959426 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs @@ -0,0 +1,94 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser.AspNetCore. +/// +/// +/// Provides meter-based telemetry for User-Agent parsing and presence detection. +/// Instrument creation is performed once and guarded by a lock-free initialization +/// check to minimize overhead on hot paths. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreMeters +{ + /// + /// Name of the meter used to publish AspNetCore User-Agent metrics. + /// + public const string MeterName = HttpUserAgentParserAccessor.MeterName; + + /// + /// Indicates whether the meter and its instruments have been initialized. + /// + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_userAgentPresent; + private static Counter? s_userAgentMissing; + + /// + /// Gets a value indicating whether meter-based telemetry is enabled. + /// + /// + /// Returns once the meter and counters have been initialized. + /// + public static bool IsEnabled + => Volatile.Read(ref s_initialized) != 0; + + /// + /// Enables meter-based telemetry and initializes all metric instruments. + /// + /// + /// Optional externally managed instance. If not provided, + /// a new meter is created using . + /// + /// + /// Initialization is performed at most once. Subsequent calls are ignored. + /// + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_userAgentPresent = s_meter.CreateCounter( + name: "useragent-present", + unit: "calls", + description: "User-Agent header present"); + + s_userAgentMissing = s_meter.CreateCounter( + name: "useragent-missing", + unit: "calls", + description: "User-Agent header missing"); + } + + /// + /// Records a metric indicating that a User-Agent header was present. + /// + /// + /// This method is optimized for hot paths and performs no work + /// if the counter has not been initialized. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentPresent() + => s_userAgentPresent?.Add(1); + + /// + /// Records a metric indicating that a User-Agent header was missing. + /// + /// + /// This method is optimized for hot paths and performs no work + /// if the counter has not been initialized. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() + => s_userAgentMissing?.Add(1); +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs new file mode 100644 index 0000000..e2bc8cf --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -0,0 +1,128 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// Opt-in switch for AspNetCore package telemetry. +/// +/// +/// Controls whether telemetry is emitted via event counters and/or meters. +/// The state is evaluated using lock-free, thread-safe reads and is intended +/// to be checked on hot paths. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreTelemetry +{ + /// + /// Flag indicating that event counter–based telemetry is enabled. + /// + private const int EventCountersFlag = 1; + + /// + /// Flag indicating that meter-based telemetry is enabled. + /// + private const int MetersFlag = 2; + + /// + /// Bit field storing the currently enabled telemetry backends. + /// + /// + /// Accessed using volatile reads to ensure cross-thread visibility + /// without requiring synchronization. + /// + private static int s_enabledFlags; + + /// + /// Gets a value indicating whether any telemetry backend is enabled. + /// + /// + /// Returns if at least one telemetry backend + /// has been enabled. + /// + public static bool IsEnabled + => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Gets a value indicating whether event counter telemetry is enabled. + /// + /// + /// Returns only if the event counter flag is set + /// and the underlying event source is enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserAspNetCoreEventSource.Log.IsEnabled(); + + /// + /// Gets a value indicating whether meter-based telemetry is enabled. + /// + /// + /// Returns only if the meter flag is set + /// and the meter provider is enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserAspNetCoreMeters.IsEnabled; + + /// + /// Enables EventCounter telemetry for the AspNetCore package. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserAspNetCoreMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records telemetry indicating that a User-Agent header was present. + /// + /// + /// Emits telemetry only for the enabled backends (event counters and/or meters). + /// The method is optimized for hot paths and performs a single volatile flag read. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentPresent() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentPresent(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserAspNetCoreMeters.UserAgentPresent(); + } + } + + /// + /// Records telemetry indicating that a User-Agent header was missing. + /// + /// + /// Emits telemetry only for the enabled backends (event counters and/or meters). + /// The method is optimized for hot paths and performs a single volatile flag read. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentMissing(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserAspNetCoreMeters.UserAgentMissing(); + } + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md index 2cf587b..612da44 100644 --- a/src/HttpUserAgentParser.AspNetCore/readme.md +++ b/src/HttpUserAgentParser.AspNetCore/readme.md @@ -1,5 +1,113 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.AspNetCore -Parsing HTTP User Agents with .NET +ASP.NET Core integration for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Quick start + +Register a provider (any of the available ones) and then add the accessor: + +The accessor pattern reads the `User-Agent` header from the current `HttpContext` and parses it using the registered provider. + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() // or: AddHttpUserAgentParser / AddHttpUserAgentCachedParser + .AddHttpUserAgentParserAccessor(); +``` + +Usage: + +```csharp +public sealed class MyController(IHttpUserAgentParserAccessor accessor) +{ + public HttpUserAgentInformation Get() => accessor.Get(); +} +``` + +### Just read the header + +If you only want the raw User-Agent string: + +```csharp +string? ua = HttpContext.GetUserAgentString(); +``` + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +``` + +> The accessor registration returns the same options object, so you can chain this after any parser registration. + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreEventSource.EventSourceName`) + +- `useragent-present` (incrementing) +- `useragent-missing` (incrementing) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreMeters.MeterName`) + +- `useragent-present` (counter) +- `useragent-missing` (counter) + +## Export to OpenTelemetry / Application Insights + +Collect via OpenTelemetry EventCounters instrumentation: + +```csharp +using OpenTelemetry.Metrics; + +metrics.AddEventCountersInstrumentation(options => +{ + options.AddEventSources(HttpUserAgentParserAspNetCoreEventSource.EventSourceName); +}); +``` + +Then export using your preferred exporter (OTLP, Prometheus, Azure Monitor / Application Insights, …). + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +metrics.AddMeter(HttpUserAgentParserAspNetCoreMeters.MeterName); +``` diff --git a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..f88ca16 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,34 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry for the MemoryCache package. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables EventCounter telemetry for the MemoryCache provider. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserMemoryCacheTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserMemoryCacheTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index c8ecb91..db9f567 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -1,6 +1,8 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Caching.Memory; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; using MyCSharp.HttpUserAgentParser.Providers; namespace MyCSharp.HttpUserAgentParser.MemoryCache; @@ -13,17 +15,71 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache; public class HttpUserAgentParserMemoryCachedProvider( HttpUserAgentParserMemoryCachedProviderOptions options) : IHttpUserAgentParserProvider { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + private readonly Microsoft.Extensions.Caching.Memory.MemoryCache _memoryCache = new(options.CacheOptions); private readonly HttpUserAgentParserMemoryCachedProviderOptions _options = options; /// + /// + /// This method includes performance optimizations for telemetry: + /// + /// Telemetry checks use a volatile flag to ensure zero overhead when disabled. + /// Cache size tracking (via and ) is skipped entirely if the size metric is not enabled to avoid allocations. + /// + /// public HttpUserAgentInformation Parse(string userAgent) { CacheKey key = GetKey(userAgent); + if (!HttpUserAgentParserMemoryCacheTelemetry.IsEnabled) + { + return ParseWithoutTelemetry(key); + } + + if (_memoryCache.TryGetValue(key, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserMemoryCacheTelemetry.CacheHit(); + return cached; + } + + return _memoryCache.GetOrCreate(key, static entry => + { + CacheKey key = (entry.Key as CacheKey)!; + entry.SlidingExpiration = key.Options.CacheEntryOptions.SlidingExpiration; + entry.SetSize(1); + + // Miss path. Note: Like other cache implementations, races can happen; counters are best-effort. + HttpUserAgentParserMemoryCacheTelemetry.CacheMiss(); + + if (HttpUserAgentParserMemoryCacheTelemetry.IsCacheSizeEnabled) + { + // Optimization: Avoid Interlocked overhead and delegate allocation if telemetry is disabled. + HttpUserAgentParserMemoryCacheTelemetry.CacheSizeIncrement(); + entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheTelemetry.CacheSizeDecrement()); + } + + return HttpUserAgentParser.Parse(key.UserAgent); + }); + } + + /// + /// Parses the user agent string using the memory cache without emitting telemetry. + /// + /// + /// This method is excluded from code coverage as it mainly wires together + /// cache access and parsing logic without additional behavior. + /// + [ExcludeFromCodeCoverage] + private HttpUserAgentInformation ParseWithoutTelemetry(CacheKey key) + { return _memoryCache.GetOrCreate(key, static entry => { CacheKey key = (entry.Key as CacheKey)!; + entry.SlidingExpiration = key.Options.CacheEntryOptions.SlidingExpiration; entry.SetSize(1); @@ -31,9 +87,22 @@ public HttpUserAgentInformation Parse(string userAgent) }); } + /// + /// Thread-local reusable cache key instance to avoid per-call allocations. + /// + /// + /// Marked as to ensure thread safety without locking. + /// [ThreadStatic] private static CacheKey? s_tKey; + /// + /// Gets a cache key instance initialized for the specified user agent. + /// + /// + /// Reuses a thread-local instance to minimize allocations. The returned instance + /// must not be stored or shared across threads. + /// private CacheKey GetKey(string userAgent) { CacheKey key = s_tKey ??= new CacheKey(); @@ -44,15 +113,42 @@ private CacheKey GetKey(string userAgent) return key; } + /// + /// Cache key used for memory-cached HTTP User-Agent parsing. + /// + /// + /// Implements as required by IMemoryCache + /// to ensure correct key comparison semantics. + /// private class CacheKey : IEquatable // required for IMemoryCache { + /// + /// Gets or sets the raw User-Agent string. + /// public string UserAgent { get; set; } = null!; + /// + /// Gets or sets the cache configuration options associated with this key. + /// public HttpUserAgentParserMemoryCachedProviderOptions Options { get; set; } = null!; - public bool Equals(CacheKey? other) => string.Equals(UserAgent, other?.UserAgent, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => Equals(obj as CacheKey); + /// + /// Determines equality based on the User-Agent string, ignoring case. + /// + public bool Equals(CacheKey? other) + => string.Equals(UserAgent, other?.UserAgent, StringComparison.OrdinalIgnoreCase); + + /// + public override bool Equals(object? obj) + => Equals(obj as CacheKey); - public override int GetHashCode() => UserAgent.GetHashCode(StringComparison.Ordinal); + /// + /// Returns a hash code based on the User-Agent string. + /// + /// + /// Uses ordinal comparison for performance and consistency with the cache. + /// + public override int GetHashCode() + => UserAgent.GetHashCode(StringComparison.Ordinal); } } diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs new file mode 100644 index 0000000..da71f14 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -0,0 +1,87 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.MemoryCache. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + + internal static HttpUserAgentParserMemoryCacheEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter? _cacheHit; + private readonly IncrementingEventCounter _cacheMiss; + private readonly PollingCounter _cacheSize; + + private HttpUserAgentParserMemoryCacheEventSource() + { + _cacheHit = new IncrementingEventCounter("cache-hit", this) + { + DisplayName = "MemoryCache cache hit", + DisplayUnits = "calls", + }; + + _cacheMiss = new IncrementingEventCounter("cache-miss", this) + { + DisplayName = "MemoryCache cache miss", + DisplayUnits = "calls", + }; + + _cacheSize = new PollingCounter("cache-size", this, static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize) + { + DisplayName = "MemoryCache cache size", + DisplayUnits = "entries", + }; + } + + [NonEvent] + internal void CacheHit() + { + if (!IsEnabled()) + { + return; + } + + _cacheHit?.Increment(); + } + + [NonEvent] + internal void CacheMiss() + { + if (!IsEnabled()) + { + return; + } + + _cacheMiss?.Increment(); + } + + [NonEvent] + internal static void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + + [NonEvent] + internal static void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cacheHit?.Dispose(); + _cacheMiss?.Dispose(); + _cacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs new file mode 100644 index 0000000..0a1a7a5 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs @@ -0,0 +1,57 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser.MemoryCache. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheMeters +{ + public const string MeterName = HttpUserAgentParserMemoryCachedProvider.MeterName; + + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_cacheHit; + private static Counter? s_cacheMiss; + private static ObservableGauge? s_cacheSize; + + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_cacheHit = s_meter.CreateCounter( + name: "cache-hit", + unit: "calls", + description: "MemoryCache cache hit"); + + s_cacheMiss = s_meter.CreateCounter( + name: "cache-miss", + unit: "calls", + description: "MemoryCache cache miss"); + + s_cacheSize = s_meter.CreateObservableGauge( + name: "cache-size", + observeValue: static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize, + unit: "entries", + description: "MemoryCache cache size"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() => s_cacheHit?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() => s_cacheMiss?.Add(1); +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs new file mode 100644 index 0000000..62a87fd --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -0,0 +1,123 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// Opt-in switch for MemoryCache package telemetry. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetry +{ + // Bit flags to track which telemetry systems are enabled. + // This allows us to support both EventCounters and Meters simultaneously with a single check. + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; + + // Volatile integer used as a bitmask. + // Volatile.Read is used to ensure we get the latest value without the overhead of a full lock, + // making the "is telemetry enabled?" check extremely cheap on the hot path. + private static int s_enabledFlags; + + /// + /// Fast check if ANY telemetry is enabled. + /// Used to guard the entire telemetry block to minimize overhead when not in use. + /// + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Checks if EventCounters are specifically enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserMemoryCacheEventSource.Log.IsEnabled(); + + /// + /// Checks if Meters are specifically enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMemoryCacheMeters.IsEnabled; + + /// + /// Checks if cache size tracking is enabled for either system. + /// This is used to guard expensive operations like .Count or Interlocked updates. + /// + public static bool IsCacheSizeEnabled + => AreCountersEnabled || AreMetersEnabled; + + /// + /// Enables EventCounter telemetry for the MemoryCache provider. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMemoryCacheMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records a cache hit. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserMemoryCacheEventSource.Log.CacheHit(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMemoryCacheMeters.CacheHit(); + } + } + + /// + /// Records a cache miss. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserMemoryCacheEventSource.Log.CacheMiss(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMemoryCacheMeters.CacheMiss(); + } + } + + /// + /// Increments the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeIncrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + + /// + /// Decrements the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeDecrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs new file mode 100644 index 0000000..727f691 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs @@ -0,0 +1,42 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// Holds telemetry state for tracking the size of the HTTP User-Agent parser memory cache. +/// +/// +/// This class is excluded from code coverage as it contains only simple +/// thread-safe state management logic. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetryState +{ + private static long s_cacheSize; + + /// + /// Gets the current number of entries in the memory cache. + /// + /// + /// The value is read atomically to ensure thread safety. + /// + public static long CacheSize => Volatile.Read(ref s_cacheSize); + + /// + /// Increments the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// + public static void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + + /// + /// Decrements the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// + public static void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); +} diff --git a/src/HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md index 2cf587b..33d7b8e 100644 --- a/src/HttpUserAgentParser.MemoryCache/readme.md +++ b/src/HttpUserAgentParser.MemoryCache/readme.md @@ -1,5 +1,144 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.MemoryCache -Parsing HTTP User Agents with .NET +IMemoryCache-based caching provider for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## Quick start + +Register the provider: + +```csharp +services.AddHttpUserAgentMemoryCachedParser(); +``` + +Then inject `IHttpUserAgentParserProvider`: + +```csharp +public sealed class MyService(IHttpUserAgentParserProvider parser) +{ + public HttpUserAgentInformation Parse(string userAgent) => parser.Parse(userAgent); +``` + +### Configure cache + +```csharp +services.AddHttpUserAgentMemoryCachedParser(options => +{ + options.CacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(60); // default is 1 day + options.CacheOptions.SizeLimit = 1024; // default is null (= no limit) +}); +``` + +Notes: + +- Each unique user-agent string counts as one cache entry. +- The provider is registered as singleton and owns its internal `MemoryCache` instance. +- Like any cache, concurrent requests for a new key can race; counters are best-effort. + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheTelemetry(); +``` + +Optionally enable core counters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() + .WithMemoryCacheTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheEventSource.EventSourceName`) + +- `cache-hit` (incrementing) +- `cache-miss` (incrementing) +- `cache-size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheMeterTelemetry(); +``` + +Optionally enable core meters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMeterTelemetry() + .WithMemoryCacheMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheMeters.MeterName`) + +- `cache-hit` (counter) +- `cache-miss` (counter) +- `cache-size` (observable gauge) + +## Export to OpenTelemetry / Application Insights + +You can collect these counters with OpenTelemetry’s EventCounters instrumentation. + +Add the EventSource name: + +```csharp +using OpenTelemetry.Metrics; + +metrics.AddEventCountersInstrumentation(options => +{ + options.AddEventSources(HttpUserAgentParserMemoryCacheEventSource.EventSourceName); +}); +``` + +From there you can export to: + +- OTLP (Collector) +- Prometheus +- Azure Monitor / Application Insights (via an Azure Monitor exporter) + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +metrics.AddMeter(HttpUserAgentParserMemoryCacheMeters.MeterName); +``` + +### Application Insights listener registration + +If you prefer a direct listener instead of OpenTelemetry, you can attach an `EventListener` and forward values into Application Insights. diff --git a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..c04fc2f --- /dev/null +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,35 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables core EventCounter telemetry for the parser. + /// This is opt-in to keep the default path free of telemetry overhead. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// This is opt-in to keep the default path free of telemetry overhead. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 410637a..5b6d9a1 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using MyCSharp.HttpUserAgentParser.Telemetry; namespace MyCSharp.HttpUserAgentParser; @@ -15,10 +16,41 @@ namespace MyCSharp.HttpUserAgentParser; /// public static class HttpUserAgentParser { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser"; + /// /// Parses given user agent /// + /// + /// If telemetry is enabled, this method will emit metrics for parse requests and duration. + /// The telemetry check is designed to be zero-overhead when disabled (using a volatile boolean check). + /// public static HttpUserAgentInformation Parse(string userAgent) + { + if (!HttpUserAgentParserTelemetry.IsEnabled) + { + return ParseInternal(userAgent); + } + + bool measureDuration = HttpUserAgentParserTelemetry.ShouldMeasureParseDuration; + long startTimestamp = measureDuration ? Stopwatch.GetTimestamp() : 0; + + HttpUserAgentParserTelemetry.ParseRequest(); + + HttpUserAgentInformation result = ParseInternal(userAgent); + + if (measureDuration) + { + HttpUserAgentParserTelemetry.ParseDuration(Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + } + + return result; + } + + private static HttpUserAgentInformation ParseInternal(string userAgent) { // prepare userAgent = Cleanup(userAgent); @@ -51,13 +83,13 @@ public static HttpUserAgentInformation Parse(string userAgent) public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules) + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) in HttpUserAgentStatics.s_platformRules) { - if (ContainsIgnoreCase(ua, platform.Token)) + if (ContainsIgnoreCase(ua, Token)) { return new HttpUserAgentPlatformInformation( - HttpUserAgentStatics.GetPlatformRegexForToken(platform.Token), - platform.Name, platform.PlatformType); + HttpUserAgentStatics.GetPlatformRegexForToken(Token), + Name, PlatformType); } } @@ -80,9 +112,9 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) + foreach ((string Name, string DetectToken, string? VersionToken) in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) + if (!TryIndexOf(ua, DetectToken, out int detectIndex)) { continue; } @@ -91,37 +123,37 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) int versionSearchStart; // For rules without a specific version token, ensure pattern Token/ - if (string.IsNullOrEmpty(browserRule.VersionToken)) + if (string.IsNullOrEmpty(VersionToken)) { - int afterDetect = detectIndex + browserRule.DetectToken.Length; + int afterDetect = detectIndex + DetectToken.Length; if (afterDetect >= ua.Length || ua[afterDetect] != '/') { // Likely a misspelling or partial token (e.g., Edgg, Oprea, Chromee) continue; } } - if (!string.IsNullOrEmpty(browserRule.VersionToken)) + if (!string.IsNullOrEmpty(VersionToken)) { - if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) + if (TryIndexOf(ua, VersionToken!, out int vtIndex)) { - versionSearchStart = vtIndex + browserRule.VersionToken!.Length; + versionSearchStart = vtIndex + VersionToken!.Length; } else { // If specific version token wasn't found, fall back to detect token area - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } } else { - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } ReadOnlySpan search = ua.Slice(versionSearchStart); if (TryExtractVersion(search, out Range range)) { string? version = search[range].ToString(); - return (browserRule.Name, version); + return (Name, version); } // If we didn't find a version for this rule, try next rule diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index 381fd5b..a3f792b 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -1,6 +1,7 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Collections.Concurrent; +using MyCSharp.HttpUserAgentParser.Telemetry; namespace MyCSharp.HttpUserAgentParser.Providers; @@ -17,8 +18,41 @@ public class HttpUserAgentParserCachedProvider : IHttpUserAgentParserProvider /// /// Parses the user agent or uses the internal cached information /// + /// + /// This method includes performance optimizations for telemetry: + /// + /// Telemetry checks use a volatile flag to ensure zero overhead when disabled. + /// Cache size reporting (which requires an expensive lock) is only executed if the specific metric is enabled. + /// + /// public HttpUserAgentInformation Parse(string userAgent) - => _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + { + if (!HttpUserAgentParserTelemetry.IsEnabled) + { + return _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + } + + if (_cache.TryGetValue(userAgent, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserTelemetry.ConcurrentCacheHit(); + return cached; + } + + // Note: ConcurrentDictionary can invoke the factory multiple times in races; counters are best-effort. + HttpUserAgentInformation result = _cache.GetOrAdd(userAgent, static ua => + { + HttpUserAgentParserTelemetry.ConcurrentCacheMiss(); + return HttpUserAgentParser.Parse(ua); + }); + + if (HttpUserAgentParserTelemetry.IsCacheSizeEnabled) + { + // Optimization: Avoid expensive .Count property access (locks all buckets) if telemetry is disabled. + HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); + } + + return result; + } /// /// Total count of entries in cache diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs new file mode 100644 index 0000000..3474bec --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -0,0 +1,167 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser. +/// +/// +/// The implementation is designed to keep overhead negligible unless a listener +/// is enabled. All counters are updated using lightweight, non-blocking operations +/// suitable for hot paths. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser"; + + /// + /// Singleton instance of the EventSource. + /// + internal static HttpUserAgentParserEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter _parseRequests; + private readonly EventCounter? _parseDurationMs; + + private readonly IncrementingEventCounter _concurrentCacheHit; + private readonly IncrementingEventCounter _concurrentCacheMiss; + private readonly PollingCounter _concurrentCacheSize; + + /// + /// Initializes all EventCounters and polling counters used by this EventSource. + /// + private HttpUserAgentParserEventSource() + { + // Parser + _parseRequests = new IncrementingEventCounter("parse-requests", this) + { + DisplayName = "User-Agent parse requests", + DisplayUnits = "calls", + }; + + _parseDurationMs = new EventCounter("parse-duration", this) + { + DisplayName = "Parse duration", + DisplayUnits = "ms", + }; + + // Providers (cache) + _concurrentCacheHit = new IncrementingEventCounter("cache-concurrentdictionary-hit", this) + { + DisplayName = "ConcurrentDictionary cache hit", + DisplayUnits = "calls", + }; + + _concurrentCacheMiss = new IncrementingEventCounter("cache-concurrentdictionary-miss", this) + { + DisplayName = "ConcurrentDictionary cache miss", + DisplayUnits = "calls", + }; + + _concurrentCacheSize = new PollingCounter( + "cache-concurrentdictionary-size", + this, + static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize) + { + DisplayName = "ConcurrentDictionary cache size", + DisplayUnits = "entries", + }; + } + + /// + /// Records a User-Agent parse request. + /// + [NonEvent] + internal void ParseRequest() + { + if (!IsEnabled()) + { + return; + } + + _parseRequests?.Increment(); + } + + /// + /// Records the duration of a User-Agent parse operation. + /// + /// Elapsed parse time in milliseconds. + [NonEvent] + internal void ParseDuration(double milliseconds) + { + if (!IsEnabled()) + { + return; + } + + _parseDurationMs?.WriteMetric(milliseconds); + } + + /// + /// Records a cache hit in the concurrent dictionary provider. + /// + [NonEvent] + internal void ConcurrentCacheHit() + { + if (!IsEnabled()) + { + return; + } + + _concurrentCacheHit?.Increment(); + } + + /// + /// Records a cache miss in the concurrent dictionary provider. + /// + [NonEvent] + internal void ConcurrentCacheMiss() + { + if (!IsEnabled()) + { + return; + } + + _concurrentCacheMiss?.Increment(); + } + + /// + /// Updates the concurrent cache size used by the polling counter. + /// + /// Current number of entries in the cache. + /// + /// The size is updated even when telemetry is disabled so that the polling + /// counter reports a correct value once a listener attaches. + /// + [NonEvent] + internal static void ConcurrentCacheSizeSet(int size) => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + + /// + /// Releases all EventCounter and PollingCounter resources used by this EventSource. + /// + /// + /// when called from ; + /// when called from a finalizer. + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _parseRequests?.Dispose(); + _parseDurationMs?.Dispose(); + + _concurrentCacheHit?.Dispose(); + _concurrentCacheMiss?.Dispose(); + _concurrentCacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs new file mode 100644 index 0000000..ad65203 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -0,0 +1,126 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser. +/// This is opt-in and designed to keep overhead negligible unless a listener is enabled. +/// +/// +/// Instruments are created once on first enablement and emit no data unless observed +/// by an active listener. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMeters +{ + /// + /// The meter name used for all instruments. + /// + public const string MeterName = HttpUserAgentParser.MeterName; + + private static int s_initialized; + + private static Meter? s_meter; + + private static Counter? s_parseRequests; + private static Histogram? s_parseDurationMs; + + private static Counter? s_concurrentCacheHit; + private static Counter? s_concurrentCacheMiss; + private static ObservableGauge? s_concurrentCacheSize; + + /// + /// Gets whether meters have been initialized. + /// + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + /// + /// Gets whether the parse duration histogram is currently enabled by a listener. + /// + public static bool IsParseDurationEnabled => s_parseDurationMs?.Enabled ?? false; + + /// + /// Initializes the meter and creates all metric instruments. + /// + /// + /// Initialization is performed at most once. Subsequent calls are ignored. + /// + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_parseRequests = s_meter.CreateCounter( + name: "parse-requests", + unit: "calls", + description: "User-Agent parse requests"); + + s_parseDurationMs = s_meter.CreateHistogram( + name: "parse-duration", + unit: "ms", + description: "Parse duration"); + + s_concurrentCacheHit = s_meter.CreateCounter( + name: "cache-concurrentdictionary-hit", + unit: "calls", + description: "ConcurrentDictionary cache hit"); + + s_concurrentCacheMiss = s_meter.CreateCounter( + name: "cache-concurrentdictionary-miss", + unit: "calls", + description: "ConcurrentDictionary cache miss"); + + s_concurrentCacheSize = s_meter.CreateObservableGauge( + name: "cache-concurrentdictionary-size", + observeValue: static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize, + unit: "entries", + description: "ConcurrentDictionary cache size"); + } + + /// + /// Emits a counter increment for a parse request. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseRequest() => s_parseRequests?.Add(1); + + /// + /// Records the parse duration in milliseconds. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(double milliseconds) => s_parseDurationMs?.Record(milliseconds); + + /// + /// Emits a counter increment for a concurrent dictionary cache hit. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheHit() => s_concurrentCacheHit?.Add(1); + + /// + /// Emits a counter increment for a concurrent dictionary cache miss. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheMiss() => s_concurrentCacheMiss?.Add(1); + + /// + /// Resets static state to support isolated unit tests. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_initialized, 0); + + s_meter = null; + s_parseRequests = null; + s_parseDurationMs = null; + s_concurrentCacheHit = null; + s_concurrentCacheMiss = null; + s_concurrentCacheSize = null; + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs new file mode 100644 index 0000000..95ed5b2 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -0,0 +1,164 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// Opt-in switch for core telemetry. +/// Telemetry is disabled by default to ensure zero overhead unless explicitly enabled. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserTelemetry +{ + // Bit flags to track which telemetry systems are enabled. + // This allows us to support both EventCounters and Meters simultaneously with a single check. + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; + + // Volatile integer used as a bitmask. + // Volatile.Read is used to ensure we get the latest value without the overhead of a full lock, + // making the "is telemetry enabled?" check extremely cheap on the hot path. + private static int s_enabledFlags; + + /// + /// Fast check if ANY telemetry is enabled. + /// Used to guard the entire telemetry block to minimize overhead when not in use. + /// + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Checks if EventCounters are specifically enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserEventSource.Log.IsEnabled(); + + /// + /// Checks if Meters are specifically enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMeters.IsEnabled; + + /// + /// Checks if parse duration should be measured. + /// This is true if either EventCounters are enabled OR if the specific Meter instrument for duration is enabled. + /// + public static bool ShouldMeasureParseDuration + => AreCountersEnabled || HttpUserAgentParserMeters.IsParseDurationEnabled; + + /// + /// Checks if cache size tracking is enabled for either system. + /// This is used to guard expensive operations like .Count or Interlocked updates. + /// + public static bool IsCacheSizeEnabled + => AreCountersEnabled || AreMetersEnabled; + + /// + /// Enables core EventCounter telemetry for the parser. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records a parse request event. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseRequest() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ParseRequest(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ParseRequest(); + } + } + + /// + /// Records the duration of a parse request. + /// + /// The duration in milliseconds. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(double milliseconds) + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ParseDuration(milliseconds); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ParseDuration(milliseconds); + } + } + + /// + /// Records a cache hit in the concurrent dictionary. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheHit() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheHit(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ConcurrentCacheHit(); + } + } + + /// + /// Records a cache miss in the concurrent dictionary. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheMiss() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheMiss(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ConcurrentCacheMiss(); + } + } + + /// + /// Updates the concurrent cache size. + /// + /// The current size of the cache. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheSizeSet(int size) + => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + + /// + /// Resets telemetry state for unit testing. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_enabledFlags, 0); + HttpUserAgentParserTelemetryState.ResetForTests(); + HttpUserAgentParserMeters.ResetForTests(); + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs new file mode 100644 index 0000000..7bd68b9 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs @@ -0,0 +1,38 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// Holds shared telemetry state for the concurrent dictionary cache. +/// +/// +/// The state is updated independently of whether telemetry is currently enabled +/// so that polling-based instruments can report correct values once a listener +/// attaches. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserTelemetryState +{ + private static long s_concurrentCacheSize; + + /// + /// Gets the current size of the concurrent dictionary cache. + /// + public static long ConcurrentCacheSize + => Volatile.Read(ref s_concurrentCacheSize); + + /// + /// Updates the current size of the concurrent dictionary cache. + /// + /// Current number of entries in the cache. + public static void SetConcurrentCacheSize(int size) + => Volatile.Write(ref s_concurrentCacheSize, size); + + /// + /// Resets the telemetry state for unit tests. + /// + public static void ResetForTests() + => Volatile.Write(ref s_concurrentCacheSize, 0); +} diff --git a/src/HttpUserAgentParser/readme.md b/src/HttpUserAgentParser/readme.md index 2cf587b..64b2aa3 100644 --- a/src/HttpUserAgentParser/readme.md +++ b/src/HttpUserAgentParser/readme.md @@ -1,5 +1,174 @@ # MyCSharp.HttpUserAgentParser -Parsing HTTP User Agents with .NET +Fast HTTP User-Agent parsing for .NET. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser +``` + +## Quick start (no DI) + +```csharp +string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; +HttpUserAgentInformation info = HttpUserAgentParser.Parse(userAgent); +// or: HttpUserAgentInformation.Parse(userAgent) +``` + +## Dependency injection + +If you want to inject a parser (e.g., in ASP.NET Core), use `IHttpUserAgentParserProvider`. + +### No cache + +```csharp +services + .AddHttpUserAgentParser(); +``` + +### ConcurrentDictionary cache + +```csharp +services + .AddHttpUserAgentCachedParser(); +// or: .AddHttpUserAgentParser(); +``` + +## Telemetry (EventCounters) + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: counters are only written when a listener is attached + +### Enable telemetry (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserEventSource.EventSourceName`) + +- `parse-requests` (incrementing) +- `parse-duration` (ms, event counter) +- `cache-concurrentdictionary-hit` (incrementing) +- `cache-concurrentdictionary-miss` (incrementing) +- `cache-concurrentdictionary-size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +``` + +## Telemetry (native Meters) + +In addition to EventCounters, this package can emit **native** `System.Diagnostics.Metrics` instruments. + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: measurements are only recorded when enabled + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserMeters.MeterName`) + +- `parse-requests` (counter) +- `parse-duration` (histogram, ms) +- `cache-concurrentdictionary-hit` (counter) +- `cache-concurrentdictionary-miss` (counter) +- `cache-concurrentdictionary-size` (observable gauge) + +## Export to OpenTelemetry + +You can collect these EventCounters via OpenTelemetry metrics and export them (OTLP, Prometheus, Azure Monitor, …). + +Packages you typically need: + +- `OpenTelemetry` +- `OpenTelemetry.Exporter.OpenTelemetryProtocol` (or another exporter) +- `OpenTelemetry.Instrumentation.EventCounters` + +Example (minimal): + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources(HttpUserAgentParserEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +> If you also use the MemoryCache/AspNetCore packages, add their EventSource names too. + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddMeter(HttpUserAgentParserMeters.MeterName) + .AddOtlpExporter(); + }); +``` + +## Export to Application Insights + +There are two common approaches: + +### 1) Recommended: OpenTelemetry → Application Insights + +Collect with OpenTelemetry (see above) and export to Azure Monitor / Application Insights using an Azure Monitor exporter. +This keeps your pipeline consistent and avoids custom listeners. + +Typical packages (names may differ by version): + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- `Azure.Monitor.OpenTelemetry.Exporter` + +### 2) Custom EventListener → TelemetryClient + +If you prefer a direct listener, you can attach an `EventListener` and forward values as custom metrics. + +High-level idea: + +- Enable the EventSource +- Parse the `EventCounters` payload +- Track as Application Insights metrics + +Notes: + +- This is best-effort telemetry (caches can race) +- Keep aggregation intervals reasonable (e.g. 10s) diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..9a75e72 --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,47 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + ["EventCounterIntervalSec"] = "0.1" + }); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs new file mode 100644 index 0000000..81280ff --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs @@ -0,0 +1,35 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + using MeterTestListener listener = new("MyCSharp.HttpUserAgentParser.AspNetCore"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreMeterTelemetry(); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.Contains("useragent-present", listener.InstrumentNames); + Assert.Contains("useragent-missing", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs new file mode 100644 index 0000000..607e44f --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs @@ -0,0 +1,34 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreTelemetry(); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..dd5a09b --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,39 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void Dispose() => _listener.Dispose(); +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..760c7c6 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,61 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + private volatile bool _enabled; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + ["EventCounterIntervalSec"] = "0.1" + }); + + _enabled = true; + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } + + public bool WaitUntilEnabled(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_enabled && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _enabled; + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs new file mode 100644 index 0000000..3a72d81 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs @@ -0,0 +1,37 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache; +using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + using MeterTestListener listener = new("MyCSharp.HttpUserAgentParser.MemoryCache"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheMeterTelemetry(); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + _ = provider.Parse(ua1); // miss + _ = provider.Parse(ua1); // hit + _ = provider.Parse(ua2); // miss + + listener.RecordObservableInstruments(); + + Assert.Contains("cache-hit", listener.InstrumentNames); + Assert.Contains("cache-miss", listener.InstrumentNames); + Assert.Contains("cache-size", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs new file mode 100644 index 0000000..1b8950b --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs @@ -0,0 +1,36 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Xunit; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserMemoryCacheEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheTelemetry(); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + // First call ensures the EventSource gets created (listener enables right after creation). + _ = provider.Parse(ua1); + Assert.True(listener.WaitUntilEnabled(TimeSpan.FromSeconds(2))); + + // Now exercise telemetry-enabled paths: miss (ua2), hit (ua1) + _ = provider.Parse(ua2); // miss under enabled + _ = provider.Parse(ua1); // hit under enabled + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..47a07d1 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,41 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void RecordObservableInstruments() => _listener.RecordObservableInstruments(); + + public void Dispose() => _listener.Dispose(); +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..91f0e9b --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,48 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + // Make the test responsive while keeping runtime short. + ["EventCounterIntervalSec"] = "0.1" + }); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs new file mode 100644 index 0000000..47c92ce --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs @@ -0,0 +1,52 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.Providers; +using MyCSharp.HttpUserAgentParser.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserMetersTelemetryTests +{ + [Fact] + public void Meters_DoNotEmit_WhenDisabled() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + _ = HttpUserAgentInformation.Parse(ua); + + Assert.Empty(listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMeterTelemetry(); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = HttpUserAgentInformation.Parse(ua); + + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + listener.RecordObservableInstruments(); + + Assert.Contains("parse-requests", listener.InstrumentNames); + Assert.Contains("parse-duration", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-hit", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-miss", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-size", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs new file mode 100644 index 0000000..a5e2682 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs @@ -0,0 +1,34 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.Providers; +using MyCSharp.HttpUserAgentParser.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserEventSource.EventSourceName); + + // Opt-in telemetry so production default stays overhead-free. + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithTelemetry(); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + // Core parser + _ = HttpUserAgentInformation.Parse(ua); + + // ConcurrentDictionary cached provider + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..cfa7825 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,46 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void RecordObservableInstruments() => _listener.RecordObservableInstruments(); + + public void Dispose() => _listener.Dispose(); +}