Skip to content

fix(telemetry): align Pulse activities and metrics with OpenTelemetry conventions and record abandoned streams - #908

Merged
samtrion merged 16 commits into
mainfrom
fix/831-otel-activity-metrics
Sep 29, 2026
Merged

samtrion merged 16 commits into
mainfrom
fix/831-otel-activity-metrics

Conversation

@samtrion

@samtrion samtrion commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Aligns the built-in telemetry of NetEvolve.Pulse with the OpenTelemetry Trace API and semantic conventions, and fixes the missing duration and outcome for stream queries whose consumer stops early.

Closes #831
Closes #832

Specification references (checked against the current pages):

  • Trace API, Set Status: "Generally, Instrumentation Libraries SHOULD NOT set the status code to Ok, unless explicitly configured to do so." and "Instrumentation Libraries SHOULD leave the status code as Unset unless there is an error".
  • Recording errors: "Span Status Code MUST be left unset if the instrumented operation has ended without any errors.", the span "SHOULD set the error.type attribute", the metric "SHOULD include the error.type attribute", and "Instrumentation SHOULD ensure error.type is applied consistently across spans and metrics when both are reported." The value is the fully qualified exception type name.
  • Metrics guidelines, instrument units: "When instruments are measuring durations, seconds (i.e. s) SHOULD be used." and "if measuring the number of individual requests to a process the unit would be {request}, not {requests}."
  • C# async streams, disposal: disposing a suspended iterator runs only the pending finally blocks, which is why the old recording code after the loop never ran.

Changes

  • ADR decisions/2026-09-28-opentelemetry-telemetry-conventions.md (state proposed) records the telemetry contract and the transition plan.
  • ActivityAndMetricsStreamQueryInterceptor: outcome recording moved into the iterator's finally block. Completed, faulted and abandoned streams record pulse.stream_query.duration exactly once. Abandoned streams carry pulse.stream.completed=false on the histogram and the activity, keep status Unset and carry no pulse.success. A handler delegate that throws synchronously is now recorded as a failure. An exception from the inner enumerator's DisposeAsync or Current is recorded as a failure too; when DisposeAsync throws after an earlier fault, the earlier exception is thrown and recorded, so the thrown exception and error.type always match. The rethrow happens inside the finally block because an early stop never reaches code after it.
  • All three ActivityAndMetrics*Interceptors: no ActivityStatusCode.Ok on success (stays Unset); error.type = Exception.GetType().FullName on the span, the error counter and the duration histogram on failure only. The existing pulse.exception.* tags stay.
  • New public ActivityAndMetricsOptions.UseSemanticConventionUnits (default false) and AddActivityAndMetrics(Action<ActivityAndMetricsOptions>) overload. When enabled, durations are recorded in seconds (unit s, bucket advice 0.005 … 10 s from the HTTP semantic conventions) and counters use UCUM annotations. The option also applies to OutboxProcessorHostedService.
  • Instrument lifetime: the interceptors get their instruments from a static cache in TelemetryUnits (one instrument per name and unit mode on the static NetEvolve.Pulse meter). OutboxProcessorHostedService creates its counters and duration histogram on its own instance meter, which Dispose() already disposed for the pending gauge.
  • Removed the unused Defaults.Tags.StreamQueryType constant.
  • src/NetEvolve.Pulse/README.md: new delimited ## Telemetry section with the conventions, the unit table and the migration steps. A client disconnect is not listed as an abandoned stream: a handler that honours RequestAborted throws OperationCanceledException, which is recorded as a failure (documented and pinned by a test).
  • Tests: PulseMeasurementCollector (a MeterListener helper filtered by tag) plus new MeterListener/ActivityListener tests; the existing Ok assertions (unit and Integration/Pipeline/RequestInterceptorsTests.cs) now assert Unset.

Bug hunt

Suspicion Confirmed? Test
Early break on a stream query records no duration and no outcome (#831) Yes (failed before the fix) HandleAsync_WhenConsumerStopsEarly_RecordsDurationOnceWithAbandonedOutcome, HandleAsync_WhenConsumerStopsEarly_TagsActivityAbandonedAndLeavesStatusUnset
Stream handler delegate that throws synchronously (before returning an enumerable) skips error counter, duration and Error status, although the total counter already fired Yes (failed before the fix) HandleAsync_WhenHandlerThrowsSynchronously_RecordsErrorAndDuration
Moving the recording into finally could record a completed stream twice No (guard test, green before and after) HandleAsync_WhenStreamCompletes_RecordsDurationOnceWithSuccess
Successful request, event and stream spans set Ok instead of Unset (#832) Yes HandleAsync_WhenHandlerSucceeds_LeavesActivityStatusUnset (request, event), HandleAsync_WhenStreamCompletes_LeavesActivityStatusUnset, HandleAsync_WithEmptyStream_LeavesActivityStatusUnset, integration RequestInterceptorsTests
error.type missing on span, error counter and duration histogram on failure (#832) Yes HandleAsync_WhenHandlerThrows_SetsErrorTypeOnActivityAndMetrics (request, event), HandleAsync_WhenStreamFaults_RecordsErrorTypeOnMetrics, HandleAsync_WhenHandlerThrows_SetsErrorTypeOnActivity
error.type leaks into successful measurements No (guard test) HandleAsync_WhenHandlerSucceeds_DoesNotSetErrorType (request, event)
Review: inner DisposeAsync throws after the stream completed; telemetry recorded success while the caller got the exception Yes (failed before the fix) HandleAsync_WhenDisposeThrowsAfterCompletion_RecordsDisposeFailure
Review: inner DisposeAsync throws after a fault; the thrown exception differed from the recorded error.type Yes (failed before the fix) HandleAsync_WhenDisposeThrowsAfterFault_ThrowsTheRecordedException
Inner DisposeAsync throws after an early stop; recorded as abandoned instead of failed Yes (failed before the fix) HandleAsync_WhenDisposeThrowsAfterEarlyStop_RecordsDisposeFailure
Inner enumerator's Current throws; recorded as abandoned, no error counter (found while fixing the dispose path) Yes (failed before the fix) HandleAsync_WhenCurrentThrows_RecordsError
Review: a client disconnect is documented as abandoned, but a cancelled token is recorded as a failure Yes (docs were wrong; behaviour kept and documented) HandleAsync_WhenTokenIsCancelledDuringEnumeration_RecordsCancellationAsError (green, pins the behaviour)
Review: outbox counters and histogram were created per service instance on the never-disposed static meter Yes (failed before the fix on all target frameworks) Dispose_ReleasesCounterAndHistogramInstruments
Review: interceptors create instruments per instance on the static meter Partly. .NET reuses an identical instrument on the same meter, so sequential constructions are bounded, but under the parallel unit run 2 to 3 distinct instances were published (observed on net8.0, and for the stream interceptor also on net9.0/net10.0). Fixed with a locked static cache Constructor_CalledRepeatedly_ReusesInstruments (request, event, stream)

Impact

This PR changes emitted telemetry. Per the Extensibility Interface Evolution Before 1.0 decision, it uses plain fix:/feat: commits while the major version is 0.

Always on (no opt-in):

  • Span status of successful requests, events and stream queries is Unset instead of Ok. Dashboards or alerts that filter on Ok must filter on "not Error".
  • New tag error.type on failed spans, on pulse.request.errors, pulse.event.errors, pulse.stream_query.errors, and on the failed measurements of pulse.request.duration, pulse.event.duration, pulse.stream_query.duration (new metric dimension).
  • New tag pulse.stream.completed=false on the span and pulse.stream_query.duration of abandoned streams. Abandoned streams now appear in pulse.stream_query.duration (previously missing).
  • Synchronously throwing stream handlers are now counted in pulse.stream_query.errors and pulse.stream_query.duration.

Opt-in via ActivityAndMetricsOptions.UseSemanticConventionUnits = true (default unchanged):

Instrument Default unit Opt-in unit
pulse.requests.total requests {request}
pulse.events.total events {event}
pulse.stream_query.total queries {query}
pulse.request.errors, pulse.event.errors, pulse.stream_query.errors errors {error}
pulse.outbox.processed.total, pulse.outbox.failed.total, pulse.outbox.deadletter.total, pulse.outbox.pending messages {message}
pulse.request.duration, pulse.event.duration, pulse.stream_query.duration, pulse.outbox.processing.duration ms s (values in seconds, explicit bucket advice)

Exporters that append the unit to the name (Prometheus) export different names once the option is enabled. Metric and tag names themselves are unchanged.

Public API additions: ActivityAndMetricsOptions (namespace NetEvolve.Pulse.Interceptors) and ActivityMetricsExtensions.AddActivityAndMetrics(IMediatorBuilder, Action<ActivityAndMetricsOptions>). No NetEvolve.Pulse.Extensibility interface changes, so external implementers are not affected. The internal OutboxProcessorHostedService constructor gained an optional IOptions<ActivityAndMetricsOptions>? parameter.

Instruments: the interceptors share one instrument per name and unit mode for the whole process (at most two per name: legacy and opt-in units). The outbox instruments live on the service's own meter and are released by Dispose(). The number of instruments no longer grows with the number of service providers.

Review findings not applied:

  • Setting the ADR to accepted: not changed. The project rule for this work is that new ADRs are filed as proposed so the maintainer accepts them in review. Please switch the state to accepted when approving.

Out of scope: the optional end entry for abandoned streams in LoggingStreamQueryInterceptor, replacing pulse.exception.stacktrace with the standard exception event, and renaming the .total counters (noted in the ADR).

Test evidence

Local results (Windows, Release):

  • dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings.
  • csharpier format on the changed folders; the build's formatting check is clean.
  • Unit tests NetEvolve.Pulse.Tests.Unit, all target frameworks (net8.0, net9.0, net10.0): 7353 / 7353 passed.
  • Integration without Docker (RequestInterceptorsTests, InMemoryEntityFrameworkOutboxTests, SQLiteAdoNetOutboxTests, SQLiteEntityFrameworkOutboxTests) on net10.0: 190 / 190 passed. Docker-backed providers are left to CI.

Red phases:

  • test(interceptors): ... (59413a5): 11 tests failed before the fixes (missing duration measurement for the abandoned and synchronously throwing streams, Ok instead of Unset, missing error.type). The guard tests were green.
  • test(telemetry): ... (088200c): red because it did not compile; ActivityAndMetricsOptions did not exist yet.
  • test(outbox): ... (32b7987) fixes a race in the new outbox unit test.
  • test(interceptors): cover throwing inner dispose, ... (0e96031): the three dispose tests and the Current test failed on every target framework; the cancellation test was green. The fake stream is hand-written because TUnit.Mocks cannot generate IAsyncEnumerator<T> on .NET 9 and later (CS9244 on the allows ref struct type parameter).
  • test(telemetry): require shared interceptor instruments ... (43f2338): Dispose_ReleasesCounterAndHistogramInstruments failed on every target framework. Constructor_CalledRepeatedly_ReusesInstruments failed in the parallel run (net8.0 for all three, and for the stream interceptor on net9.0/net10.0 too) and passed in isolation.
  • Seen once and unrelated to this PR: ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendingCount failed once on net10.0 and then passed three times in a row. The test listens to pulse.outbox.pending from every service instance that runs in parallel.

CI on 8fa0528: all checks green (build, tests, formatting, commitlint, CodeQL, NativeAOT net8.0/net9.0/net10.0, codecov patch and project).

…ype in telemetry interceptors

Adds failing tests for an abandoned stream query (duration and outcome not
recorded), a stream handler that throws synchronously, the Ok status on
success and the missing error.type on spans and metrics.
…consumer stops early

Moves the outcome recording of ActivityAndMetricsStreamQueryInterceptor into
the finally block of the iterator, so completed, faulted and abandoned streams
record pulse.stream_query.duration exactly once. Abandoned streams carry
pulse.stream.completed=false and keep the status Unset. A handler that throws
synchronously is now recorded as a failure. Successful streams no longer set
the Ok status, failures carry error.type, and the unused StreamQueryType tag
constant is removed.
…or.type

The request and event telemetry interceptors no longer set
ActivityStatusCode.Ok on success and add error.type (the full exception type
name) to the span, the error counter and the duration histogram on failure.
Adds tests for ActivityAndMetricsOptions.UseSemanticConventionUnits: durations
in seconds (unit s) and UCUM annotation units for the interceptor and outbox
counters, while the default keeps the legacy units.
…or Pulse metrics

Adds ActivityAndMetricsOptions.UseSemanticConventionUnits and an
AddActivityAndMetrics(configure) overload. When enabled, the interceptor and
outbox duration histograms record seconds (unit s) with explicit bucket
boundaries, and the counters use UCUM annotation units ({request}, {event},
{query}, {error}, {message}). The default keeps the legacy units. The README
documents the telemetry conventions and the migration steps.
@samtrion
samtrion requested a review from a team as a code owner September 28, 2026 23:38
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.73950% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.20%. Comparing base (972fb55) to head (8fa0528).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...MetricsStreamQueryInterceptor{TQuery,TResponse}.cs 97.10% 0 Missing and 2 partials ⚠️
...volve.Pulse/Outbox/OutboxProcessorHostedService.cs 97.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #908      +/-   ##
==========================================
+ Coverage   96.09%   96.20%   +0.10%     
==========================================
  Files         274      275       +1     
  Lines       11511    11721     +210     
  Branches     1069     1103      +34     
==========================================
+ Hits        11062    11276     +214     
+ Misses        232      229       -3     
+ Partials      217      216       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samtrion
samtrion merged commit 0a4c857 into main Sep 29, 2026
14 checks passed
@samtrion
samtrion deleted the fix/831-otel-activity-metrics branch September 29, 2026 00:51
samtrion added a commit that referenced this pull request Sep 29, 2026
Accept the seven decisions merged in state proposed with #893, #903,
#909, #908, #902, #905 and #904.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant