#39 Add OpenTelemetry - #56
Conversation
Signed-off-by: Matthew DeVenny <matt@codecargo.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class cache telemetry (metrics + tracing) to NatsCache using System.Diagnostics primitives (Meter/ActivitySource) with OTel-friendly naming/tags, plus tests and documentation so consumers can opt-in via AddMeter(...) / AddSource(...).
Changes:
- Introduces
NatsCacheTelemetry+NatsCacheOperationScopeto emit a duration histogram, miss counter, and client spans with stable names/tags. - Wires
IMeterFactoryfrom DI (when available) to avoid cross-test/process meter contamination; falls back to a shared process-wide Meter when absent. - Adds unit + integration coverage for tagging, miss reasons, cancellation/error classification, and “no double counting” across overloads; documents usage and example queries.
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| util/ReadmeExample/DistributedCache.cs | Updates example to show how to subscribe to the cache’s Meter/ActivitySource via OpenTelemetry. |
| util/Benchmarks/packages.win-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| util/Benchmarks/packages.osx-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| util/Benchmarks/packages.linux-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| util/Benchmarks/packages.linux-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| test/UnitTests/UnitTests.csproj | Adds diagnostics testing packages for metric collection in unit tests. |
| test/UnitTests/packages.win-x64.lock.json | Updates lockfile for new test dependencies. |
| test/UnitTests/packages.osx-arm64.lock.json | Updates lockfile for new test dependencies. |
| test/UnitTests/packages.linux-x64.lock.json | Updates lockfile for new test dependencies. |
| test/UnitTests/packages.linux-arm64.lock.json | Updates lockfile for new test dependencies. |
| test/UnitTests/Cache/TelemetryUnitTests.cs | New unit tests asserting telemetry names, units, tagging, and disabled-path behavior without a NATS server. |
| test/TestUtils/Services/Diagnostics/RecordingActivityListener.cs | Adds a test helper to capture completed activities for span assertions. |
| test/TestUtils/packages.win-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| test/TestUtils/packages.osx-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| test/TestUtils/packages.linux-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| test/TestUtils/packages.linux-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| test/IntegrationTests/TestBase.cs | Registers AddMetrics() so integration tests get isolated meters per ServiceProvider. |
| test/IntegrationTests/packages.win-x64.lock.json | Updates lockfile for new testing dependencies. |
| test/IntegrationTests/packages.osx-arm64.lock.json | Updates lockfile for new testing dependencies. |
| test/IntegrationTests/packages.linux-x64.lock.json | Updates lockfile for new testing dependencies. |
| test/IntegrationTests/packages.linux-arm64.lock.json | Updates lockfile for new testing dependencies. |
| test/IntegrationTests/IntegrationTests.csproj | Adds diagnostics/time-provider testing packages for integration tests. |
| test/IntegrationTests/Cache/TelemetryTests.cs | New end-to-end telemetry integration tests (metrics + spans + HybridCache interactions). |
| src/NatsHybridCacheExtensions/packages.win-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| src/NatsHybridCacheExtensions/packages.osx-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| src/NatsHybridCacheExtensions/packages.linux-x64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| src/NatsHybridCacheExtensions/packages.linux-arm64.lock.json | Updates lockfile for added DiagnosticSource dependency. |
| src/NatsDistributedCache/packages.win-x64.lock.json | Updates lockfile to add DiagnosticSource as a direct dependency. |
| src/NatsDistributedCache/packages.osx-arm64.lock.json | Updates lockfile to add DiagnosticSource as a direct dependency. |
| src/NatsDistributedCache/packages.linux-x64.lock.json | Updates lockfile to add DiagnosticSource as a direct dependency. |
| src/NatsDistributedCache/packages.linux-arm64.lock.json | Updates lockfile to add DiagnosticSource as a direct dependency. |
| src/NatsDistributedCache/NatsDistributedCacheExtensions.cs | Resolves IMeterFactory from DI (optional) and assigns it onto NatsCache. |
| src/NatsDistributedCache/NatsDistributedCache.csproj | Adds explicit System.Diagnostics.DiagnosticSource package reference. |
| src/NatsDistributedCache/NatsCacheTelemetryOptions.cs | Adds public telemetry options (currently RecordCacheKeys). |
| src/NatsDistributedCache/NatsCacheTelemetryNames.cs | Adds stable public Meter/ActivitySource/instrument name constants + docs. |
| src/NatsDistributedCache/NatsCacheTelemetry.cs | Implements per-cache telemetry bundle (Meter instruments + ActivitySource + span naming). |
| src/NatsDistributedCache/NatsCacheOptions.cs | Adds non-null Telemetry options property to configure span key recording. |
| src/NatsDistributedCache/NatsCacheOperationScope.cs | Adds the core scope type that records histogram/miss counter + tags and stops activities safely. |
| src/NatsDistributedCache/NatsCache.cs | Wires telemetry into Set/Get/Refresh/Remove paths with correct hit/miss/error/cancel classification and “no double count” design. |
| README.md | Documents telemetry names, instruments, tags, miss reasons, queries, and tuning guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Signed-off-by: Matthew DeVenny <matt@codecargo.com>
The docs claimed nothing is measured, timed, or allocated until a listener subscribes. The first two hold, but each cache instance builds its meter, two instruments, and four span-name strings on first use regardless of listeners, so the blanket allocation claim over-promised. Reword the README, the NatsCacheTelemetryNames XML docs, both options doc comments, and the ReadmeExample snippet to promise no *per-operation* allocation, and state the one-time setup cost explicitly. The behavior is unchanged; UnsubscribedScopeAllocatesNothing already pinned the per-operation guarantee this wording now describes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
mtmk
left a comment
There was a problem hiding this comment.
LGTM. Seams are correct and well tested. One nit before merge: README PromQL uses nats_cache_operation_duration_count/_bucket, but the default OTel->Prometheus unit suffix makes the real series ..._seconds_count/_seconds_bucket (and misses -> nats_cache_misses_total), so the examples return nothing on a stock exporter. Fix names or note add_metric_suffixes.
The example queries used nats_cache_operation_duration_count / _bucket, but the OTel->Prometheus exporter appends the unit and a _total suffix by default (add_metric_suffixes = true): the histogram's `s` unit makes the real series nats_cache_operation_duration_seconds_count / _seconds_bucket, and the counter becomes nats_cache_misses_total. As written the examples returned nothing on a stock exporter. Use the suffixed names, note the add_metric_suffixes toggle, and add a miss-rate-by-reason example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
|
Good catch — fixed in d3953be. The default OTel Prometheus exporter ( |
Summary
Adds first-class metrics and tracing to
NatsCacheviaSystem.Diagnostics— a namedMeterwith hit/miss, latency and error instruments, and a namedActivitySourcefor spans around KV operations. Closes #39.No OpenTelemetry package dependency. Consumers opt in by registering the documented names. Also bumps NATS.Net to 3.0.1 and the integration-test server to 2.14.3.
This is genuinely additive rather than duplicating the platform: neither
IDistributedCachenorHybridCacheexposes backend cache metrics. Verified by disassembly —Microsoft.Extensions.Caching.Hybrid10.7.0 reports throughEventCountersonHybridCacheEventSource, not aMeter, soAddMeter("Microsoft.Extensions.Caching.Hybrid")yields nothing. Even a future Meter there could not carry NATS-specific L2 latency, error types, or miss reasons.What's new
NatsCacheTelemetryNames— publicconstnames for the meter, activity source, and both instruments, so consumers never hardcode strings:NatsCacheOptions.Telemetry(NatsCacheTelemetryOptions) — currently one knob,RecordCacheKeys(defaultfalse). Get-only so it can never be null.Instruments
nats.cache.operation.durationHistogram<double>snats.cache.operation,nats.cache.bucket,nats.cache.result,error.type(errors only)nats.cache.missesCounter<long>{miss}nats.cache.operation,nats.cache.bucket,nats.cache.miss.reasonnats.cache.resultishit|miss|ok|error|cancelled.nats.cache.miss.reasonis a closed set ofnot_found|expired|undeserializable|revision_conflict— one perreturn nullsite, never derived from data.The histogram's count gives operation rate, hit ratio and error rate, so there is no companion operations counter — one that duplicates a histogram's count is an OTel anti-pattern (the HTTP conventions deleted exactly such a counter). Conversely
miss.reasonlives only on the counter, so a 4-valued dimension never multiplies the histogram's bucket arrays.Spans are
ActivityKind.Client, named{operation} {bucket}. A miss leaves the statusUnset; only genuine failures setError.Behavior / design notes
SetAsync(byte[]),GetAndRefreshAsync,RemoveAsync— each the innermost layer every caller funnels through.RemoveCoreAsyncis deliberately not instrumented: the absolute-expiry eviction path calls it, so instrumenting the core would emit a phantomoperation=removeon every expired read and nest a bogus remove span inside a get.Set(ReadOnlySequence)is three delegations deep andTryGetAsyncwraps the read core — the two chainsHybridCachedrives for L2 — and both record exactly once.nats.cache.*is a library-scoped prefix.db.client.*was rejected: NATS KV has no registereddb.system.namevalue, the DB conventions cannot express a hit/miss, and emitting it would merge cache traffic into every consumer's database dashboards. A futurecache.*convention can be adopted alongside these names without breaking dashboards.IMeterFactorywhen DI provides it, a process-wide static meter of the same name as fallback, injected viainitlike the existingTimeProvider. This costs no new dependency —IMeterFactoryships inSystem.Diagnostics.DiagnosticSource, already in the lock files; the explicitPackageReferenceadded here only flips itTransitive→Directand adds zero graph nodes. It buys per-container meter isolation, without which concurrent tests and multi-host processes cross-contaminate.LogException/LogSwallowedExceptionare untouched. ATryGetfailure that is swallowed tofalsestill recordsresult=error, nevermiss— the scope closes inside the read core before the exception reachesTryGetAsync's catch — so an outage cannot masquerade as a cold cache.result=cancelled. AnOperationCanceledExceptionfrom anything else — a NATS request timeout — isresult=error, matching howTryGetAsyncalready classifies the same event withwhen (token.IsCancellationRequested)before logging it at Warning. The inverse would hide real outages behind the cancellation filter.RecordCacheKeysadds them to spans only, defaulting off because keys routinely embed user or tenant identifiers.finally, andMeter/ActivitySourcedo not guard listener callbacks, so a throwing exporter would otherwise replace the in-flight exception or fail a successful operation. Each stage is isolated, andActivity.Currentis restored explicitly —Activity.Stopnotifies subscribers before restoring it, so a throwing subscriber would otherwise leave every later span misparented.NATS 3.0.1 / server 2.14.3
The client bump needed zero source changes. Two cleanups it enabled: 3.0 moved
JetStreamContextandBucketontoINatsKVStore, so the(NatsKVStore)cast inSetAsyncis gone, and both stale#852todos are removed.NatsExtensions.csstays. Verified by reflecting over the released 3.0.1 assembly: native KV TTL still covers onlyCreateAsync/TryCreateAsyncandPurgeAsync— there is noPutAsync-with-TTL (upsert, needed bySet) and noUpdateAsync-with-TTL (by-revision, needed by the sliding-expiration refresh). The #852 work stayed backed out through 3.0. That rationale now lives in theNatsExtensionsclass doc so it isn't re-derived on the next upgrade.Docs
New README "Telemetry" section — wiring snippet, instrument and tag tables, miss-reason meanings, example PromQL for hit ratio and p99, and tuning via
AddView(..., MetricStreamConfiguration.Drop)(the misses counter keeps recording when the histogram is dropped).It also documents the non-obvious gotchas, each pinned by a test: keys never on metrics,
TryGetfailures aserrornotmiss,TryGetreporting asoperation=get, caller-cancellation vs timeouts, invalid-expiration throws not being counted, and the naming rationale. A HybridCache interaction note explains thatnats.cache.*measures only the L2 layer — an L1 hit produces no measurement — and that HybridCache's own telemetry isEventCounters, not a Meter.util/ReadmeExamplegets a commented pointer; noServiceDefaultsproject, which would pull OpenTelemetry packages into a repo that has none and imply the dependency this PR exists to avoid.Testing
109 unit (net8.0 + net10.0) + 78 integration, all green, 0 build warnings; BOM,
dotnet format, and lock-file drift checks clean. CI green on Linux including thenats:2.14.3container.Beyond happy paths, the suite pins what would otherwise regress silently:
ReadOnlySequencechains;error.typetagging;refreshdistinguishable fromget;cancelledvserrorclassification for caller-cancellation vs a timeout-shapedTaskCanceledException; misses still recording while the histogram is disabled; keys absent from metrics withRecordCacheKeysboth on and off; instrument units (guards seconds-vs-milliseconds, invisible until it reaches a dashboard); public name stability (this repo has noPublicAPI.Shipped.txt, so it is the only thing turning a rename into a visible failure); a throwing exporter breaking neither the operation norActivity.Current; and an exact zero-byte allocation delta over 1,000 unsubscribed operations.set/hit/ok/missresult sequence; each miss reason includingexpiredvia aFakeTimeProviderclock jump (the entry keeps its real-time TTL while the cache's clock advances past it); no phantomremoveon an expired read;HybridCacheL2 read and write through the two riskiest overloads; per-ServiceProvidermeter isolation; and end-to-end caller cancellation.Not included
Deliberately deferred to follow-ups: entry-size histogram, connection / bucket-creation counters, per-key-prefix tags, and histogram bucket-boundary advice (
InstrumentAdviceis .NET 9+ and would force an#iffor the net8.0 target; the OTel SDK defaults match anyway).🤖 Generated with Claude Code