Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

grpc: add interceptor that tracks the sizes of all messages sent by servers/clients #55381

Merged
ggilmore merged 11 commits intomainfrom
grpc-size-monitoring
Aug 1, 2023
Merged

grpc: add interceptor that tracks the sizes of all messages sent by servers/clients #55381
ggilmore merged 11 commits intomainfrom
grpc-size-monitoring

Conversation

@ggilmore
Copy link
Contributor

@ggilmore ggilmore commented Jul 27, 2023

Follow up to https://github.com/sourcegraph/sourcegraph/pull/55209 and https://github.com/sourcegraph/sourcegraph/pull/55242.

This PR adds interceptors that records Prometheus metrics that observe:

  • the individual size of each sent protobuf message by a server or client
  • the total amount data sent over the course a single RPC by a server (responses) or client (requests)

This allows us to track the total amount of data returned by any of our RPCs. In some cases, this can reveal opportunities for future performance / stability improvements (Example: symbols' LocalCodeIntel method returning ~gigabyte sized responses that has to be held all at once in memory).

This PR also provides new grafana dashboards that track this metric for every gRPC service. See below for a screenshot of what this looks like when I run the symbols service locally.

Screenshot 2023-08-01 at 11 41 36 AM

Test plan

  • Unit tests
  • Manual tests (using local sourcegraph instance with sg start monitoring)
    • I ran a couple of searches to verify that the data was populated as I expected. (ex: context:global type:diff test and then see the relevant dashboards fill in for gitserver)
    • Using the repository and file mentioned in this log message, I navigated to that file and hovered over a token to trigger's SymbolsLocalIntel response. Then took a screenshot of it's grafana dashboard above. The total response size dashboards show the total large allocation (~1 GB), while the individual message sizes hover closer to 1MB (as expected) .

@cla-bot cla-bot bot added the cla-signed label Jul 27, 2023
// SplitMethodName splits a full gRPC method name (e.g. "/package.service/method") in to its individual components (service, method)
//
// Copied from github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/reporter.go
func SplitMethodName(fullMethod string) (string, string) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought this small snippet was worth making it re-usable across different packages. I am fine with copying it though if you feel like that's preferable.

var metricSingleMessageSize = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "src_grpc_sent_individual_message_size_per_rpc_bytes",
Help: "Size of individual messages sent per RPC.",
Buckets: []float64{
Copy link
Contributor Author

@ggilmore ggilmore Jul 28, 2023

Choose a reason for hiding this comment

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

The bucket sizes are identical in both metrics.

I am open to other proposals to reduce the dimensionality, but this was my first thought.

Copy link
Member

Choose a reason for hiding this comment

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

to make it clear they are the same, should we pull the buckets out into a separate shared var?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought we might want to tune one metric's buckets after review, but if you're fine with this set of buckets for both metrics - then I can pull it out into a shared variable.


// messageSizeObserver is a utility that records Prometheus metrics that observe the size of each sent message and the
// cumulative size of all sent messages during the course of a single RPC call.
type messageSizeObserver struct {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I decided to make a small struct type here to encapsulate the Prometheus observation logic since it's shared amongst 4 different interceptors.

// messageSizeObserver is a utility that records Prometheus metrics that observe the size of each sent message and the
// cumulative size of all sent messages during the course of a single RPC call.
type messageSizeObserver struct {
onSingleFunc func(messageSizeBytes uint64)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I decided to use provided callbacks to make this easier to unit test. The prometheus-specific functionality comes in when you use the newMessageSizeObserver constructor.

// FinishRPC records the total size of all sent messages during the course of a single RPC call.
// This function should only be called once the RPC call has completed.
func (o *messageSizeObserver) FinishRPC() {
o.finishOnce.Do(func() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps over-kill, but I wrapped FinishRPC in a sync.Once to prevent accidental misuse.


err := handler(srv, wrappedStream)
if err != nil {
// Don't record the total size of the messages if there was an error sending them, since they may not have been sent.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This applies to every interceptor, but I decided to avoid recording sizes if sending a request / response ever failed - since we can't be sure if the message itself was actually sent to the recipient on the wire.


// Track total response size per method

{
Copy link
Contributor Author

@ggilmore ggilmore Jul 28, 2023

Choose a reason for hiding this comment

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

TL;DR

  • These dashboards track the p99.9, p90, and p75 total amount of response data sent per RPC.
  • These dashbaords also track the p99.9, p90, and p75 response individual message sizes.

We're collecting similar data for clients, but I didn't add a dashboard for this yet.

I want to do some refactoring (maybe renaming) of this dashboard logic before I add that (which can be done in a future change).

@ggilmore
Copy link
Contributor Author

cc @sourcegraph/code-intel: This adds a generic system that can give you insight into how much data a given gRPC call (including the problematic symbols calls) is sending (and perhaps allocating all at once).

@ggilmore ggilmore requested review from a team, camdencheek and mucles July 28, 2023 17:40
@ggilmore ggilmore marked this pull request as ready for review July 28, 2023 17:40
@sourcegraph-bot
Copy link
Contributor

sourcegraph-bot commented Jul 28, 2023

Codenotify: Notifying subscribers in CODENOTIFY files for diff 274fac0...b181405.

Notify File(s)
@bobheadxi monitoring/definitions/frontend.go
monitoring/definitions/git_server.go
monitoring/definitions/repo_updater.go
monitoring/definitions/searcher.go
monitoring/definitions/shared/grpc.go
monitoring/definitions/symbols.go
@slimsag monitoring/definitions/frontend.go
monitoring/definitions/git_server.go
monitoring/definitions/repo_updater.go
monitoring/definitions/searcher.go
monitoring/definitions/shared/grpc.go
monitoring/definitions/symbols.go
@sourcegraph/delivery doc/admin/observability/dashboards.md
monitoring/definitions/frontend.go
monitoring/definitions/git_server.go
monitoring/definitions/repo_updater.go
monitoring/definitions/searcher.go
monitoring/definitions/shared/grpc.go
monitoring/definitions/symbols.go

var metricSingleMessageSize = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "src_grpc_sent_individual_message_size_per_rpc_bytes",
Help: "Size of individual messages sent per RPC.",
Buckets: []float64{
Copy link
Member

Choose a reason for hiding this comment

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

to make it clear they are the same, should we pull the buckets out into a separate shared var?

Comment on lines +223 to +224
// Note: we don't call FinishRPC() if there was a real error, since the total size of the messages sent during the
// course of the RPC call may not be accurate.
Copy link
Member

Choose a reason for hiding this comment

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

I think it's still valuable to log the size of messages received even in the event of an error. An error could just be a cancelled context. If we ignore the errored streams, I think this metric might end up misrepresenting the volume of traffic.

What do you mean by "not accurate" here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I might need to refactor the logic for this a bit.

I was thinking that if we ever get an error back in the course of sending a message, that we don't have a definitive way of knowing whether or not anything actually went out over the wire to the recipient - all we know is that we encountered an error while doing so.

As such, including that message in either the "individual message" Prometheus calculations or the "overall request / response size" traffic seems misleading.


However, if we do get a context cancelled or deadline exceeded error, then maybe we can record the previous observations anyway?

One question that comes to mind...if we immediately cancel / exceed a deadline before the server responses, should we record that "empty" response size? That seems misleading... Maybe we should have an "error" label on the metric to split up the times for successful and failed requests?

Copy link
Member

@camdencheek camdencheek Jul 28, 2023

Choose a reason for hiding this comment

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

Well, we'll have both client-side and server-side metrics for this, so I think it's a reasonable signal to record "bytes sent" and "bytes received", which could be different. In the case that sending a single message returns an error, I agree that it's reasonable to not log those bytes as "sent", but I think we still want to log zero so we're not skewing our results against errors

I'm hesitant to say adding a dimension for "is error" would be useful since cancellation is often a "success" (we got enough results to end the stream early). I wouldn't want those to be missing from the metrics

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@camdencheek

I tweaked the total RPC size behavior to ignore errors and added a lot of tests in 9f32121 (#55381). Can you take a look at the tests to see if this is the behavior you wanted? (TestStreamingClientInterceptor in particular)

Copy link
Member

Choose a reason for hiding this comment

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

those tests look great! Thank you!


// Observe records the size of a single message.
func (o *messageSizeObserver) Observe(message proto.Message) {
s := uint64(proto.Size(message))
Copy link
Member

Choose a reason for hiding this comment

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

Could you do a quick sanity check that this doesn't marshal the message to get the size? Looks like there is a legacy code path that encodes the message to get the size. A benchmark showing that proto.Size doesn't allocate is good enough for me.

Copy link
Contributor Author

@ggilmore ggilmore Jul 28, 2023

Choose a reason for hiding this comment

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

@camdencheek I added a benchmark here: 0dedef1 (#55381)

BenchmarkObserverBinary-10              54629676                25.73 ns/op            0 B/op          0 allocs/op
BenchmarkObserverKeyValue-10             3871278               298.7 ns/op           192 B/op          5 allocs/op
BenchmarkObserverArticle-10              1500800               786.2 ns/op           384 B/op         10 allocs/op

Is this what you were thinking? (first time writing a benchmark)

It seems like it does allocate some depending on the input, but not in the same order of magnitude as to the message size (indicating it's not fully marshaling).

Copy link
Member

Choose a reason for hiding this comment

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

Huh! Very interesting. Based on the profiles, it looks like nearly all the memory allocations come from sizing the maps. 10 allocations / message is nothing to sneeze at, but given that we don't use maps extensively in our APIs, I think this is probably within the range of acceptable. I wonder if that could be improved 🤔

(go test -bench=. -run=xxx ./internal/grpc/messagesize -memprofile=memprofile.txt && go tool pprof -http=:9096 ./memprofile.txt)

Screenshot 2023-07-28 at 14 51 45

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When writing the logic for https://github.com/sourcegraph/sourcegraph/pull/55130, I did use a few breakpoints to determine that our messages types do use the first (non-legacy) codepath when calculating the size (since we also check sizes in that PR).

I assume the "marshal it out and check the length" is for messages that were generated using older versions of the protobuf go package?

Copy link
Member

Choose a reason for hiding this comment

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

I assume the "marshal it out and check the length" is for messages that were generated using older versions of the protobuf go package?

I also assume that. I mostly just wanted to make sure that my assumptions weren't unfounded 🙂 And I learned something new about gRPC along the way!


// Observe records the size of a single message.
func (o *messageSizeObserver) Observe(message proto.Message) {
s := uint64(proto.Size(message))
Copy link
Member

Choose a reason for hiding this comment

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

Huh! Very interesting. Based on the profiles, it looks like nearly all the memory allocations come from sizing the maps. 10 allocations / message is nothing to sneeze at, but given that we don't use maps extensively in our APIs, I think this is probably within the range of acceptable. I wonder if that could be improved 🤔

(go test -bench=. -run=xxx ./internal/grpc/messagesize -memprofile=memprofile.txt && go tool pprof -http=:9096 ./memprofile.txt)

Screenshot 2023-07-28 at 14 51 45

@ggilmore ggilmore force-pushed the grpc-size-monitoring branch from 3d7316d to 9e165b0 Compare August 1, 2023 18:25
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants