fix(telemetry): align Pulse activities and metrics with OpenTelemetry conventions and record abandoned streams - #908
Merged
Merged
Conversation
…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.
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
…d cancellation in the stream telemetry interceptor
…outbox instruments
…eam query as errors
… meter so Dispose releases them
… instead of creating them per instance
…x the stale pulse.success remarks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Aligns the built-in telemetry of
NetEvolve.Pulsewith 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):
Ok, unless explicitly configured to do so." and "Instrumentation Libraries SHOULD leave the status code asUnsetunless there is an error".error.typeattribute", the metric "SHOULD include theerror.typeattribute", and "Instrumentation SHOULD ensureerror.typeis applied consistently across spans and metrics when both are reported." The value is the fully qualified exception type name.s) SHOULD be used." and "if measuring the number of individual requests to a process the unit would be{request}, not{requests}."finallyblocks, which is why the old recording code after the loop never ran.Changes
decisions/2026-09-28-opentelemetry-telemetry-conventions.md(stateproposed) records the telemetry contract and the transition plan.ActivityAndMetricsStreamQueryInterceptor: outcome recording moved into the iterator'sfinallyblock. Completed, faulted and abandoned streams recordpulse.stream_query.durationexactly once. Abandoned streams carrypulse.stream.completed=falseon the histogram and the activity, keep statusUnsetand carry nopulse.success. A handler delegate that throws synchronously is now recorded as a failure. An exception from the inner enumerator'sDisposeAsyncorCurrentis recorded as a failure too; whenDisposeAsyncthrows after an earlier fault, the earlier exception is thrown and recorded, so the thrown exception anderror.typealways match. The rethrow happens inside thefinallyblock because an early stop never reaches code after it.ActivityAndMetrics*Interceptors: noActivityStatusCode.Okon success (staysUnset);error.type=Exception.GetType().FullNameon the span, the error counter and the duration histogram on failure only. The existingpulse.exception.*tags stay.ActivityAndMetricsOptions.UseSemanticConventionUnits(defaultfalse) andAddActivityAndMetrics(Action<ActivityAndMetricsOptions>)overload. When enabled, durations are recorded in seconds (units, bucket advice 0.005 … 10 s from the HTTP semantic conventions) and counters use UCUM annotations. The option also applies toOutboxProcessorHostedService.TelemetryUnits(one instrument per name and unit mode on the staticNetEvolve.Pulsemeter).OutboxProcessorHostedServicecreates its counters and duration histogram on its own instance meter, whichDispose()already disposed for the pending gauge.Defaults.Tags.StreamQueryTypeconstant.src/NetEvolve.Pulse/README.md: new delimited## Telemetrysection with the conventions, the unit table and the migration steps. A client disconnect is not listed as an abandoned stream: a handler that honoursRequestAbortedthrowsOperationCanceledException, which is recorded as a failure (documented and pinned by a test).PulseMeasurementCollector(aMeterListenerhelper filtered by tag) plus newMeterListener/ActivityListenertests; the existingOkassertions (unit andIntegration/Pipeline/RequestInterceptorsTests.cs) now assertUnset.Bug hunt
breakon a stream query records no duration and no outcome (#831)HandleAsync_WhenConsumerStopsEarly_RecordsDurationOnceWithAbandonedOutcome,HandleAsync_WhenConsumerStopsEarly_TagsActivityAbandonedAndLeavesStatusUnsetErrorstatus, although the total counter already firedHandleAsync_WhenHandlerThrowsSynchronously_RecordsErrorAndDurationfinallycould record a completed stream twiceHandleAsync_WhenStreamCompletes_RecordsDurationOnceWithSuccessOkinstead ofUnset(#832)HandleAsync_WhenHandlerSucceeds_LeavesActivityStatusUnset(request, event),HandleAsync_WhenStreamCompletes_LeavesActivityStatusUnset,HandleAsync_WithEmptyStream_LeavesActivityStatusUnset, integrationRequestInterceptorsTestserror.typemissing on span, error counter and duration histogram on failure (#832)HandleAsync_WhenHandlerThrows_SetsErrorTypeOnActivityAndMetrics(request, event),HandleAsync_WhenStreamFaults_RecordsErrorTypeOnMetrics,HandleAsync_WhenHandlerThrows_SetsErrorTypeOnActivityerror.typeleaks into successful measurementsHandleAsync_WhenHandlerSucceeds_DoesNotSetErrorType(request, event)DisposeAsyncthrows after the stream completed; telemetry recorded success while the caller got the exceptionHandleAsync_WhenDisposeThrowsAfterCompletion_RecordsDisposeFailureDisposeAsyncthrows after a fault; the thrown exception differed from the recordederror.typeHandleAsync_WhenDisposeThrowsAfterFault_ThrowsTheRecordedExceptionDisposeAsyncthrows after an early stop; recorded as abandoned instead of failedHandleAsync_WhenDisposeThrowsAfterEarlyStop_RecordsDisposeFailureCurrentthrows; recorded as abandoned, no error counter (found while fixing the dispose path)HandleAsync_WhenCurrentThrows_RecordsErrorHandleAsync_WhenTokenIsCancelledDuringEnumeration_RecordsCancellationAsError(green, pins the behaviour)Dispose_ReleasesCounterAndHistogramInstrumentsConstructor_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 is0.Always on (no opt-in):
Unsetinstead ofOk. Dashboards or alerts that filter onOkmust filter on "notError".error.typeon failed spans, onpulse.request.errors,pulse.event.errors,pulse.stream_query.errors, and on the failed measurements ofpulse.request.duration,pulse.event.duration,pulse.stream_query.duration(new metric dimension).pulse.stream.completed=falseon the span andpulse.stream_query.durationof abandoned streams. Abandoned streams now appear inpulse.stream_query.duration(previously missing).pulse.stream_query.errorsandpulse.stream_query.duration.Opt-in via
ActivityAndMetricsOptions.UseSemanticConventionUnits = true(default unchanged):pulse.requests.totalrequests{request}pulse.events.totalevents{event}pulse.stream_query.totalqueries{query}pulse.request.errors,pulse.event.errors,pulse.stream_query.errorserrors{error}pulse.outbox.processed.total,pulse.outbox.failed.total,pulse.outbox.deadletter.total,pulse.outbox.pendingmessages{message}pulse.request.duration,pulse.event.duration,pulse.stream_query.duration,pulse.outbox.processing.durationmss(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(namespaceNetEvolve.Pulse.Interceptors) andActivityMetricsExtensions.AddActivityAndMetrics(IMediatorBuilder, Action<ActivityAndMetricsOptions>). NoNetEvolve.Pulse.Extensibilityinterface changes, so external implementers are not affected. The internalOutboxProcessorHostedServiceconstructor gained an optionalIOptions<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:
accepted: not changed. The project rule for this work is that new ADRs are filed asproposedso the maintainer accepts them in review. Please switch the state toacceptedwhen approving.Out of scope: the optional end entry for abandoned streams in
LoggingStreamQueryInterceptor, replacingpulse.exception.stacktracewith the standard exception event, and renaming the.totalcounters (noted in the ADR).Test evidence
Local results (Windows, Release):
dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings.csharpier formaton the changed folders; the build's formatting check is clean.NetEvolve.Pulse.Tests.Unit, all target frameworks (net8.0, net9.0, net10.0): 7353 / 7353 passed.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,Okinstead ofUnset, missingerror.type). The guard tests were green.test(telemetry): ...(088200c): red because it did not compile;ActivityAndMetricsOptionsdid 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 theCurrenttest failed on every target framework; the cancellation test was green. The fake stream is hand-written because TUnit.Mocks cannot generateIAsyncEnumerator<T>on .NET 9 and later (CS9244 on theallows ref structtype parameter).test(telemetry): require shared interceptor instruments ...(43f2338):Dispose_ReleasesCounterAndHistogramInstrumentsfailed on every target framework.Constructor_CalledRepeatedly_ReusesInstrumentsfailed in the parallel run (net8.0 for all three, and for the stream interceptor on net9.0/net10.0 too) and passed in isolation.ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendingCountfailed once on net10.0 and then passed three times in a row. The test listens topulse.outbox.pendingfrom 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).