Record the RPC outcome on telemetry activities - #4943
Conversation
The telemetry interceptor and middleware now record the outcome of the invocation or dispatch on the activity they start: the rpc.response.status_code tag holds the status code of the response, and a response with a status code other than Ok, or an exception, sets the activity status to Error together with the error.type tag, following the OpenTelemetry semantic conventions for RPC spans. Fixes icerpc#4807
There was a problem hiding this comment.
🔵 Needs a closer look
Remaining documentation inconsistencies and non-unanimous readiness warrant final human review.
Pull request overview
Updates IceRPC telemetry to record RPC outcomes on activities for responses, exceptions, and cancellations.
Changes:
- Adds status and error tagging for failed RPC outcomes.
- Adds shared telemetry outcome helpers and documentation.
- Adds interceptor and middleware coverage for success, failure, and exception paths.
File summaries
| File | Summary |
|---|---|
tests/IceRpc.Telemetry.Tests/TelemetryMiddlewareTests.cs |
Tests dispatch outcome telemetry. |
tests/IceRpc.Telemetry.Tests/TelemetryInterceptorTests.cs |
Tests invocation outcome telemetry. |
src/IceRpc.Telemetry/TelemetryMiddleware.cs |
Records dispatch outcomes before stopping activities. |
src/IceRpc.Telemetry/TelemetryInterceptor.cs |
Records invocation outcomes before stopping activities. |
src/IceRpc.Telemetry/README.md |
Documents telemetry outcome recording. |
src/IceRpc.Telemetry/Internal/ActivityExtensions.cs |
Centralizes activity status and tag handling. |
Review details
Suppressed comments (2)
src/IceRpc.Telemetry/TelemetryInterceptor.cs:62
- The PR description says every activity carries
rpc.response.status_code, but this exception path records onlyerror.type; the new test also explicitly expects the status-code tag to be absent for exceptions. Since an exception may produce no response status, please either narrow that description to response outcomes or define an explicit status value instead of leaving the stated invariant contradictory.
catch (Exception exception)
{
activity.RecordException(exception);
src/IceRpc.Telemetry/TelemetryMiddleware.cs:58
- The PR description says every activity carries
rpc.response.status_code, but this exception path records onlyerror.type; the new test also explicitly expects the status-code tag to be absent for exceptions. Since an exception may produce no response status, please either narrow that description to response outcomes or define an explicit status value instead of leaving the stated invariant contradictory.
catch (Exception exception)
{
activity.RecordException(exception);
- Files reviewed: 6/6 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| internal static void RecordStatusCode(this Activity activity, StatusCode statusCode, string? errorMessage) | ||
| { | ||
| string statusCodeName = statusCode.ToString(); | ||
| activity.SetTag("rpc.response.status_code", statusCodeName); |
There was a problem hiding this comment.
rpc.response.status_code is already deprecated upstream: open-telemetry/semantic-conventions#3921 renamed it to rpc.status_code, and the registry on main lists the old name as replaced. It is unreleased as of v1.44.0, but I'd ship the new name rather than one the next release retires. The remarks, the README and the tests carry the old name too.
There was a problem hiding this comment.
🤖 Claude: Done in d180690: the tag is now rpc.status_code in the code, the remarks and the tests. Checked against the semantic-conventions registry on main, which lists rpc.response.status_code as renamed to rpc.status_code. The README paragraph is gone, per Bernard's comment.
| { | ||
| string statusCodeName = statusCode.ToString(); | ||
| activity.SetTag("rpc.response.status_code", statusCodeName); | ||
| if (statusCode != StatusCode.Ok) |
There was a problem hiding this comment.
HTTP draws this line by span kind: a client span records 4xx and 5xx as errors, which matches what the interceptor does, but a server span records only 5xx and unhandled exceptions. .NET follows that in HttpClient, in ASP.NET Core's built-in tracing and in the OpenTelemetry contrib instrumentation. Would it make sense for the middleware to do the same and leave the codes that report the caller's problem, such as NotFound, Unauthorized and InvalidData, unset?
There was a problem hiding this comment.
🤖 Claude: Done in d180690. The middleware now leaves the activity status unset for ApplicationError, NotFound, InvalidData, TruncatedPayload and Unauthorized, while still recording rpc.status_code for them. NotImplemented, Unavailable, InternalError, DeadlineExceeded, NotSupported and unknown values set Error, mirroring the gRPC server list (UNIMPLEMENTED, UNAVAILABLE, INTERNAL, DEADLINE_EXCEEDED, UNKNOWN). TruncatedPayload sits on the request side because its documentation says it is usually caused by the invoker; ApplicationError because it is an application-defined outcome rather than a server failure. The middleware remarks list both sets. The interceptor is unchanged: every status code other than Ok is an error, as for HTTP client spans.
bernardnormier
left a comment
There was a problem hiding this comment.
Agreed with Joe on both points.
RecordStatusCode and RecordException are inconsistent: the first records the status code tag, the second
doesn't. Give RecordException a StatusCode? parameter, with null meaning no status code tag. The interceptor
passes null: an exception there means no response. The middleware passes the status code the caller will get, which
the protocol connection decides after the middleware returns (ConvertToInternalError, the mapping of
InvalidDataException and friends). Don't reproduce that mapping in the middleware: fold the two halves of the
conversion (the type switch in IceRpcProtocolConnection and ToOutgoingResponse) into one internal helper in
IceRpc, call it from both places, and add InternalsVisibleTo("IceRpc.Telemetry"). Internal because the mapping is
icerpc-specific: the ice connection maps only InvalidDataException. Please file an issue on whether the two
protocols should share one mapping, exposed publicly for any middleware that reports outcomes.
Please delete the new paragraph in the README: the README is a high-level description, and the tags are a detail
that belongs in the class remarks.
The new remarks sentence in both classes is hard to parse: four facts in one sentence with a nested parenthesis.
One sentence per fact, for example on the interceptor:
The activity records the outcome of the invocation. When the invocation returns a response, the
`rpc.status_code` tag holds its status code. When the status code is not `Ok` or the invocation throws an
exception, the activity status is `Error` and the `error.type` tag identifies the failure.
Two test nits, take them or leave them: Assert.That(outcome.Tags, Does.ContainKey("x").WithValue("y")) replaces
each ContainsKey/indexer pair, and Assert.CatchAsync replaces the try/catch that captures the thrown exception.
- Rename the status code tag to rpc.status_code, the current name in the OpenTelemetry semantic conventions; rpc.response.status_code is deprecated upstream. - The middleware leaves the activity status unset for status codes that report a problem with the request (ApplicationError, NotFound, InvalidData, TruncatedPayload, Unauthorized), as HTTP server spans do for 4xx. Any other failure status code or an exception is an Error. - RecordException takes the status code the caller receives. The interceptor passes null; the middleware passes the status code the icerpc connection derives from the exception, and no status code when the dispatch is canceled by its cancellation token. - Fold the exception to status code mapping of IceRpcProtocolConnection and DispatchException.ToOutgoingResponse into IceRpc.Internal.ExceptionExtensions, and make IceRpc internals visible to IceRpc.Telemetry. - Drop the README paragraph and rewrite the remarks one sentence per fact. - Use Does.ContainKey().WithValue() and Assert.CatchAsync in the tests.
|
🤖 Claude: Applied in d180690:
|
|
🤖 Claude: Two policy questions on the middleware that d180690 leaves as reviewed, for @externl and
|
There was a problem hiding this comment.
🟡 Changes recommended
Unknown status codes can create unbounded error.type cardinality and need a bounded fallback.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Balanced
| activity.SetTag("rpc.status_code", statusCodeName); | ||
| if (isError) | ||
| { | ||
| activity.SetTag("error.type", statusCodeName); |
Fixes #4807
The telemetry interceptor and middleware start an
Activityper icerpc request, but the activity ended with its statusleft
Unsetwhatever the outcome: a response with a status code other thanOk, an exception thrown by the invocationor the dispatch, and a cancellation all looked the same as a successful call in the exported traces, and no
error.typeattribute was recorded. Trace backends render
Unsetas success, so failed RPCs did not show up as errors.Both components now record the outcome before the activity stops, following the OpenTelemetry semantic conventions for
RPC spans and the "Recording errors" guidance. The rules differ by span kind, as they do for HTTP client and server
spans:
rpc.status_code. A status code other thanOksets theactivity status to
Error, with the response error message as the status description, and tagserror.typewiththe status code name. An exception sets the activity status to
Errorwith the exception message as the statusdescription and tags
error.typewith the exception's fully qualified type name; no status code is recorded sincethere is no response.
rpc.status_codeof the dispatch outcome: the status code ofthe returned response, or the status code the icerpc connection derives from an exception thrown by the dispatch
(
DispatchExceptionand itsConvertToInternalErrorflag,InvalidDataException,NotSupportedException, truncatedpayloads, and
InternalErrorfor anything else). A dispatch canceled by the middleware's cancellation token recordsno status code, since the connection sends no response for it. A status code that reports a problem with the request
(
ApplicationError,NotFound,InvalidData,TruncatedPayload,Unauthorized) leaves the activity status unset,like a 4xx on an HTTP server span. Any other failure status code, or an exception, sets the activity status to
Errorand tags
error.typewith the status code name or the exception's fully qualified type name.Unsetand sets noerror.typeon both sides.To let the middleware report the status code the caller receives without duplicating the connection's mapping, the
type switch in
IceRpcProtocolConnectionandDispatchException.ToOutgoingResponseare folded intoIceRpc.Internal.ExceptionExtensions(ToStatusCodeandToOutgoingResponse), and IceRpc makes its internalsvisible to IceRpc.Telemetry. The response the connection sends is unchanged.
The RPC behavior is unchanged: the same response is returned and the same exception propagates.
New tests capture the activity from an
ActivityListenerwhen it stops and assert the status, the description, andthe tags for a failure response, for an exception, and for a successful response, on both the interceptor and the
middleware. The middleware tests cover every status code on both sides of the server-error line, and the exception
mapping including
ConvertToInternalErrorand both kinds of cancellation.What's Changed entry
Area: Telemetry
Every activity that has a response carries an
rpc.status_codetag. On the interceptor, a status code other thanOkor an exception sets the activity status toErrorwith anerror.typetag. On the middleware, a status codethat reports a server failure or an exception sets the activity status to
Error, while a status code that reportsa problem with the request, such as
NotFoundorUnauthorized, leaves it unset.