Skip to content

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

    • LDAP telemetry: request latency, concurrent/failed request metrics, and unresolvable principal counts.
    • Optional latency-observation callbacks added to timeout execution paths.
    • Full metrics subsystem: metric definitions, registry, router, factory, sinks, aggregators, writer, and a file-based sink with configurable formatting and flush options.
    • Label-values interning/cache and periodic flush timer.
  • Tests

    • Extensive unit tests covering metrics, routing, aggregation, file sink, caching, and timeout latency observations (Windows-gated where applicable).

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

Warning

Rate limit exceeded

@mykeelium has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 48 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 76719a8 and 3333ef6.

📒 Files selected for processing (4)
  • src/CommonLib/Models/MetricDefinition.cs
  • src/CommonLib/Services/FileMetricSink.cs
  • test/unit/AdaptiveTimeoutTest.cs
  • test/unit/MetricDefinitionTests.cs

Walkthrough

Adds optional latency observation callbacks to timeout/sampling APIs and a new metrics subsystem: definitions, registry, router, sinks, aggregators, writer, file sink, label-value interning, periodic flush timer, and LDAP instrumentation to emit latency/concurrency/failure metrics.

Changes

Cohort / File(s) Summary
Latency observation
src/CommonLib/AdaptiveTimeout.cs, src/CommonLib/ExecutionTimeSampler.cs
Added optional Action<double> latencyObservation parameter to timeout and sampling APIs; forwarded/invoked it to report observed elapsed milliseconds.
Metrics interfaces
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
New interfaces for label interning, metric factory, registry, router, sinks, and metric output formatting.
Metric models
src/CommonLib/Models/MetricDefinition.cs, src/CommonLib/Models/MetricObservation.cs, src/CommonLib/Models/FileMetricSinkOptions.cs, src/CommonLib/Models/IsExternalInit.cs
New immutable metric types: LabelValues, metric definition hierarchy (Counter/Gauge/CumulativeHistogram with bucket normalization), MetricObservation.DoubleMetricObservation, file sink options, and IsExternalInit shim.
Metric services & implementations
src/CommonLib/Services/DefaultLabelValuesCache.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/FileMetricSink.cs, src/CommonLib/Services/MetricsFlushTimer.cs
Concrete implementations: label cache, aggregators (counter/gauge/cumulative histogram), factory (and NoOp), registry with sealing, router and NoOp router, metric writer formatting, file-based sink with aggregation and flush, and periodic flush timer.
Static metrics & defaults
src/CommonLib/Static/Metrics.cs, src/CommonLib/Static/DefaultMetricRegistry.cs
Global metrics factory holder, LDAP metric ID placeholders, in-flight counter, and extension to register default LDAP metric definitions and IDs.
LDAP integration
src/CommonLib/LdapConnectionPool.cs, src/CommonLib/LdapUtils.cs
Constructors accept optional IMetricRouter; LDAP flows updated to increment/decrement in-flight, record latencies via latency callbacks, and emit request/failure/unresolvable-principal metrics across request, retry, and error paths.
Unit tests & test infra
test/unit/AdaptiveTimeoutTest.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/CommonLibHelperTests.cs, test/unit/Utils.cs
New and updated tests covering latency observations, label cache concurrency, aggregators/snapshots, writer/sink flush behavior, registry semantics, router dispatch, and Windows-only theory/fact gating.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant LDAP as LdapConnectionPool
    participant Timeout as AdaptiveTimeout
    participant Router as MetricRouter
    participant Cache as LabelValuesCache
    participant Sink as FileMetricSink

    rect rgb(200,220,240)
    Note over App,LDAP: Application issues LDAP request
    App->>LDAP: SendRequest()
    LDAP->>Timeout: ExecuteWithTimeout(func, latencyObservation)
    Timeout->>Timeout: execute func, measure elapsed
    Timeout->>LDAP: latencyObservation(elapsed)
    Timeout-->>LDAP: Result
    end

    rect rgb(240,230,210)
    Note over LDAP,Router: Emit metrics
    LDAP->>Router: Observe(definitionId, value, labelValues)
    Router->>Cache: Intern(labelValues)
    Cache-->>Router: internedValues
    Router->>Sink: Observe(DoubleMetricObservation)
    Sink->>Sink: Aggregate value
    end

    rect rgb(220,240,220)
    Note over Router,Sink: Periodic flush
    Router->>Sink: Flush()
    Sink->>Sink: Snapshot & format metrics
    Sink-->>_System_: Write output (file/textwriter)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through code with timing bright,

Buckets, counters, labels tight,
Latency whispers, routers hum,
LDAP lessons neatly drum,
Rabbity metrics, all systems right!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.71% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding metrics collection capability to SharpHound with reference to the tracking issue BED-7080.
Description check ✅ Passed The description covers all critical sections from the template: motivation/context (BED-7080), change type (new feature selected), and testing status (tests added and passing).

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.

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.

2 participants