diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 9b69370..dcefe9b 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -1,45 +1,84 @@
{
- "version": "2.0.0",
- "tasks": [
- {
- "label": "clean",
- "type": "shell",
- "command": "dotnet clean",
- "problemMatcher": "$msCompile"
- },
- {
- "label": "restore",
- "type": "shell",
- "command": "dotnet restore",
- "problemMatcher": "$msCompile"
- },
- {
- "label": "build",
- "type": "shell",
- "command": "dotnet build --nologo",
- "problemMatcher": "$msCompile",
- "group": "build"
- },
- {
- "label": "test",
- "type": "shell",
- "command": "dotnet test --nologo",
- "problemMatcher": "$msCompile",
- "group": "test"
- },
- {
- "label": "ci:validate",
- "dependsOn": [
- "clean",
- "restore",
- "build",
- "test"
- ],
- "dependsOrder": "sequence",
- "group": {
- "kind": "build",
- "isDefault": true
- }
- }
- ]
+ "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": [],
+ "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
+ }
+ ]
}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 61d389f..edb2c82 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,7 +6,7 @@
-
+
@@ -71,4 +71,4 @@
runtime; build; native; contentfiles; analyzers
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 599a556..18d3f13 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Parsing HTTP User Agents with .NET
| NuGet |
|-|
| [](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser) |
-| [](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCach.MemoryCache` |
+| [](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache` |
| [](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.AspNetCore) | `dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore` |
@@ -110,6 +110,269 @@ 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` (s)
+- `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
+```
+
+## Telemetry (Meters)
+
+Native `System.Diagnostics.Metrics` instruments are **opt-in** per package.
+
+### Enable meters (Fluent API)
+
+Core parser meters:
+
+```csharp
+public void ConfigureServices(IServiceCollection services)
+{
+ services
+ .AddHttpUserAgentParser()
+ .WithMeterTelemetry();
+}
+```
+
+MemoryCache meters:
+
+```csharp
+public void ConfigureServices(IServiceCollection services)
+{
+ services
+ .AddHttpUserAgentMemoryCachedParser()
+ .WithMeterTelemetry() // core meters (optional)
+ .WithMemoryCacheMeterTelemetry();
+}
+```
+
+ASP.NET Core meters:
+
+```csharp
+public void ConfigureServices(IServiceCollection services)
+{
+ services
+ .AddHttpUserAgentMemoryCachedParser()
+ .AddHttpUserAgentParserAccessor()
+ .WithAspNetCoreMeterTelemetry();
+}
+```
+
+### Meter names and instruments
+
+Core meter (default: `mycsharp.http_user_agent_parser`)
+
+- `parse.requests` (counter, `{call}`)
+- `parse.duration` (histogram, `s`)
+- `cache.hit` (counter, `{call}`)
+- `cache.miss` (counter, `{call}`)
+- `cache.size` (observable gauge, `{entry}`)
+
+MemoryCache meter (default: `mycsharp.http_user_agent_parser.memorycache`)
+
+- `cache.hit` (counter, `{call}`)
+- `cache.miss` (counter, `{call}`)
+- `cache.size` (observable gauge, `{entry}`)
+
+ASP.NET Core meter (default: `mycsharp.http_user_agent_parser.aspnetcore`)
+
+- `user_agent.present` (counter, `{call}`)
+- `user_agent.missing` (counter, `{call}`)
+
+### Meter prefix configuration
+
+The default prefix is `mycsharp.`. The prefix can be configured via DI:
+
+```csharp
+public void ConfigureServices(IServiceCollection services)
+{
+ services
+ .AddHttpUserAgentParser()
+ .WithMeterTelemetryPrefix("acme.");
+}
+```
+
+Rules:
+
+- `""` (empty) is allowed and removes the prefix.
+- Otherwise the prefix must be **alphanumeric** and **end with `.`**.
+
+Example results:
+
+- Prefix `"mycsharp."` -> `mycsharp.http_user_agent_parser`
+- Prefix `""` -> `http_user_agent_parser`
+- Prefix `"acme."` -> `acme.http_user_agent_parser`
+
+### 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..9e8b54a
--- /dev/null
+++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs
@@ -0,0 +1,49 @@
+// 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;
+ }
+
+ ///
+ /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package using a custom meter prefix.
+ ///
+ /// The options container.
+ /// The prefix to use for the meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreMeterTelemetryPrefix(
+ this HttpUserAgentParserDependencyInjectionOptions options,
+ string meterPrefix)
+ {
+ Meter meter = new(HttpUserAgentParserAspNetCoreMeters.GetMeterName(meterPrefix));
+ 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/HttpUserAgentParser.AspNetCore.csproj b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj
index fd97e4e..19d4fa6 100644
--- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj
+++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj
@@ -24,4 +24,8 @@
+
+
+
+
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..8917594
--- /dev/null
+++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs
@@ -0,0 +1,150 @@
+// 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.
+ ///
+ private const string MeterNameSuffix = "http_user_agent_parser.aspnetcore";
+
+ ///
+ /// Name of the meter used to publish AspNetCore User-Agent metrics.
+ ///
+ public const string MeterName = "mycsharp." + MeterNameSuffix;
+
+ ///
+ /// Builds a meter name from a custom prefix.
+ ///
+ ///
+ /// The prefix to use. When null, the default prefix is used. When empty,
+ /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'.
+ ///
+ /// The full meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static string GetMeterName(string? meterPrefix)
+ {
+ if (meterPrefix is null)
+ {
+ return MeterName;
+ }
+
+ meterPrefix = meterPrefix.Trim();
+ if (meterPrefix.Length == 0)
+ {
+ return MeterNameSuffix;
+ }
+
+ if (!meterPrefix.EndsWith('.'))
+ {
+ throw new ArgumentException("Meter prefix must end with '.'.", nameof(meterPrefix));
+ }
+
+ for (int i = 0; i < meterPrefix.Length - 1; i++)
+ {
+ char c = meterPrefix[i];
+ if (!char.IsLetterOrDigit(c))
+ {
+ throw new ArgumentException("Meter prefix must be alphanumeric.", nameof(meterPrefix));
+ }
+ }
+
+ return meterPrefix + MeterNameSuffix;
+ }
+
+ ///
+ /// 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: "user_agent.present",
+ unit: "{call}",
+ description: "User-Agent header present");
+
+ s_userAgentMissing = s_meter.CreateCounter(
+ name: "user_agent.missing",
+ unit: "{call}",
+ 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);
+
+ ///
+ /// Resets static state to support isolated unit tests.
+ ///
+ public static void ResetForTests()
+ {
+ Volatile.Write(ref s_initialized, 0);
+
+ s_meter = null;
+ s_userAgentPresent = null;
+ s_userAgentMissing = null;
+ }
+}
diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs
new file mode 100644
index 0000000..ca5d976
--- /dev/null
+++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs
@@ -0,0 +1,143 @@
+// 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()
+ {
+ // Force EventSource construction at enable-time so listeners can subscribe deterministically.
+ // This avoids CI-only timing races where first telemetry events happen before listener attachment.
+ _ = HttpUserAgentParserAspNetCoreEventSource.Log;
+ 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();
+ }
+ }
+
+ ///
+ /// Resets telemetry state for unit tests.
+ ///
+ public static void ResetForTests()
+ {
+ Volatile.Write(ref s_enabledFlags, 0);
+ HttpUserAgentParserAspNetCoreMeters.ResetForTests();
+ }
+}
diff --git a/src/HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md
index 2cf587b..277a74d 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`)
+
+- `user_agent.present` (incrementing)
+- `user_agent.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`)
+
+- `user_agent.present` (counter)
+- `user_agent.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..6b31060
--- /dev/null
+++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs
@@ -0,0 +1,49 @@
+// 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;
+ }
+
+ ///
+ /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider using a custom meter prefix.
+ ///
+ /// The options container.
+ /// The prefix to use for the meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheMeterTelemetryPrefix(
+ this HttpUserAgentParserDependencyInjectionOptions options,
+ string meterPrefix)
+ {
+ Meter meter = new(HttpUserAgentParserMemoryCacheMeters.GetMeterName(meterPrefix));
+ HttpUserAgentParserMemoryCacheTelemetry.EnableMeters(meter);
+ return options;
+ }
+}
diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj
index 31057cb..9a97676 100644
--- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj
+++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj
@@ -25,4 +25,8 @@
+
+
+
+
diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs
index c8ecb91..d059628 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.http_user_agent_parser.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..ce9bcce
--- /dev/null
+++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs
@@ -0,0 +1,108 @@
+// 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
+{
+ private const string MeterNameSuffix = "http_user_agent_parser.memorycache";
+
+ public const string MeterName = "mycsharp." + MeterNameSuffix;
+
+ ///
+ /// Builds a meter name from a custom prefix.
+ ///
+ ///
+ /// The prefix to use. When null, the default prefix is used. When empty,
+ /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'.
+ ///
+ /// The full meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static string GetMeterName(string? meterPrefix)
+ {
+ if (meterPrefix is null)
+ {
+ return MeterName;
+ }
+
+ meterPrefix = meterPrefix.Trim();
+ if (meterPrefix.Length == 0)
+ {
+ return MeterNameSuffix;
+ }
+
+ if (!meterPrefix.EndsWith('.'))
+ {
+ throw new ArgumentException("Meter prefix must end with '.'.", nameof(meterPrefix));
+ }
+
+ for (int i = 0; i < meterPrefix.Length - 1; i++)
+ {
+ char c = meterPrefix[i];
+ if (!char.IsLetterOrDigit(c))
+ {
+ throw new ArgumentException("Meter prefix must be alphanumeric.", nameof(meterPrefix));
+ }
+ }
+
+ return meterPrefix + MeterNameSuffix;
+ }
+
+ 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: "{call}",
+ description: "Cache hit");
+
+ s_cacheMiss = s_meter.CreateCounter(
+ name: "cache.miss",
+ unit: "{call}",
+ description: "Cache miss");
+
+ s_cacheSize = s_meter.CreateObservableGauge(
+ name: "cache.size",
+ observeValue: static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize,
+ unit: "{entry}",
+ description: "Cache size");
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void CacheHit() => s_cacheHit?.Add(1);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void CacheMiss() => s_cacheMiss?.Add(1);
+
+ public static void ResetForTests()
+ {
+ Volatile.Write(ref s_initialized, 0);
+
+ s_meter = null;
+ s_cacheHit = null;
+ s_cacheMiss = null;
+ s_cacheSize = null;
+ }
+}
diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs
new file mode 100644
index 0000000..448de31
--- /dev/null
+++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs
@@ -0,0 +1,139 @@
+// 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()
+ {
+ // Force EventSource construction at enable-time so listeners can subscribe deterministically.
+ // This avoids CI-only timing races where first telemetry events happen before listener attachment.
+ _ = HttpUserAgentParserMemoryCacheEventSource.Log;
+ 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();
+
+ ///
+ /// Resets telemetry state for unit tests.
+ ///
+ public static void ResetForTests()
+ {
+ Volatile.Write(ref s_enabledFlags, 0);
+ HttpUserAgentParserMemoryCacheTelemetryState.ResetForTests();
+ HttpUserAgentParserMemoryCacheMeters.ResetForTests();
+ }
+}
diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs
new file mode 100644
index 0000000..7dc525f
--- /dev/null
+++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs
@@ -0,0 +1,47 @@
+// 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);
+
+ ///
+ /// Resets the cache size for unit tests.
+ ///
+ public static void ResetForTests() => Volatile.Write(ref s_cacheSize, 0);
+}
diff --git a/src/HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md
index 2cf587b..fcdaaba 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`)
+
+- `user_agent_parser.cache.hit` (incrementing)
+- `user_agent_parser.cache.miss` (incrementing)
+- `user_agent_parser.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`)
+
+- `user_agent_parser.cache.hit` (counter)
+- `user_agent_parser.cache.miss` (counter)
+- `user_agent_parser.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..a5e6ee8
--- /dev/null
+++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs
@@ -0,0 +1,50 @@
+// 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;
+ }
+
+ ///
+ /// Enables native System.Diagnostics.Metrics telemetry for the parser using a custom meter prefix.
+ ///
+ /// The options container.
+ /// The prefix to use for the meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static HttpUserAgentParserDependencyInjectionOptions WithMeterTelemetryPrefix(
+ this HttpUserAgentParserDependencyInjectionOptions options,
+ string meterPrefix)
+ {
+ Meter meter = new(HttpUserAgentParserMeters.GetMeterName(meterPrefix));
+ HttpUserAgentParserTelemetry.EnableMeters(meter);
+ return options;
+ }
+}
diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs
index 410637a..e0bc8f7 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;
@@ -18,7 +19,33 @@ public static class 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));
+ }
+
+ return result;
+ }
+
+ private static HttpUserAgentInformation ParseInternal(string userAgent)
{
// prepare
userAgent = Cleanup(userAgent);
@@ -51,13 +78,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 +107,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 +118,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..0934b26
--- /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? _parseDurationSeconds;
+
+ private readonly IncrementingEventCounter _cacheHit;
+ private readonly IncrementingEventCounter _cacheMiss;
+ private readonly PollingCounter _cacheSize;
+
+ ///
+ /// 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",
+ };
+
+ _parseDurationSeconds = new EventCounter("parse-duration", this)
+ {
+ DisplayName = "Parse duration",
+ DisplayUnits = "s",
+ };
+
+ // Providers (cache)
+ _cacheHit = new IncrementingEventCounter("cache-hit", this)
+ {
+ DisplayName = "Cache hit",
+ DisplayUnits = "calls",
+ };
+
+ _cacheMiss = new IncrementingEventCounter("cache-miss", this)
+ {
+ DisplayName = "Cache miss",
+ DisplayUnits = "calls",
+ };
+
+ _cacheSize = new PollingCounter(
+ "cache-size",
+ this,
+ static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize)
+ {
+ DisplayName = "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 seconds.
+ [NonEvent]
+ internal void ParseDuration(double seconds)
+ {
+ if (!IsEnabled())
+ {
+ return;
+ }
+
+ _parseDurationSeconds?.WriteMetric(seconds);
+ }
+
+ ///
+ /// Records a cache hit.
+ ///
+ [NonEvent]
+ internal void CacheHit()
+ {
+ if (!IsEnabled())
+ {
+ return;
+ }
+
+ _cacheHit?.Increment();
+ }
+
+ ///
+ /// Records a cache miss.
+ ///
+ [NonEvent]
+ internal void CacheMiss()
+ {
+ if (!IsEnabled())
+ {
+ return;
+ }
+
+ _cacheMiss?.Increment();
+ }
+
+ ///
+ /// Updates the 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 CacheSizeSet(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();
+ _parseDurationSeconds?.Dispose();
+
+ _cacheHit?.Dispose();
+ _cacheMiss?.Dispose();
+ _cacheSize?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs
new file mode 100644
index 0000000..39897b4
--- /dev/null
+++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs
@@ -0,0 +1,165 @@
+// 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.
+ ///
+ private const string MeterNameSuffix = "http_user_agent_parser";
+
+ ///
+ /// The meter name used for all instruments.
+ ///
+ public const string MeterName = "mycsharp." + MeterNameSuffix;
+
+ ///
+ /// Builds a meter name from a custom prefix.
+ ///
+ ///
+ /// The prefix to use. When null, the default prefix is used. When empty,
+ /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'.
+ ///
+ /// The full meter name.
+ /// Thrown when the prefix is not empty and does not match the required format.
+ public static string GetMeterName(string? meterPrefix)
+ {
+ if (meterPrefix is null)
+ {
+ return MeterName;
+ }
+
+ meterPrefix = meterPrefix.Trim();
+ if (meterPrefix.Length == 0)
+ {
+ return MeterNameSuffix;
+ }
+
+ if (!meterPrefix.EndsWith('.'))
+ {
+ throw new ArgumentException("Meter prefix must end with '.'.", nameof(meterPrefix));
+ }
+
+ for (int i = 0; i < meterPrefix.Length - 1; i++)
+ {
+ char c = meterPrefix[i];
+ if (!char.IsLetterOrDigit(c))
+ {
+ throw new ArgumentException("Meter prefix must be alphanumeric.", nameof(meterPrefix));
+ }
+ }
+
+ return meterPrefix + MeterNameSuffix;
+ }
+
+ private static int s_initialized;
+
+ private static Meter? s_meter;
+
+ private static Counter? s_parseRequests;
+ private static Histogram? s_parseDuration;
+
+ 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_parseDuration?.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)
+ {
+ s_meter = meter ?? new Meter(MeterName);
+
+ s_parseRequests = s_meter.CreateCounter(
+ name: "parse.requests",
+ unit: "{call}",
+ description: "User-Agent parse requests");
+
+ s_parseDuration = s_meter.CreateHistogram(
+ name: "parse.duration",
+ unit: "s",
+ description: "Parse duration");
+
+ s_concurrentCacheHit = s_meter.CreateCounter(
+ name: "cache.hit",
+ unit: "{call}",
+ description: "Cache hit");
+
+ s_concurrentCacheMiss = s_meter.CreateCounter(
+ name: "cache.miss",
+ unit: "{call}",
+ description: "Cache miss");
+
+ s_concurrentCacheSize = s_meter.CreateObservableGauge(
+ name: "cache.size",
+ observeValue: static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize,
+ unit: "{entry}",
+ description: "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 seconds.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void ParseDuration(double seconds) => s_parseDuration?.Record(seconds);
+
+ ///
+ /// Emits a counter increment for a cache hit.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void CacheHit() => s_concurrentCacheHit?.Add(1);
+
+ ///
+ /// Emits a counter increment for a cache miss.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void CacheMiss() => 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_parseDuration = 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..defb596
--- /dev/null
+++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs
@@ -0,0 +1,170 @@
+// 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()
+ {
+ // Force EventSource construction at enable-time so listeners can subscribe deterministically.
+ // This avoids CI-only timing races where first telemetry events happen before listener attachment.
+ _ = HttpUserAgentParserEventSource.Log;
+ 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 elapsed duration.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void ParseDuration(TimeSpan duration)
+ {
+ int flags = Volatile.Read(ref s_enabledFlags);
+ if ((flags & EventCountersFlag) != 0)
+ {
+ HttpUserAgentParserEventSource.Log.ParseDuration(duration.TotalSeconds);
+ }
+
+ if ((flags & MetersFlag) != 0)
+ {
+ HttpUserAgentParserMeters.ParseDuration(duration.TotalSeconds);
+ }
+ }
+
+ ///
+ /// 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.CacheHit();
+ }
+
+ if ((flags & MetersFlag) != 0)
+ {
+ HttpUserAgentParserMeters.CacheHit();
+ }
+ }
+
+ ///
+ /// 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.CacheMiss();
+ }
+
+ if ((flags & MetersFlag) != 0)
+ {
+ HttpUserAgentParserMeters.CacheMiss();
+ }
+ }
+
+ ///
+ /// 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..1d3c653 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.hit` (incrementing)
+- `cache.miss` (incrementing)
+- `cache.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.hit` (counter)
+- `cache.miss` (counter)
+- `cache.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..b405d3b
--- /dev/null
+++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs
@@ -0,0 +1,61 @@
+// 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;
+ 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.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs
new file mode 100644
index 0000000..de66438
--- /dev/null
+++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs
@@ -0,0 +1,93 @@
+// 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()
+ {
+ HttpUserAgentParserAspNetCoreTelemetry.ResetForTests();
+
+ using MeterTestListener listener = new("mycsharp.http_user_agent_parser.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("user_agent.present", listener.InstrumentNames);
+ Assert.Contains("user_agent.missing", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithCustomPrefix()
+ {
+ HttpUserAgentParserAspNetCoreTelemetry.ResetForTests();
+
+ const string prefix = "acme.";
+ using MeterTestListener listener = new("acme.http_user_agent_parser.aspnetcore");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithAspNetCoreMeterTelemetryPrefix(prefix);
+
+ 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("user_agent.present", listener.InstrumentNames);
+ Assert.Contains("user_agent.missing", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithEmptyPrefix()
+ {
+ HttpUserAgentParserAspNetCoreTelemetry.ResetForTests();
+
+ using MeterTestListener listener = new("http_user_agent_parser.aspnetcore");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithAspNetCoreMeterTelemetryPrefix(string.Empty);
+
+ DefaultHttpContext ctx = new();
+
+ // present
+ ctx.Request.Headers.UserAgent = "UA";
+ Assert.NotNull(ctx.GetUserAgentString());
+
+ Assert.Contains("user_agent.present", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void WithAspNetCoreMeterTelemetryPrefix_Throws_WhenInvalid()
+ {
+ HttpUserAgentParserAspNetCoreTelemetry.ResetForTests();
+
+ HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection());
+
+ Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme"));
+ Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme-"));
+ Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme.."));
+ }
+}
diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs
new file mode 100644
index 0000000..bb6d3a0
--- /dev/null
+++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs
@@ -0,0 +1,38 @@
+// 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();
+
+ // First call ensures the EventSource gets created (listener enables right after creation).
+ ctx.Request.Headers.UserAgent = "UA";
+ Assert.NotNull(ctx.GetUserAgentString());
+ Assert.True(listener.WaitUntilEnabled(TimeSpan.FromSeconds(2)));
+
+ // Now exercise telemetry-enabled paths.
+ ctx.Request.Headers.UserAgent = "UA";
+ Assert.NotNull(ctx.GetUserAgentString());
+
+ 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..06f6801
--- /dev/null
+++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs
@@ -0,0 +1,99 @@
+// Copyright © https://myCSharp.de - all rights reserved
+
+using Microsoft.Extensions.DependencyInjection;
+using MyCSharp.HttpUserAgentParser.DependencyInjection;
+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()
+ {
+ HttpUserAgentParserMemoryCacheTelemetry.ResetForTests();
+
+ using MeterTestListener listener = new("mycsharp.http_user_agent_parser.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);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithCustomPrefix()
+ {
+ HttpUserAgentParserMemoryCacheTelemetry.ResetForTests();
+
+ const string prefix = "acme.";
+ using MeterTestListener listener = new("acme.http_user_agent_parser.memorycache");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithMemoryCacheMeterTelemetryPrefix(prefix);
+
+ 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);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithEmptyPrefix()
+ {
+ HttpUserAgentParserMemoryCacheTelemetry.ResetForTests();
+
+ using MeterTestListener listener = new("http_user_agent_parser.memorycache");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithMemoryCacheMeterTelemetryPrefix(string.Empty);
+
+ HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions());
+
+ 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";
+
+ _ = provider.Parse(ua); // miss
+
+ listener.RecordObservableInstruments();
+
+ Assert.Contains("cache.miss", listener.InstrumentNames);
+ Assert.Contains("cache.size", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void WithMemoryCacheMeterTelemetryPrefix_Throws_WhenInvalid()
+ {
+ HttpUserAgentParserMemoryCacheTelemetry.ResetForTests();
+
+ HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection());
+
+ Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme"));
+ Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme-"));
+ Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme.."));
+ }
+}
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..0d7715a
--- /dev/null
+++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs
@@ -0,0 +1,114 @@
+// 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.http_user_agent_parser");
+
+ 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.hit", listener.InstrumentNames);
+ Assert.Contains("cache.miss", listener.InstrumentNames);
+ Assert.Contains("cache.size", listener.InstrumentNames);
+
+ Assert.Equal("s", listener.InstrumentUnits["parse.duration"]);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithCustomPrefix()
+ {
+ HttpUserAgentParserTelemetry.ResetForTests();
+
+ const string prefix = "acme.";
+ using MeterTestListener listener = new("acme.http_user_agent_parser");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithMeterTelemetryPrefix(prefix);
+
+ 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.hit", listener.InstrumentNames);
+ Assert.Contains("cache.miss", listener.InstrumentNames);
+ Assert.Contains("cache.size", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void Meters_Emit_WhenEnabled_WithEmptyPrefix()
+ {
+ HttpUserAgentParserTelemetry.ResetForTests();
+
+ using MeterTestListener listener = new("http_user_agent_parser");
+
+ new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection())
+ .WithMeterTelemetryPrefix(string.Empty);
+
+ 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);
+
+ listener.RecordObservableInstruments();
+
+ Assert.Contains("parse.requests", listener.InstrumentNames);
+ Assert.Contains("parse.duration", listener.InstrumentNames);
+ }
+
+ [Fact]
+ public void WithMeterTelemetryPrefix_Throws_WhenInvalid()
+ {
+ HttpUserAgentParserTelemetry.ResetForTests();
+
+ HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection());
+
+ Assert.Throws(() => options.WithMeterTelemetryPrefix("acme"));
+ Assert.Throws(() => options.WithMeterTelemetryPrefix("acme-"));
+ Assert.Throws(() => options.WithMeterTelemetryPrefix("acme.."));
+ }
+}
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..666e9b5
--- /dev/null
+++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs
@@ -0,0 +1,48 @@
+// 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 ConcurrentDictionary InstrumentUnits { get; } = new(StringComparer.Ordinal);
+
+ public MeterTestListener(string meterName)
+ {
+ _meterName = meterName;
+ _listener = new MeterListener();
+
+ _listener.InstrumentPublished = (instrument, listener) =>
+ {
+ if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ InstrumentUnits[instrument.Name] = instrument.Unit;
+ 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();
+}