Skip to content

feat(Spanner.V1): Add interceptor for Built-In Metrics - #15766

Merged
robertvoinescu-work merged 1 commit into
googleapis:mainfrom
robertvoinescu-work:spanner/builtInMetricsInterceptor
Sep 10, 2026
Merged

robertvoinescu-work merged 1 commit into
googleapis:mainfrom
robertvoinescu-work:spanner/builtInMetricsInterceptor

Conversation

@robertvoinescu-work

Copy link
Copy Markdown
Contributor

b/404948213

@robertvoinescu-work
robertvoinescu-work requested a review from a team as a code owner July 16, 2026 17:31
@product-auto-label product-auto-label Bot added the api: spanner Issues related to the Spanner API. label Jul 16, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a gRPC interceptor to record attempt-level metrics for Spanner, including attempt counts, latencies, and GFE latencies extracted from the 'server-timing' header. The implementation integrates this interceptor into the Spanner client builder. My review identified three issues: an unused field that should be removed, an incorrect usage of context.Method.Name instead of context.Method.FullName when extracting method names, and a premature return in the GFE latency parsing logic that prevents processing multiple entries in the 'server-timing' header.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch from 5f7d234 to 01b86b9 Compare July 16, 2026 17:46
@robertvoinescu-work
robertvoinescu-work marked this pull request as draft July 16, 2026 17:54
@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch 2 times, most recently from 648b959 to 2df210a Compare July 17, 2026 18:21
@robertvoinescu-work
robertvoinescu-work marked this pull request as ready for review July 17, 2026 18:23

@efevans efevans left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good and very clean. Now that we have the Interceptor class implementation, is this going too far including metrics, instruments, labels, and methods that are generic enough to extract into a shared library? What if we changed the underlying SpannerBuiltInMetrics static class to an abstract class that extracts the shared resources, and then each API library can extend from that and add service-specific metrics and labels? Then each library would expose an instance of their implementation as a singleton, and pass that to the Interceptor constructor to call for library-specific instrumentation.

Quick peek at the metrics and labels in SpannerBuiltInMetrics and I'm guessing database is the lone Spanner specific label, and other labels like client_name being shared with implementation-specific values that could be obtained through overriding virtual GetClientName methods.

Looking to start a discussion on this before asking for this done since it's a pretty big ask.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch from 2df210a to 7105f2b Compare July 23, 2026 22:31
@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch 3 times, most recently from 73c320a to 57d2acd Compare July 24, 2026 19:01
@robertvoinescu-work
robertvoinescu-work requested a review from a team July 26, 2026 00:31
@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch from 57d2acd to f8f930f Compare July 27, 2026 18:03
@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch 4 times, most recently from 4ca632d to e0ae9de Compare July 30, 2026 20:39

@efevans efevans left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

// No headers are available if continuation fails, but we can record status (if RpcException) and latency.
// An RpcException here is unexpected but defensively handled in case a previous interceptor throws it.
string status = ex is RpcException rpcEx ? rpcEx.StatusCode.ToString() : s_statusUnknown;
_ = RecordAttemptMetricsAsync(headersTask: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a sync version of this method that receives no header tasks at all? You can use that one for the blocking unary call as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But honestly I wonder if we need to instrument here, where the call didn't even reached the service yet? Do we know what other languages did?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. At least in Java this seems to be the case, any failures due to faulty interceptors are caught by the exact same logic handling RPC network exceptions. I will ask spanner team for clarification if this was intentional. Leaving this open for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spoke with spanner team and they said we should "record all the RPCs which are initiated [client side] and has not completed for whatever reason."

{
if (string.Equals(header.Key, ServerTimingHeader, StringComparison.OrdinalIgnoreCase))
{
EmitServerTimingMetrics(header.Value, GfeMetricPrefix, duration => s_gfeLatency.Record(duration, labels));

@amanda-tarafa amanda-tarafa Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to pass these as a parameters. If we ever parse more headers, then we can add parameters.

Suggested change
EmitServerTimingMetrics(header.Value, GfeMetricPrefix, duration => s_gfeLatency.Record(duration, labels));
EmitServerTimingMetrics(header.Value);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spoke with Surbhi and got some clarification that we will regardless receive AFE headers - direct path enabled or not. We will need to add some additional headers to get this x-goog-spanner-enable-afe-server-timing. In the meantime I've left the code so it's easily extensible for when that is added along with a TODO.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch from e0ae9de to c4849b6 Compare August 7, 2026 04:53
private readonly IStopwatchProvider _stopwatchProvider = stopwatchProvider ?? DefaultStopwatchProvider.Instance;

/// <inheritdoc/>
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(

@robertvoinescu-work robertvoinescu-work Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amanda-tarafa I ran some integration tests and we are still hitting this interceptor even with the request id being populated through a CallOptions header mutation. I might have missed something when we originally discussed so let me know if theres a different situation where you expect blocking unary calls not to occur.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this one will always be used at least for async calls. It's the blocking one that may be unused if the caller added response header handlers. That's because the gRPC blocking client method does not expose response headers, but our client does allow to set response header handlers for both async and blocking. See the code that does that here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pinned this to the wrong method, I meant to pin it to the blocking unary. I ran some integration tests and found that blocking unary was still called even when we have the RequestId population as a callback (the current state of things).

But I misunderstood what you were saying originally - it's the response handler that causes the skip as you mentioned - and I just verified that.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch 2 times, most recently from 9908b64 to f492d41 Compare August 28, 2026 19:18

@amanda-tarafa amanda-tarafa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly nits, a single thing that we should change immediately. The latency in blocking I think it's being miscalculated on errors.

private readonly IStopwatchProvider _stopwatchProvider = stopwatchProvider ?? DefaultStopwatchProvider.Instance;

/// <inheritdoc/>
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this one will always be used at least for async calls. It's the blocking one that may be unused if the caller added response header handlers. That's because the gRPC blocking client method does not expose response headers, but our client does allow to set response header handlers for both async and blocking. See the code that does that here.

Comment thread apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/SpannerBuiltInMetrics.cs Outdated
}
catch (Exception ex)
{
elapsedMs = stopwatch.ElapsedMilliseconds;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value will be overwritten in finally, I think you want to stop the stopwatch here and after success as well, and in finally only access the the elapsed milliseconds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed!

Comment on lines +85 to +86
RecordAttemptMetrics(elapsedMs, labels);
await RecordServerTimingMetricsAsync(call.ResponseHeadersAsync, labels).ConfigureAwait(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
RecordAttemptMetrics(elapsedMs, labels);
await RecordServerTimingMetricsAsync(call.ResponseHeadersAsync, labels).ConfigureAwait(false);
var recordTimingTask = RecordServerTimingMetricsAsync(call.ResponseHeadersAsync, labels);
RecordAttemptMetrics(elapsedMs, labels);
await recordTimingTask.ConfigureAwait(false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/SpannerBuiltInMetrics.cs Outdated

public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) =>
throw new NotImplementedException();
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)

@robertvoinescu-work robertvoinescu-work Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need AsyncUnary implemented now that all blocking unary calls are handled as async. Using this interceptor as it was would result in "NotImplementedException".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we delete the test code below that uses the blocking call? No need to keep test code around.

Also, let's clean the production code from interceptors blocking calls that have them, leaving a note on why we can do so.

@amanda-tarafa amanda-tarafa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only nits. Feel free to push those changes here or in a follow up PR.

/// <summary>
/// Records server-timing metrics (such as GFE latency) from response headers.
/// </summary>
internal static void RecordServerTimingMetrics(Metadata headers, KeyValuePair<string, object>[] labels)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Equally, if this is now being called from above only, squash it into a single method.

}
}

internal static void RecordServerTimingMetrics(string header, string metricPrefix, Action<double> recordAction)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: And this one can probable be a local method to the method above.


public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) =>
throw new NotImplementedException();
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we delete the test code below that uses the blocking call? No need to keep test code around.

Also, let's clean the production code from interceptors blocking calls that have them, leaving a note on why we can do so.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInMetricsInterceptor branch from 3eb4579 to c5bebc6 Compare September 10, 2026 17:08
@robertvoinescu-work

Copy link
Copy Markdown
Contributor Author

I've left a couple open because I believe they methods will need to be internal for follow-up work, and will look back here once done. I also will remove all blocking unary interceptors in a follow-up (note test code has been cleaned up). Here is the bug to track both:

b/559681168

@robertvoinescu-work
robertvoinescu-work merged commit 6f4d5cc into googleapis:main Sep 10, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: spanner Issues related to the Spanner API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants