Skip to content

feat: Add Metrics to SharpHound to be collected - BED-7080#267

Open
mykeelium wants to merge 20 commits intov4from
poc/metrics
Open

feat: Add Metrics to SharpHound to be collected - BED-7080#267
mykeelium wants to merge 20 commits intov4from
poc/metrics

Conversation

@mykeelium
Copy link
Contributor

@mykeelium mykeelium commented Jan 5, 2026

Description

Created a proof of concept for the ability to collect metrics while collecting as well as extending out the logic to be able to flush metrics to multiple types of sinks.

Motivation and Context

BED-7080

How Has This Been Tested?

Screenshots (if appropriate):

image

Types of changes

  • Chore (a change that does not modify the application functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • Documentation updates are needed, and have been made accordingly.
  • I have added and/or updated tests to cover my changes.
  • All new and existing tests passed.
  • My changes include a database migration.

Summary by CodeRabbit

New Features

  • Added metrics collection for LDAP operations including request latency, concurrent request tracking, and success/failure metrics.
  • Added telemetry infrastructure to capture performance metrics and enable observability across timeout execution and request handling.
  • Added capability to export collected metrics to file with configurable flush intervals and timestamp formatting.

@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

Walkthrough

This pull request introduces a comprehensive metrics and observability system to the codebase. Changes include new metric interfaces, service implementations for routing, aggregation, and persistence, optional latency observation callbacks for timeout/sampling operations, and integration of metrics into LDAP operations.

Changes

Cohort / File(s) Summary
Core Metric Interfaces
src/CommonLib/Interfaces/IMetric*.cs
New interfaces defining contracts for metric routing (IMetricRouter), registry (IMetricRegistry), sinks (IMetricSink), writers (IMetricWriter), factories (IMetricFactory), and label caching (ILabelValuesCache).
Metric Model and Definition Types
src/CommonLib/Models/MetricDefinition.cs, src/CommonLib/Models/MetricObservation.cs, src/CommonLib/Models/FileMetricSinkOptions.cs, src/CommonLib/Models/IsExternalInit.cs
New record types for metric definitions (CounterDefinition, GaugeDefinition, CumulativeHistogramDefinition), label values handling (LabelValues), observations (DoubleMetricObservation), and configuration (FileMetricSinkOptions). Adds IsExternalInit shim for .NET Framework record support.
Metric Service Implementations
src/CommonLib/Services/MetricRouter.cs, src/CommonLib/Services/MetricRegistry.cs, src/CommonLib/Services/MetricFactory.cs, src/CommonLib/Services/MetricAggregator.cs, src/CommonLib/Services/MetricWriter.cs, src/CommonLib/Services/FileMetricSink.cs, src/CommonLib/Services/DefaultLabelValuesCache.cs, src/CommonLib/Services/MetricsFlushTimer.cs
Implementations for routing metrics to sinks, registering definitions, aggregating observations, writing formatted output, persisting to files, caching label values, and scheduling periodic flushes.
Static Metric Infrastructure
src/CommonLib/Static/Metrics.cs, src/CommonLib/Static/DefaultMetricRegistry.cs
Static factory and registry helpers, LDAP-specific metric definitions, in-flight request tracking, and extension method for registering default metrics.
Timeout and Sampling Instrumentation
src/CommonLib/AdaptiveTimeout.cs, src/CommonLib/ExecutionTimeSampler.cs
Added optional Action\<double\> latencyObservation parameter to ExecuteWithTimeout and SampleExecutionTime methods to enable external observation of execution latency.
LDAP Integration
src/CommonLib/LdapConnectionPool.cs, src/CommonLib/LdapUtils.cs
Extended constructors to accept IMetricRouter parameter; integrated metric observations for request lifecycle events, failures, and concurrent request tracking.
Unit Tests
test/unit/MetricDefinition*.cs, test/unit/MetricAggregator*.cs, test/unit/MetricRegistry*.cs, test/unit/MetricRouter*.cs, test/unit/FileMetricSink*.cs, test/unit/DefaultLabelValuesCache*.cs, test/unit/AdaptiveTimeoutTest.cs, test/unit/Utils.cs
Comprehensive test suites validating metric type behavior, aggregation, routing, persistence, label caching, latency observation, and concurrent access patterns.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MetricRouter
    participant LabelCache
    participant MetricSink
    participant TextWriter
    
    Client->>MetricRouter: Observe(definitionId, value, labelValues)
    MetricRouter->>LabelCache: Intern(labelValues.Values)
    LabelCache-->>MetricRouter: internedArray
    MetricRouter->>MetricSink: Observe(DoubleMetricObservation)
    MetricSink->>MetricSink: Create/Update MetricAggregator
    MetricSink->>MetricSink: aggregator.Observe(value)
    
    Client->>MetricRouter: Flush()
    MetricRouter->>MetricSink: Flush()
    MetricSink->>MetricSink: aggregator.Snapshot()
    MetricSink->>MetricSink: metricWriter.StringBuilderAppendMetric()
    MetricSink->>TextWriter: Write(formatted metric line)
    MetricSink->>TextWriter: Flush()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Metrics flow like warren streams,
Through routers, sinks, and aggregator dreams,
Labels interned, observations dance,
Latency observed at last—a second chance!
Now LDAP speaks in measurements true,
A telemetry story for me and for you! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary feature added: metrics collection for SharpHound with the ticket reference BED-7080.
Description check ✅ Passed The PR description includes motivation (BED-7080), how it was tested (tests added/passing), and type classification (new feature), but omits details on testing environment and specific test coverage methodology.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch poc/metrics

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mykeelium mykeelium self-assigned this Jan 6, 2026
@mykeelium mykeelium added the enhancement New feature or request label Jan 6, 2026
@mykeelium mykeelium marked this pull request as ready for review January 6, 2026 19:40
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/CommonLib/AdaptiveTimeout.cs (1)

160-169: Add latencyObservation parameter to ExecuteNetAPIWithTimeout and ExecuteRPCWithTimeout methods for API consistency.

The ExecuteWithTimeout overloads (lines 67, 90, 114, 137) support optional latencyObservation callbacks, but ExecuteNetAPIWithTimeout (line 160) and both ExecuteRPCWithTimeout overloads (lines 183, 206) do not. This creates an inconsistent API surface where callers can observe latency for general operations but not for NetAPI or RPC operations, despite the underlying ExecutionTimeSampler.SampleExecutionTime supporting this capability for all variants.

Add the optional Action<double> latencyObservation = null parameter to these three methods and pass it through to the sampler calls to maintain consistency.

src/CommonLib/LdapConnectionPool.cs (1)

1097-1120: Thread-safety concern with LdapMetrics.InFlightRequests decrement on exception paths.

If SendRequestWithTimeout throws before the decrement on line 1110, InFlightRequests will remain incremented indefinitely. Consider wrapping the decrement in a try-finally block to ensure proper accounting.

🔎 Proposed fix to ensure InFlightRequests is always decremented
 private async Task<SearchResponse> SendRequestWithTimeout(LdapConnection connection, SearchRequest request, AdaptiveTimeout adaptiveTimeout) {
     // Prerequest metrics
     var concurrentRequests = Interlocked.Increment(ref LdapMetrics.InFlightRequests);
     _metric.Observe(LdapMetricDefinitions.ConcurrentRequests, concurrentRequests, 
         new LabelValues([nameof(LdapConnectionPool), _poolIdentifier]));
-            
-    // Add padding to account for network latency and processing overhead
-    const int TimeoutPaddingSeconds = 3;
-    var timeout = adaptiveTimeout.GetAdaptiveTimeout();
-    var timeoutWithPadding = timeout + TimeSpan.FromSeconds(TimeoutPaddingSeconds);
-    var result = await adaptiveTimeout.ExecuteWithTimeout((_) => connection.SendRequestAsync(request, timeoutWithPadding), latencyObservation: LatencyObservation);
-            
-    // Postrequest metrics
-    concurrentRequests = Interlocked.Decrement(ref LdapMetrics.InFlightRequests);
-    _metric.Observe(LdapMetricDefinitions.ConcurrentRequests, concurrentRequests,
-        new LabelValues([nameof(LdapConnectionPool), _poolIdentifier]));
-    _metric.Observe(LdapMetricDefinitions.RequestsTotal, 1,
-        new LabelValues([nameof(LdapConnectionPool), _poolIdentifier]));
-            
-    if (result.IsSuccess)
-        return (SearchResponse)result.Value;
-    else
-        throw new TimeoutException($"LDAP {request.Scope} query to '{request.DistinguishedName}' timed out after {timeout.TotalMilliseconds}ms.");
+
+    try {
+        // Add padding to account for network latency and processing overhead
+        const int TimeoutPaddingSeconds = 3;
+        var timeout = adaptiveTimeout.GetAdaptiveTimeout();
+        var timeoutWithPadding = timeout + TimeSpan.FromSeconds(TimeoutPaddingSeconds);
+        var result = await adaptiveTimeout.ExecuteWithTimeout((_) => connection.SendRequestAsync(request, timeoutWithPadding), latencyObservation: LatencyObservation);
+
+        if (result.IsSuccess)
+            return (SearchResponse)result.Value;
+        else
+            throw new TimeoutException($"LDAP {request.Scope} query to '{request.DistinguishedName}' timed out after {timeout.TotalMilliseconds}ms.");
+    }
+    finally {
+        // Postrequest metrics - always decrement in-flight counter
+        concurrentRequests = Interlocked.Decrement(ref LdapMetrics.InFlightRequests);
+        _metric.Observe(LdapMetricDefinitions.ConcurrentRequests, concurrentRequests,
+            new LabelValues([nameof(LdapConnectionPool), _poolIdentifier]));
+        _metric.Observe(LdapMetricDefinitions.RequestsTotal, 1,
+            new LabelValues([nameof(LdapConnectionPool), _poolIdentifier]));
+    }
 }
🤖 Fix all issues with AI Agents
In @src/CommonLib/Services/DefaultLabelValuesCache.cs:
- Around line 31-33: MakeKey currently joins string[] values with Separator
which allows collisions when values contain the separator; update MakeKey in
DefaultLabelValuesCache (and any inverse Parse/Lookup code) to use a robust
encoding such as escaping instances of Separator in each value (e.g., replace
Separator with an escape sequence) or use a length-prefixing scheme (prefix each
value with its length) before concatenation, and update any code that decodes
the key accordingly so MakeKey and its consumers handle encoded/decoded values
symmetrically.

In @src/CommonLib/Services/FileMetricSink.cs:
- Around line 91-93: The Dispose method in FileMetricSink currently just
disposes _textWriter and can drop any buffered metrics in _states; update
FileMetricSink.Dispose to call Flush() (or otherwise persist/flush the contents
of _states) before disposing _textWriter, ensure Flush is safe to call multiple
times (idempotent) and handle/propagate any exceptions appropriately so metrics
are not lost during dispose.

In @src/CommonLib/Services/MetricAggregator.cs:
- Around line 40-60: The aggregator is not thread-safe: concurrent Observe calls
mutate _bucketCounts, _count and _sum and SnapshotHistogram exposes the internal
_bucketCounts array. Fix by introducing a private readonly sync object (e.g.,
_sync) and wrap the body of Observe in lock(_sync) so _bucketCounts[idx]++,
_count++ and _sum += value are atomic relative to each other; also modify
SnapshotHistogram (and Snapshot()) to lock(_sync) while creating the
HistogramSnapshot and pass a defensive copy of _bucketCounts (e.g., clone the
array) instead of the internal reference to prevent external mutation.

In @src/CommonLib/Services/MetricWriter.cs:
- Around line 33-38: The histogram line construction creates two separate label
blocks when labelText is non-empty; update the logic around builder.AppendFormat
(the block that formats definition.Name + "_bucket" using labelText and
snapshot.Bounds[i]) to merge labels: if labelText is non-empty, insert the le
label before the closing brace (e.g., replace the trailing '}' with
',le="{value}"}') otherwise emit a new {le="..."} block; ensure this change is
applied where cumulativeValue and snapshot.Bounds[i] are used so output becomes
a single combined label block like metric_bucket{method:GET,le="1"} = 5.

In @src/CommonLib/Static/DefaultMetricRegistry.cs:
- Around line 7-39: The RegisterDefaultMetrics extension currently ignores the
boolean return from IMetricRegistry.TryRegister causing silent failures (leaving
LdapMetricDefinitions.* as MetricId.InvalidId); update RegisterDefaultMetrics to
check each TryRegister result and when it returns false either log an error
(including the definition Name and which metric failed) or throw an exception to
fail fast; reference the TryRegister calls that populate
LdapMetricDefinitions.RequestsTotal, FailedRequests, ConcurrentRequests,
RequestLatency, and UnresolvablePrincipals and ensure you surface the failure
for each so callers no longer get silent MetricId.InvalidId values.

In @src/CommonLib/Static/Metrics.cs:
- Around line 15-17: LdapMetrics.InFlightRequests is a plain int and suffers
lost updates under concurrency; change it to a private long backing field and
provide atomic operations (e.g., Interlocked.Increment/Interlocked.Decrement and
Interlocked.Read) via public methods or properties (e.g.,
LdapMetrics.IncrementInFlight(), LdapMetrics.DecrementInFlight(), and
LdapMetrics.GetInFlight()) so all increments/decrements/read use Interlocked to
be thread-safe, or if you must keep a public member, document and enforce use of
Interlocked on the public field.

In @test/unit/MetricAggregatorTests.cs:
- Around line 49-56: The first assertion in the HistogramSnapshot branch
incorrectly compares ehs.TotalCount to itself; update the assertion in the block
that checks "if (expectedSnapshot is HistogramSnapshot ehs && snapshot is
HistogramSnapshot ahs)" so that the Assert.Equal call compares ehs.TotalCount
(expected) to ahs.TotalCount (actual) instead of ehs.TotalCount to
ehs.TotalCount, leaving the other Assert.Equal calls for Sum, Bounds, and Counts
unchanged.
🧹 Nitpick comments (14)
src/CommonLib/ExecutionTimeSampler.cs (1)

46-54: Consider exception handling for latencyObservation callback.

If the latencyObservation callback throws an exception, AddTimeSample will not be invoked, resulting in sample loss for the statistical aggregation. Consider wrapping the callback invocation in a try-catch block to ensure timing samples are recorded regardless of callback behavior, or document that callers must not throw from this callback.

🔎 Proposed fix to add defensive exception handling
 public async Task<T> SampleExecutionTime<T>(Func<Task<T>> func, Action<double> latencyObservation = null) {
     var stopwatch = Stopwatch.StartNew();
     var result = await func.Invoke();
     stopwatch.Stop();
-    latencyObservation?.Invoke(stopwatch.ElapsedMilliseconds);
+    try {
+        latencyObservation?.Invoke(stopwatch.ElapsedMilliseconds);
+    } catch {
+        // Suppress callback exceptions to ensure sample is recorded
+    }
     AddTimeSample(stopwatch.Elapsed);

     return result;
 }

Apply the same pattern to the other three overloads (lines 56-62, 64-72, 74-80).

test/unit/AdaptiveTimeoutTest.cs (1)

30-30: Unnecessary early return statements.

The return; statements at lines 30, 55, 86, 120, and 159 are redundant as they immediately follow assertions with no subsequent code. These can be safely removed to simplify the test methods.

🔎 Proposed cleanup
     Assert.InRange(observedLatency, 0.0, 60);
-    return;

     void LatencyObservation(double latency) {

Apply the same removal to lines 55, 86, 120, and 159.

Also applies to: 55-55, 86-86, 120-120, 159-159

src/CommonLib/Models/FileMetricSinkOptions.cs (1)

7-7: Consider validating the timestamp format.

The TimestampFormat string is not validated and could be set to an invalid format, causing a FormatException at runtime when DateTimeOffset.ToString() is called during metric flushing.

🔎 Proposed validation approach

Add validation in the setter to fail early:

-    public string TimestampFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fff";
+    private string _timestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
+    public string TimestampFormat {
+        get => _timestampFormat;
+        set {
+            // Validate format by attempting to format a sample timestamp
+            try {
+                _ = DateTimeOffset.Now.ToString(value);
+                _timestampFormat = value;
+            } catch (FormatException ex) {
+                throw new ArgumentException($"Invalid timestamp format: {value}", nameof(value), ex);
+            }
+        }
+    }

Alternatively, document the expected format and acceptable values in an XML comment.

src/CommonLib/Services/MetricsFlushTimer.cs (2)

22-28: Consider logging swallowed exceptions for observability.

Silently catching all exceptions masks failures and makes debugging production issues difficult. At minimum, consider logging to a diagnostics channel or incrementing an error counter.

🔎 Proposed improvement
 private void FlushSafe() {
     try {
         _flush();
-    } catch {
-        // catch all exception and do not kill the process
+    } catch (Exception ex) {
+        // Log the exception but don't kill the timer thread
+        System.Diagnostics.Debug.WriteLine($"MetricsFlushTimer flush failed: {ex.Message}");
     }
 }

30-32: Dispose doesn't wait for in-flight flush callbacks.

If Dispose() is called while a flush is in progress, the callback may still be executing. For graceful shutdown, consider using Timer.Dispose(WaitHandle) to wait for completion.

src/CommonLib/Models/MetricObservation.cs (1)

3-4: Private constructor on abstract record is redundant.

Abstract records cannot be instantiated directly. The private constructor adds no protection beyond what abstract already provides.

🔎 Simplified version
-public abstract record MetricObservation {
-    private MetricObservation() { }
+public abstract record MetricObservation;
test/unit/MetricRouterTests.cs (1)

62-85: Consider adding a test for empty sinks array.

The flush test is good, but consider adding coverage for edge cases like an empty sinks collection to ensure MetricRouter handles that gracefully.

src/CommonLib/Services/MetricFactory.cs (1)

5-9: Consider null validation on constructor parameter.

If router is null, CreateMetricRouter() will return null, which may cause NullReferenceExceptions downstream.

🔎 Proposed improvement
-public sealed class MetricFactory(IMetricRouter router) : IMetricFactory {
-    private readonly IMetricRouter _router = router;
+public sealed class MetricFactory : IMetricFactory {
+    private readonly IMetricRouter _router;
+    
+    public MetricFactory(IMetricRouter router) {
+        _router = router ?? throw new ArgumentNullException(nameof(router));
+    }
     
     public IMetricRouter CreateMetricRouter() => _router; 
 }
src/CommonLib/Services/MetricWriter.cs (1)

61-69: Output format inconsistency between histogram and default metrics.

Histogram metrics output values directly (e.g., = 5) while DefaultAppend wraps values in braces (= {5}). If this is intentional for distinguishing metric types in the output file, consider adding a brief comment. Otherwise, align the formats.

test/unit/DefaultLabelValuesCacheTests.cs (1)

36-37: Accessing internal _cache field in tests.

While accessing internal fields in unit tests is acceptable for verifying internal state, consider whether exposing a Count property on DefaultLabelValuesCache would provide a cleaner public API for testing cache size without exposing implementation details.

src/CommonLib/Services/MetricRouter.cs (1)

8-14: Redundant field for _labelCache with primary constructor.

With C# primary constructors, labelCache is already captured as a hidden field. The explicit _labelCache field creates a duplicate. Either remove the explicit field or switch to a traditional constructor if you prefer explicit fields.

🔎 Option 1: Remove redundant field
 public sealed class MetricRouter(
     IReadOnlyList<MetricDefinition> definitions,
     IEnumerable<IMetricSink> sinks,
     ILabelValuesCache labelCache) : IMetricRouter {
     private readonly int _definitionCount = definitions.Count;
     private readonly IMetricSink[] _sinks = sinks.ToArray();
-    private readonly ILabelValuesCache _labelCache = labelCache;
     
     // ...
     public void Observe(int definitionId, double value, LabelValues labelValues) {
         if ((uint)definitionId >= (uint)_definitionCount)
             return;
         
-        var interned = _labelCache.Intern(labelValues.Values);
+        var interned = labelCache.Intern(labelValues.Values);
test/unit/MetricDefinitionTests.cs (1)

87-101: Test structure is slightly confusing - sorting after construction.

The test sorts initBuckets after constructing the definition to create the expected values. While this works, it might be clearer to use a separate expected array or document why the sort is done post-construction.

🔎 Clearer alternative
 [Fact]
 public void CumulativeHistogramDefinition_NormalizesBuckets() {
     // setup
     double[] initBuckets = [5, 4, 3, 2, 1];
+    double[] expectedBuckets = [1, 2, 3, 4, 5];
     
     // act
     var definition = new CumulativeHistogramDefinition("name", initBuckets, []);
-    Array.Sort(initBuckets);
     
     // assert
-    Assert.Equal(initBuckets.Length, definition.Buckets.Length);
+    Assert.Equal(expectedBuckets.Length, definition.Buckets.Length);
     for (var i = 0; i < definition.Buckets.Length; ++i) {
-        Assert.Equal(initBuckets[i], definition.Buckets[i]);
+        Assert.Equal(expectedBuckets[i], definition.Buckets[i]);
     }
 }
test/unit/MetricAggregatorTests.cs (1)

60-81: Remove unused helper method.

The snapShotArrays method is defined but never called anywhere in the test file. Consider removing it to reduce dead code.

🔎 Proposed fix
-    private string snapShotArrays(double[] bounds, long[] counts) {
-        var builder = new StringBuilder();
-        builder.Append("bounds: [ ");
-        Iterate(builder, bounds);
-        builder.Append(" ],  counts: [ ");
-        Iterate(builder, counts);
-        builder.Append(" ]");
-        return builder.ToString();
-            
-        
-        void Iterate<T>(StringBuilder sb, T[] os) {
-            var first = true;
-
-            for (var i = 0; i < os.Length; i++) {
-                if (!first)
-                    builder.Append(", ");
-                
-                builder.Append(os[i]);
-                first = false;
-            }
-        }
-    }
src/CommonLib/Services/MetricAggregator.cs (1)

31-36: Thread-safety concern: GaugeAggregator assignment is not atomic.

The _value = value assignment is not thread-safe for double on 32-bit systems. While modern 64-bit runtimes handle this atomically, consider using Interlocked.Exchange for explicit thread-safety if this will be called from multiple threads.

🔎 Proposed fix for thread-safe gauge updates
 public sealed class GaugeAggregator : MetricAggregator {
     private double _value;
     
-    public override void Observe(double value) => _value = value;
+    public override void Observe(double value) => Interlocked.Exchange(ref _value, value);
-    public override object Snapshot() => _value;
+    public override object Snapshot() => Volatile.Read(ref _value);
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8837f50 and 4b04027.

📒 Files selected for processing (33)
  • src/CommonLib/AdaptiveTimeout.cs
  • src/CommonLib/ExecutionTimeSampler.cs
  • src/CommonLib/Interfaces/ILabelValuesCache.cs
  • src/CommonLib/Interfaces/IMetricFactory.cs
  • src/CommonLib/Interfaces/IMetricRegistry.cs
  • src/CommonLib/Interfaces/IMetricRouter.cs
  • src/CommonLib/Interfaces/IMetricSink.cs
  • src/CommonLib/Interfaces/IMetricWriter.cs
  • src/CommonLib/LdapConnectionPool.cs
  • src/CommonLib/LdapUtils.cs
  • src/CommonLib/Models/FileMetricSinkOptions.cs
  • src/CommonLib/Models/IsExternalInit.cs
  • src/CommonLib/Models/MetricDefinition.cs
  • src/CommonLib/Models/MetricObservation.cs
  • src/CommonLib/Services/DefaultLabelValuesCache.cs
  • src/CommonLib/Services/FileMetricSink.cs
  • src/CommonLib/Services/MetricAggregator.cs
  • src/CommonLib/Services/MetricFactory.cs
  • src/CommonLib/Services/MetricRegistry.cs
  • src/CommonLib/Services/MetricRouter.cs
  • src/CommonLib/Services/MetricWriter.cs
  • src/CommonLib/Services/MetricsFlushTimer.cs
  • src/CommonLib/Static/DefaultMetricRegistry.cs
  • src/CommonLib/Static/Metrics.cs
  • test/unit/AdaptiveTimeoutTest.cs
  • test/unit/CommonLibHelperTests.cs
  • test/unit/DefaultLabelValuesCacheTests.cs
  • test/unit/FileMetricSinkTests.cs
  • test/unit/MetricAggregatorTests.cs
  • test/unit/MetricDefinitionTests.cs
  • test/unit/MetricRegistryTests.cs
  • test/unit/MetricRouterTests.cs
  • test/unit/Utils.cs
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-07-11T20:15:16.238Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 222
File: src/CommonLib/ExecutionTimeSampler.cs:35-43
Timestamp: 2025-07-11T20:15:16.238Z
Learning: In SharpHoundCommon ExecutionTimeSampler statistical methods, the user prefers to keep simple implementations without thread safety snapshots because precise values aren't needed for analysis purposes - they're comfortable with potentially imprecise values from concurrent access.

Applied to files:

  • src/CommonLib/ExecutionTimeSampler.cs
📚 Learning: 2025-07-15T17:45:25.688Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 222
File: src/CommonLib/Processors/LocalGroupProcessor.cs:19-27
Timestamp: 2025-07-15T17:45:25.688Z
Learning: In SharpHoundCommon, the team prefers to keep code simple rather than implement perfect resource management when the resources being managed are non-critical. Specifically, they accept not implementing IDisposable for AdaptiveTimeout instances when the Dispose method is primarily for flushing analytics logs from ExecutionTimeSampler, viewing it as a courtesy rather than a safety requirement.

Applied to files:

  • test/unit/AdaptiveTimeoutTest.cs
  • src/CommonLib/Services/MetricsFlushTimer.cs
📚 Learning: 2025-06-26T16:38:49.677Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 217
File: src/CommonLib/Timeout.cs:17-36
Timestamp: 2025-06-26T16:38:49.677Z
Learning: In SharpHoundCommonLib's Timeout.cs, the team initially had concerns about disposing CancellationTokenSource instances before tasks could check cancellation tokens, but they understand that orphaned tasks (those that exceed timeout) won't be impacted by disposed tokens since their results are already ignored by the timeout handler. They prefer proper resource management with using statements for CancellationTokenSource disposal.

Applied to files:

  • src/CommonLib/Services/MetricsFlushTimer.cs
📚 Learning: 2025-10-17T13:43:46.833Z
Learnt from: MikeX777
Repo: SpecterOps/SharpHoundCommon PR: 241
File: src/CommonLib/Processors/LdapPropertyProcessor.cs:168-169
Timestamp: 2025-10-17T13:43:46.833Z
Learning: Properties added to dictionaries returned by methods in SharpHoundCommon (such as those in LdapPropertyProcessor) may be consumed by dependent projects like SharpHound (SH) and SharpHoundEnterprise (SHE), even if they are not used within the SharpHoundCommon repository itself.

Applied to files:

  • src/CommonLib/LdapUtils.cs
  • src/CommonLib/LdapConnectionPool.cs
  • src/CommonLib/Static/Metrics.cs
📚 Learning: 2025-06-26T16:59:06.972Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 217
File: src/CommonLib/LdapConnectionPool.cs:677-677
Timestamp: 2025-06-26T16:59:06.972Z
Learning: In SharpHoundCommonLib's LdapConnectionPool.cs, the team acknowledges that method `GetConnectionForSpecificServerAsync` has misleading naming - it contains "Async" in the name but is actually synchronous and blocks on async code, which they recognize as confusing but may need to live with due to backwards compatibility constraints.

Applied to files:

  • src/CommonLib/LdapConnectionPool.cs
🧬 Code graph analysis (25)
src/CommonLib/ExecutionTimeSampler.cs (1)
src/CommonLib/AdaptiveTimeout.cs (7)
  • Task (67-76)
  • Task (90-99)
  • Task (114-123)
  • Task (137-146)
  • Task (160-169)
  • Task (183-192)
  • Task (206-215)
src/CommonLib/Interfaces/IMetricSink.cs (2)
src/CommonLib/Services/MetricRouter.cs (4)
  • Observe (19-30)
  • Observe (42-44)
  • Flush (32-35)
  • Flush (46-48)
src/CommonLib/Interfaces/IMetricRouter.cs (2)
  • Observe (6-6)
  • Flush (7-7)
test/unit/MetricRouterTests.cs (2)
src/CommonLib/Services/MetricRouter.cs (1)
  • MetricRouter (8-36)
src/CommonLib/Models/MetricObservation.cs (1)
  • MetricObservation (4-4)
src/CommonLib/Services/DefaultLabelValuesCache.cs (1)
src/CommonLib/Interfaces/ILabelValuesCache.cs (1)
  • Intern (4-4)
test/unit/CommonLibHelperTests.cs (1)
test/unit/Utils.cs (4)
  • WindowsOnlyTheory (94-98)
  • WindowsOnlyTheory (95-97)
  • WindowsOnlyFact (86-92)
  • WindowsOnlyFact (88-91)
src/CommonLib/Interfaces/IMetricRouter.cs (5)
src/CommonLib/Interfaces/IMetricFactory.cs (1)
  • IMetricRouter (4-4)
src/CommonLib/Services/MetricFactory.cs (2)
  • IMetricRouter (8-8)
  • IMetricRouter (14-14)
src/CommonLib/Services/FileMetricSink.cs (2)
  • Observe (40-51)
  • Flush (55-89)
src/CommonLib/Services/MetricRouter.cs (4)
  • Observe (19-30)
  • Observe (42-44)
  • Flush (32-35)
  • Flush (46-48)
src/CommonLib/Interfaces/IMetricSink.cs (2)
  • Observe (6-6)
  • Flush (7-7)
src/CommonLib/Interfaces/ILabelValuesCache.cs (1)
src/CommonLib/Services/DefaultLabelValuesCache.cs (1)
  • Intern (13-29)
test/unit/Utils.cs (1)
test/unit/CommonLibHelperTests.cs (1)
  • WindowsOnlyTheory (305-323)
test/unit/DefaultLabelValuesCacheTests.cs (1)
src/CommonLib/Services/DefaultLabelValuesCache.cs (3)
  • DefaultLabelValuesCache (7-36)
  • MakeKey (31-33)
  • Intern (13-29)
src/CommonLib/Services/MetricsFlushTimer.cs (3)
src/CommonLib/LdapConnectionPool.cs (2)
  • TimeSpan (668-672)
  • Dispose (809-813)
src/CommonLib/Services/FileMetricSink.cs (1)
  • Dispose (91-93)
src/CommonLib/LdapUtils.cs (1)
  • Dispose (1156-1158)
src/CommonLib/Static/DefaultMetricRegistry.cs (3)
src/CommonLib/Services/MetricRegistry.cs (1)
  • TryRegister (15-26)
src/CommonLib/Interfaces/IMetricRegistry.cs (1)
  • TryRegister (7-7)
src/CommonLib/Static/Metrics.cs (1)
  • LdapMetricDefinitions (24-30)
src/CommonLib/Services/MetricWriter.cs (3)
src/CommonLib/Interfaces/IMetricWriter.cs (1)
  • StringBuilderAppendMetric (9-16)
src/CommonLib/Services/MetricAggregator.cs (7)
  • MetricAggregator (9-16)
  • MetricAggregator (19-22)
  • CumulativeHistogramAggregator (40-61)
  • Snapshot (21-21)
  • Snapshot (28-28)
  • Snapshot (35-35)
  • Snapshot (57-57)
src/CommonLib/Models/MetricDefinition.cs (1)
  • ToDisplayString (8-28)
test/unit/MetricDefinitionTests.cs (1)
src/CommonLib/Models/MetricDefinition.cs (1)
  • ToDisplayString (8-28)
test/unit/FileMetricSinkTests.cs (3)
src/CommonLib/Models/MetricObservation.cs (1)
  • MetricObservation (4-4)
src/CommonLib/Models/FileMetricSinkOptions.cs (1)
  • FileMetricSinkOptions (5-9)
src/CommonLib/ConcurrentHashSet.cs (1)
  • Contains (44-46)
src/CommonLib/LdapUtils.cs (5)
src/CommonLib/Static/Metrics.cs (3)
  • Metrics (6-13)
  • LdapMetricDefinitions (24-30)
  • LdapMetrics (15-17)
src/CommonLib/Processors/PortScanner.cs (3)
  • PortScanner (9-96)
  • PortScanner (14-16)
  • PortScanner (18-21)
src/CommonLib/Services/FileMetricSink.cs (1)
  • Observe (40-51)
src/CommonLib/Services/MetricRouter.cs (2)
  • Observe (19-30)
  • Observe (42-44)
src/CommonLib/Interfaces/IMetricRouter.cs (1)
  • Observe (6-6)
src/CommonLib/Services/MetricRouter.cs (4)
src/CommonLib/Interfaces/IMetricRouter.cs (2)
  • Observe (6-6)
  • Flush (7-7)
src/CommonLib/Interfaces/IMetricSink.cs (2)
  • Observe (6-6)
  • Flush (7-7)
src/CommonLib/Interfaces/ILabelValuesCache.cs (1)
  • Intern (4-4)
src/CommonLib/Models/MetricObservation.cs (1)
  • MetricObservation (4-4)
src/CommonLib/Services/MetricFactory.cs (2)
src/CommonLib/Interfaces/IMetricFactory.cs (1)
  • IMetricRouter (4-4)
src/CommonLib/Services/MetricRouter.cs (2)
  • NoOpMetricRouter (38-49)
  • NoOpMetricRouter (40-40)
test/unit/MetricRegistryTests.cs (3)
test/unit/MetricAggregatorTests.cs (1)
  • Fact (26-33)
src/CommonLib/Services/MetricRegistry.cs (2)
  • MetricRegistry (8-29)
  • Seal (28-28)
src/CommonLib/Static/Metrics.cs (1)
  • MetricId (20-22)
src/CommonLib/Static/Metrics.cs (1)
src/CommonLib/Services/MetricFactory.cs (2)
  • NoOpMetricFactory (11-15)
  • NoOpMetricFactory (13-13)
src/CommonLib/Interfaces/IMetricRegistry.cs (1)
src/CommonLib/Services/MetricRegistry.cs (1)
  • TryRegister (15-26)
src/CommonLib/Interfaces/IMetricWriter.cs (2)
src/CommonLib/Services/MetricWriter.cs (1)
  • StringBuilderAppendMetric (9-17)
src/CommonLib/Services/MetricAggregator.cs (2)
  • MetricAggregator (9-16)
  • MetricAggregator (19-22)
src/CommonLib/Services/MetricRegistry.cs (3)
src/CommonLib/Interfaces/IMetricRegistry.cs (1)
  • TryRegister (7-7)
src/CommonLib/Static/Metrics.cs (1)
  • MetricId (20-22)
src/CommonLib/ConcurrentHashSet.cs (1)
  • Add (26-28)
src/CommonLib/Services/FileMetricSink.cs (6)
src/CommonLib/Models/FileMetricSinkOptions.cs (1)
  • FileMetricSinkOptions (5-9)
src/CommonLib/Services/MetricAggregator.cs (6)
  • MetricAggregator (9-16)
  • MetricAggregator (19-22)
  • Observe (20-20)
  • Observe (27-27)
  • Observe (34-34)
  • Observe (45-55)
src/CommonLib/Services/MetricRouter.cs (4)
  • Observe (19-30)
  • Observe (42-44)
  • Flush (32-35)
  • Flush (46-48)
src/CommonLib/Interfaces/IMetricRouter.cs (2)
  • Observe (6-6)
  • Flush (7-7)
src/CommonLib/Models/MetricObservation.cs (1)
  • MetricObservation (4-4)
src/CommonLib/Interfaces/IMetricWriter.cs (1)
  • StringBuilderAppendMetric (9-16)
src/CommonLib/Services/MetricAggregator.cs (3)
src/CommonLib/Services/FileMetricSink.cs (1)
  • Observe (40-51)
src/CommonLib/Services/MetricRouter.cs (2)
  • Observe (19-30)
  • Observe (42-44)
src/CommonLib/Interfaces/IMetricSink.cs (1)
  • Observe (6-6)
src/CommonLib/AdaptiveTimeout.cs (2)
src/CommonLib/ExecutionTimeSampler.cs (4)
  • Task (46-54)
  • Task (56-62)
  • T (64-72)
  • SampleExecutionTime (74-80)
src/CommonLib/LdapConnectionPool.cs (11)
  • Task (74-81)
  • Task (456-486)
  • Task (742-758)
  • Task (771-774)
  • Task (776-793)
  • Task (815-907)
  • Task (909-916)
  • Task (918-952)
  • Task (1000-1064)
  • Task (1072-1086)
  • Task (1097-1120)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (37)
test/unit/CommonLibHelperTests.cs (2)

305-323: LGTM! Appropriate platform gating for Windows-specific APIs.

The test uses SecurityIdentifier, which is only available on Windows. Using [WindowsOnlyTheory] ensures the test is skipped on non-Windows platforms rather than failing at runtime.


325-329: LGTM! Consistent platform gating.

The test correctly uses [WindowsOnlyFact] to skip on non-Windows platforms where SecurityIdentifier is unavailable.

test/unit/Utils.cs (1)

94-98: LGTM! Clean implementation for platform-conditional Theory tests.

The WindowsOnlyTheory attribute correctly mirrors WindowsOnlyFact and enables Theory-based tests (with inline data) to be skipped on non-Windows platforms. The implementation is consistent and follows the same pattern as the existing WindowsOnlyFact.

src/CommonLib/Models/IsExternalInit.cs (1)

1-11: LGTM! Standard polyfill for C# 9 records on .NET Framework.

This implementation correctly follows the established pattern for enabling C# 9 init-only properties and records when targeting .NET Framework. The type is properly scoped as internal, hidden from IntelliSense, and placed in the required namespace with the exact name the compiler expects.

src/CommonLib/Interfaces/IMetricSink.cs (1)

1-8: LGTM! Clean interface design.

The interface is well-designed with appropriate use of the in modifier for the observation parameter, avoiding unnecessary struct copies while maintaining immutability guarantees.

src/CommonLib/Services/MetricRegistry.cs (1)

8-29: Document that metric registration is single-threaded.

The MetricRegistry uses non-thread-safe collections without synchronization. While the code contains potential race conditions in the check-then-act pattern (lines 15-26), the current usage pattern shows metrics are registered only during initialization and sealed before concurrent access occurs.

To clarify the contract, add documentation to the class and TryRegister method stating that registration is not thread-safe and must be completed before the registry is sealed or accessed concurrently.

Alternatively, if concurrent registration becomes a future requirement, add a lock statement around the registration logic (lines 15-26).

src/CommonLib/Interfaces/IMetricFactory.cs (1)

3-5: LGTM!

Clean factory interface for creating metric routers. The single-method contract is straightforward and appropriate for the factory pattern.

src/CommonLib/Interfaces/ILabelValuesCache.cs (1)

3-5: LGTM!

The interface contract is clear. The Intern method name follows the established convention for string deduplication/caching patterns.

src/CommonLib/Interfaces/IMetricRouter.cs (1)

5-8: LGTM!

The interface provides a clean contract for metric observation and flushing. The method signatures are straightforward and appropriate for the routing pattern.

src/CommonLib/Interfaces/IMetricRegistry.cs (1)

6-9: LGTM!

The interface follows the Try pattern correctly with the boolean return and out parameter. The read-only collection property provides appropriate access to registered definitions.

src/CommonLib/Models/FileMetricSinkOptions.cs (1)

5-9: Clean options class with sensible defaults.

The sealed class with auto-properties and reasonable default values follows standard .NET options pattern conventions.

src/CommonLib/Interfaces/IMetricWriter.cs (1)

8-17: LGTM!

The interface is well-defined with a clear single responsibility. The default timestamp format parameter provides sensible defaults while allowing customization.

src/CommonLib/Models/MetricObservation.cs (1)

6-9: LGTM - nested struct design is appropriate for value semantics.

The readonly record struct provides efficient pass-by-value semantics for the hot path. Note that LabelsValues array contents can be mutated externally, but the interning pattern in DefaultLabelValuesCache provides immutability guarantees at the usage site.

src/CommonLib/Services/DefaultLabelValuesCache.cs (1)

13-29: LGTM - thread-safe interning with proper copy semantics.

The implementation correctly copies input arrays before storing, protecting against external mutation. The lock ensures thread safety for concurrent access.

test/unit/MetricRouterTests.cs (2)

11-35: LGTM - good boundary testing for definition IDs.

The test correctly validates that IDs outside the valid range (negative, equal to count, greater than count) are rejected without caching or observing.


37-60: LGTM - validates interning and multi-sink observation.

The test properly verifies that label values are interned exactly once and that each sink receives the observation.

src/CommonLib/Services/MetricFactory.cs (1)

11-15: LGTM - clean singleton implementation for no-op factory.

The NoOpMetricFactory correctly uses the singleton pattern and delegates to NoOpMetricRouter.Instance for consistent no-op behavior.

src/CommonLib/Static/DefaultMetricRegistry.cs (1)

27-32: Histogram bucket boundaries look reasonable for LDAP latency.

The buckets [100, 250, 500, 1000, 2500, 5000] milliseconds provide good granularity for typical LDAP operation latencies, from fast (sub-100ms) to slow (5+ seconds) queries.

test/unit/MetricRegistryTests.cs (1)

1-65: LGTM!

The tests provide good coverage for the MetricRegistry behavior:

  • Validates sequential ID assignment on successful registration
  • Confirms name-based deduplication returns the same ID
  • Verifies that sealing prevents further registrations and returns InvalidId
src/CommonLib/Static/Metrics.cs (1)

6-13: Good defensive null handling on Factory setter.

The null-coalescing fallback to NoOpMetricFactory.Instance ensures the system gracefully degrades when no factory is configured.

test/unit/DefaultLabelValuesCacheTests.cs (1)

52-88: Good concurrency test for validating reference equality.

The test effectively validates that the cache returns the same interned reference across concurrent access from multiple threads.

src/CommonLib/Services/MetricRouter.cs (2)

19-30: Efficient bounds check and observation dispatch.

The (uint)definitionId >= (uint)_definitionCount trick handles both negative IDs and out-of-range IDs in a single branch-friendly comparison. The implementation correctly interns labels before dispatching to sinks.


38-49: LGTM!

The NoOpMetricRouter singleton pattern is correctly implemented with a private constructor and static instance field.

src/CommonLib/Services/FileMetricSink.cs (2)

25-25: Dictionary key relies on reference equality of string[] from interning.

Using string[] as part of a dictionary key relies on reference equality, which only works correctly because the MetricRouter interns label values before passing them to sinks. If any code path bypasses interning, observations with identical labels will create separate aggregators.

Consider either:

  1. Adding a comment documenting this contract
  2. Using a custom key type with proper Equals/GetHashCode implementation

This works correctly because MetricRouter.Observe calls _labelCache.Intern(labelValues.Values) before creating the observation. Verify no other code paths can bypass interning.


28-38: File constructor uses FileMode.Create, overwriting existing files.

This will truncate any existing file. If append behavior is needed (e.g., for long-running processes that restart), consider making this configurable or using FileMode.Append.

Is overwriting the file on each instantiation the intended behavior?

test/unit/MetricDefinitionTests.cs (1)

1-85: LGTM!

Good test coverage for LabelValues display formatting edge cases and metric definition construction. The tests validate:

  • Empty label names behavior
  • Mismatch error formatting for both directions
  • Correct label output formatting
  • Proper value assignment across all definition types
src/CommonLib/LdapConnectionPool.cs (3)

42-44: LGTM - Metrics field integration.

The optional IMetricRouter injection with a sensible default via Metrics.Factory.CreateMetricRouter() follows the existing pattern for _log and maintains backward compatibility.


83-84: LGTM - Clean latency observation callback.

The LatencyObservation helper method provides a concise way to observe request latency with consistent labeling across all LDAP operations.


127-137: LGTM - Consistent failure metrics tracking.

The FailedRequests metric observations are correctly placed in each error/retry path with consistent labeling, providing good visibility into LDAP operation failures.

test/unit/MetricAggregatorTests.cs (1)

12-24: LGTM - Good coverage for aggregator factory.

The test correctly validates that MetricAggregatorExtensions.Create returns the expected aggregator type for each metric definition.

src/CommonLib/LdapUtils.cs (2)

53-55: LGTM - Consistent metrics integration pattern.

The metrics router injection follows the same pattern as LdapConnectionPool.cs with proper fallback to Metrics.Factory.CreateMetricRouter() when not provided.

Also applies to: 82-96


1143-1145: LGTM - InFlightRequests reset during utility reset.

Resetting LdapMetrics.InFlightRequests to 0 during ResetUtils() ensures metric state is consistent when the connection pool is reset.

test/unit/FileMetricSinkTests.cs (2)

13-19: LGTM - Clean test helper implementation.

SimpleMetricWriter provides a minimal implementation for testing that captures essential information without complex formatting logic.


54-80: LGTM - Good coverage for autoFlush behavior.

The parameterized test correctly validates that Flush() is only called on the underlying writer when FlushWriter is true.

src/CommonLib/Services/MetricAggregator.cs (1)

24-29: LGTM - Thread-safe counter implementation.

CounterAggregator correctly uses Interlocked.Add for atomic increments.

src/CommonLib/Models/MetricDefinition.cs (2)

7-29: LGTM - Well-designed LabelValues struct.

The ToDisplayString method handles edge cases gracefully, including empty labels and count mismatches with a helpful error message format.


41-54: LGTM - Robust bucket normalization.

NormalizeBuckets correctly validates input, clones to avoid mutation of the original array, sorts, and validates strictly increasing values. The exception messages are descriptive.

Comment on lines +31 to +33
internal static string MakeKey(string[] values) {
return values.Length == 1 ? values[0] : string.Join(Separator.ToString(), values);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Key collision possible if label values contain the separator character.

If any label value contains \u001F, two distinct label combinations could produce the same key. Consider escaping the separator or using a more robust key generation strategy.

🔎 Example of collision
// These would produce the same key:
MakeKey(["a\u001Fb", "c"]) == MakeKey(["a", "b\u001Fc"])
// Both produce: "a\u001Fb\u001Fc"

One fix is to escape the separator:

 internal static string MakeKey(string[] values) {
-    return values.Length == 1 ? values[0] : string.Join(Separator.ToString(), values);
+    if (values.Length == 1) return values[0];
+    var escaped = values.Select(v => v.Replace(Separator.ToString(), "\\u001F"));
+    return string.Join(Separator.ToString(), escaped);
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/CommonLib/Services/DefaultLabelValuesCache.cs around lines 31 - 33,
MakeKey currently joins string[] values with Separator which allows collisions
when values contain the separator; update MakeKey in DefaultLabelValuesCache
(and any inverse Parse/Lookup code) to use a robust encoding such as escaping
instances of Separator in each value (e.g., replace Separator with an escape
sequence) or use a length-prefixing scheme (prefix each value with its length)
before concatenation, and update any code that decodes the key accordingly so
MakeKey and its consumers handle encoded/decoded values symmetrically.

Comment on lines +7 to +39
public static void RegisterDefaultMetrics(this IMetricRegistry registry) {
// LDAP Metrics
registry.TryRegister(
new CounterDefinition(
Name: "ldap_total_requests",
LabelNames: ["location", "identifier"]),
out LdapMetricDefinitions.RequestsTotal);

registry.TryRegister(
new CounterDefinition(
Name: "ldap_failed_requests",
LabelNames: ["location", "identifier"]),
out LdapMetricDefinitions.FailedRequests);

registry.TryRegister(
new GaugeDefinition(
Name: "ldap_concurrent_requests",
LabelNames: ["location", "identifier"]),
out LdapMetricDefinitions.ConcurrentRequests);

registry.TryRegister(
new CumulativeHistogramDefinition(
Name: "ldap_request_duration_milliseconds",
InitBuckets: [100, 250, 500, 1000, 2500, 5000],
LabelNames: ["location", "identifier"]),
out LdapMetricDefinitions.RequestLatency);

registry.TryRegister(
new CounterDefinition(
Name: "ldap_total_unresolvable_principals",
LabelNames: ["location"]),
out LdapMetricDefinitions.UnresolvablePrincipals);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Return values from TryRegister are ignored - silent failures possible.

If the registry is sealed or registration fails for any reason, the definition IDs remain MetricId.InvalidId with no indication of failure. Consider logging or throwing when registration fails.

🔎 Proposed improvement with failure handling
 public static void RegisterDefaultMetrics(this IMetricRegistry registry) {
     // LDAP Metrics
-    registry.TryRegister(
+    if (!registry.TryRegister(
         new CounterDefinition(
             Name: "ldap_total_requests",
             LabelNames: ["location", "identifier"]),
-        out LdapMetricDefinitions.RequestsTotal);
+        out LdapMetricDefinitions.RequestsTotal)) {
+        // Log warning or throw based on requirements
+    }
     
     // ... similar for other registrations
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/CommonLib/Static/DefaultMetricRegistry.cs around lines 7 - 39, The
RegisterDefaultMetrics extension currently ignores the boolean return from
IMetricRegistry.TryRegister causing silent failures (leaving
LdapMetricDefinitions.* as MetricId.InvalidId); update RegisterDefaultMetrics to
check each TryRegister result and when it returns false either log an error
(including the definition Name and which metric failed) or throw an exception to
fail fast; reference the TryRegister calls that populate
LdapMetricDefinitions.RequestsTotal, FailedRequests, ConcurrentRequests,
RequestLatency, and UnresolvablePrincipals and ensure you surface the failure
for each so callers no longer get silent MetricId.InvalidId values.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI Agents
In @test/unit/AdaptiveTimeoutTest.cs:
- Around line 102-123: The test uses a non-thread-safe List<double> named
observations and a callback LatencyObservation that calls observations.Add
concurrently from multiple ExecuteWithTimeout tasks, causing a race; replace
observations with a thread-safe collection (e.g., ConcurrentBag<double>) or
serialize access (e.g., lock around Add) so concurrent writes from
AdaptiveTimeout.ExecuteWithTimeout tasks are safe, update the LatencyObservation
closure to add to the new thread-safe collection, and keep the rest of the
assertions intact.
- Around line 128-163: The test has a race because completedObservations and
timeoutObservations (written by LatencyCompletedObservation and
LatencyTimeoutObservation callbacks invoked from
AdaptiveTimeout.ExecuteWithTimeout tasks) are plain List<double> and may be
appended from multiple threads; replace them with a thread-safe collection
(e.g., ConcurrentBag<double>) or synchronize writes (lock) and update the two
callback delegates to add into the thread-safe container so concurrent Task
completions cannot corrupt the lists.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b04027 and 6bba590.

📒 Files selected for processing (1)
  • test/unit/AdaptiveTimeoutTest.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-15T17:45:25.688Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 222
File: src/CommonLib/Processors/LocalGroupProcessor.cs:19-27
Timestamp: 2025-07-15T17:45:25.688Z
Learning: In SharpHoundCommon, the team prefers to keep code simple rather than implement perfect resource management when the resources being managed are non-critical. Specifically, they accept not implementing IDisposable for AdaptiveTimeout instances when the Dispose method is primarily for flushing analytics logs from ExecutionTimeSampler, viewing it as a courtesy rather than a safety requirement.

Applied to files:

  • test/unit/AdaptiveTimeoutTest.cs
🧬 Code graph analysis (1)
test/unit/AdaptiveTimeoutTest.cs (2)
src/CommonLib/AdaptiveTimeout.cs (10)
  • TimeSpan (230-244)
  • AdaptiveTimeout (12-351)
  • AdaptiveTimeout (28-47)
  • Task (67-76)
  • Task (90-99)
  • Task (114-123)
  • Task (137-146)
  • Task (160-169)
  • Task (183-192)
  • Task (206-215)
src/CommonLib/ExecutionTimeSampler.cs (2)
  • Task (46-54)
  • Task (56-62)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
test/unit/AdaptiveTimeoutTest.cs (3)

18-36: LGTM - latency observation pattern correctly validates callback invocation.

The test properly validates that latency is observed and falls within expected bounds for a 50ms delay. The sentinel value initialization and range assertion pattern is appropriate for verifying callback execution.


38-68: LGTM - sequential execution with individual latency tracking.

The test correctly validates latency observation across three sequential timeout executions with adaptive timeout disabled.


70-98: LGTM - validates adaptive timeout calculation with observed latencies.

Sequential execution correctly captures individual latencies with appropriate range margins for timing variability.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI Agents
In @src/CommonLib/Models/MetricDefinition.cs:
- Around line 7-32: LabelValues.ToDisplayString currently emits labels as
name:value (e.g., {method:GET}) which breaks OpenMetrics/Prometheus; change the
formatting to name="value" and ensure values are escaped and quoted per the
spec. Update the loop in the ToDisplayString method to append labelNames[i] +
"=" + "\"" + EscapeLabelValue(Values[i]) + "\"" (and similarly for
additionalName/additionalValue), and add a small helper inside the type (e.g.,
private static string EscapeLabelValue(string v)) that escapes backslashes,
double quotes, and newlines (at minimum) before enclosing the value in quotes.
Ensure the improper-count check and empty labelNames behavior remain unchanged
and reference the existing Labels Values array, labelNames parameter, and
additionalName/additionalValue parameters when applying the fix.

In @src/CommonLib/Services/FileMetricSink.cs:
- Around line 91-94: Dispose() currently only flushes and disposes _textWriter,
which loses metrics still buffered in _states; call this class's Flush() method
(which drains/serializes _states to the writer) before calling
_textWriter.Flush() and _textWriter.Dispose() so all observed metrics are
persisted; update the Dispose() implementation in FileMetricSink to invoke
Flush() then flush and dispose _textWriter.
🧹 Nitpick comments (3)
src/CommonLib/LdapUtils.cs (1)

137-146: Good metric placement; consider caching LabelValues.

The metric observations correctly track unresolvable principals in error paths, providing valuable observability.

Consider caching the LabelValues instance to avoid repeated allocations since the label is constant across all observations:

🔎 Proposed optimization

Add a static readonly field near the top of the class:

+        private static readonly LabelValues LdapUtilsLabel = new LabelValues([nameof(LdapUtils)]);
+
         private readonly ConcurrentDictionary<string, string>
             _hostResolutionMap = new(StringComparer.OrdinalIgnoreCase);

Then update the observation calls (lines 137, 146, and 978):

-                    _metric.Observe(LdapMetricDefinitions.UnresolvablePrincipals, 1, new LabelValues([nameof(LdapUtils)]));
+                    _metric.Observe(LdapMetricDefinitions.UnresolvablePrincipals, 1, LdapUtilsLabel);
src/CommonLib/Services/MetricWriter.cs (1)

12-12: Remove unused variable computation.

The labelText variable is computed here but only used in the else branch at line 16. In the if branch (lines 13-14), CumulativeHistogramAppend recomputes the label text. Consider removing this line and computing labelText only in the else branch.

🔎 Proposed refactor
 public void StringBuilderAppendMetric(StringBuilder builder, MetricDefinition definition, LabelValues labelValues,
         MetricAggregator aggregator, DateTimeOffset timestamp, string timestampOutputString = "yyyy-MM-dd HH:mm:ss.fff") {
-        var labelText = labelValues.ToDisplayString(definition.LabelNames);
         if (aggregator is CumulativeHistogramAggregator cha) {
             CumulativeHistogramAppend(builder, definition, labelValues, cha, timestamp, timestampOutputString);
         } else {
+            var labelText = labelValues.ToDisplayString(definition.LabelNames);
             DefaultAppend(builder, definition, labelValues.ToDisplayString(definition.LabelNames), aggregator, timestamp, timestampOutputString);
         }
     }
src/CommonLib/Services/MetricAggregator.cs (1)

31-36: GaugeAggregator lacks thread-safety, inconsistent with other aggregators.

GaugeAggregator.Observe (line 34) directly assigns _value without synchronization, while CounterAggregator uses Interlocked (line 27) and CumulativeHistogramAggregator uses a lock (lines 53-57). This creates an inconsistency in thread-safety guarantees.

If aggregators are meant to be independently thread-safe, consider using Interlocked.Exchange or volatile for the gauge. If aggregators are always protected by external locks (as in FileMetricSink), the synchronization in Counter and Histogram may be unnecessary overhead.

Based on learnings, simple implementations without thread safety may be acceptable for statistical values where precise readings aren't critical.

🔎 Option 1: Make Gauge thread-safe
 public sealed class GaugeAggregator : MetricAggregator {
     private double _value;
     
-    public override void Observe(double value) => _value = value;
-    public override object Snapshot() => _value;
+    public override void Observe(double value) => Interlocked.Exchange(ref _value, value);
+    public override object Snapshot() => Interlocked.CompareExchange(ref _value, 0, 0);
 }
🔎 Option 2: Document that aggregators rely on external synchronization

Add a comment to the MetricAggregator base class documenting the expected threading model and remove internal synchronization from Counter and Histogram if external locking is guaranteed.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6bba590 and 76719a8.

📒 Files selected for processing (8)
  • src/CommonLib/LdapConnectionPool.cs
  • src/CommonLib/LdapUtils.cs
  • src/CommonLib/Models/MetricDefinition.cs
  • src/CommonLib/Services/FileMetricSink.cs
  • src/CommonLib/Services/MetricAggregator.cs
  • src/CommonLib/Services/MetricWriter.cs
  • src/CommonLib/Static/Metrics.cs
  • test/unit/MetricAggregatorTests.cs
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/unit/MetricAggregatorTests.cs
  • src/CommonLib/LdapConnectionPool.cs
  • src/CommonLib/Static/Metrics.cs
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-10-17T13:43:46.833Z
Learnt from: MikeX777
Repo: SpecterOps/SharpHoundCommon PR: 241
File: src/CommonLib/Processors/LdapPropertyProcessor.cs:168-169
Timestamp: 2025-10-17T13:43:46.833Z
Learning: Properties added to dictionaries returned by methods in SharpHoundCommon (such as those in LdapPropertyProcessor) may be consumed by dependent projects like SharpHound (SH) and SharpHoundEnterprise (SHE), even if they are not used within the SharpHoundCommon repository itself.

Applied to files:

  • src/CommonLib/LdapUtils.cs
📚 Learning: 2025-07-11T20:15:16.238Z
Learnt from: definitelynotagoblin
Repo: SpecterOps/SharpHoundCommon PR: 222
File: src/CommonLib/ExecutionTimeSampler.cs:35-43
Timestamp: 2025-07-11T20:15:16.238Z
Learning: In SharpHoundCommon ExecutionTimeSampler statistical methods, the user prefers to keep simple implementations without thread safety snapshots because precise values aren't needed for analysis purposes - they're comfortable with potentially imprecise values from concurrent access.

Applied to files:

  • src/CommonLib/Services/MetricAggregator.cs
🧬 Code graph analysis (3)
src/CommonLib/LdapUtils.cs (4)
src/CommonLib/Static/Metrics.cs (4)
  • Metrics (7-14)
  • LdapMetricDefinitions (31-37)
  • LdapMetrics (16-24)
  • ResetInFlight (23-23)
src/CommonLib/Services/FileMetricSink.cs (1)
  • Observe (40-51)
src/CommonLib/Interfaces/IMetricRouter.cs (1)
  • Observe (6-6)
src/CommonLib/Services/MetricRouter.cs (2)
  • Observe (19-30)
  • Observe (42-44)
src/CommonLib/Services/MetricAggregator.cs (4)
src/CommonLib/Services/FileMetricSink.cs (1)
  • Observe (40-51)
src/CommonLib/Services/MetricRouter.cs (2)
  • Observe (19-30)
  • Observe (42-44)
src/CommonLib/Interfaces/IMetricSink.cs (1)
  • Observe (6-6)
src/CommonLib/ConcurrentHashSet.cs (1)
  • Add (26-28)
src/CommonLib/Services/FileMetricSink.cs (5)
src/CommonLib/Models/FileMetricSinkOptions.cs (1)
  • FileMetricSinkOptions (5-9)
src/CommonLib/Services/MetricAggregator.cs (7)
  • MetricAggregator (9-16)
  • MetricAggregator (19-22)
  • Observe (20-20)
  • Observe (27-27)
  • Observe (34-34)
  • Observe (46-58)
  • MetricAggregatorExtensions (8-17)
src/CommonLib/Models/MetricObservation.cs (1)
  • MetricObservation (4-4)
src/CommonLib/Services/MetricWriter.cs (1)
  • StringBuilderAppendMetric (10-18)
src/CommonLib/Interfaces/IMetricWriter.cs (1)
  • StringBuilderAppendMetric (9-16)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (9)
src/CommonLib/LdapUtils.cs (4)

18-23: LGTM: Using statements added for metrics integration.

The new using directives appropriately support the metrics functionality (IMetricRouter interface, LabelValues model, and Metrics static class).


978-978: LGTM: Consistent metric observation in exception path.

The metric observation correctly tracks unresolvable distinguished names, maintaining consistency with the other error paths in this file.


1143-1145: LGTM: Metrics reset integrated into utility reset.

Resetting the in-flight request counter alongside other cached state is appropriate and maintains consistency across the reset operation.


53-96: LGTM: Metrics field and constructor integration implemented correctly.

The metric router field is properly initialized via constructor injection with a factory fallback. The dependency injection pattern is clean and follows existing conventions. The new optional parameter is backward compatible—all existing instantiations in the codebase use the parameterless constructor and are unaffected.

src/CommonLib/Services/MetricWriter.cs (1)

87-88: Verify the intended output format for non-histogram metrics.

The format string uses {{{3}}} which produces output like metric_name{labels} = {42} with braces around the value. This differs from typical metric formats which usually emit just metric_name{labels} value or metric_name{labels} = value without braces around the value. Please confirm this is the intended format.

src/CommonLib/Services/FileMetricSink.cs (2)

28-38: LGTM! File path constructor correctly chains to primary constructor.

The secondary constructor properly creates a StreamWriter with appropriate file mode and sharing settings, then delegates to the primary constructor.


55-89: LGTM! Flush implementation minimizes lock hold time.

The flush logic correctly locks only during metric formatting, then writes the output outside the lock. This is good design for minimizing contention.

src/CommonLib/Services/MetricAggregator.cs (1)

24-29: Verify double-to-long truncation in CounterAggregator.

Line 27 casts the double value parameter to long, which truncates any fractional part. If counters are only expected to be incremented by whole numbers, consider changing the signature to accept long instead of double. If fractional increments are valid, the current implementation silently loses precision.

src/CommonLib/Models/MetricDefinition.cs (1)

42-58: LGTM! Histogram bucket normalization is correct.

The NormalizeBuckets method properly validates non-null/non-empty input, sorts the buckets, and ensures strictly increasing values. The error messages are clear and appropriate.

Comment on lines 7 to 32
public readonly record struct LabelValues(string[] Values) {
public string ToDisplayString(IReadOnlyList<string> labelNames, string additionalName = null, string additionalValue = null) {
if (labelNames.Count == 0)
return string.Empty;

if (labelNames.Count != Values.Length)
return $"{{Improper Observation Labels, LabelNamesCount: {labelNames.Count}, LabelValuesCount: {Values.Length}}}";

var sb = new StringBuilder();
sb.Append('{');
for (var i = 0; i < labelNames.Count; i++) {
if (i > 0)
sb.Append(',');

sb.Append(labelNames[i])
.Append(':')
.Append(Values[i]);
}

if (!string.IsNullOrEmpty(additionalName) && !string.IsNullOrEmpty(additionalValue)) {
sb.Append(',').Append(additionalName).Append(':').Append(additionalValue);
}

sb.Append('}');
return sb.ToString();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Label format violates OpenMetrics/Prometheus specification.

Lines 22-23 and 27 produce label output like {method:GET,path:/api}, but the OpenMetrics specification requires labels in the format {method="GET",path="/api"} with:

  • = instead of : between label names and values
  • Quoted values (escaped UTF-8 strings)

This format incompatibility means the output cannot be parsed by standard Prometheus/OpenMetrics tools.

🔎 Proposed fix to conform to OpenMetrics spec
 public readonly record struct LabelValues(string[] Values) {
     public string ToDisplayString(IReadOnlyList<string> labelNames, string additionalName = null, string additionalValue = null) {
         if (labelNames.Count == 0)
             return string.Empty;
         
         if (labelNames.Count != Values.Length)
             return $"{{Improper Observation Labels, LabelNamesCount: {labelNames.Count}, LabelValuesCount: {Values.Length}}}";

         var sb = new StringBuilder();
         sb.Append('{');
         for (var i = 0; i < labelNames.Count; i++) {
             if (i > 0)
                 sb.Append(',');
             
-            sb.Append(labelNames[i])
-                .Append(':')
-                .Append(Values[i]);
+            sb.Append(labelNames[i])
+                .Append('=')
+                .Append('"')
+                .Append(Values[i].Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n"))
+                .Append('"');
         }

         if (!string.IsNullOrEmpty(additionalName) && !string.IsNullOrEmpty(additionalValue)) {
-            sb.Append(',').Append(additionalName).Append(':').Append(additionalValue);
+            sb.Append(',').Append(additionalName).Append('=').Append('"')
+                .Append(additionalValue.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n"))
+                .Append('"');
         }
         
         sb.Append('}');
         return sb.ToString();
     }
 };

Note: The escaping shown handles backslash, quote, and newline. The full OpenMetrics spec defines additional escape sequences that may need to be handled.

🤖 Prompt for AI Agents
In @src/CommonLib/Models/MetricDefinition.cs around lines 7 - 32,
LabelValues.ToDisplayString currently emits labels as name:value (e.g.,
{method:GET}) which breaks OpenMetrics/Prometheus; change the formatting to
name="value" and ensure values are escaped and quoted per the spec. Update the
loop in the ToDisplayString method to append labelNames[i] + "=" + "\"" +
EscapeLabelValue(Values[i]) + "\"" (and similarly for
additionalName/additionalValue), and add a small helper inside the type (e.g.,
private static string EscapeLabelValue(string v)) that escapes backslashes,
double quotes, and newlines (at minimum) before enclosing the value in quotes.
Ensure the improper-count check and empty labelNames behavior remain unchanged
and reference the existing Labels Values array, labelNames parameter, and
additionalName/additionalValue parameters when applying the fix.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (1)
src/CommonLib/Models/MetricDefinition.cs (1)

22-30: ⚠️ Potential issue | 🟠 Major

Escape label values before writing metric text format.

Line 24 and Line 29 append raw values. Quotes, backslashes, and newlines can produce invalid metric lines.

Proposed fix
             sb.Append(labelNames[i])
                 .Append("=\"")
-                .Append(Values[i])
+                .Append(EscapeLabelValue(Values[i]))
                 .Append('"');
@@
         if (!string.IsNullOrEmpty(additionalName) && !string.IsNullOrEmpty(additionalValue)) {
-            sb.Append(',').Append(additionalName).Append("=\"").Append(additionalValue).Append('"');
+            sb.Append(',').Append(additionalName).Append("=\"").Append(EscapeLabelValue(additionalValue)).Append('"');
         }
@@
     }
+
+    private static string EscapeLabelValue(string value) =>
+        value.Replace("\\", "\\\\")
+            .Replace("\"", "\\\"")
+            .Replace("\n", "\\n");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Models/MetricDefinition.cs` around lines 22 - 30, The code
appends raw label values (Values[i] and additionalValue) into the metric text
output, which can produce invalid metric lines when values contain quotes,
backslashes, newlines or returns; add a helper to escape label values (e.g.,
replace backslash with \\ , double quote with \\" , newline with \\n , carriage
return with \\r) and call it where values are appended in MetricDefinition
(escape Values[i] when appending with labelNames[i], and escape additionalValue
when appending with additionalName); implement the helper as a private static
string EscapeLabelValue(string) and use it in the code path that builds the
sb.Append(...) fragments.
🧹 Nitpick comments (2)
test/unit/AdaptiveTimeoutTest.cs (1)

32-32: Redundant return; statements in test methods

These return; lines are unnecessary and add noise; removing them makes the tests easier to scan.

Also applies to: 57-57, 88-88, 122-122, 161-161

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/AdaptiveTimeoutTest.cs` at line 32, Remove the redundant trailing
"return;" statements from the test methods in AdaptiveTimeoutTest.cs (they are
no-ops that add noise); locate each "return;" in the AdaptiveTimeoutTest class
(occurring at the ends of individual test methods) and delete those lines (e.g.,
the occurrences reported around lines 32, 57, 88, 122, 161) while leaving the
method bodies and assertions intact so behavior does not change.
test/unit/MetricDefinitionTests.cs (1)

48-84: Add regression tests for escaping and non-finite bucket validation.

Current coverage misses two high-risk paths: escaped label values in ToDisplayString and rejection of NaN/Infinity buckets in CumulativeHistogramDefinition.

Also applies to: 112-126

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/MetricDefinitionTests.cs` around lines 48 - 84, Add regression
tests covering two missing paths: ensure LabelValues.ToDisplayString properly
escapes special characters by adding a test where label values contain quotes,
backslashes, or commas and asserting the output uses correct escaping; and
ensure CumulativeHistogramDefinition rejects non-finite bucket boundaries by
adding tests that construct a CumulativeHistogramDefinition (or call its
validation method) with Double.NaN, Double.PositiveInfinity, and
Double.NegativeInfinity buckets and assert that the constructor/validator throws
the expected exception (e.g., ArgumentException/ArgumentOutOfRangeException).
Reference LabelValues.ToDisplayString for the escaping tests and
CumulativeHistogramDefinition (its constructor or Validate/ValidateBuckets
method) for the non-finite bucket tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/CommonLib/Models/MetricDefinition.cs`:
- Around line 66-76: In NormalizeBuckets, after cloning and sorting the array
(variable copy) validate that every element is a finite number: iterate over
copy and throw an ArgumentException if any element is NaN or Infinite (use
double.IsNaN/ double.IsInfinity or double.IsFinite if available) so buckets
cannot contain non-finite values that would break Array.BinarySearch and label
generation; keep the existing strictly-increasing check in the same loop (or a
subsequent check) and include a clear message like "Histogram buckets must be
finite numbers" when throwing.

In `@src/CommonLib/Services/FileMetricSink.cs`:
- Around line 40-46: In Observe(in MetricObservation.DoubleMetricObservation
observation) guard access to _definitions by validating observation.DefinitionId
before indexing: check that _definitions contains the key/index (or that the
index is in range) and if missing, log or ignore the observation and return
instead of indexing; then only call MetricAggregatorExtensions.Create(...) and
modify _states when the definition exists. This prevents a throw when Observe is
called outside MetricRouter validation and keeps behavior localized in the
FileMetricSink class.
- Around line 73-78: The metric lines are not using the configured timestamp
format because StringBuilderAppendMetric is called without passing
_options.TimestampFormat; update the call in FileMetricSink (the invocation of
_metricWriter.StringBuilderAppendMetric(sb, definition, new
LabelValues(labelValues), aggregator, timestamp)) to pass the configured format
(e.g., add _options.TimestampFormat as an argument) or modify
StringBuilderAppendMetric to accept and apply a timestampFormat parameter so
that the same _options.TimestampFormat used for the flush header is applied to
each metric line.
- Around line 55-89: The Flush() method currently builds the output under lock
but calls _textWriter.Write(...) and _textWriter.Flush() outside the lock, which
allows concurrent Flush()/Dispose() to interleave writes; move the calls to
_textWriter.Write(output) and conditional _textWriter.Flush() inside the
existing lock (_lock) so the entire build-and-write sequence is atomic, or
alternatively ensure Dispose() also acquires the same _lock before touching
_textWriter; update FileMetricSink.Flush() and any Dispose() that writes to
_textWriter to use the same lock protection.

In `@src/CommonLib/Services/MetricAggregator.cs`:
- Around line 42-43: The constructor of CumulativeHistogramAggregator currently
stores a reference to the mutable double[] bounds; defensively copy it into a
private readonly double[] (e.g., _bounds = (double[])bounds.Clone()) and use
_bounds when sizing _bucketCounts and computing buckets; likewise, when
producing HistogramSnapshot (the method that returns snapshot/bounds), return a
copy of the bounds (not the internal _bounds reference) so external callers
cannot mutate internal bucket semantics. Ensure any other places that previously
referenced the constructor parameter now use the private _bounds field.

In `@src/CommonLib/Services/MetricWriter.cs`:
- Around line 54-59: The histogram metric formatting in MetricWriter.cs is not
using culture-invariant formatting; update all builder.AppendFormat calls and
timestamp.ToString calls in the MetricWriter class/method that writes histogram
metrics so they use CultureInfo.InvariantCulture (either by calling
ToString(CultureInfo.InvariantCulture) for timestamp, snapshot.Bounds[i],
snapshot.Sum, or by using the AppendFormat overload that accepts an
IFormatProvider). Specifically change the AppendFormat invocations that format
timestamp, definition.Name + "_bucket", snapshot.Bounds[i], cumulativeValue, and
the similar lines that emit _count and _sum to use CultureInfo.InvariantCulture
so numbers and timestamps serialize with invariant culture consistently (mirror
the existing correct use of
snapshot.Bounds[i].ToString(CultureInfo.InvariantCulture)).
- Around line 92-110: The two DefaultAppend overloads
(MetricWriter.DefaultAppend for MetricAggregator<double> and
MetricAggregator<long>) currently emit numeric values wrapped in literal braces
and use culture-sensitive formatting; update both to remove the extra braces
around the value placeholder so the format string renders the raw numeric value,
and format both the timestamp and the aggregator snapshot using
CultureInfo.InvariantCulture (e.g., call
timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture) and
convert the aggregator.Snapshot() to a string with InvariantCulture) so output
is locale-independent and produces " = value" instead of " = {value}".

In `@test/unit/AdaptiveTimeoutTest.cs`:
- Around line 119-121: The range checks over 'observations' are vacuous when the
collection is empty; update the test in AdaptiveTimeoutTest to first assert the
expected count (e.g., Assert.True/Assert.Equal on observations.Count or
observations.Length) before iterating, then perform the existing Assert.InRange
checks; apply the same fix to the second occurrence referenced (the block around
lines 155-160). Ensure you reference the 'observations' collection in both test
blocks so missing callbacks fail the count assertion before value checks.

---

Duplicate comments:
In `@src/CommonLib/Models/MetricDefinition.cs`:
- Around line 22-30: The code appends raw label values (Values[i] and
additionalValue) into the metric text output, which can produce invalid metric
lines when values contain quotes, backslashes, newlines or returns; add a helper
to escape label values (e.g., replace backslash with \\ , double quote with \\"
, newline with \\n , carriage return with \\r) and call it where values are
appended in MetricDefinition (escape Values[i] when appending with
labelNames[i], and escape additionalValue when appending with additionalName);
implement the helper as a private static string EscapeLabelValue(string) and use
it in the code path that builds the sb.Append(...) fragments.

---

Nitpick comments:
In `@test/unit/AdaptiveTimeoutTest.cs`:
- Line 32: Remove the redundant trailing "return;" statements from the test
methods in AdaptiveTimeoutTest.cs (they are no-ops that add noise); locate each
"return;" in the AdaptiveTimeoutTest class (occurring at the ends of individual
test methods) and delete those lines (e.g., the occurrences reported around
lines 32, 57, 88, 122, 161) while leaving the method bodies and assertions
intact so behavior does not change.

In `@test/unit/MetricDefinitionTests.cs`:
- Around line 48-84: Add regression tests covering two missing paths: ensure
LabelValues.ToDisplayString properly escapes special characters by adding a test
where label values contain quotes, backslashes, or commas and asserting the
output uses correct escaping; and ensure CumulativeHistogramDefinition rejects
non-finite bucket boundaries by adding tests that construct a
CumulativeHistogramDefinition (or call its validation method) with Double.NaN,
Double.PositiveInfinity, and Double.NegativeInfinity buckets and assert that the
constructor/validator throws the expected exception (e.g.,
ArgumentException/ArgumentOutOfRangeException). Reference
LabelValues.ToDisplayString for the escaping tests and
CumulativeHistogramDefinition (its constructor or Validate/ValidateBuckets
method) for the non-finite bucket tests.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76719a8 and 35b3425.

📒 Files selected for processing (8)
  • src/CommonLib/Models/MetricDefinition.cs
  • src/CommonLib/Services/FileMetricSink.cs
  • src/CommonLib/Services/MetricAggregator.cs
  • src/CommonLib/Services/MetricWriter.cs
  • test/unit/AdaptiveTimeoutTest.cs
  • test/unit/FileMetricSinkTests.cs
  • test/unit/MetricAggregatorTests.cs
  • test/unit/MetricDefinitionTests.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/unit/MetricAggregatorTests.cs

Comment on lines +66 to +76
private static double[] NormalizeBuckets(double[] buckets) {
if (buckets is null || buckets.Length == 0)
throw new ArgumentException("Histogram buckets cannot be empty");

var copy = (double[])buckets.Clone();
Array.Sort(copy);

for (var i = 1; i < copy.Length; i++) {
if (copy[i] <= copy[i - 1])
throw new ArgumentException("Histogram buckets must be strictly increasing");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the MetricDefinition.cs file
find . -name "MetricDefinition.cs" -type f

Repository: SpecterOps/SharpHoundCommon

Length of output: 113


🏁 Script executed:

# Search for NormalizeBuckets method
rg "NormalizeBuckets" -A 5 -B 2

Repository: SpecterOps/SharpHoundCommon

Length of output: 979


🏁 Script executed:

# Search for histogram bucket related tests
rg "histogram.*bucket|bucket.*histogram" -i -l

Repository: SpecterOps/SharpHoundCommon

Length of output: 189


🏁 Script executed:

# Read the complete MetricDefinition.cs file
cat -n src/CommonLib/Models/MetricDefinition.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 3963


🏁 Script executed:

# Read the test file to understand existing tests
cat -n test/unit/MetricDefinitionTests.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 5876


🏁 Script executed:

# Search for any handling of infinity or NaN in the codebase
rg "IsNaN|IsInfinity" -B 3 -A 3

Repository: SpecterOps/SharpHoundCommon

Length of output: 53


🏁 Script executed:

# Search for MetricAggregator usage to understand how buckets are used
cat -n src/CommonLib/Services/MetricAggregator.cs | head -100

Repository: SpecterOps/SharpHoundCommon

Length of output: 3108


🏁 Script executed:

# Search for any histogram generation or "le" label usage
rg "le" -i -B 2 -A 2

Repository: SpecterOps/SharpHoundCommon

Length of output: 50385


🏁 Script executed:

# Let's search more specifically for histogram output or prometheus formatting
rg "histogram|prometheus" -i -l

Repository: SpecterOps/SharpHoundCommon

Length of output: 342


🏁 Script executed:

# Look for how buckets are actually used in output/serialization
rg "Buckets" -B 3 -A 3 src/CommonLib

Repository: SpecterOps/SharpHoundCommon

Length of output: 2493


🏁 Script executed:

# Check if there's any documentation about the histogram format or expected behavior
find . -name "*.md" -o -name "*.txt" | xargs grep -l "histogram\|bucket" 2>/dev/null | head -5

Repository: SpecterOps/SharpHoundCommon

Length of output: 53


🏁 Script executed:

# Let's check if NaN/Infinity would cause issues with Array.Sort and Array.BinarySearch
python3 << 'EOF'
# Simulate what happens with NaN/Infinity in sorted arrays

import math

# Test 1: Array.Sort behavior with NaN
buckets = [1.0, 2.0, math.nan, 3.0]
buckets_sorted = sorted(buckets)
print("Original buckets:", [1.0, 2.0, math.nan, 3.0])
print("After sort:", buckets_sorted)
print()

# Test 2: Strictly increasing check
test_arrays = [
    [1.0, 2.0, math.inf],
    [1.0, math.nan, 2.0],
    [-math.inf, 0.0, 1.0],
    [1.0, math.nan, math.nan],
]

for arr in test_arrays:
    print(f"Testing array: {arr}")
    sorted_arr = sorted(arr)
    print(f"After sort: {sorted_arr}")
    
    # Check strictly increasing
    is_increasing = all(sorted_arr[i] <= sorted_arr[i-1] for i in range(1, len(sorted_arr)))
    print(f"Passes strictly increasing check: {not is_increasing}")
    
    # Check what comparison would fail
    for i in range(1, len(sorted_arr)):
        cmp = sorted_arr[i] <= sorted_arr[i-1]
        print(f"  sorted_arr[{i}] ({sorted_arr[i]}) <= sorted_arr[{i-1}] ({sorted_arr[i-1]}): {cmp}")
    print()

# Test 3: BinarySearch behavior
arr = [1.0, 2.0, math.inf]
search_values = [0.5, 1.5, 2.5, math.nan, math.inf]
print("BinarySearch simulation with array:", arr)
for val in search_values:
    # Python's bisect doesn't work well with NaN, but Array.BinarySearch in .NET would have issues
    print(f"  Searching for {val}: would have unpredictable behavior with NaN")
EOF

Repository: SpecterOps/SharpHoundCommon

Length of output: 1330


🏁 Script executed:

cat -n src/CommonLib/Services/MetricWriter.cs | head -150

Repository: SpecterOps/SharpHoundCommon

Length of output: 5624


🏁 Script executed:

# Search for how buckets are written to output
rg "Bounds|buckets" -B 2 -A 2 src/CommonLib/Services/MetricWriter.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 782


🏁 Script executed:

# Check the test file for histogram tests
cat -n test/unit/MetricAggregatorTests.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 5259


Validate histogram bucket values are finite.

Lines 66-76 do not reject NaN or Infinity values. NaN comparisons always evaluate to false, allowing NaN buckets to pass the strictly-increasing check. Non-finite buckets would then corrupt bucket placement in Array.BinarySearch and produce invalid le label values in metric output.

Proposed fix
     private static double[] NormalizeBuckets(double[] buckets) {
         if (buckets is null || buckets.Length == 0)
             throw new ArgumentException("Histogram buckets cannot be empty");
 
         var copy = (double[])buckets.Clone();
         Array.Sort(copy);
 
-        for (var i = 1; i < copy.Length; i++) {
-            if (copy[i] <= copy[i - 1])
+        for (var i = 0; i < copy.Length; i++) {
+            if (double.IsNaN(copy[i]) || double.IsInfinity(copy[i]))
+                throw new ArgumentException("Histogram buckets must be finite numbers");
+
+            if (i > 0 && copy[i] <= copy[i - 1])
                 throw new ArgumentException("Histogram buckets must be strictly increasing");
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static double[] NormalizeBuckets(double[] buckets) {
if (buckets is null || buckets.Length == 0)
throw new ArgumentException("Histogram buckets cannot be empty");
var copy = (double[])buckets.Clone();
Array.Sort(copy);
for (var i = 1; i < copy.Length; i++) {
if (copy[i] <= copy[i - 1])
throw new ArgumentException("Histogram buckets must be strictly increasing");
}
private static double[] NormalizeBuckets(double[] buckets) {
if (buckets is null || buckets.Length == 0)
throw new ArgumentException("Histogram buckets cannot be empty");
var copy = (double[])buckets.Clone();
Array.Sort(copy);
for (var i = 0; i < copy.Length; i++) {
if (double.IsNaN(copy[i]) || double.IsInfinity(copy[i]))
throw new ArgumentException("Histogram buckets must be finite numbers");
if (i > 0 && copy[i] <= copy[i - 1])
throw new ArgumentException("Histogram buckets must be strictly increasing");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Models/MetricDefinition.cs` around lines 66 - 76, In
NormalizeBuckets, after cloning and sorting the array (variable copy) validate
that every element is a finite number: iterate over copy and throw an
ArgumentException if any element is NaN or Infinite (use double.IsNaN/
double.IsInfinity or double.IsFinite if available) so buckets cannot contain
non-finite values that would break Array.BinarySearch and label generation; keep
the existing strictly-increasing check in the same loop (or a subsequent check)
and include a clear message like "Histogram buckets must be finite numbers" when
throwing.

Comment on lines +40 to +46
public void Observe(in MetricObservation.DoubleMetricObservation observation) {
var key = (observation.DefinitionId, observation.LabelsValues);

lock (_lock) {
if (!_states.TryGetValue(key, out var aggregator)) {
aggregator = MetricAggregatorExtensions.Create(_definitions[observation.DefinitionId]);
_states[key] = aggregator;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against invalid definition IDs at sink boundary.

Line 45 indexes _definitions directly. If this sink is used outside MetricRouter validation, it can throw.

Proposed fix
     public void Observe(in MetricObservation.DoubleMetricObservation observation) {
+        if ((uint)observation.DefinitionId >= (uint)_definitions.Length)
+            return;
+
         var key = (observation.DefinitionId, observation.LabelsValues);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void Observe(in MetricObservation.DoubleMetricObservation observation) {
var key = (observation.DefinitionId, observation.LabelsValues);
lock (_lock) {
if (!_states.TryGetValue(key, out var aggregator)) {
aggregator = MetricAggregatorExtensions.Create(_definitions[observation.DefinitionId]);
_states[key] = aggregator;
public void Observe(in MetricObservation.DoubleMetricObservation observation) {
if ((uint)observation.DefinitionId >= (uint)_definitions.Length)
return;
var key = (observation.DefinitionId, observation.LabelsValues);
lock (_lock) {
if (!_states.TryGetValue(key, out var aggregator)) {
aggregator = MetricAggregatorExtensions.Create(_definitions[observation.DefinitionId]);
_states[key] = aggregator;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/FileMetricSink.cs` around lines 40 - 46, In Observe(in
MetricObservation.DoubleMetricObservation observation) guard access to
_definitions by validating observation.DefinitionId before indexing: check that
_definitions contains the key/index (or that the index is in range) and if
missing, log or ignore the observation and return instead of indexing; then only
call MetricAggregatorExtensions.Create(...) and modify _states when the
definition exists. This prevents a throw when Observe is called outside
MetricRouter validation and keeps behavior localized in the FileMetricSink
class.

Comment on lines +55 to +89
public void Flush() {
string output;
lock (_lock) {
var sb = new StringBuilder(EstimateSize());

var timestamp = DateTimeOffset.Now;
sb.Append("Metric Flush: ")
.Append(timestamp.ToString(_options.TimestampFormat))
.AppendLine();
sb.Append('=', 40).AppendLine();

// Must use this deconstruction for .Net Version
foreach (var kvp in _states) {
var definitionId = kvp.Key.Item1;
var labelValues = kvp.Key.Item2;
var aggregator = kvp.Value;
var definition = _definitions[definitionId];

_metricWriter.StringBuilderAppendMetric(
sb,
definition,
new LabelValues(labelValues),
aggregator,
timestamp);
}

sb.Append('=', 40).AppendLine().AppendLine().AppendLine().AppendLine().AppendLine();
output = sb.ToString();
}

_textWriter.Write(output);

if (_options.FlushWriter)
_textWriter.Flush();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent interleaved writes during concurrent flushes.

Line 85-88 run outside synchronization. Concurrent Flush()/Dispose() can interleave TextWriter writes.

Proposed fix
 public sealed class FileMetricSink(
@@
     private readonly object _lock = new();
+    private readonly object _writeLock = new();
@@
     public void Flush() {
         string output;
         lock (_lock) {
@@
             output = sb.ToString();
         }
-        
-        _textWriter.Write(output);
-
-        if (_options.FlushWriter) 
-            _textWriter.Flush();
+
+        lock (_writeLock) {
+            _textWriter.Write(output);
+            if (_options.FlushWriter) 
+                _textWriter.Flush();
+        }
     }
@@
     public void Dispose() {
         Flush();
-        _textWriter.Dispose();
+        lock (_writeLock) {
+            _textWriter.Dispose();
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/FileMetricSink.cs` around lines 55 - 89, The Flush()
method currently builds the output under lock but calls _textWriter.Write(...)
and _textWriter.Flush() outside the lock, which allows concurrent
Flush()/Dispose() to interleave writes; move the calls to
_textWriter.Write(output) and conditional _textWriter.Flush() inside the
existing lock (_lock) so the entire build-and-write sequence is atomic, or
alternatively ensure Dispose() also acquires the same _lock before touching
_textWriter; update FileMetricSink.Flush() and any Dispose() that writes to
_textWriter to use the same lock protection.

Comment on lines +73 to +78
_metricWriter.StringBuilderAppendMetric(
sb,
definition,
new LabelValues(labelValues),
aggregator,
timestamp);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply configured timestamp format to metric lines too.

Line 73-78 call StringBuilderAppendMetric without _options.TimestampFormat, so only the flush header honors the option.

Proposed fix
                 _metricWriter.StringBuilderAppendMetric(
                     sb,
                     definition,
                     new LabelValues(labelValues),
                     aggregator,
-                    timestamp);
+                    timestamp,
+                    _options.TimestampFormat);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_metricWriter.StringBuilderAppendMetric(
sb,
definition,
new LabelValues(labelValues),
aggregator,
timestamp);
_metricWriter.StringBuilderAppendMetric(
sb,
definition,
new LabelValues(labelValues),
aggregator,
timestamp,
_options.TimestampFormat);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/FileMetricSink.cs` around lines 73 - 78, The metric
lines are not using the configured timestamp format because
StringBuilderAppendMetric is called without passing _options.TimestampFormat;
update the call in FileMetricSink (the invocation of
_metricWriter.StringBuilderAppendMetric(sb, definition, new
LabelValues(labelValues), aggregator, timestamp)) to pass the configured format
(e.g., add _options.TimestampFormat as an argument) or modify
StringBuilderAppendMetric to accept and apply a timestampFormat parameter so
that the same _options.TimestampFormat used for the flush header is applied to
each metric line.

Comment on lines +42 to +43
public sealed class CumulativeHistogramAggregator(double[] bounds) : MetricAggregator<HistogramSnapshot> {
private readonly long[] _bucketCounts = new long[bounds.Length + 1]; // Includes the Inf+ bucket
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defensively copy histogram bounds to prevent external mutation.

Line 42 captures a mutable double[] reference, and Line 66 returns the same bounds reference in snapshots. External mutation can change bucket semantics after initialization.

Proposed fix
 public sealed class CumulativeHistogramAggregator(double[] bounds) : MetricAggregator<HistogramSnapshot> {
+    private readonly double[] _bounds = (double[])bounds.Clone();
     private readonly long[] _bucketCounts = new long[bounds.Length + 1]; // Includes the Inf+ bucket
@@
-        var idx = Array.BinarySearch(bounds, value);
+        var idx = Array.BinarySearch(_bounds, value);
@@
     public HistogramSnapshot SnapshotHistogram() {
         lock (_lock) {
-            return new HistogramSnapshot(bounds, (long[])_bucketCounts.Clone(), _count, _sum);
+            return new HistogramSnapshot((double[])_bounds.Clone(), (long[])_bucketCounts.Clone(), _count, _sum);
         }
     }
 }

Also applies to: 64-67

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/MetricAggregator.cs` around lines 42 - 43, The
constructor of CumulativeHistogramAggregator currently stores a reference to the
mutable double[] bounds; defensively copy it into a private readonly double[]
(e.g., _bounds = (double[])bounds.Clone()) and use _bounds when sizing
_bucketCounts and computing buckets; likewise, when producing HistogramSnapshot
(the method that returns snapshot/bounds), return a copy of the bounds (not the
internal _bounds reference) so external callers cannot mutate internal bucket
semantics. Ensure any other places that previously referenced the constructor
parameter now use the private _bounds field.

Comment on lines +54 to +59
builder.AppendFormat("{0} {1}{2}{{le=\"{3}\"}} = {4}\n",
timestamp.ToString(timestampOutputString),
definition.Name + "_bucket",
defaultLabelText,
snapshot.Bounds[i],
cumulativeValue);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "MetricWriter.cs"

Repository: SpecterOps/SharpHoundCommon

Length of output: 151


🏁 Script executed:

wc -l src/CommonLib/Services/MetricWriter.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 113


🏁 Script executed:

cat -n src/CommonLib/Services/MetricWriter.cs

Repository: SpecterOps/SharpHoundCommon

Length of output: 5624


Apply invariant culture formatting to histogram metrics and timestamps.

Lines 54-59 and 78-82 lack culture-invariant formatting. The AppendFormat() calls, timestamp.ToString() calls, and the snapshot.Bounds[i] and snapshot.Sum properties will serialize with commas on non-en-US locales, breaking metric parsing.

Also fix similar issues at lines 64-65, 71-72, and 84-85 where AppendFormat() and timestamp.ToString() lack CultureInfo.InvariantCulture. Note that line 51 already correctly uses snapshot.Bounds[i].ToString(CultureInfo.InvariantCulture), indicating the pattern should be applied consistently throughout the method.

Proposed fix
- builder.AppendFormat("{0} {1}{2}{{le=\"{3}\"}} = {4}\n",
-     timestamp.ToString(timestampOutputString),
+ builder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}{2}{{le=\"{3}\"}} = {4}\n",
+     timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture),
      definition.Name + "_bucket",
      defaultLabelText,
-     snapshot.Bounds[i],
+     snapshot.Bounds[i].ToString(CultureInfo.InvariantCulture),
      cumulativeValue);
 ...
- builder.AppendFormat("{0} {1}{2} = {3}\n",
-     timestamp.ToString(timestampOutputString),
+ builder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}{2} = {3}\n",
+     timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture),
      definition.Name + "_sum",
      defaultLabelText,
      snapshot.Sum);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
builder.AppendFormat("{0} {1}{2}{{le=\"{3}\"}} = {4}\n",
timestamp.ToString(timestampOutputString),
definition.Name + "_bucket",
defaultLabelText,
snapshot.Bounds[i],
cumulativeValue);
builder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}{2}{{le=\"{3}\"}} = {4}\n",
timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture),
definition.Name + "_bucket",
defaultLabelText,
snapshot.Bounds[i].ToString(CultureInfo.InvariantCulture),
cumulativeValue);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/MetricWriter.cs` around lines 54 - 59, The histogram
metric formatting in MetricWriter.cs is not using culture-invariant formatting;
update all builder.AppendFormat calls and timestamp.ToString calls in the
MetricWriter class/method that writes histogram metrics so they use
CultureInfo.InvariantCulture (either by calling
ToString(CultureInfo.InvariantCulture) for timestamp, snapshot.Bounds[i],
snapshot.Sum, or by using the AppendFormat overload that accepts an
IFormatProvider). Specifically change the AppendFormat invocations that format
timestamp, definition.Name + "_bucket", snapshot.Bounds[i], cumulativeValue, and
the similar lines that emit _count and _sum to use CultureInfo.InvariantCulture
so numbers and timestamps serialize with invariant culture consistently (mirror
the existing correct use of
snapshot.Bounds[i].ToString(CultureInfo.InvariantCulture)).

Comment on lines +92 to +110
private static void DefaultAppend(
StringBuilder builder,
MetricDefinition definition,
string labelText,
MetricAggregator<double> aggregator,
DateTimeOffset timestamp,
string timestampOutputString) =>
builder.AppendFormat("{0} {1}{2} = {{{3}}}\n", timestamp.ToString(timestampOutputString),
definition.Name, labelText, aggregator.Snapshot());

private static void DefaultAppend(
StringBuilder builder,
MetricDefinition definition,
string labelText,
MetricAggregator<long> aggregator,
DateTimeOffset timestamp,
string timestampOutputString) =>
builder.AppendFormat("{0} {1}{2} = {{{3}}}\n", timestamp.ToString(timestampOutputString),
definition.Name, labelText, aggregator.Snapshot());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the MetricWriter.cs file
find . -name "MetricWriter.cs" -type f

Repository: SpecterOps/SharpHoundCommon

Length of output: 111


🏁 Script executed:

# Read lines 92-110 from the file (approximate range to get full context)
if [ -f "src/CommonLib/Services/MetricWriter.cs" ]; then
  cat -n "src/CommonLib/Services/MetricWriter.cs" | sed -n '85,115p'
fi

Repository: SpecterOps/SharpHoundCommon

Length of output: 1293


🏁 Script executed:

# Also check imports to see what's available (CultureInfo usage)
if [ -f "src/CommonLib/Services/MetricWriter.cs" ]; then
  head -30 "src/CommonLib/Services/MetricWriter.cs"
fi

Repository: SpecterOps/SharpHoundCommon

Length of output: 1445


🏁 Script executed:

# Check entire DefaultAppend method definitions to understand context
if [ -f "src/CommonLib/Services/MetricWriter.cs" ]; then
  rg -n "DefaultAppend" "src/CommonLib/Services/MetricWriter.cs" -A 10
fi

Repository: SpecterOps/SharpHoundCommon

Length of output: 1823


🏁 Script executed:

# Look for CumulativeHistogramAppend to see if it has similar issues
if [ -f "src/CommonLib/Services/MetricWriter.cs" ]; then
  rg -n "CumulativeHistogramAppend|AppendFormat" "src/CommonLib/Services/MetricWriter.cs" -B 2 -A 5
fi

Repository: SpecterOps/SharpHoundCommon

Length of output: 3478


🏁 Script executed:

# Get full file size to see the complete context
if [ -f "src/CommonLib/Services/MetricWriter.cs" ]; then
  wc -l "src/CommonLib/Services/MetricWriter.cs"
fi

Repository: SpecterOps/SharpHoundCommon

Length of output: 113


Fix gauge/counter sample rendering to emit raw numeric values and use invariant culture.

Lines 99 and 109 currently produce malformed output due to {{{3}}} in the format string, which wraps numeric values in literal braces (e.g., = {value} instead of = value). Additionally, both timestamp.ToString() and aggregator.Snapshot() are formatted without CultureInfo.InvariantCulture, causing locale-sensitive output (decimal commas in some regions will break the metric format).

Proposed fix
 private static void DefaultAppend(
     StringBuilder builder, 
     MetricDefinition definition, 
     string labelText, 
     MetricAggregator<double> aggregator, 
     DateTimeOffset timestamp, 
     string timestampOutputString) => 
-    builder.AppendFormat("{0} {1}{2} = {{{3}}}\n", timestamp.ToString(timestampOutputString),
+    builder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}{2} = {3}\n",
+        timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture),
         definition.Name, labelText, aggregator.Snapshot());
 
 private static void DefaultAppend(
     StringBuilder builder, 
     MetricDefinition definition, 
     string labelText, 
     MetricAggregator<long> aggregator, 
     DateTimeOffset timestamp, 
     string timestampOutputString) => 
-    builder.AppendFormat("{0} {1}{2} = {{{3}}}\n", timestamp.ToString(timestampOutputString),
+    builder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}{2} = {3}\n",
+        timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture),
         definition.Name, labelText, aggregator.Snapshot());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CommonLib/Services/MetricWriter.cs` around lines 92 - 110, The two
DefaultAppend overloads (MetricWriter.DefaultAppend for MetricAggregator<double>
and MetricAggregator<long>) currently emit numeric values wrapped in literal
braces and use culture-sensitive formatting; update both to remove the extra
braces around the value placeholder so the format string renders the raw numeric
value, and format both the timestamp and the aggregator snapshot using
CultureInfo.InvariantCulture (e.g., call
timestamp.ToString(timestampOutputString, CultureInfo.InvariantCulture) and
convert the aggregator.Snapshot() to a string with InvariantCulture) so output
is locale-independent and produces " = value" instead of " = {value}".

Comment on lines +119 to +121
foreach (var t in observations) {
Assert.InRange(t, 0.0, 1000.1);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Range-only loops are vacuous when no observations are recorded

These assertions pass if the bags are empty, so callback regressions can slip through undetected (especially in parallel/timeout paths). Add count assertions for the expected successful observations before value-range checks.

Suggested test hardening
         var adaptiveTimeoutResult = adaptiveTimeout.GetAdaptiveTimeout();
         Assert.Equal(maxTimeout, adaptiveTimeoutResult);
+        Assert.Equal(numSamples + 3, observations.Count);
         foreach (var t in observations) {
             Assert.InRange(t, 0.0, 1000.1);
         }
         Assert.True(adaptiveTimeoutResult < maxTimeout);
+        Assert.Equal(numSamples + 4, completedObservations.Count);
         foreach (var t in completedObservations) {
             Assert.InRange(t, 0.0, 50.0);
         }
         foreach (var t in timeoutObservations) {
             Assert.InRange(t, 0.0, 1000.1);
         }

Also applies to: 155-160

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/AdaptiveTimeoutTest.cs` around lines 119 - 121, The range checks
over 'observations' are vacuous when the collection is empty; update the test in
AdaptiveTimeoutTest to first assert the expected count (e.g.,
Assert.True/Assert.Equal on observations.Count or observations.Length) before
iterating, then perform the existing Assert.InRange checks; apply the same fix
to the second occurrence referenced (the block around lines 155-160). Ensure you
reference the 'observations' collection in both test blocks so missing callbacks
fail the count assertion before value checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant