From 3d1abc60e1fd53ba0660de1156d41f3d4e2a7f1b Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 15:58:38 +0100 Subject: [PATCH 1/7] feat(telemetry): add telemetry support for user agent parsing --- .vscode/tasks.json | 62 ++++++++-- ...encyInjectionOptionsTelemetryExtensions.cs | 22 ++++ .../HttpContextExtensions.cs | 9 ++ ...ttpUserAgentParserAspNetCoreEventSource.cs | 59 +++++++++ .../HttpUserAgentParserAspNetCoreTelemetry.cs | 20 +++ ...encyInjectionOptionsTelemetryExtensions.cs | 22 ++++ ...HttpUserAgentParserMemoryCachedProvider.cs | 32 +++++ ...tpUserAgentParserMemoryCacheEventSource.cs | 75 +++++++++++ ...HttpUserAgentParserMemoryCacheTelemetry.cs | 20 +++ ...encyInjectionOptionsTelemetryExtensions.cs | 22 ++++ .../HttpUserAgentParser.cs | 14 +++ .../HttpUserAgentParserCachedProvider.cs | 23 +++- .../HttpUserAgentParserEventSource.cs | 117 ++++++++++++++++++ .../Telemetry/HttpUserAgentParserTelemetry.cs | 21 ++++ .../Telemetry/EventCounterTestListener.cs | 43 +++++++ ...UserAgentParserAspNetCoreTelemetryTests.cs | 27 ++++ .../Telemetry/EventCounterTestListener.cs | 57 +++++++++ ...serAgentParserMemoryCacheTelemetryTests.cs | 29 +++++ .../Telemetry/EventCounterTestListener.cs | 44 +++++++ .../HttpUserAgentParserTelemetryTests.cs | 27 ++++ 20 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs create mode 100644 src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5ddca9a..49a9187 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,13 +1,51 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "test", - "type": "shell", - "command": "dotnet test --nologo", - "args": [], - "problemMatcher": [ - "$msCompile" - ], - "group": "build" - } + "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 + } + } + ] +} diff --git a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..a856493 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,22 @@ +// Copyright © https://myCSharp.de - all rights reserved + +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; + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs index 9da3b29..f07eb54 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,15 @@ public static class HttpContextExtensions public static string? GetUserAgentString(this HttpContext httpContext) { if (httpContext.Request.Headers.TryGetValue("User-Agent", out StringValues value)) + { + if (HttpUserAgentParserAspNetCoreTelemetry.AreCountersEnabled) + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentPresent(); + return value; + } + + if (HttpUserAgentParserAspNetCoreTelemetry.AreCountersEnabled) + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentMissing(); return null; } diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs new file mode 100644 index 0000000..80647e8 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -0,0 +1,59 @@ +// 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. +/// +[EventSource(Name = "MyCSharp.HttpUserAgentParser.AspNetCore")] +[ExcludeFromCodeCoverage] +internal sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource +{ + public static readonly HttpUserAgentParserAspNetCoreEventSource Log = new(); + + private readonly IncrementingEventCounter _userAgentPresent; + private readonly IncrementingEventCounter _userAgentMissing; + + 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", + }; + } + + [NonEvent] + public void UserAgentPresent() + { + if (!IsEnabled()) return; + _userAgentPresent?.Increment(); + } + + [NonEvent] + public void UserAgentMissing() + { + if (!IsEnabled()) return; + _userAgentMissing?.Increment(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _userAgentPresent?.Dispose(); + _userAgentMissing?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs new file mode 100644 index 0000000..2f6ab28 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -0,0 +1,20 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// Opt-in switch for AspNetCore package telemetry. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreTelemetry +{ + private static volatile bool s_enabled; + + public static bool IsEnabled => s_enabled; + + public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserAspNetCoreEventSource.Log.IsEnabled(); + + public static void Enable() => s_enabled = true; +} diff --git a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..bd8bd40 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,22 @@ +// Copyright © https://myCSharp.de - all rights reserved + +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; + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index c8ecb91..193bb9b 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -1,7 +1,9 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Caching.Memory; using MyCSharp.HttpUserAgentParser.Providers; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; namespace MyCSharp.HttpUserAgentParser.MemoryCache; @@ -21,6 +23,36 @@ public HttpUserAgentInformation Parse(string userAgent) { CacheKey key = GetKey(userAgent); + if (!HttpUserAgentParserMemoryCacheTelemetry.IsEnabled) + return ParseWithoutTelemetry(key); + + bool countersEnabled = HttpUserAgentParserMemoryCacheTelemetry.AreCountersEnabled; + if (countersEnabled && _memoryCache.TryGetValue(key, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserMemoryCacheEventSource.Log.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. + if (HttpUserAgentParserMemoryCacheTelemetry.AreCountersEnabled) + HttpUserAgentParserMemoryCacheEventSource.Log.CacheMiss(); + + HttpUserAgentParserMemoryCacheEventSource.Log.CacheSizeIncrement(); + entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheEventSource.Log.CacheSizeDecrement()); + + return HttpUserAgentParser.Parse(key.UserAgent); + }); + } + + [ExcludeFromCodeCoverage] + private HttpUserAgentInformation ParseWithoutTelemetry(CacheKey key) + { return _memoryCache.GetOrCreate(key, static entry => { CacheKey key = (entry.Key as CacheKey)!; diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs new file mode 100644 index 0000000..1c699d8 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -0,0 +1,75 @@ +// 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 = "MyCSharp.HttpUserAgentParser.MemoryCache")] +[ExcludeFromCodeCoverage] +internal sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource +{ + public static readonly HttpUserAgentParserMemoryCacheEventSource Log = new(); + + private readonly IncrementingEventCounter? _cacheHit; + private readonly IncrementingEventCounter _cacheMiss; + private readonly PollingCounter _cacheSize; + + private static long s_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 () => Volatile.Read(ref s_cacheSize)) + { + DisplayName = "MemoryCache cache size", + DisplayUnits = "entries", + }; + } + + [NonEvent] + public void CacheHit() + { + if (!IsEnabled()) return; + _cacheHit?.Increment(); + } + + [NonEvent] + public void CacheMiss() + { + if (!IsEnabled()) return; + _cacheMiss?.Increment(); + } + + [NonEvent] + public void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + + [NonEvent] + public void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cacheHit?.Dispose(); + _cacheMiss?.Dispose(); + _cacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs new file mode 100644 index 0000000..78ff8a0 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -0,0 +1,20 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// Opt-in switch for MemoryCache package telemetry. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetry +{ + private static volatile bool s_enabled; + + public static bool IsEnabled => s_enabled; + + public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserMemoryCacheEventSource.Log.IsEnabled(); + + public static void Enable() => s_enabled = true; +} diff --git a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..9106ea9 --- /dev/null +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,22 @@ +// Copyright © https://myCSharp.de - all rights reserved + +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; + } +} diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 410637a..929df78 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; @@ -19,6 +20,19 @@ public static class HttpUserAgentParser /// Parses given user agent /// public static HttpUserAgentInformation Parse(string userAgent) + { + if (!HttpUserAgentParserTelemetry.AreCountersEnabled) + return ParseInternal(userAgent); + + long startTimestamp = Stopwatch.GetTimestamp(); + HttpUserAgentParserEventSource.Log.ParseRequest(); + + HttpUserAgentInformation result = ParseInternal(userAgent); + HttpUserAgentParserEventSource.Log.ParseDuration(Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + return result; + } + + private static HttpUserAgentInformation ParseInternal(string userAgent) { // prepare userAgent = Cleanup(userAgent); diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index 381fd5b..69e7b67 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; @@ -18,7 +19,27 @@ public class HttpUserAgentParserCachedProvider : IHttpUserAgentParserProvider /// Parses the user agent or uses the internal cached information /// public HttpUserAgentInformation Parse(string userAgent) - => _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + { + if (!HttpUserAgentParserTelemetry.AreCountersEnabled) + return _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + + if (_cache.TryGetValue(userAgent, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheHit(); + HttpUserAgentParserEventSource.Log.ConcurrentCacheSizeSet(_cache.Count); + return cached; + } + + // Note: ConcurrentDictionary can invoke the factory multiple times in races; counters are best-effort. + HttpUserAgentInformation result = _cache.GetOrAdd(userAgent, static ua => + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheMiss(); + return HttpUserAgentParser.Parse(ua); + }); + + HttpUserAgentParserEventSource.Log.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..f028245 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -0,0 +1,117 @@ +// 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. +/// +[EventSource(Name = "MyCSharp.HttpUserAgentParser")] +[ExcludeFromCodeCoverage] +internal sealed class HttpUserAgentParserEventSource : EventSource +{ + public static readonly HttpUserAgentParserEventSource Log = new(); + + private readonly IncrementingEventCounter _parseRequests; + private readonly EventCounter? _parseDurationMs; + + private readonly IncrementingEventCounter _concurrentCacheHit; + private readonly IncrementingEventCounter _concurrentCacheMiss; + private readonly PollingCounter _concurrentCacheSize; + + // Backing values for PollingCounter and for easy verification. + private static long s_concurrentCacheSize; + + private HttpUserAgentParserEventSource() + { + // Parser + _parseRequests = new IncrementingEventCounter("parse-requests", this) + { + DisplayName = "User-Agent parse requests", + DisplayUnits = "calls", + }; + + _parseDurationMs = new EventCounter("parse-duration", this) + { + DisplayName = "Parse duration", + DisplayUnits = "ms", + }; + + // Providers (cache) + _concurrentCacheHit = new IncrementingEventCounter("cache-concurrentdictionary-hit", this) + { + DisplayName = "ConcurrentDictionary cache hit", + DisplayUnits = "calls", + }; + + _concurrentCacheMiss = new IncrementingEventCounter("cache-concurrentdictionary-miss", this) + { + DisplayName = "ConcurrentDictionary cache miss", + DisplayUnits = "calls", + }; + + _concurrentCacheSize = new PollingCounter("cache-concurrentdictionary-size", this, static () => Volatile.Read(ref s_concurrentCacheSize)) + { + DisplayName = "ConcurrentDictionary cache size", + DisplayUnits = "entries", + }; + } + + [NonEvent] + public bool IsTelemetryEnabled() => IsEnabled(); + + [NonEvent] + public void ParseRequest() + { + if (!IsEnabled()) return; + _parseRequests?.Increment(); + } + + [NonEvent] + public void ParseDuration(double milliseconds) + { + if (!IsEnabled()) return; + _parseDurationMs?.WriteMetric(milliseconds); + } + + [NonEvent] + public void ConcurrentCacheHit() + { + if (!IsEnabled()) return; + _concurrentCacheHit?.Increment(); + } + + [NonEvent] + public void ConcurrentCacheMiss() + { + if (!IsEnabled()) return; + _concurrentCacheMiss?.Increment(); + } + + [NonEvent] + public void ConcurrentCacheSizeSet(int size) + { + // Size should be updated even if telemetry is currently disabled, so the polling counter is correct + // once a listener attaches. + Volatile.Write(ref s_concurrentCacheSize, size); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _parseRequests?.Dispose(); + _parseDurationMs?.Dispose(); + + _concurrentCacheHit?.Dispose(); + _concurrentCacheMiss?.Dispose(); + _concurrentCacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs new file mode 100644 index 0000000..16b778d --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -0,0 +1,21 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +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 +{ + private static volatile bool s_enabled; + + public static bool IsEnabled => s_enabled; + + public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserEventSource.Log.IsEnabled(); + + public static void Enable() => s_enabled = true; +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..6efe26b --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,43 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + return; + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + ["EventCounterIntervalSec"] = "0.1" + }); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + _sawEventCounters = true; + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs new file mode 100644 index 0000000..84449c7 --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs @@ -0,0 +1,27 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..af616a7 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,57 @@ +// 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/HttpUserAgentParserMemoryCacheTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs new file mode 100644 index 0000000..4140f42 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs @@ -0,0 +1,29 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + + 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.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..246e0e6 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,44 @@ +// 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/HttpUserAgentParserTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs new file mode 100644 index 0000000..258abdc --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs @@ -0,0 +1,27 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using MyCSharp.HttpUserAgentParser.Providers; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + + 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))); + } +} From 26636f184003ab5a28839c2658badfa8ed83b89e Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 16:16:09 +0100 Subject: [PATCH 2/7] feat(telemetry): add telemetry support and documentation --- .vscode/tasks.json | 133 ++++++++----- README.md | 179 +++++++++++++++++- ...ttpUserAgentParserAspNetCoreEventSource.cs | 16 +- src/HttpUserAgentParser.AspNetCore/readme.md | 82 +++++++- ...tpUserAgentParserMemoryCacheEventSource.cs | 20 +- src/HttpUserAgentParser.MemoryCache/readme.md | 103 +++++++++- .../HttpUserAgentParserEventSource.cs | 25 +-- src/HttpUserAgentParser/readme.md | 127 ++++++++++++- ...UserAgentParserAspNetCoreTelemetryTests.cs | 9 +- ...serAgentParserMemoryCacheTelemetryTests.cs | 9 +- .../HttpUserAgentParserTelemetryTests.cs | 9 +- 11 files changed, 630 insertions(+), 82 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 49a9187..04b0a19 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,51 +1,84 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "dotnet: restore", - "type": "shell", - "command": "dotnet", - "args": ["restore"], - "problemMatcher": "$msCompile", - "presentation": { - "reveal": "silent", - "panel": "dedicated", - "close": true, - "showReuseMessage": false - } - }, - { - "label": "dotnet: build", - "type": "shell", - "command": "dotnet", - "args": ["build", "--no-restore"], - "problemMatcher": "$msCompile", - "dependsOn": "dotnet: restore", - "presentation": { - "reveal": "always", - "panel": "dedicated", - "close": true, - "showReuseMessage": false - }, - "group": "build" - }, - { - "label": "dotnet: test", - "type": "shell", - "command": "dotnet", - "args": [ - "test", - "--no-build", - "--nologo" - ], - "problemMatcher": "$msCompile", - "dependsOn": "dotnet: build", - "presentation": { - "reveal": "always", - "panel": "dedicated", - "close": true, - "showReuseMessage": false - } - } - ] -} + "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 + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 599a556..e868aae 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Parsing HTTP User Agents with .NET | NuGet | |-| | [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser) | -| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCach.MemoryCache` | +| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache` | | [![MyCSharp.HttpUserAgentParser.AspNetCore](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.AspNetCore.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.AspNetCore)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.AspNetCore) | `dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore` | @@ -110,6 +110,183 @@ public void MyMethod(IHttpUserAgentParserAccessor parserAccessor) } ``` +## Telemetry (EventCounters) + +Telemetry is **opt-in** and **modular per package**. + +- Opt-in: no telemetry overhead unless you explicitly enable it. +- Modular: each package has its own `EventSource` name, so you can monitor only what you use. + +### Enable telemetry (Fluent API) + +Core parser telemetry: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentParser() + .WithTelemetry(); +} +``` + +MemoryCache telemetry (in addition to core, optional): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() // core counters (optional) + .WithMemoryCacheTelemetry(); +} +``` + +ASP.NET Core telemetry (header present/missing): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +} +``` + +### EventSource names and counters + +Core (`MyCSharp.HttpUserAgentParser`) + +- `parse-requests` +- `parse-duration` (ms) +- `cache-concurrentdictionary-hit` +- `cache-concurrentdictionary-miss` +- `cache-concurrentdictionary-size` + +MemoryCache (`MyCSharp.HttpUserAgentParser.MemoryCache`) + +- `cache-hit` +- `cache-miss` +- `cache-size` + +ASP.NET Core (`MyCSharp.HttpUserAgentParser.AspNetCore`) + +- `useragent-present` +- `useragent-missing` + +### Monitor counters + +Using `dotnet-counters`: + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +### Export to OpenTelemetry + +You can collect these EventCounters via OpenTelemetry metrics. + +Packages you typically need: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- an exporter (e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol`) + +Example (minimal): + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +### Export to Application Insights + +Two common approaches: + +1) OpenTelemetry → Application Insights (recommended) + - Collect counters with OpenTelemetry (see above) + - Export using an Azure Monitor / Application Insights exporter (API varies by package/version) + +2) Custom `EventListener` → `TelemetryClient` + - Attach an `EventListener` + - Parse the `EventCounters` payload + - Forward values as custom metrics + +### OpenTelemetry listener (recommended) + +You can collect EventCounters as OpenTelemetry metrics. + +Typical packages: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- An exporter, e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol` + +Example: + +```csharp +using OpenTelemetry.Metrics; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +From there you can route metrics to: + +- OpenTelemetry Collector +- Prometheus +- Azure Monitor / Application Insights (via an Azure Monitor exporter) + +### Application Insights listener (custom) + +If you want a direct listener, you can attach an `EventListener` and forward counter values into Application Insights custom metrics. + +High-level steps: + +1) Enable telemetry via `.WithTelemetry()` / `.WithMemoryCacheTelemetry()` / `.WithAspNetCoreTelemetry()` +2) Register an `EventListener` that enables the corresponding EventSources +3) On `EventCounters` payload, forward values to `TelemetryClient.GetMetric(...).TrackValue(...)` + +Notes: + +- This is best-effort telemetry. +- Prefer longer polling intervals (e.g. 10s) to reduce noise. + +> Notes +> +> - Counters are only emitted when telemetry is enabled and a listener is attached. +> - Values are best-effort and may include cache races. + ## Benchmark ```shell diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs index 80647e8..5104ce5 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -8,11 +8,16 @@ namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; /// /// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.AspNetCore. /// -[EventSource(Name = "MyCSharp.HttpUserAgentParser.AspNetCore")] +[EventSource(Name = EventSourceName)] [ExcludeFromCodeCoverage] -internal sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource +public sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource { - public static readonly HttpUserAgentParserAspNetCoreEventSource Log = new(); + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + + internal static HttpUserAgentParserAspNetCoreEventSource Log { get; } = new(); private readonly IncrementingEventCounter _userAgentPresent; private readonly IncrementingEventCounter _userAgentMissing; @@ -33,19 +38,20 @@ private HttpUserAgentParserAspNetCoreEventSource() } [NonEvent] - public void UserAgentPresent() + internal void UserAgentPresent() { if (!IsEnabled()) return; _userAgentPresent?.Increment(); } [NonEvent] - public void UserAgentMissing() + internal void UserAgentMissing() { if (!IsEnabled()) return; _userAgentMissing?.Increment(); } + /// protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md index 2cf587b..17d2074 100644 --- a/src/HttpUserAgentParser.AspNetCore/readme.md +++ b/src/HttpUserAgentParser.AspNetCore/readme.md @@ -1,5 +1,83 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.AspNetCore -Parsing HTTP User Agents with .NET +ASP.NET Core integration for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Quick start + +Register a provider (any of the available ones) and then add the accessor: + +The accessor pattern reads the `User-Agent` header from the current `HttpContext` and parses it using the registered provider. + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() // or: AddHttpUserAgentParser / AddHttpUserAgentCachedParser + .AddHttpUserAgentParserAccessor(); +``` + +Usage: + +```csharp +public sealed class MyController(IHttpUserAgentParserAccessor accessor) +{ + public HttpUserAgentInformation Get() => accessor.Get(); +} +``` + +### Just read the header + +If you only want the raw User-Agent string: + +```csharp +string? ua = HttpContext.GetUserAgentString(); +``` + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +``` + +> The accessor registration returns the same options object, so you can chain this after any parser registration. + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreEventSource.EventSourceName`) + +- `useragent-present` (incrementing) +- `useragent-missing` (incrementing) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Export to OpenTelemetry / Application Insights + +Collect via OpenTelemetry EventCounters instrumentation: + +```csharp +using OpenTelemetry.Metrics; + +metrics.AddEventCountersInstrumentation(options => +{ + options.AddEventSources(MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry.HttpUserAgentParserAspNetCoreEventSource.EventSourceName); +}); +``` + +Then export using your preferred exporter (OTLP, Prometheus, Azure Monitor / Application Insights, …). diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs index 1c699d8..485b4c2 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -8,11 +8,16 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; /// /// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.MemoryCache. /// -[EventSource(Name = "MyCSharp.HttpUserAgentParser.MemoryCache")] +[EventSource(Name = EventSourceName)] [ExcludeFromCodeCoverage] -internal sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource +public sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource { - public static readonly HttpUserAgentParserMemoryCacheEventSource Log = new(); + /// + /// 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; @@ -42,25 +47,26 @@ private HttpUserAgentParserMemoryCacheEventSource() } [NonEvent] - public void CacheHit() + internal void CacheHit() { if (!IsEnabled()) return; _cacheHit?.Increment(); } [NonEvent] - public void CacheMiss() + internal void CacheMiss() { if (!IsEnabled()) return; _cacheMiss?.Increment(); } [NonEvent] - public void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + internal void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); [NonEvent] - public void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); + internal void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); + /// protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md index 2cf587b..a835b92 100644 --- a/src/HttpUserAgentParser.MemoryCache/readme.md +++ b/src/HttpUserAgentParser.MemoryCache/readme.md @@ -1,5 +1,104 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.MemoryCache -Parsing HTTP User Agents with .NET +IMemoryCache-based caching provider for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## Quick start + +Register the provider: + +```csharp +services.AddHttpUserAgentMemoryCachedParser(); +``` + +Then inject `IHttpUserAgentParserProvider`: + +```csharp +public sealed class MyService(IHttpUserAgentParserProvider parser) +{ + public HttpUserAgentInformation Parse(string userAgent) => parser.Parse(userAgent); +``` + +### Configure cache + +```csharp +services.AddHttpUserAgentMemoryCachedParser(options => +{ + options.CacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(60); // default is 1 day + options.CacheOptions.SizeLimit = 1024; // default is null (= no limit) +}); +``` + +Notes: + +- Each unique user-agent string counts as one cache entry. +- The provider is registered as singleton and owns its internal `MemoryCache` instance. +- Like any cache, concurrent requests for a new key can race; counters are best-effort. + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheTelemetry(); +``` + +Optionally enable core counters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() + .WithMemoryCacheTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheEventSource.EventSourceName`) + +- `cache-hit` (incrementing) +- `cache-miss` (incrementing) +- `cache-size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## 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) + +### 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/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs index f028245..6f05266 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -10,11 +10,16 @@ namespace MyCSharp.HttpUserAgentParser.Telemetry; /// /// The implementation is designed to keep overhead negligible unless a listener is enabled. /// -[EventSource(Name = "MyCSharp.HttpUserAgentParser")] +[EventSource(Name = EventSourceName)] [ExcludeFromCodeCoverage] -internal sealed class HttpUserAgentParserEventSource : EventSource +public sealed class HttpUserAgentParserEventSource : EventSource { - public static readonly HttpUserAgentParserEventSource Log = new(); + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser"; + + internal static HttpUserAgentParserEventSource Log { get; } = new(); private readonly IncrementingEventCounter _parseRequests; private readonly EventCounter? _parseDurationMs; @@ -62,44 +67,42 @@ private HttpUserAgentParserEventSource() } [NonEvent] - public bool IsTelemetryEnabled() => IsEnabled(); - - [NonEvent] - public void ParseRequest() + internal void ParseRequest() { if (!IsEnabled()) return; _parseRequests?.Increment(); } [NonEvent] - public void ParseDuration(double milliseconds) + internal void ParseDuration(double milliseconds) { if (!IsEnabled()) return; _parseDurationMs?.WriteMetric(milliseconds); } [NonEvent] - public void ConcurrentCacheHit() + internal void ConcurrentCacheHit() { if (!IsEnabled()) return; _concurrentCacheHit?.Increment(); } [NonEvent] - public void ConcurrentCacheMiss() + internal void ConcurrentCacheMiss() { if (!IsEnabled()) return; _concurrentCacheMiss?.Increment(); } [NonEvent] - public void ConcurrentCacheSizeSet(int size) + internal void ConcurrentCacheSizeSet(int size) { // Size should be updated even if telemetry is currently disabled, so the polling counter is correct // once a listener attaches. Volatile.Write(ref s_concurrentCacheSize, size); } + /// protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/HttpUserAgentParser/readme.md b/src/HttpUserAgentParser/readme.md index 2cf587b..71e579f 100644 --- a/src/HttpUserAgentParser/readme.md +++ b/src/HttpUserAgentParser/readme.md @@ -1,5 +1,130 @@ # MyCSharp.HttpUserAgentParser -Parsing HTTP User Agents with .NET +Fast HTTP User-Agent parsing for .NET. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser +``` + +## Quick start (no DI) + +```csharp +string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; +HttpUserAgentInformation info = HttpUserAgentParser.Parse(userAgent); +// or: HttpUserAgentInformation.Parse(userAgent) +``` + +## Dependency injection + +If you want to inject a parser (e.g., in ASP.NET Core), use `IHttpUserAgentParserProvider`. + +### No cache + +```csharp +services + .AddHttpUserAgentParser(); +``` + +### ConcurrentDictionary cache + +```csharp +services + .AddHttpUserAgentCachedParser(); +// or: .AddHttpUserAgentParser(); +``` + +## Telemetry (EventCounters) + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: counters are only written when a listener is attached + +### Enable telemetry (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserEventSource.EventSourceName`) + +- `parse-requests` (incrementing) +- `parse-duration` (ms, event counter) +- `cache-concurrentdictionary-hit` (incrementing) +- `cache-concurrentdictionary-miss` (incrementing) +- `cache-concurrentdictionary-size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +``` + +## 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 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/HttpUserAgentParserAspNetCoreTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs index 84449c7..607e44f 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs @@ -1,6 +1,10 @@ // 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; @@ -10,7 +14,10 @@ public class HttpUserAgentParserAspNetCoreTelemetryTests [Fact] public void EventCounters_DoNotThrow_WhenEnabled() { - using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + using EventCounterTestListener listener = new(HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreTelemetry(); DefaultHttpContext ctx = new(); diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs index 4140f42..1b8950b 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs @@ -1,6 +1,10 @@ // 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; @@ -9,7 +13,10 @@ public class HttpUserAgentParserMemoryCacheTelemetryTests [Fact] public void EventCounters_DoNotThrow_WhenEnabled() { - using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + using EventCounterTestListener listener = new(HttpUserAgentParserMemoryCacheEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheTelemetry(); HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs index 258abdc..a5e2682 100644 --- a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs @@ -1,6 +1,9 @@ // 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; @@ -10,7 +13,11 @@ public class HttpUserAgentParserTelemetryTests [Fact] public void EventCounters_DoNotThrow_WhenEnabled() { - using EventCounterTestListener listener = new("MyCSharp.HttpUserAgentParser"); + 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"; From 770ece1b34e2ea2d57d584e74b2c8a6e125e6b58 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 20:17:12 +0100 Subject: [PATCH 3/7] feat(telemetry): add native metrics support for user agent parser --- ...encyInjectionOptionsTelemetryExtensions.cs | 12 ++ .../HttpContextExtensions.cs | 12 +- ...ttpUserAgentParserAspNetCoreEventSource.cs | 12 +- .../HttpUserAgentParserAspNetCoreMeters.cs | 50 ++++++++ .../HttpUserAgentParserAspNetCoreTelemetry.cs | 61 +++++++++- src/HttpUserAgentParser.AspNetCore/readme.md | 32 +++++- ...encyInjectionOptionsTelemetryExtensions.cs | 12 ++ ...HttpUserAgentParserMemoryCachedProvider.cs | 14 +-- ...tpUserAgentParserMemoryCacheEventSource.cs | 20 ++-- .../HttpUserAgentParserMemoryCacheMeters.cs | 57 ++++++++++ ...HttpUserAgentParserMemoryCacheTelemetry.cs | 69 ++++++++++- ...serAgentParserMemoryCacheTelemetryState.cs | 17 +++ src/HttpUserAgentParser.MemoryCache/readme.md | 40 +++++++ ...encyInjectionOptionsTelemetryExtensions.cs | 13 +++ .../HttpUserAgentParser.cs | 17 ++- .../HttpUserAgentParserCachedProvider.cs | 12 +- .../HttpUserAgentParserEventSource.cs | 31 +++-- .../Telemetry/HttpUserAgentParserMeters.cs | 95 ++++++++++++++++ .../Telemetry/HttpUserAgentParserTelemetry.cs | 107 +++++++++++++++++- .../HttpUserAgentParserTelemetryState.cs | 19 ++++ src/HttpUserAgentParser/readme.md | 44 +++++++ .../Telemetry/EventCounterTestListener.cs | 4 + ...entParserAspNetCoreMetersTelemetryTests.cs | 35 ++++++ .../Telemetry/MeterTestListener.cs | 39 +++++++ .../Telemetry/EventCounterTestListener.cs | 4 + ...ntParserMemoryCacheMetersTelemetryTests.cs | 37 ++++++ .../Telemetry/MeterTestListener.cs | 41 +++++++ .../Telemetry/EventCounterTestListener.cs | 4 + ...HttpUserAgentParserMetersTelemetryTests.cs | 56 +++++++++ .../Telemetry/MeterTestListener.cs | 46 ++++++++ 30 files changed, 961 insertions(+), 51 deletions(-) create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs diff --git a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs index a856493..60cb10a 100644 --- a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -1,5 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.Metrics; using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; using MyCSharp.HttpUserAgentParser.DependencyInjection; @@ -19,4 +20,15 @@ public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreTeleme HttpUserAgentParserAspNetCoreTelemetry.Enable(); return options; } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserAspNetCoreTelemetry.EnableMeters(meter); + return options; + } } diff --git a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs index f07eb54..d6f2462 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs @@ -18,14 +18,18 @@ public static class HttpContextExtensions { if (httpContext.Request.Headers.TryGetValue("User-Agent", out StringValues value)) { - if (HttpUserAgentParserAspNetCoreTelemetry.AreCountersEnabled) - HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentPresent(); + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentPresent(); + } return value; } - if (HttpUserAgentParserAspNetCoreTelemetry.AreCountersEnabled) - HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentMissing(); + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentMissing(); + } return null; } diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs index 5104ce5..69932a4 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -40,14 +40,22 @@ private HttpUserAgentParserAspNetCoreEventSource() [NonEvent] internal void UserAgentPresent() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _userAgentPresent?.Increment(); } [NonEvent] internal void UserAgentMissing() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _userAgentMissing?.Increment(); } diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs new file mode 100644 index 0000000..4551145 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs @@ -0,0 +1,50 @@ +// 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. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreMeters +{ + public const string MeterName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_userAgentPresent; + private static Counter? s_userAgentMissing; + + 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_userAgentPresent = s_meter.CreateCounter( + name: "useragent-present", + unit: "calls", + description: "User-Agent header present"); + + s_userAgentMissing = s_meter.CreateCounter( + name: "useragent-missing", + unit: "calls", + description: "User-Agent header missing"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentPresent() => s_userAgentPresent?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() => s_userAgentMissing?.Add(1); +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs index 2f6ab28..89666df 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -1,6 +1,8 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; @@ -10,11 +12,62 @@ namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserAspNetCoreTelemetry { - private static volatile bool s_enabled; + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; - public static bool IsEnabled => s_enabled; + private static int s_enabledFlags; - public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserAspNetCoreEventSource.Log.IsEnabled(); + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; - public static void Enable() => s_enabled = true; + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserAspNetCoreEventSource.Log.IsEnabled(); + + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserAspNetCoreMeters.IsEnabled; + + /// + /// Enables EventCounter telemetry for the AspNetCore package. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserAspNetCoreMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + [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(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentMissing(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserAspNetCoreMeters.UserAgentMissing(); + } + } } diff --git a/src/HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md index 17d2074..612da44 100644 --- a/src/HttpUserAgentParser.AspNetCore/readme.md +++ b/src/HttpUserAgentParser.AspNetCore/readme.md @@ -67,6 +67,25 @@ EventSource: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgent dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore ``` +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreMeters.MeterName`) + +- `useragent-present` (counter) +- `useragent-missing` (counter) + ## Export to OpenTelemetry / Application Insights Collect via OpenTelemetry EventCounters instrumentation: @@ -76,8 +95,19 @@ using OpenTelemetry.Metrics; metrics.AddEventCountersInstrumentation(options => { - options.AddEventSources(MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry.HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + 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 index bd8bd40..f88ca16 100644 --- a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -1,5 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.Metrics; using MyCSharp.HttpUserAgentParser.DependencyInjection; using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; @@ -19,4 +20,15 @@ public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheTelem HttpUserAgentParserMemoryCacheTelemetry.Enable(); return options; } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserMemoryCacheTelemetry.EnableMeters(meter); + return options; + } } diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index 193bb9b..1bdb4db 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -24,12 +24,13 @@ public HttpUserAgentInformation Parse(string userAgent) CacheKey key = GetKey(userAgent); if (!HttpUserAgentParserMemoryCacheTelemetry.IsEnabled) + { return ParseWithoutTelemetry(key); + } - bool countersEnabled = HttpUserAgentParserMemoryCacheTelemetry.AreCountersEnabled; - if (countersEnabled && _memoryCache.TryGetValue(key, out HttpUserAgentInformation cached)) + if (_memoryCache.TryGetValue(key, out HttpUserAgentInformation cached)) { - HttpUserAgentParserMemoryCacheEventSource.Log.CacheHit(); + HttpUserAgentParserMemoryCacheTelemetry.CacheHit(); return cached; } @@ -40,11 +41,10 @@ public HttpUserAgentInformation Parse(string userAgent) entry.SetSize(1); // Miss path. Note: Like other cache implementations, races can happen; counters are best-effort. - if (HttpUserAgentParserMemoryCacheTelemetry.AreCountersEnabled) - HttpUserAgentParserMemoryCacheEventSource.Log.CacheMiss(); + HttpUserAgentParserMemoryCacheTelemetry.CacheMiss(); - HttpUserAgentParserMemoryCacheEventSource.Log.CacheSizeIncrement(); - entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheEventSource.Log.CacheSizeDecrement()); + HttpUserAgentParserMemoryCacheTelemetry.CacheSizeIncrement(); + entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheTelemetry.CacheSizeDecrement()); return HttpUserAgentParser.Parse(key.UserAgent); }); diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs index 485b4c2..ddc5a55 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -23,8 +23,6 @@ public sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource private readonly IncrementingEventCounter _cacheMiss; private readonly PollingCounter _cacheSize; - private static long s_cacheSize; - private HttpUserAgentParserMemoryCacheEventSource() { _cacheHit = new IncrementingEventCounter("cache-hit", this) @@ -39,7 +37,7 @@ private HttpUserAgentParserMemoryCacheEventSource() DisplayUnits = "calls", }; - _cacheSize = new PollingCounter("cache-size", this, static () => Volatile.Read(ref s_cacheSize)) + _cacheSize = new PollingCounter("cache-size", this, static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize) { DisplayName = "MemoryCache cache size", DisplayUnits = "entries", @@ -49,22 +47,30 @@ private HttpUserAgentParserMemoryCacheEventSource() [NonEvent] internal void CacheHit() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _cacheHit?.Increment(); } [NonEvent] internal void CacheMiss() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _cacheMiss?.Increment(); } [NonEvent] - internal void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + internal void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); [NonEvent] - internal void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); + internal void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); /// protected override void Dispose(bool disposing) diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs new file mode 100644 index 0000000..adaf411 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs @@ -0,0 +1,57 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser.MemoryCache. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheMeters +{ + public const string MeterName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_cacheHit; + private static Counter? s_cacheMiss; + private static ObservableGauge? s_cacheSize; + + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_cacheHit = s_meter.CreateCounter( + name: "cache-hit", + unit: "calls", + description: "MemoryCache cache hit"); + + s_cacheMiss = s_meter.CreateCounter( + name: "cache-miss", + unit: "calls", + description: "MemoryCache cache miss"); + + s_cacheSize = s_meter.CreateObservableGauge( + name: "cache-size", + observeValue: static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize, + unit: "entries", + description: "MemoryCache cache size"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() => s_cacheHit?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() => s_cacheMiss?.Add(1); +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs index 78ff8a0..e957055 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -1,6 +1,8 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; @@ -10,11 +12,70 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserMemoryCacheTelemetry { - private static volatile bool s_enabled; + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; - public static bool IsEnabled => s_enabled; + private static int s_enabledFlags; - public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserMemoryCacheEventSource.Log.IsEnabled(); + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; - public static void Enable() => s_enabled = true; + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserMemoryCacheEventSource.Log.IsEnabled(); + + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMemoryCacheMeters.IsEnabled; + + /// + /// Enables EventCounter telemetry for the MemoryCache provider. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMemoryCacheMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + [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(); + } + } + + [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(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeIncrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeDecrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); } diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs new file mode 100644 index 0000000..c232365 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs @@ -0,0 +1,17 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetryState +{ + private static long s_cacheSize; + + public static long CacheSize => Volatile.Read(ref s_cacheSize); + + public static void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + + public static void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); +} diff --git a/src/HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md index a835b92..33d7b8e 100644 --- a/src/HttpUserAgentParser.MemoryCache/readme.md +++ b/src/HttpUserAgentParser.MemoryCache/readme.md @@ -78,6 +78,35 @@ EventSource: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgen dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache ``` +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheMeterTelemetry(); +``` + +Optionally enable core meters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMeterTelemetry() + .WithMemoryCacheMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheMeters.MeterName`) + +- `cache-hit` (counter) +- `cache-miss` (counter) +- `cache-size` (observable gauge) + ## Export to OpenTelemetry / Application Insights You can collect these counters with OpenTelemetry’s EventCounters instrumentation. @@ -99,6 +128,17 @@ From there you can export to: - 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 index 9106ea9..c04fc2f 100644 --- a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -1,5 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.Metrics; using MyCSharp.HttpUserAgentParser.Telemetry; namespace MyCSharp.HttpUserAgentParser.DependencyInjection; @@ -19,4 +20,16 @@ public static HttpUserAgentParserDependencyInjectionOptions WithTelemetry( HttpUserAgentParserTelemetry.Enable(); return options; } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// This is opt-in to keep the default path free of telemetry overhead. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserTelemetry.EnableMeters(meter); + return options; + } } diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 929df78..297f416 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -21,14 +21,23 @@ public static class HttpUserAgentParser /// public static HttpUserAgentInformation Parse(string userAgent) { - if (!HttpUserAgentParserTelemetry.AreCountersEnabled) + if (!HttpUserAgentParserTelemetry.IsEnabled) + { return ParseInternal(userAgent); + } - long startTimestamp = Stopwatch.GetTimestamp(); - HttpUserAgentParserEventSource.Log.ParseRequest(); + bool measureDuration = HttpUserAgentParserTelemetry.ShouldMeasureParseDuration; + long startTimestamp = measureDuration ? Stopwatch.GetTimestamp() : 0; + + HttpUserAgentParserTelemetry.ParseRequest(); HttpUserAgentInformation result = ParseInternal(userAgent); - HttpUserAgentParserEventSource.Log.ParseDuration(Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + + if (measureDuration) + { + HttpUserAgentParserTelemetry.ParseDuration(Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + } + return result; } diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index 69e7b67..b60af6e 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -20,24 +20,26 @@ public class HttpUserAgentParserCachedProvider : IHttpUserAgentParserProvider /// public HttpUserAgentInformation Parse(string userAgent) { - if (!HttpUserAgentParserTelemetry.AreCountersEnabled) + if (!HttpUserAgentParserTelemetry.IsEnabled) + { return _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + } if (_cache.TryGetValue(userAgent, out HttpUserAgentInformation cached)) { - HttpUserAgentParserEventSource.Log.ConcurrentCacheHit(); - HttpUserAgentParserEventSource.Log.ConcurrentCacheSizeSet(_cache.Count); + HttpUserAgentParserTelemetry.ConcurrentCacheHit(); + HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); return cached; } // Note: ConcurrentDictionary can invoke the factory multiple times in races; counters are best-effort. HttpUserAgentInformation result = _cache.GetOrAdd(userAgent, static ua => { - HttpUserAgentParserEventSource.Log.ConcurrentCacheMiss(); + HttpUserAgentParserTelemetry.ConcurrentCacheMiss(); return HttpUserAgentParser.Parse(ua); }); - HttpUserAgentParserEventSource.Log.ConcurrentCacheSizeSet(_cache.Count); + HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); return result; } diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs index 6f05266..f42ed68 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -28,9 +28,6 @@ public sealed class HttpUserAgentParserEventSource : EventSource private readonly IncrementingEventCounter _concurrentCacheMiss; private readonly PollingCounter _concurrentCacheSize; - // Backing values for PollingCounter and for easy verification. - private static long s_concurrentCacheSize; - private HttpUserAgentParserEventSource() { // Parser @@ -59,7 +56,7 @@ private HttpUserAgentParserEventSource() DisplayUnits = "calls", }; - _concurrentCacheSize = new PollingCounter("cache-concurrentdictionary-size", this, static () => Volatile.Read(ref s_concurrentCacheSize)) + _concurrentCacheSize = new PollingCounter("cache-concurrentdictionary-size", this, static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize) { DisplayName = "ConcurrentDictionary cache size", DisplayUnits = "entries", @@ -69,28 +66,44 @@ private HttpUserAgentParserEventSource() [NonEvent] internal void ParseRequest() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _parseRequests?.Increment(); } [NonEvent] internal void ParseDuration(double milliseconds) { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _parseDurationMs?.WriteMetric(milliseconds); } [NonEvent] internal void ConcurrentCacheHit() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _concurrentCacheHit?.Increment(); } [NonEvent] internal void ConcurrentCacheMiss() { - if (!IsEnabled()) return; + if (!IsEnabled()) + { + return; + } + _concurrentCacheMiss?.Increment(); } @@ -99,7 +112,7 @@ internal void ConcurrentCacheSizeSet(int size) { // Size should be updated even if telemetry is currently disabled, so the polling counter is correct // once a listener attaches. - Volatile.Write(ref s_concurrentCacheSize, size); + HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); } /// diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs new file mode 100644 index 0000000..456e9fc --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -0,0 +1,95 @@ +// 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. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMeters +{ + public const string MeterName = "MyCSharp.HttpUserAgentParser"; + + private static int s_initialized; + + private static Meter? s_meter; + + private static Counter? s_parseRequests; + private static Histogram? s_parseDurationMs; + + private static Counter? s_concurrentCacheHit; + private static Counter? s_concurrentCacheMiss; + private static ObservableGauge? s_concurrentCacheSize; + + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + public static bool IsParseDurationEnabled => s_parseDurationMs?.Enabled ?? false; + + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_parseRequests = s_meter.CreateCounter( + name: "parse-requests", + unit: "calls", + description: "User-Agent parse requests"); + + s_parseDurationMs = s_meter.CreateHistogram( + name: "parse-duration", + unit: "ms", + description: "Parse duration"); + + s_concurrentCacheHit = s_meter.CreateCounter( + name: "cache-concurrentdictionary-hit", + unit: "calls", + description: "ConcurrentDictionary cache hit"); + + s_concurrentCacheMiss = s_meter.CreateCounter( + name: "cache-concurrentdictionary-miss", + unit: "calls", + description: "ConcurrentDictionary cache miss"); + + s_concurrentCacheSize = s_meter.CreateObservableGauge( + name: "cache-concurrentdictionary-size", + observeValue: static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize, + unit: "entries", + description: "ConcurrentDictionary cache size"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseRequest() => s_parseRequests?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(double milliseconds) => s_parseDurationMs?.Record(milliseconds); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheHit() => s_concurrentCacheHit?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheMiss() => s_concurrentCacheMiss?.Add(1); + +#if DEBUG + public static void ResetForTests() + { + Volatile.Write(ref s_initialized, 0); + + s_meter = null; + s_parseRequests = null; + s_parseDurationMs = null; + s_concurrentCacheHit = null; + s_concurrentCacheMiss = null; + s_concurrentCacheSize = null; + } +#endif +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs index 16b778d..ee9551c 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -1,6 +1,8 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser.Telemetry; @@ -11,11 +13,108 @@ namespace MyCSharp.HttpUserAgentParser.Telemetry; [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserTelemetry { - private static volatile bool s_enabled; + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; - public static bool IsEnabled => s_enabled; + private static int s_enabledFlags; - public static bool AreCountersEnabled => s_enabled && HttpUserAgentParserEventSource.Log.IsEnabled(); + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; - public static void Enable() => s_enabled = true; + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserEventSource.Log.IsEnabled(); + + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMeters.IsEnabled; + + public static bool ShouldMeasureParseDuration + => AreCountersEnabled || HttpUserAgentParserMeters.IsParseDurationEnabled; + + /// + /// Enables core EventCounter telemetry for the parser. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + [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(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(double milliseconds) + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ParseDuration(milliseconds); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ParseDuration(milliseconds); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheHit() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheHit(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ConcurrentCacheHit(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheMiss() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ConcurrentCacheMiss(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ConcurrentCacheMiss(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheSizeSet(int size) + => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + +#if DEBUG + public static void ResetForTests() + { + Volatile.Write(ref s_enabledFlags, 0); + HttpUserAgentParserTelemetryState.ResetForTests(); + HttpUserAgentParserMeters.ResetForTests(); + } +#endif } diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs new file mode 100644 index 0000000..3f624b8 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs @@ -0,0 +1,19 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserTelemetryState +{ + private static long s_concurrentCacheSize; + + public static long ConcurrentCacheSize => Volatile.Read(ref s_concurrentCacheSize); + + public static void SetConcurrentCacheSize(int size) => Volatile.Write(ref s_concurrentCacheSize, size); + +#if DEBUG + public static void ResetForTests() => Volatile.Write(ref s_concurrentCacheSize, 0); +#endif +} diff --git a/src/HttpUserAgentParser/readme.md b/src/HttpUserAgentParser/readme.md index 71e579f..64b2aa3 100644 --- a/src/HttpUserAgentParser/readme.md +++ b/src/HttpUserAgentParser/readme.md @@ -69,6 +69,33 @@ EventSource: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserEvent dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser ``` +## Telemetry (native Meters) + +In addition to EventCounters, this package can emit **native** `System.Diagnostics.Metrics` instruments. + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: measurements are only recorded when enabled + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserMeters.MeterName`) + +- `parse-requests` (counter) +- `parse-duration` (histogram, ms) +- `cache-concurrentdictionary-hit` (counter) +- `cache-concurrentdictionary-miss` (counter) +- `cache-concurrentdictionary-size` (observable gauge) + ## Export to OpenTelemetry You can collect these EventCounters via OpenTelemetry metrics and export them (OTLP, Prometheus, Azure Monitor, …). @@ -99,6 +126,23 @@ builder.Services.AddOpenTelemetry() > 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: diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs index 6efe26b..9a75e72 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs @@ -12,7 +12,9 @@ internal sealed class EventCounterTestListener(string eventSourceName) : EventLi protected override void OnEventSourceCreated(EventSource eventSource) { if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { return; + } EnableEvents( eventSource, @@ -27,7 +29,9 @@ protected override void OnEventSourceCreated(EventSource eventSource) protected override void OnEventWritten(EventWrittenEventArgs eventData) { if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { _sawEventCounters = true; + } } public bool WaitForCounters(TimeSpan timeout) diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs new file mode 100644 index 0000000..81280ff --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs @@ -0,0 +1,35 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + using MeterTestListener listener = new("MyCSharp.HttpUserAgentParser.AspNetCore"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreMeterTelemetry(); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.Contains("useragent-present", listener.InstrumentNames); + Assert.Contains("useragent-missing", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/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 index af616a7..760c7c6 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs @@ -13,7 +13,9 @@ internal sealed class EventCounterTestListener(string eventSourceName) : EventLi protected override void OnEventSourceCreated(EventSource eventSource) { if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { return; + } EnableEvents( eventSource, @@ -30,7 +32,9 @@ protected override void OnEventSourceCreated(EventSource eventSource) protected override void OnEventWritten(EventWrittenEventArgs eventData) { if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { _sawEventCounters = true; + } } public bool WaitForCounters(TimeSpan timeout) diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs new file mode 100644 index 0000000..3a72d81 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs @@ -0,0 +1,37 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache; +using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + using MeterTestListener listener = new("MyCSharp.HttpUserAgentParser.MemoryCache"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheMeterTelemetry(); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + _ = provider.Parse(ua1); // miss + _ = provider.Parse(ua1); // hit + _ = provider.Parse(ua2); // miss + + listener.RecordObservableInstruments(); + + Assert.Contains("cache-hit", listener.InstrumentNames); + Assert.Contains("cache-miss", listener.InstrumentNames); + Assert.Contains("cache-size", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/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 index 246e0e6..91f0e9b 100644 --- a/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs @@ -12,7 +12,9 @@ internal sealed class EventCounterTestListener(string eventSourceName) : EventLi protected override void OnEventSourceCreated(EventSource eventSource) { if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { return; + } EnableEvents( eventSource, @@ -28,7 +30,9 @@ protected override void OnEventSourceCreated(EventSource eventSource) protected override void OnEventWritten(EventWrittenEventArgs eventData) { if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { _sawEventCounters = true; + } } public bool WaitForCounters(TimeSpan timeout) diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs new file mode 100644 index 0000000..98844eb --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs @@ -0,0 +1,56 @@ +// 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() + { +#if DEBUG + HttpUserAgentParserTelemetry.ResetForTests(); +#endif + + 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() + { +#if DEBUG + HttpUserAgentParserTelemetry.ResetForTests(); +#endif + + using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMeterTelemetry(); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = HttpUserAgentInformation.Parse(ua); + + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + listener.RecordObservableInstruments(); + + Assert.Contains("parse-requests", listener.InstrumentNames); + Assert.Contains("parse-duration", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-hit", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-miss", listener.InstrumentNames); + Assert.Contains("cache-concurrentdictionary-size", listener.InstrumentNames); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..cfa7825 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,46 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void RecordObservableInstruments() => _listener.RecordObservableInstruments(); + + public void Dispose() => _listener.Dispose(); +} From 081727ecdb0f75eb37c3bd7293d8eec6dfaba835 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 20:33:29 +0100 Subject: [PATCH 4/7] feat(telemetry): enhance metrics and telemetry documentation --- .../HttpUserAgentParserAccessor.cs | 5 ++ ...ttpUserAgentParserAspNetCoreEventSource.cs | 25 +++++- .../HttpUserAgentParserAspNetCoreMeters.cs | 52 +++++++++++- .../HttpUserAgentParserAspNetCoreTelemetry.cs | 58 +++++++++++++- ...HttpUserAgentParserMemoryCachedProvider.cs | 79 +++++++++++++++++-- .../HttpUserAgentParserMemoryCacheMeters.cs | 2 +- ...HttpUserAgentParserMemoryCacheTelemetry.cs | 50 ++++++++++-- ...serAgentParserMemoryCacheTelemetryState.cs | 25 ++++++ .../HttpUserAgentParser.cs | 9 +++ .../HttpUserAgentParserCachedProvider.cs | 15 +++- .../HttpUserAgentParserEventSource.cs | 49 ++++++++++-- .../Telemetry/HttpUserAgentParserMeters.cs | 30 ++++++- .../Telemetry/HttpUserAgentParserTelemetry.cs | 64 ++++++++++++--- 13 files changed, 423 insertions(+), 40 deletions(-) diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs index c5f87ed..d2f8fd2 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs @@ -14,6 +14,11 @@ namespace MyCSharp.HttpUserAgentParser.AspNetCore; public class HttpUserAgentParserAccessor(IHttpUserAgentParserProvider httpUserAgentParser) : IHttpUserAgentParserAccessor { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + private readonly IHttpUserAgentParserProvider _httpUserAgentParser = httpUserAgentParser; /// diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs index 69932a4..60ebc82 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -8,6 +8,11 @@ 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 @@ -17,11 +22,17 @@ public sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource /// 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) @@ -37,6 +48,9 @@ private HttpUserAgentParserAspNetCoreEventSource() }; } + /// + /// Increments the EventCounter for requests with a present User-Agent header. + /// [NonEvent] internal void UserAgentPresent() { @@ -48,6 +62,9 @@ internal void UserAgentPresent() _userAgentPresent?.Increment(); } + /// + /// Increments the EventCounter for requests with a missing User-Agent header. + /// [NonEvent] internal void UserAgentMissing() { @@ -59,7 +76,13 @@ internal void UserAgentMissing() _userAgentMissing?.Increment(); } - /// + /// + /// Releases all EventCounter resources used by this EventSource. + /// + /// + /// when called from ; + /// when called from a finalizer. + /// protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs index 4551145..3959426 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs @@ -9,19 +9,47 @@ 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 { - public const string MeterName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + /// + /// Name of the meter used to publish AspNetCore User-Agent metrics. + /// + public const string MeterName = HttpUserAgentParserAccessor.MeterName; + /// + /// Indicates whether the meter and its instruments have been initialized. + /// private static int s_initialized; private static Meter? s_meter; private static Counter? s_userAgentPresent; private static Counter? s_userAgentMissing; - public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + /// + /// 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) @@ -42,9 +70,25 @@ public static void Enable(Meter? meter = null) 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); + 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); + public static void UserAgentMissing() + => s_userAgentMissing?.Add(1); } diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs index 89666df..cfac77b 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -9,20 +9,61 @@ 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; - public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; + /// + /// 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; @@ -41,6 +82,13 @@ public static void EnableMeters(Meter? meter = null) 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() { @@ -56,6 +104,13 @@ public static void 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() { @@ -70,4 +125,5 @@ public static void UserAgentMissing() HttpUserAgentParserAspNetCoreMeters.UserAgentMissing(); } } + } diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index 1bdb4db..2560a8c 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -2,8 +2,8 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Caching.Memory; -using MyCSharp.HttpUserAgentParser.Providers; using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using MyCSharp.HttpUserAgentParser.Providers; namespace MyCSharp.HttpUserAgentParser.MemoryCache; @@ -15,10 +15,22 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache; public class HttpUserAgentParserMemoryCachedProvider( HttpUserAgentParserMemoryCachedProviderOptions options) : IHttpUserAgentParserProvider { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + private readonly Microsoft.Extensions.Caching.Memory.MemoryCache _memoryCache = new(options.CacheOptions); private readonly HttpUserAgentParserMemoryCachedProviderOptions _options = options; /// + /// + /// This method includes performance optimizations for telemetry: + /// + /// Telemetry checks use a volatile flag to ensure zero overhead when disabled. + /// Cache size tracking (via and ) is skipped entirely if the size metric is not enabled to avoid allocations. + /// + /// public HttpUserAgentInformation Parse(string userAgent) { CacheKey key = GetKey(userAgent); @@ -43,19 +55,31 @@ public HttpUserAgentInformation Parse(string userAgent) // Miss path. Note: Like other cache implementations, races can happen; counters are best-effort. HttpUserAgentParserMemoryCacheTelemetry.CacheMiss(); - HttpUserAgentParserMemoryCacheTelemetry.CacheSizeIncrement(); - entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheTelemetry.CacheSizeDecrement()); + 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); @@ -63,9 +87,22 @@ private HttpUserAgentInformation ParseWithoutTelemetry(CacheKey key) }); } + /// + /// 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(); @@ -76,15 +113,43 @@ 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); - - public override int GetHashCode() => UserAgent.GetHashCode(StringComparison.Ordinal); + /// + /// 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); + + /// + /// 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/HttpUserAgentParserMemoryCacheMeters.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs index adaf411..0a1a7a5 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs @@ -12,7 +12,7 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserMemoryCacheMeters { - public const string MeterName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + public const string MeterName = HttpUserAgentParserMemoryCachedProvider.MeterName; private static int s_initialized; diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs index e957055..e7e1b9c 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -1,7 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Metrics; using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; @@ -12,21 +11,43 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache.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. /// @@ -35,12 +56,9 @@ public static bool AreMetersEnabled /// /// 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() { @@ -56,6 +74,9 @@ public static void CacheHit() } } + /// + /// Records a cache miss. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void CacheMiss() { @@ -71,11 +92,26 @@ public static void CacheMiss() } } + /// + /// Increments the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + /// + /// Decrements the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); + } diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs index c232365..727f691 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs @@ -4,14 +4,39 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +/// +/// Holds telemetry state for tracking the size of the HTTP User-Agent parser memory cache. +/// +/// +/// This class is excluded from code coverage as it contains only simple +/// thread-safe state management logic. +/// [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserMemoryCacheTelemetryState { private static long s_cacheSize; + /// + /// Gets the current number of entries in the memory cache. + /// + /// + /// The value is read atomically to ensure thread safety. + /// public static long CacheSize => Volatile.Read(ref s_cacheSize); + /// + /// Increments the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// public static void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + /// + /// Decrements the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// public static void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); } diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 297f416..a3a2dae 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -16,9 +16,18 @@ namespace MyCSharp.HttpUserAgentParser; /// public static class HttpUserAgentParser { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "MyCSharp.HttpUserAgentParser"; + /// /// Parses given user agent /// + /// + /// If telemetry is enabled, this method will emit metrics for parse requests and duration. + /// The telemetry check is designed to be zero-overhead when disabled (using a volatile boolean check). + /// public static HttpUserAgentInformation Parse(string userAgent) { if (!HttpUserAgentParserTelemetry.IsEnabled) diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index b60af6e..a3f792b 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -18,6 +18,13 @@ 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) { if (!HttpUserAgentParserTelemetry.IsEnabled) @@ -28,7 +35,6 @@ public HttpUserAgentInformation Parse(string userAgent) if (_cache.TryGetValue(userAgent, out HttpUserAgentInformation cached)) { HttpUserAgentParserTelemetry.ConcurrentCacheHit(); - HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); return cached; } @@ -39,7 +45,12 @@ public HttpUserAgentInformation Parse(string userAgent) return HttpUserAgentParser.Parse(ua); }); - HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); + if (HttpUserAgentParserTelemetry.IsCacheSizeEnabled) + { + // Optimization: Avoid expensive .Count property access (locks all buckets) if telemetry is disabled. + HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); + } + return result; } diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs index f42ed68..dabb71d 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -7,9 +7,12 @@ namespace MyCSharp.HttpUserAgentParser.Telemetry; /// /// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser. -/// -/// The implementation is designed to keep overhead negligible unless a listener is enabled. /// +/// +/// 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 @@ -19,6 +22,9 @@ public sealed class HttpUserAgentParserEventSource : EventSource /// public const string EventSourceName = "MyCSharp.HttpUserAgentParser"; + /// + /// Singleton instance of the EventSource. + /// internal static HttpUserAgentParserEventSource Log { get; } = new(); private readonly IncrementingEventCounter _parseRequests; @@ -28,6 +34,9 @@ public sealed class HttpUserAgentParserEventSource : EventSource private readonly IncrementingEventCounter _concurrentCacheMiss; private readonly PollingCounter _concurrentCacheSize; + /// + /// Initializes all EventCounters and polling counters used by this EventSource. + /// private HttpUserAgentParserEventSource() { // Parser @@ -56,13 +65,19 @@ private HttpUserAgentParserEventSource() DisplayUnits = "calls", }; - _concurrentCacheSize = new PollingCounter("cache-concurrentdictionary-size", this, static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize) + _concurrentCacheSize = new PollingCounter( + "cache-concurrentdictionary-size", + this, + static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize) { DisplayName = "ConcurrentDictionary cache size", DisplayUnits = "entries", }; } + /// + /// Records a User-Agent parse request. + /// [NonEvent] internal void ParseRequest() { @@ -74,6 +89,10 @@ internal void ParseRequest() _parseRequests?.Increment(); } + /// + /// Records the duration of a User-Agent parse operation. + /// + /// Elapsed parse time in milliseconds. [NonEvent] internal void ParseDuration(double milliseconds) { @@ -85,6 +104,9 @@ internal void ParseDuration(double milliseconds) _parseDurationMs?.WriteMetric(milliseconds); } + /// + /// Records a cache hit in the concurrent dictionary provider. + /// [NonEvent] internal void ConcurrentCacheHit() { @@ -96,6 +118,9 @@ internal void ConcurrentCacheHit() _concurrentCacheHit?.Increment(); } + /// + /// Records a cache miss in the concurrent dictionary provider. + /// [NonEvent] internal void ConcurrentCacheMiss() { @@ -107,15 +132,27 @@ internal void ConcurrentCacheMiss() _concurrentCacheMiss?.Increment(); } + /// + /// Updates the concurrent cache size used by the polling counter. + /// + /// Current number of entries in the cache. + /// + /// The size is updated even when telemetry is disabled so that the polling + /// counter reports a correct value once a listener attaches. + /// [NonEvent] internal void ConcurrentCacheSizeSet(int size) { - // Size should be updated even if telemetry is currently disabled, so the polling counter is correct - // once a listener attaches. 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) diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs index 456e9fc..d0935d2 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -8,13 +8,15 @@ 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. /// [ExcludeFromCodeCoverage] internal static class HttpUserAgentParserMeters { - public const string MeterName = "MyCSharp.HttpUserAgentParser"; + /// + /// The meter name used for all instruments. + /// + public const string MeterName = HttpUserAgentParser.MeterName; private static int s_initialized; @@ -27,10 +29,19 @@ internal static class HttpUserAgentParserMeters private static Counter? s_concurrentCacheMiss; private static ObservableGauge? s_concurrentCacheSize; + /// + /// Gets whether meters have been initialized. + /// public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + /// + /// Gets whether the parse duration histogram is currently enabled by a listener. + /// public static bool IsParseDurationEnabled => s_parseDurationMs?.Enabled ?? false; + /// + /// Initializes the meter and creates the instruments (idempotent). + /// public static void Enable(Meter? meter = null) { if (Interlocked.Exchange(ref s_initialized, 1) == 1) @@ -67,19 +78,34 @@ public static void Enable(Meter? meter = null) description: "ConcurrentDictionary cache size"); } + /// + /// Emits a counter increment for a parse request. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ParseRequest() => s_parseRequests?.Add(1); + /// + /// Records the parse duration in milliseconds. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ParseDuration(double milliseconds) => s_parseDurationMs?.Record(milliseconds); + /// + /// Emits a counter increment for a concurrent dictionary cache hit. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheHit() => s_concurrentCacheHit?.Add(1); + /// + /// Emits a counter increment for a concurrent dictionary cache miss. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheMiss() => s_concurrentCacheMiss?.Add(1); #if DEBUG + /// + /// Resets static state to support isolated unit tests. + /// public static void ResetForTests() { Volatile.Write(ref s_initialized, 0); diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs index ee9551c..c790230 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -13,38 +13,57 @@ namespace MyCSharp.HttpUserAgentParser.Telemetry; [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; /// - /// Enables core EventCounter telemetry for the parser. + /// Checks if cache size tracking is enabled for either system. + /// This is used to guard expensive operations like .Count or Interlocked updates. /// - public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + public static bool IsCacheSizeEnabled + => AreCountersEnabled || AreMetersEnabled; /// - /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// Enables core EventCounter telemetry for the parser. + /// + public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); +/// + /// Records a parse request event. /// - public static void EnableMeters(Meter? meter = null) - { - HttpUserAgentParserMeters.Enable(meter); - Interlocked.Or(ref s_enabledFlags, MetersFlag); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ParseRequest() { @@ -60,6 +79,10 @@ public static void ParseRequest() } } + /// + /// Records the duration of a parse request. + /// + /// The duration in milliseconds. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ParseDuration(double milliseconds) { @@ -75,6 +98,9 @@ public static void ParseDuration(double milliseconds) } } + /// + /// Records a cache hit in the concurrent dictionary. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheHit() { @@ -90,6 +116,9 @@ public static void ConcurrentCacheHit() } } + /// + /// Records a cache miss in the concurrent dictionary. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheMiss() { @@ -105,6 +134,23 @@ public static void ConcurrentCacheMiss() } } + /// + /// Updates the concurrent cache size. + /// + /// The current size of the cache. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheSizeSet(int size) + => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + +#if DEBUG + /// + /// Resets telemetry state for unit testing. + /// f ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ConcurrentCacheMiss(); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheSizeSet(int size) => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); From 055ed40eccd09dd18c5a2350c997c10265e18033 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 20:37:26 +0100 Subject: [PATCH 5/7] feat(telemetry): enhance telemetry documentation and add meters support --- .../Telemetry/HttpUserAgentParserMeters.cs | 9 ++++++- .../Telemetry/HttpUserAgentParserTelemetry.cs | 26 +++++++++--------- .../HttpUserAgentParserTelemetryState.cs | 27 ++++++++++++++++--- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs index d0935d2..fbaafd6 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -10,6 +10,10 @@ 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 { @@ -40,8 +44,11 @@ internal static class HttpUserAgentParserMeters public static bool IsParseDurationEnabled => s_parseDurationMs?.Enabled ?? false; /// - /// Initializes the meter and creates the instruments (idempotent). + /// Initializes the meter and creates all metric instruments. /// + /// + /// Initialization is performed at most once. Subsequent calls are ignored. + /// public static void Enable(Meter? meter = null) { if (Interlocked.Exchange(ref s_initialized, 1) == 1) diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs index c790230..ec89f80 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -61,7 +61,17 @@ public static bool IsCacheSizeEnabled /// Enables core EventCounter telemetry for the parser. /// public static void Enable() => Interlocked.Or(ref s_enabledFlags, EventCountersFlag); -/// + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// /// Records a parse request event. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -144,18 +154,8 @@ public static void ConcurrentCacheSizeSet(int size) #if DEBUG /// - /// Resets telemetry state for unit testing. - /// f ((flags & MetersFlag) != 0) - { - HttpUserAgentParserMeters.ConcurrentCacheMiss(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ConcurrentCacheSizeSet(int size) - => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); - -#if DEBUG + /// Resets static state to support isolated unit tests. + /// public static void ResetForTests() { Volatile.Write(ref s_enabledFlags, 0); diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs index 3f624b8..3a49cfa 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs @@ -4,16 +4,37 @@ 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; - public static long ConcurrentCacheSize => Volatile.Read(ref s_concurrentCacheSize); + /// + /// Gets the current size of the concurrent dictionary cache. + /// + public static long ConcurrentCacheSize + => Volatile.Read(ref s_concurrentCacheSize); - public static void SetConcurrentCacheSize(int size) => Volatile.Write(ref s_concurrentCacheSize, size); + /// + /// 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); #if DEBUG - public static void ResetForTests() => Volatile.Write(ref s_concurrentCacheSize, 0); + /// + /// Resets the telemetry state for unit tests. + /// + public static void ResetForTests() + => Volatile.Write(ref s_concurrentCacheSize, 0); #endif } From 3ac5975c14182cc2fb482fcfb26b988601c9c041 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 20:43:21 +0100 Subject: [PATCH 6/7] refactor(telemetry): simplify method signatures and improve readability --- ...ttpUserAgentParserAspNetCoreEventSource.cs | 2 +- .../HttpUserAgentParserAspNetCoreTelemetry.cs | 1 - ...HttpUserAgentParserMemoryCachedProvider.cs | 1 - ...tpUserAgentParserMemoryCacheEventSource.cs | 4 +-- ...HttpUserAgentParserMemoryCacheTelemetry.cs | 8 +++++- .../HttpUserAgentParser.cs | 28 +++++++++---------- .../HttpUserAgentParserEventSource.cs | 5 +--- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs index 60ebc82..7a1fcab 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -80,7 +80,7 @@ internal void UserAgentMissing() /// Releases all EventCounter resources used by this EventSource. /// /// - /// when called from ; + /// when called from Dispose; /// when called from a finalizer. /// protected override void Dispose(bool disposing) diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs index cfac77b..e2bc8cf 100644 --- a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -125,5 +125,4 @@ public static void UserAgentMissing() HttpUserAgentParserAspNetCoreMeters.UserAgentMissing(); } } - } diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index 2560a8c..db9f567 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -151,5 +151,4 @@ public override bool Equals(object? obj) public override int GetHashCode() => UserAgent.GetHashCode(StringComparison.Ordinal); } - } diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs index ddc5a55..da71f14 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -67,10 +67,10 @@ internal void CacheMiss() } [NonEvent] - internal void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + internal static void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); [NonEvent] - internal void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); + internal static void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); /// protected override void Dispose(bool disposing) diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs index e7e1b9c..62a87fd 100644 --- a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -1,6 +1,7 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; @@ -56,6 +57,12 @@ public static bool IsCacheSizeEnabled /// /// 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. /// @@ -113,5 +120,4 @@ public static void CacheSizeIncrement() [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); - } diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index a3a2dae..5b6d9a1 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -83,13 +83,13 @@ private static HttpUserAgentInformation ParseInternal(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); } } @@ -112,9 +112,9 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) + foreach ((string Name, string DetectToken, string? VersionToken) in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) + if (!TryIndexOf(ua, DetectToken, out int detectIndex)) { continue; } @@ -123,37 +123,37 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) int versionSearchStart; // For rules without a specific version token, ensure pattern Token/ - if (string.IsNullOrEmpty(browserRule.VersionToken)) + if (string.IsNullOrEmpty(VersionToken)) { - int afterDetect = detectIndex + browserRule.DetectToken.Length; + int afterDetect = detectIndex + DetectToken.Length; if (afterDetect >= ua.Length || ua[afterDetect] != '/') { // Likely a misspelling or partial token (e.g., Edgg, Oprea, Chromee) continue; } } - if (!string.IsNullOrEmpty(browserRule.VersionToken)) + if (!string.IsNullOrEmpty(VersionToken)) { - if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) + if (TryIndexOf(ua, VersionToken!, out int vtIndex)) { - versionSearchStart = vtIndex + browserRule.VersionToken!.Length; + versionSearchStart = vtIndex + VersionToken!.Length; } else { // If specific version token wasn't found, fall back to detect token area - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } } else { - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } ReadOnlySpan search = ua.Slice(versionSearchStart); if (TryExtractVersion(search, out Range range)) { string? version = search[range].ToString(); - return (browserRule.Name, version); + return (Name, version); } // If we didn't find a version for this rule, try next rule diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs index dabb71d..3474bec 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -141,10 +141,7 @@ internal void ConcurrentCacheMiss() /// counter reports a correct value once a listener attaches. /// [NonEvent] - internal void ConcurrentCacheSizeSet(int size) - { - HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); - } + internal static void ConcurrentCacheSizeSet(int size) => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); /// /// Releases all EventCounter and PollingCounter resources used by this EventSource. From 44f030f112cc5b63bfcd49ca3250d3be9fd40345 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 27 Dec 2025 20:50:22 +0100 Subject: [PATCH 7/7] refactor(telemetry): remove DEBUG conditional compilation --- .../Telemetry/HttpUserAgentParserMeters.cs | 2 -- .../Telemetry/HttpUserAgentParserTelemetry.cs | 4 +--- .../Telemetry/HttpUserAgentParserTelemetryState.cs | 2 -- .../Telemetry/HttpUserAgentParserMetersTelemetryTests.cs | 4 ---- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs index fbaafd6..ad65203 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -109,7 +109,6 @@ public static void Enable(Meter? meter = null) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConcurrentCacheMiss() => s_concurrentCacheMiss?.Add(1); -#if DEBUG /// /// Resets static state to support isolated unit tests. /// @@ -124,5 +123,4 @@ public static void ResetForTests() s_concurrentCacheMiss = null; s_concurrentCacheSize = null; } -#endif } diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs index ec89f80..95ed5b2 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -152,9 +152,8 @@ public static void ConcurrentCacheMiss() public static void ConcurrentCacheSizeSet(int size) => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); -#if DEBUG /// - /// Resets static state to support isolated unit tests. + /// Resets telemetry state for unit testing. /// public static void ResetForTests() { @@ -162,5 +161,4 @@ public static void ResetForTests() HttpUserAgentParserTelemetryState.ResetForTests(); HttpUserAgentParserMeters.ResetForTests(); } -#endif } diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs index 3a49cfa..7bd68b9 100644 --- a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs @@ -30,11 +30,9 @@ public static long ConcurrentCacheSize public static void SetConcurrentCacheSize(int size) => Volatile.Write(ref s_concurrentCacheSize, size); -#if DEBUG /// /// Resets the telemetry state for unit tests. /// public static void ResetForTests() => Volatile.Write(ref s_concurrentCacheSize, 0); -#endif } diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs index 98844eb..47c92ce 100644 --- a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs @@ -13,9 +13,7 @@ public class HttpUserAgentParserMetersTelemetryTests [Fact] public void Meters_DoNotEmit_WhenDisabled() { -#if DEBUG HttpUserAgentParserTelemetry.ResetForTests(); -#endif using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName); @@ -28,9 +26,7 @@ public void Meters_DoNotEmit_WhenDisabled() [Fact] public void Meters_Emit_WhenEnabled() { -#if DEBUG HttpUserAgentParserTelemetry.ResetForTests(); -#endif using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName);