Skip to content

feat(instrumentation): opt-in instrumentation of sessions, RPCs, proxy and client - #210

Merged
dborowitz merged 5 commits into
mainfrom
dborowitz-timing
Oct 28, 2026
Merged

dborowitz merged 5 commits into
mainfrom
dborowitz-timing

Conversation

@dborowitz

@dborowitz dborowitz commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Note

Part 1 of 2 in stack (followed by #211).

Summary

Adds an opt-in, dependency-free instrumentation package (google.cloud.managed_spark_connect.instrumentation) that records timed spans, events, counters, and environment metadata across the Managed Spark Connect client stack.

Instrumentation is off by default and adds zero overhead when disabled (all internal hooks route through a shared NOOP_RECORDER, no gRPC interceptors are installed, and the PySpark client is left untouched). Users or benchmarks can opt in programmatically via ManagedSparkSession.builder.instrumentation(inst) or without code changes via the MANAGED_SPARK_CONNECT_INSTRUMENTATION_FILE and MANAGED_SPARK_CONNECT_INSTRUMENTATION_LOG environment variables.

What is recorded

  • Dataproc session lifecycle (session.*, dataproc.get_session): session.get_or_create (session.create_rpc, session.wait_active with server-side state_history, session.connect; or session.reuse with dataproc.get_session, session.wait_endpoint, session.connect), session.stop (session.terminate_rpc, session.wait_terminating), and every control-plane GetSession RPC tagged with reason=rpc_guard|reuse|poll|stop and new_client.
  • Two-layer Spark Connect gRPC tracing (grpc.<Method>):
    • layer="outer" wraps ProxiedChannel (measuring total client-perceived RPC latency, including guard_ns spent in the session liveness guard).
    • layer="inner" wraps the underlying channel to the local WebSocket proxy (measuring wire + server time).
    • Streaming RPCs (ExecutePlan, ReattachExecute) use a call-preserving iterator wrapper that tracks message/byte counts, records first_response and status events, freezes duration_ns when the final gRPC status arrives on the channel thread, and safely emits spans even when PySpark drops an iterator at result_complete without draining it.
  • Local TCP-to-WebSocket proxy (proxy.*, auth.*, ws.handshake): Records proxy.start, per-connection forwarding spans (proxy.connection with bytes_in, bytes_out, frames_in, frames_out), and WebSocket connection establishment (proxy.ws_connect -> auth.default, auth.refresh, ws.handshake).
  • Client-side conversions (client.*): Tracks client.plan_to_proto (plan_bytes), client.arrow_to_rows (rows), and client.to_pandas -> client.arrow_to_pandas (rows, columns).
  • In-flight span tracking (inflight(), wait_idle()): Allows callers and benchmarks to wait for asynchronous background gRPC calls (such as PySpark's post-query ReleaseExecute RPCs) to finish before starting the next measurement.

See docs/instrumentation.md for the full API reference, JSONL record schema, and span catalogue.

Commits in this PR

  1. chore: ignore .venv/ — Adds .venv/ to .gitignore.
  2. fix: keep with_call()/future() on guarded ProxiedChannel methods — Replaces the plain function wrapper in ProxiedChannel._wrap_method with _GuardedMultiCallable, preserving the with_call() and future() methods required by grpc.intercept_channel() and forwarding subscribe()/unsubscribe() directly.
  3. fix: honor add_interceptor() on ManagedSparkChannelBuilder — Applies interceptors registered via ChannelBuilder.add_interceptor() around the ProxiedChannel in ManagedSparkChannelBuilder.toChannel().
  4. feat: opt-in instrumentation of sessions, RPCs, proxy and client — Implements google.cloud.managed_spark_connect.instrumentation (Instrumentation, Recorder, Span, JsonlFileSink, InMemorySink, LoggingSink, CallbackSink, gRPC interceptors, proxy/session/client hooks) and adds docs/instrumentation.md.
  5. feat(instrumentation): track in-flight spans; add wait_idle() — Adds Recorder.inflight(prefix="") and Instrumentation.wait_idle(timeout_s, prefix="grpc.").

Testing

  • Added unit and in-process gRPC server round-trip tests in tests/unit/test_instrumentation.py (44 tests), tests/unit/test_core.py, and tests/unit/test_proxy.py covering span nesting, attribute snapshotting, streaming call lifecycle, abandoned stream finalization, with_call()/future() interceptor compatibility, and wait_idle().

@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 opt-in instrumentation for Managed Spark Connect sessions, enabling the recording of timed spans for session lifecycle events, gRPC calls, proxy connections, and client-side conversions. The feedback identifies three key areas for improvement: a potential race condition in _active_recorder() when accessing the shared _registered list without a lock, a performance overhead in the is_active liveness check caused by repeatedly instantiating a new SessionControllerClient, and a missing timeout in the terminate_s8s_session polling loop that could lead to indefinite hangs during session termination.

@dborowitz
dborowitz force-pushed the dborowitz-timing branch 2 times, most recently from b3258e5 to 78b2072 Compare October 26, 2026 19:53
@dborowitz
dborowitz added this pull request to stack #220 October 26, 2026 19:54
ProxiedChannel._wrap_method replaced each gRPC multi-callable with a plain
function, dropping the with_call() and future() entry points. Anything that
layers grpc.intercept_channel() on top of the channel (upstream
ChannelBuilder.add_interceptor, tracing/metrics interceptors) calls
with_call() for unary-unary and stream-unary RPCs and failed with an
AttributeError.

Wrap the multi-callable in a small object that runs the liveness guard
before __call__, with_call and future alike. subscribe()/unsubscribe()
return None and are now forwarded instead of being wrapped.
ManagedSparkChannelBuilder inherits add_interceptor() from the upstream
ChannelBuilder, but toChannel() returned the ProxiedChannel directly, so
registered interceptors were silently ignored (the inner channel is built
by a fresh DefaultChannelBuilder with an empty interceptor list).

Apply the builder's interceptors around the ProxiedChannel, matching what
ChannelBuilder._insecure_channel/_secure_channel do upstream.
Add google.cloud.managed_spark_connect.instrumentation: a small,
dependency-free tracing layer that records timed spans (plus events,
counters and one meta record) to pluggable sinks (JSONL file, in-memory,
logging, callback). Off by default: without an Instrumentation the
library goes through a shared no-op recorder, installs no gRPC
interceptor and leaves the pyspark client untouched.

Opt in with ManagedSparkSession.builder.instrumentation(inst), or via
MANAGED_SPARK_CONNECT_INSTRUMENTATION_FILE / _LOG. The session exposes
`session.instrumentation` and closes it on stop().

What is recorded:
- session lifecycle: get_or_create (create_rpc, wait_active with
  Dataproc state history, connect; or reuse with get_session,
  wait_endpoint, connect), stop (terminate_rpc, wait_terminating), and
  every GetSession (reason=rpc_guard|reuse|poll|stop).
- every Spark Connect RPC at two layers of the channel stack: an outer
  span around the ProxiedChannel (what the client waits for, including
  the liveness guard, reported as guard_ns) and an inner span around the
  plain channel to the local proxy. Streaming calls go through an
  iterator proxy that keeps the grpc.Call surface, counts responses,
  ends the span at the final status timestamp, and handles abandoned
  streams via __del__ and weakref status callbacks.
- the local proxy: start, each connection on the forwarding thread with
  byte/frame counters, and each websocket connect (ADC lookup, token
  refresh, handshake).
- client-side conversions: plan building, Arrow to rows, Arrow to pandas.

Spans nest through a contextvar and snapshot global attributes when they
start, so a trace reads top-down from the user call to the wire. See
docs/instrumentation.md for the API, the record schema and the span
catalogue.
Recorder.inflight(prefix) lists spans that have started but not ended,
and Recorder.wait_idle(timeout_s, prefix='grpc.') (also on
Instrumentation) blocks until none match. Benchmarks use it to let the
background ReleaseExecute RPCs pyspark issues after a query drain before
the next measurement starts, instead of sleeping a fixed amount.
@dborowitz
dborowitz merged commit 5269a2b into main Oct 28, 2026
20 checks passed
@dborowitz
dborowitz deleted the dborowitz-timing branch October 28, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants