Skip to content

feat(Spanner.V1): Add operation metrics on unary RPCs for built in metrics - #15807

Merged
robertvoinescu-work merged 2 commits into
googleapis:mainfrom
robertvoinescu-work:spanner/builtInWrapper
Oct 8, 2026
Merged

robertvoinescu-work merged 2 commits into
googleapis:mainfrom
robertvoinescu-work:spanner/builtInWrapper

Conversation

@robertvoinescu-work

@robertvoinescu-work robertvoinescu-work commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

b/404948213

@product-auto-label product-auto-label Bot added the api: spanner Issues related to the Spanner API. label Aug 7, 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 updates the Renovate configuration to include the Google Cloud CommerceProducer V1Beta API, bumps dependency versions for SharpCompress and LibGit2Sharp, and updates package descriptions in the README. Additionally, it introduces a large number of auto-generated code snippets for various services under the Google Ads AdManager V1 API, including AdRule, AdSpot, CdnConfig, ContentBundle, CreativeSet, CustomTargetingValue, LiveStream, and Order service clients. There are no review comments to address, and I have no feedback to provide.

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInWrapper branch 2 times, most recently from c660e31 to 0ecadfd Compare August 7, 2026 05:46
@robertvoinescu-work

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 BuiltInMetricsWrapper for SpannerClient to intercept API calls and record built-in metrics, along with corresponding unit tests and builder integration. The review feedback highlights several important improvements: overriding ClientIdentity in the wrapper to delegate to the inner client, addressing metric inaccuracies for lazy-loaded paged operations (ListSessions), ensuring builder interceptors do not overwrite existing client/task instances, and asserting on exception messages in unit tests to ensure correct propagation.

Comment on lines +61 to +64
public override PagedEnumerable<ListSessionsResponse, Session> ListSessions(ListSessionsRequest request, CallSettings callSettings = null)
=> ExecuteWithInstrumentation(nameof(ListSessions), request, () => _inner.ListSessions(request, callSettings));
public override PagedAsyncEnumerable<ListSessionsResponse, Session> ListSessionsAsync(ListSessionsRequest request, CallSettings callSettings = null)
=> ExecuteWithInstrumentation(nameof(ListSessionsAsync), request, () => _inner.ListSessionsAsync(request, callSettings));

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.

medium

The ListSessions and ListSessionsAsync methods return lazy PagedEnumerable and PagedAsyncEnumerable instances. The actual gRPC calls to the Spanner service are only executed when these enumerables are iterated.

Because ExecuteWithInstrumentation only wraps the initial call that constructs the enumerable, the recorded metrics (latency and status) will only reflect the negligible time taken to instantiate the lazy enumerable (typically ~0ms with OK status), rather than the actual network calls made during iteration.

Consider documenting this limitation, or if accurate metrics are required for paged operations, wrapping the returned enumerables to intercept the actual page-fetching RPCs.

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.

This is to spec leaving as is.

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.

Then do we need to wrap them here? There's no Spanner latency or anything like that we'd be recording. Nor are we encompassing the whole pagination operation.

There's some guidance on how to do this for golden signals I think, maybe we should follow that here as well.

Happy to punt these paginated methods for a later PR while we figure it out.

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 will revisit with spanner, but looking more at other languages I see they do not emit operation metrics for these methods. I've made these methods purely pass through for now.

@robertvoinescu-work
robertvoinescu-work marked this pull request as ready for review September 22, 2026 16:17
@robertvoinescu-work
robertvoinescu-work requested a review from a team as a code owner September 22, 2026 16:17
@robertvoinescu-work robertvoinescu-work changed the title feat(Spanner.V1): Add wrapper for built in metrics feat(Spanner.V1): Add operation metrics on unary RPCs for built in metrics Sep 22, 2026

@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, had one question and a few code suggestions

@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.

Overall good with a few change requests. When we get to GAPIC observability I'd really like to look into improving this implementation.

Comment thread apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/SpannerClientPartial.cs Outdated
partial void InterceptBuild(ref SpannerClient client)
{
client ??= MaybeCreateEmulatorClientBuilder()?.Build();
client ??= Build();

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.

Please call BuildImpl here and wrap the result on your client. That way, if we change the implementation of BuildImpl you don't have to make changes to this code. For instance, right now you need to add a call to Validate on the wrapping method. If in a month we add a "log client creation" call on BuildImp for all GAPICs, then you'll have to come here and change this code to add that.

@robertvoinescu-work robertvoinescu-work Oct 5, 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.

Done, but there's a cascading effect to this change as there's no way to share the client Id between the interceptor and client except through a side channel - this was making me lean towards this initially, but let me know what you think about have an async local build context instead - I've added a comment to highlight it.

private SpannerClient BuildWithMetrics(CallInvoker callInvoker)
{
Validate();
var identity = Labeler.GenerateIdentity();

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 I don't understand.

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.

Ah, is it because the attempt interceptor needs it? So, it's the attempt interceptor and the client wrapper that need it. can you just remove it from SpannerClient?

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.

Correct, removed.

public override SpannerSettings Settings => _inner.Settings;

///<inheritdoc/>
internal override ClientIdentity ClientIdentity => _clientIdentity;

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.

I think you just can remove ClientIdentity from SpannerClient.

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.

/// A delegating wrapper around <see cref="SpannerClient"/> that intercepts
/// execution of all methods to record metrics.
/// </summary>
internal class BuiltInMetricsWrapper : SpannerClient

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.

Because we cannot extend SpannerClientImp what will happen when Spanner adds a new method is not that we won't instrument it, it's that it will be unusable because the client we are returning will throw Unimplemented. We need to write a unit test for this that via reflection checks that all Unimplemented methods in SpannerClient are implemented in BuiltInMetricWrapper.

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've added a unit test that explicitly states which methods need to be implemented.

private static StatusCode GetStatus(Exception ex) => ex switch
{
RpcException rpcEx => rpcEx.StatusCode,
OperationCanceledException => StatusCode.Cancelled,

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.

Hmmm. I think we are not doing this in the attempt, and I'm not sure we should do it here either. We probably care about RPC errors only?

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.

Removed the operation cancelled exception casting. And moved this method into the base class so it can be used here and in the interceptor.

private static readonly Exception s_rpcException = new Grpc.Core.RpcException(s_timeoutStatus);

[Fact]
public void Properties_DelegateToInner()

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 need to this via reflection and for methods as well.

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 point. I added a test to make sure that the methods are also wired up correctly - checking for eventual delegation to the inner client..

@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInWrapper branch 3 times, most recently from 1045e36 to e9b4d21 Compare October 6, 2026 00:08
@robertvoinescu-work
robertvoinescu-work force-pushed the spanner/builtInWrapper branch 2 times, most recently from b76c42d to 593c136 Compare October 6, 2026 18:26

@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.

Looking good. I have a suggestion for new tests that may come in a later PR, and a couple small things. Thanks!

private BuiltInMetricsWrapper(SpannerClient inner, ClientIdentity? clientIdentity = null)
{
_inner = GaxPreconditions.CheckNotNull(inner, nameof(inner));
_clientIdentity = clientIdentity ?? Labeler.GenerateIdentity();

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.

Let's instead check that the identity is not null?

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, I mostly had this for convenience in the tests, but it's not really saving much lines of code there.

partial void InterceptBuild(ref SpannerClient client) => client = MaybeCreateEmulatorClientBuilder()?.Build();
partial void InterceptBuild(ref SpannerClient client)
{
client = MaybeCreateEmulatorClientBuilder()?.Build() ?? Build();

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.

I'm sorry, I know I suggested we didn't wrap the emulator client earlier. But thinking about it more, I think that the emulator client call invoker will have the attempt interceptor and there's no easy way to get rid of that. So maybe we should just wrap it here for completeness. I still think it's weird to have these metrics for the emulator, but it'd be weirder to have them partially. And in any case, this is all opt-in.

What do you think?

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 like having as much consistency between emulator and non emulator makes things a bit easier to follow - if it wasn't opt in I might lean the other way. On another note if users want to consume these metrics it might provide an easy onramp for them if they can get these in an emulated environment first. I moved to wrap everything consistently.

}

[Fact]
public async Task Builder_ClientIdentity_MatchesBetweenInterceptorAndWrapper_Sync()

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.

Left a comment earlier. This test is good and tests the happy case. But let's add a couple other tests for checking this same thing when the builder is used to build several clients at the same time. We can build enough clients (20. 50?) to make sure that it will at least flake if we are doing something wrong or if we remove the async local.

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.

This is a good point, I've added more tests to cover all the cases mentioned: multiple clients run a mix of sync and async builds.

builder.InitializeLocalBuildContext();
try
{
var rawClient = builder.BuildImpl();

@robertvoinescu-work robertvoinescu-work Oct 7, 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.

NOTE: Both emulated and non-emulated now use BuildImpl whereas before the emulator used Build. This makes the code a little cleaner by keeping things symmetric. It behaves effecitvely the same, but wheras before we would go through the MaybeCreateEmulatorClientBuilder a second time returning null and treating it like a normal client builder from that point on we now just avoid that recursion immediately going to BuildImpl.

@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.

Thanks!

var tasks = new List<Task<BuiltInMetricsWrapper>>();
for (int i = 0; i < clientCount; i++)
{
if (buildMode == BuildMode.AsyncOnly || (buildMode == BuildMode.Mixed && i % 2 == 0))

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.

Nice!

@robertvoinescu-work

Copy link
Copy Markdown
Contributor Author

Integration Tests for Spanner.V1 and Spanner.Data pass, merging.

@robertvoinescu-work
robertvoinescu-work merged commit f4b0ace into googleapis:main Oct 8, 2026
21 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