INFOPLAT-13349: feat(beholder): track beholder.export.* metrics per export via custom gRPC stats handler - #2251
Conversation
|
👋 kirqz23, thanks for creating this pull request! To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team. Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks! |
📊 API Diff Results
|
There was a problem hiding this comment.
Pull request overview
Adds a custom metric to restore per-node visibility into OTLP log export volume by capturing the uncompressed outbound gRPC payload size and emitting it as beholder.logs.export.bytes (labelled by csa_public_key).
Changes:
- Introduces a gRPC
stats.Handler(sizeCaptureHandler) to capturestats.OutPayload.Length. - Wraps the OTLP logs exporter with
meteredLogsExporterto increment a bytes counter on successful exports (to avoid retry inflation). - Wires the stats handler + metered exporter into
NewGRPCClient’s shared log exporter connection.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| pkg/beholder/metered_exporter.go | Adds size-capture stats handler and metered exporter wrapper emitting beholder.logs.export.bytes. |
| pkg/beholder/client.go | Wires the shared capture + exporter wrapper and attaches the gRPC stats handler(s) to the log exporter dial options. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| dialOpts := []grpc.DialOption{ | ||
| grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelOpts...)), | ||
| grpc.WithStatsHandler(&sizeCaptureHandler{capture: capture}), | ||
| } |
There was a problem hiding this comment.
gRPC builds istats.NewCombinedHandler(...) from the registered slice of multiple handlers, that is exactly the delegating handler proposed by the Copilot. This proposal is exactly what gRPC already builds for us internally. The downside of having multiple stats handlers however is that gRPC fires every handler on every event, in registration order. So for one OutPayload event there will be two HandleRPC(...) calls which might be an overhead. We might consider either keeping both statsHandlers if we need them, or dropping grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelOpts...)).
This old handler:
- Emits
rpc.client.*metrics (which part of them are going to be removed), - Creates a trace span per RPC
- Injects trace-context headers
There was a problem hiding this comment.
So, we have two approaches here:
- Keep two stats handlers as we have it now.
exportSizeHandlershouldn't add much to the performance. - Drop
grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelOpts...))taking into account that we loose 3 points mentioned in the above comment, howeverrpc.client.*metrics are totally deprecated anyway expect duration, which can be added to our custom handler alongsidebeholder.export.bytes, e.g. sth likebeholder.export.duration
cc @pkcll
There was a problem hiding this comment.
I decided to drop the otelgrpc stats handler and use only our custom beholderStatsHandler, cause it has now both size and duration metrics. We can later on expand it to produce more needed metrics.
1440cca to
1fec9f9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
pkg/beholder/client.go:580
- In
newMeterProvider, the previous implementation appendedcfg.metricOptions()(which includessdkmetric.WithCardinalityLimit(cfg.MetricCardinalityLimit)) but the new code constructs the MeterProvider without it. This silently drops the configured per-instrument cardinality limit, which can increase time-series cardinality and memory usage in production.
return sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metered, readerOpts...)),
sdkmetric.WithResource(resource),
sdkmetric.WithView(cfg.MetricViews...),
), metered, nil
pkg/beholder/client.go:153
- The PR description states
beholder.export.*should be labelled per signal type (logs/metrics/traces), but the implementation only meters log and metric exports. Trace exports created innewTracerProviderare not wrapped and do not useexportSizeHandler, sobeholder.export.bytes/beholder.export.durationwon't be emitted forotel_signal="traces".
// Shared export instruments beholder.export.bytes and
// beholder.export.duration, labelled per signal. They live on this
// MeterProvider, so the metrics exporter can only be wired up once the
// provider and its meter exist.
expMetrics, err := newExportMetrics(meter)
57e093b to
77256fb
Compare
…RPC stats handler
77256fb to
5976e9a
Compare
…context injection
Code Review: beholder export metrics (high effort)Ran line-by-line, removed-behavior, cross-file tracer, reuse, simplification, efficiency, altitude, and conventions passes, plus targeted verification on the highest-uncertainty candidates. One candidate (byte double-counting under gRPC-level transparent retries) was checked directly against vendored grpc-go v1.82.1 source and refuted — both grpc-go's transparent retries and the OTLP SDK's own retry loop create a fresh Findings
Generated via automated code review (high effort, 8 finder angles + targeted verification). |
I think its fine to drop |
Correction to finding #1 aboveRetracting part of my first finding — I filed it without reading the PR description properly, and it's unfair to the change as written. What I got wrong. Finding #1 said dropping Two small factual notes on the rationale. The vendored stack here is The narrower gap that I do think is worth closing. Of everything the old handler emitted, one signal isn't replaced by the new metrics: per-attempt gRPC status codes. On self-reference, since it came up while I was checking this. Worth recording that it isn't a differentiator between the two designs. Findings #2 through #10 in the comment above stand as written. |
Consider adding |
|
1 (otelgrpc dropped): 2 (HTTP transport): Out of scope as it was done for gRPC-only by design; s 3 4 5 (tick before attach): By design unattached handler/wrapper are tested no-ops recording nothing; the self-referential first cycle is empty anyway; the window is microseconds vs a 10s default interval. 6 shared 7 (two attachMetrics): Deliberate different payload types; a shared helper would need generics for zero behavioral gain, and the compiler catches mix-ups. 8 (newMeteredTraceProvider): implemented and the instrumentation used in the gRPC client; resolved by wiring the trace exporter 9 zero-value exportMetrics{} degrades to passthrough instead of panic + test. 10 (WithDialOption semantics): No code change needed, all three call sites already build one fully-combined dialOpts slice and call |
What
Adds a
beholder.export.bytesandbeholder.export.durationmetrics to the beholder gRPC client, restoringper-node log volume visibility and export duration (labelled by
csa_public_keyandsignal_typelogs/metrics/traces)According to these docs,
rpc.client.request.*metrics are totally deprecated without any successors in otelgrpc semconv v1.40.0RPC semantic convention stability migration guide
Semantic conventions for RPC metrics
Additionally, new
beholder.export.*metrics are not recorded on each separate message, but on the whole batch at once. In terms ofbeholder.export.bytesit shows real number of exported bytes only on success without cumulative number on all retries thatrpc.client.request.sizewas producing. This was misleading in terms of how many logs/metrics are coming through the gateway and downstream consumers.Changes
exportStatsHandler(gRPCstats.Handler) to capture outbound message sizes viaOutPayloadeventsbeholder.export.byteswith attributesotel_signalandcsa_public_keybeholder.export.duration(histogram, units) with attributesotel_signal,csa_public_keyanderror0.005–60); the SDK defaults are millisecond-scaled, so nearly every export would land in the first bucketexportMetricsstruct, attached to the metric exporter once theMeterProviderexists (attachMetrics)error={true,false}Notes
HandleRPCbecause the uncompressed proto length is only observable inside the gRPC stack. Duration is measured in the wrapper instead, so it also covers proto marshaling, connection setup and inter-attempt backoff and is still recorded when an export fails before any RPC is issued (e.g. an already-expired context, which produces no stats events at all).Requires
Supports