This is an automated email from the ASF dual-hosted git repository.

viirya pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-gateway.git


The following commit(s) were added to refs/heads/main by this push:
     new e6a8cba  [SPARK-58930] Add scg-observability crate
e6a8cba is described below

commit e6a8cba030414c90e5734fb837cea4c0f1c14738
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Sun Aug 23 18:12:15 2026 -0700

    [SPARK-58930] Add scg-observability crate
    
    ### What changes were proposed in this pull request?
    
    Part of importing the Spark Connect Gateway reference implementation into
    `apache/spark-connect-gateway` as a series of small, bottom-up PRs (tracked 
in
    the do-not-merge reference PR #1).
    
    This adds the `scg-observability` crate — the metrics, tracing, and admin
    surface for the gateway:
    
    - Prometheus metrics for the gateway
    - Distributed tracing — an OpenTelemetry exporter with W3C traceparent
      propagation
    - An admin HTTP server exposing `/metrics`, `/healthz`, and `/readyz`
    
    `scg-observability` is a leaf crate (no internal dependencies), so it 
compiles
    and tests on its own (8 unit tests). It is appended to the workspace 
`members`
    list, and is consumed later by the `proxy` and `gateway` crates.
    
    ### Why are the changes needed?
    
    Implements the reference gateway from the approved SPIP (SPARK-58455), 
imported
    one reviewable piece at a time.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D 
warnings`,
    `cargo build --workspace`, and `cargo test --workspace` all pass locally
    (scg-observability has 8 unit tests).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, co-authored with Claude Code.
    
    Closes #7 from viirya/import-05-observability.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
 Cargo.lock                          | 359 ++++++++++++++++++++++++++++++-
 Cargo.toml                          |   1 +
 crates/observability/Cargo.toml     |  58 ++++++
 crates/observability/src/admin.rs   | 182 ++++++++++++++++
 crates/observability/src/lib.rs     |  51 +++++
 crates/observability/src/metrics.rs | 406 ++++++++++++++++++++++++++++++++++++
 crates/observability/src/tracing.rs | 340 ++++++++++++++++++++++++++++++
 7 files changed, 1389 insertions(+), 8 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index b7227f0..49fc8fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -156,6 +156,15 @@ dependencies = [
  "rand_core 0.10.1",
 ]
 
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "const-oid"
 version = "0.9.6"
@@ -409,6 +418,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.4.3"
@@ -418,7 +439,7 @@ dependencies = [
  "cfg-if",
  "js-sys",
  "libc",
- "r-efi",
+ "r-efi 6.0.0",
  "rand_core 0.10.1",
  "wasm-bindgen",
 ]
@@ -802,6 +823,15 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
 
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
 [[package]]
 name = "matchit"
 version = "0.8.4"
@@ -837,6 +867,15 @@ version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
 
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "num-bigint"
 version = "0.4.8"
@@ -914,6 +953,83 @@ version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
 
+[[package]]
+name = "opentelemetry"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror 2.0.20",
+ "tracing",
+]
+
+[[package]]
+name = "opentelemetry-otlp"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
+dependencies = [
+ "http",
+ "opentelemetry",
+ "opentelemetry-proto",
+ "opentelemetry_sdk",
+ "prost",
+ "thiserror 2.0.20",
+ "tokio",
+ "tonic",
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
+dependencies = [
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "prost",
+ "tonic",
+ "tonic-prost",
+]
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846"
+
+[[package]]
+name = "opentelemetry-stdout"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "bc8887887e169414f637b18751487cce4e095be787d23fad13c454e2fb1b3811"
+dependencies = [
+ "chrono",
+ "opentelemetry",
+ "opentelemetry_sdk",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry",
+ "percent-encoding",
+ "rand 0.9.5",
+ "thiserror 2.0.20",
+ "tokio",
+ "tokio-stream",
+]
+
 [[package]]
 name = "parking_lot"
 version = "0.12.5"
@@ -1063,6 +1179,21 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "prometheus"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
+dependencies = [
+ "cfg-if",
+ "fnv",
+ "lazy_static",
+ "memchr",
+ "parking_lot",
+ "protobuf",
+ "thiserror 2.0.20",
+]
+
 [[package]]
 name = "prost"
 version = "0.14.4"
@@ -1116,6 +1247,26 @@ dependencies = [
  "prost",
 ]
 
+[[package]]
+name = "protobuf"
+version = "3.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
+dependencies = [
+ "once_cell",
+ "protobuf-support",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protobuf-support"
+version = "3.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
 [[package]]
 name = "pulldown-cmark"
 version = "0.13.4"
@@ -1150,7 +1301,7 @@ dependencies = [
  "rustc-hash",
  "rustls",
  "socket2",
- "thiserror",
+ "thiserror 2.0.20",
  "tokio",
  "tracing",
  "web-time",
@@ -1172,7 +1323,7 @@ dependencies = [
  "rustls",
  "rustls-pki-types",
  "slab",
- "thiserror",
+ "thiserror 2.0.20",
  "tinyvec",
  "tracing",
  "web-time",
@@ -1201,6 +1352,12 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
 [[package]]
 name = "r-efi"
 version = "6.0.0"
@@ -1214,10 +1371,20 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
 dependencies = [
  "libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
 [[package]]
 name = "rand"
 version = "0.10.2"
@@ -1239,6 +1406,16 @@ dependencies = [
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
 [[package]]
 name = "rand_core"
 version = "0.6.4"
@@ -1248,6 +1425,15 @@ dependencies = [
  "getrandom 0.2.17",
 ]
 
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
 [[package]]
 name = "rand_core"
 version = "0.10.1"
@@ -1398,6 +1584,7 @@ version = "0.23.43"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
 dependencies = [
+ "log",
  "once_cell",
  "ring",
  "rustls-pki-types",
@@ -1453,7 +1640,7 @@ dependencies = [
  "serde",
  "serde_json",
  "subtle",
- "thiserror",
+ "thiserror 2.0.20",
  "tokio",
  "tonic",
  "tracing",
@@ -1467,7 +1654,7 @@ dependencies = [
  "serde",
  "serde_yaml",
  "tempfile",
- "thiserror",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -1481,6 +1668,31 @@ dependencies = [
  "tonic-prost-build",
 ]
 
+[[package]]
+name = "scg-observability"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "futures",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "opentelemetry",
+ "opentelemetry-otlp",
+ "opentelemetry-semantic-conventions",
+ "opentelemetry-stdout",
+ "opentelemetry_sdk",
+ "prometheus",
+ "thiserror 2.0.20",
+ "tokio",
+ "tonic",
+ "tracing",
+ "tracing-opentelemetry",
+ "tracing-subscriber",
+ "uuid",
+]
+
 [[package]]
 name = "scg-routing"
 version = "0.1.0"
@@ -1565,6 +1777,15 @@ dependencies = [
  "unsafe-libyaml",
 ]
 
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
 [[package]]
 name = "shlex"
 version = "2.0.1"
@@ -1599,7 +1820,7 @@ checksum = 
"0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
 dependencies = [
  "num-bigint",
  "num-traits",
- "thiserror",
+ "thiserror 2.0.20",
  "time",
 ]
 
@@ -1708,13 +1929,33 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
 [[package]]
 name = "thiserror"
 version = "2.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
 dependencies = [
- "thiserror-impl",
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -1728,6 +1969,15 @@ dependencies = [
  "syn 3.0.3",
 ]
 
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "time"
 version = "0.3.55"
@@ -1868,11 +2118,13 @@ dependencies = [
  "socket2",
  "sync_wrapper",
  "tokio",
+ "tokio-rustls",
  "tokio-stream",
  "tower",
  "tower-layer",
  "tower-service",
  "tracing",
+ "webpki-roots",
 ]
 
 [[package]]
@@ -1992,6 +2244,65 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
 dependencies = [
  "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-opentelemetry"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
+dependencies = [
+ "js-sys",
+ "opentelemetry",
+ "smallvec",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-subscriber",
+ "web-time",
+]
+
+[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "serde_json",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-serde",
 ]
 
 [[package]]
@@ -2048,6 +2359,23 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
 
+[[package]]
+name = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
@@ -2069,6 +2397,15 @@ version = "0.11.1+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
 
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
 [[package]]
 name = "wasm-bindgen"
 version = "0.2.127"
@@ -2264,6 +2601,12 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
 [[package]]
 name = "writeable"
 version = "0.6.3"
diff --git a/Cargo.toml b/Cargo.toml
index c8b3f91..754343f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,7 @@ members = [
     "crates/routing",
     "crates/config",
     "crates/auth",
+    "crates/observability",
 ]
 
 [workspace.package]
diff --git a/crates/observability/Cargo.toml b/crates/observability/Cargo.toml
new file mode 100644
index 0000000..4a1cf4d
--- /dev/null
+++ b/crates/observability/Cargo.toml
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-observability"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "Prometheus metrics, OpenTelemetry tracing, and admin HTTP 
server for the Spark Connect Gateway."
+publish = false
+
+[dependencies]
+tokio = { workspace = true }
+tonic = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+prometheus = { workspace = true }
+uuid = { workspace = true }
+hyper = { workspace = true }
+hyper-util = { workspace = true }
+http-body-util = { workspace = true }
+bytes = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+opentelemetry = { workspace = true }
+opentelemetry_sdk = { workspace = true }
+opentelemetry-otlp = { workspace = true }
+opentelemetry-semantic-conventions = { workspace = true }
+tracing-opentelemetry = { workspace = true }
+
+[features]
+# Enables `install_test_subscriber` — used by integration tests in
+# downstream crates so they can observe spans via
+# `opentelemetry_sdk::trace::InMemorySpanExporter` without each test
+# binary respelling the wiring.
+testing = ["opentelemetry_sdk/testing"]
+
+[dev-dependencies]
+opentelemetry-stdout = { workspace = true }
+opentelemetry_sdk = { workspace = true, features = ["rt-tokio", "testing"] }
+futures = { workspace = true }
diff --git a/crates/observability/src/admin.rs 
b/crates/observability/src/admin.rs
new file mode 100644
index 0000000..362a02d
--- /dev/null
+++ b/crates/observability/src/admin.rs
@@ -0,0 +1,182 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Admin HTTP server: `/metrics`, `/healthz`, `/readyz`.
+//!
+//! Bound on a separate `admin_addr` from the gRPC server so that:
+//!
+//! * scrape traffic doesn't compete with hot-path gRPC for the
+//!   listener's accept queue,
+//! * the gRPC port can be exposed externally while `/metrics` stays
+//!   internal-only,
+//! * a Kubernetes Service can target `:9090` for liveness / readiness
+//!   probes without leaking gRPC.
+//!
+//! Implementation uses a minimal Hyper 1.x server. We don't pull in
+//! `axum` or `warp` because the routing surface is three endpoints.
+
+use std::convert::Infallible;
+use std::future::Future;
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use bytes::Bytes;
+use http_body_util::Full;
+use hyper::body::Incoming;
+use hyper::server::conn::http1;
+use hyper::service::service_fn;
+use hyper::{Method, Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use prometheus::Encoder;
+use tokio::net::TcpListener;
+use tracing::{debug, info, warn};
+
+use crate::metrics::Metrics;
+
+/// Configuration for the admin server.
+#[derive(Debug, Clone)]
+pub struct AdminConfig {
+    /// Address to bind, e.g. `0.0.0.0:9090`.
+    pub bind_addr: SocketAddr,
+}
+
+/// Read-only readiness signal driven by the gateway. Static-pool
+/// deployments mark themselves ready immediately; dynamic-pool
+/// deployments mark themselves ready once the watcher emits its first
+/// healthy backend list.
+#[derive(Clone)]
+pub struct ReadinessProbe {
+    inner: Arc<std::sync::atomic::AtomicBool>,
+}
+
+impl ReadinessProbe {
+    pub fn new(initial: bool) -> Self {
+        Self {
+            inner: Arc::new(std::sync::atomic::AtomicBool::new(initial)),
+        }
+    }
+    pub fn mark_ready(&self) {
+        self.inner.store(true, std::sync::atomic::Ordering::Release);
+    }
+    /// Flip readiness back to false. Used during drain on SIGTERM so
+    /// `/readyz` returns 503 and Kubernetes removes this pod from the
+    /// Service endpoints before any in-flight RPCs are torn down.
+    pub fn mark_not_ready(&self) {
+        self.inner
+            .store(false, std::sync::atomic::Ordering::Release);
+    }
+    pub fn is_ready(&self) -> bool {
+        self.inner.load(std::sync::atomic::Ordering::Acquire)
+    }
+}
+
+impl Default for ReadinessProbe {
+    fn default() -> Self {
+        Self::new(false)
+    }
+}
+
+/// Run the admin server until `shutdown` resolves. Returns when the
+/// listener has stopped accepting new connections.
+///
+/// Each accepted connection is handled on a fresh tokio task.
+pub async fn serve_admin<F>(
+    cfg: AdminConfig,
+    metrics: Metrics,
+    readiness: ReadinessProbe,
+    shutdown: F,
+) -> anyhow::Result<()>
+where
+    F: Future<Output = ()> + Send + 'static,
+{
+    let listener = TcpListener::bind(cfg.bind_addr).await?;
+    info!(addr = %cfg.bind_addr, "admin server listening");
+
+    tokio::pin!(shutdown);
+
+    loop {
+        tokio::select! {
+            _ = &mut shutdown => {
+                info!("admin server shutting down");
+                return Ok(());
+            }
+            accept = listener.accept() => {
+                let (stream, peer) = match accept {
+                    Ok(x) => x,
+                    Err(e) => {
+                        warn!(error = %e, "admin accept error");
+                        continue;
+                    }
+                };
+                debug!(%peer, "admin: connection accepted");
+                let metrics = metrics.clone();
+                let readiness = readiness.clone();
+                tokio::spawn(async move {
+                    let io = TokioIo::new(stream);
+                    let svc = service_fn(move |req| {
+                        let metrics = metrics.clone();
+                        let readiness = readiness.clone();
+                        async move { Ok::<_, Infallible>(handle(req, &metrics, 
&readiness).await) }
+                    });
+                    if let Err(e) = http1::Builder::new().serve_connection(io, 
svc).await {
+                        debug!(error = %e, "admin connection ended");
+                    }
+                });
+            }
+        }
+    }
+}
+
+async fn handle(
+    req: Request<Incoming>,
+    metrics: &Metrics,
+    readiness: &ReadinessProbe,
+) -> Response<Full<Bytes>> {
+    match (req.method(), req.uri().path()) {
+        (&Method::GET, "/metrics") => render_metrics(metrics),
+        (&Method::GET, "/healthz") => simple(StatusCode::OK, "ok"),
+        (&Method::GET, "/readyz") => {
+            if readiness.is_ready() {
+                simple(StatusCode::OK, "ready")
+            } else {
+                simple(StatusCode::SERVICE_UNAVAILABLE, "not ready")
+            }
+        }
+        _ => simple(StatusCode::NOT_FOUND, "not found"),
+    }
+}
+
+fn render_metrics(metrics: &Metrics) -> Response<Full<Bytes>> {
+    let encoder = prometheus::TextEncoder::new();
+    let mfs = metrics.registry().gather();
+    let mut buf = Vec::new();
+    if let Err(e) = encoder.encode(&mfs, &mut buf) {
+        warn!(error = %e, "failed to encode metrics");
+        return simple(StatusCode::INTERNAL_SERVER_ERROR, "encoding failed");
+    }
+    Response::builder()
+        .status(StatusCode::OK)
+        .header("content-type", encoder.format_type())
+        .body(Full::new(Bytes::from(buf)))
+        .expect("static response builder")
+}
+
+fn simple(status: StatusCode, body: &'static str) -> Response<Full<Bytes>> {
+    Response::builder()
+        .status(status)
+        .header("content-type", "text/plain; charset=utf-8")
+        .body(Full::new(Bytes::from(body)))
+        .expect("static response builder")
+}
diff --git a/crates/observability/src/lib.rs b/crates/observability/src/lib.rs
new file mode 100644
index 0000000..8261b59
--- /dev/null
+++ b/crates/observability/src/lib.rs
@@ -0,0 +1,51 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Metrics, structured spans, and the admin HTTP server.
+//!
+//! The crate exposes three things:
+//!
+//! * [`Metrics`] — a `Clone`-able handle owning a Prometheus `Registry`
+//!   and the per-RPC counters / histograms. Hand one to anything that
+//!   needs to instrument requests.
+//! * [`request_id`] — generates a per-RPC UUID v4 used as the
+//!   correlation ID stamped into both the `tracing::Span` and the
+//!   outbound gRPC `x-request-id` metadata.
+//! * [`admin`] — a small Hyper-based HTTP server that serves
+//!   `/metrics`, `/healthz`, `/readyz` on a separate admin port.
+
+pub mod admin;
+pub mod metrics;
+pub mod tracing;
+
+pub use admin::{serve_admin, AdminConfig, ReadinessProbe};
+pub use metrics::{Metrics, MetricsError, RpcGuard, StreamGuard};
+#[cfg(feature = "testing")]
+pub use tracing::install_test_subscriber;
+pub use tracing::{
+    extract_parent, init_tracing, inject_context, TracingConfig, TracingError, 
TracingHandle,
+    TRACEPARENT_HEADER, TRACER_NAME,
+};
+
+use uuid::Uuid;
+
+/// Generate a fresh correlation ID for the current RPC.
+pub fn request_id() -> String {
+    Uuid::new_v4().to_string()
+}
+
+/// gRPC metadata key under which the gateway forwards correlation IDs
+/// to backends. Public so the proxy crate can use the same constant.
+pub const REQUEST_ID_HEADER: &str = "x-request-id";
diff --git a/crates/observability/src/metrics.rs 
b/crates/observability/src/metrics.rs
new file mode 100644
index 0000000..4957956
--- /dev/null
+++ b/crates/observability/src/metrics.rs
@@ -0,0 +1,406 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Prometheus metrics for the gateway.
+//!
+//! Naming follows the Prometheus best practices: `scg_*` prefix,
+//! `_total` suffix on counters, `_seconds` suffix on duration
+//! histograms, label cardinality kept small.
+//!
+//! Cardinality budget:
+//!
+//! * `rpc` — fixed set of 12 Spark Connect RPC names. Bounded.
+//! * `code` — gRPC status codes ("OK", "Unauthenticated", …). ~16 values.
+//! * `reason` / `scope` — small fixed enums.
+//! * `tenant` — appears **only** on the two rate-limit counters.
+//!   Cardinality is bounded by the tenant set the resolver admits
+//!   (operator-configured); deployments deriving tenants from token
+//!   claims under a permissive unknown-tenant policy should count
+//!   that toward their cardinality budget.
+//! * **Never** `user_id`, `session_id`, or `operation_id` labels —
+//!   those are unbounded and would blow up Prometheus. Per-user and
+//!   per-session observability belongs in logs, audit events, and
+//!   trace attributes, not in metric labels.
+
+use std::sync::Arc;
+
+use prometheus::{
+    register_histogram_vec_with_registry, 
register_int_counter_vec_with_registry,
+    register_int_gauge_with_registry, HistogramTimer, HistogramVec, 
IntCounterVec, IntGauge,
+    Registry,
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum MetricsError {
+    #[error("registering metric: {0}")]
+    Register(#[from] prometheus::Error),
+}
+
+/// Cheap-to-clone handle to the gateway's Prometheus registry and
+/// per-RPC metric families. All metric families are eagerly registered
+/// at construction so scrapes always return a complete metric set, even
+/// before any traffic has flowed.
+#[derive(Clone)]
+pub struct Metrics {
+    inner: Arc<MetricsInner>,
+}
+
+struct MetricsInner {
+    registry: Registry,
+
+    // Per-RPC families.
+    rpcs_total: IntCounterVec,
+    rpc_duration_seconds: HistogramVec,
+
+    // Auth.
+    auth_failures_total: IntCounterVec,
+
+    // Pool / streams gauges.
+    backend_pool_size: IntGauge,
+    active_streams: IntGauge,
+
+    // Rate limit.
+    rate_limit_rejected_total: IntCounterVec,
+    /// Redis-side errors observed by the distributed rate limiter.
+    /// Counts errors, not rejects — a fail-open deployment
+    /// increments this without firing `rate_limit_rejected_total`.
+    /// `reason` is one of a small fixed set: `tenant_bucket`,
+    /// `user_bucket`.
+    rate_limit_redis_errors_total: IntCounterVec,
+}
+
+impl Metrics {
+    /// Build a fresh `Metrics` against a new `Registry`. Tests and the
+    /// gateway main both use this; every gateway process owns one
+    /// `Metrics` for the lifetime of the process.
+    pub fn new() -> Result<Self, MetricsError> {
+        let registry = Registry::new();
+        let rpcs_total = register_int_counter_vec_with_registry!(
+            "scg_rpcs_total",
+            "Total Spark Connect RPCs handled by the gateway, labelled by 
method and final gRPC code.",
+            &["rpc", "code"],
+            registry,
+        )?;
+        let rpc_duration_seconds = register_histogram_vec_with_registry!(
+            "scg_rpc_duration_seconds",
+            "Per-RPC handler duration (gateway-side, end-to-end including 
backend forward).",
+            &["rpc"],
+            // Buckets: 0.5 ms, 1 ms, 2.5 ms, 5 ms, 10 ms, 25 ms, 50 ms,
+            // 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s, 10 s, 30 s, 60 s.
+            // Spans the range from in-memory unary RPCs to long-running
+            // ExecutePlan streams.
+            vec![
+                0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 
0.5, 1.0, 2.5, 5.0,
+                10.0, 30.0, 60.0,
+            ],
+            registry,
+        )?;
+        let auth_failures_total = register_int_counter_vec_with_registry!(
+            "scg_auth_failures_total",
+            "Authentication failures, labelled by reason (e.g. missing_token, 
invalid_token, expired).",
+            &["reason"],
+            registry,
+        )?;
+        let backend_pool_size = register_int_gauge_with_registry!(
+            "scg_backend_pool_size",
+            "Current number of healthy backends the gateway can route to.",
+            registry,
+        )?;
+        let active_streams = register_int_gauge_with_registry!(
+            "scg_active_streams",
+            "Currently in-flight server-streaming or client-streaming RPCs.",
+            registry,
+        )?;
+        let rate_limit_rejected_total = 
register_int_counter_vec_with_registry!(
+            "scg_rate_limit_rejected_total",
+            "RPCs rejected by per-tenant rate limiting, labelled by tenant and 
scope (tenant|user).",
+            &["tenant", "scope"],
+            registry,
+        )?;
+        let rate_limit_redis_errors_total = 
register_int_counter_vec_with_registry!(
+            "scg_rate_limit_redis_errors_total",
+            "Backend errors from the Redis-backed rate limiter. Counts 
failures, not rejects.",
+            &["tenant", "reason"],
+            registry,
+        )?;
+        Ok(Self {
+            inner: Arc::new(MetricsInner {
+                registry,
+                rpcs_total,
+                rpc_duration_seconds,
+                auth_failures_total,
+                backend_pool_size,
+                active_streams,
+                rate_limit_rejected_total,
+                rate_limit_redis_errors_total,
+            }),
+        })
+    }
+
+    /// Bump `scg_rate_limit_redis_errors_total{tenant, reason}`.
+    /// `reason` is a small fixed-cardinality string from the limiter
+    /// (`tenant_bucket`, `user_bucket`).
+    pub fn record_rate_limit_redis_error(&self, tenant: &str, reason: &str) {
+        self.inner
+            .rate_limit_redis_errors_total
+            .with_label_values(&[tenant, reason])
+            .inc();
+    }
+
+    /// Bump `scg_rate_limit_rejected_total{tenant, scope}`. `scope`
+    /// is the fixed-cardinality string `"tenant"` or `"user"`. The
+    /// label cardinality is bounded by the configured-tenant set —
+    /// callers should not pass user-input tenants that aren't in
+    /// the resolver's allowlist.
+    pub fn record_rate_limit_reject(&self, tenant: &str, scope: &str) {
+        self.inner
+            .rate_limit_rejected_total
+            .with_label_values(&[tenant, scope])
+            .inc();
+    }
+
+    /// Underlying `Registry` — handed to the admin HTTP server so the
+    /// `/metrics` endpoint can render it in the Prometheus exposition
+    /// format.
+    pub fn registry(&self) -> &Registry {
+        &self.inner.registry
+    }
+
+    /// Start a per-RPC timer + observation guard. The guard records
+    /// completion (with the final gRPC code) on Drop, so callers don't
+    /// have to remember to call anything from error paths. See
+    /// [`RpcGuard::record`] to override the default code.
+    pub fn rpc_guard(&self, rpc: &'static str) -> RpcGuard {
+        let timer = self
+            .inner
+            .rpc_duration_seconds
+            .with_label_values(&[rpc])
+            .start_timer();
+        RpcGuard {
+            metrics: self.clone(),
+            rpc,
+            code: "Cancelled", // Replaced before drop unless caller bails 
early.
+            recorded: false,
+            _timer: timer,
+        }
+    }
+
+    /// Bump `scg_rpcs_total{rpc, code}` directly. Called by the
+    /// `RpcGuard` Drop impl so the counter still reflects requests
+    /// where the handler returned early.
+    pub(crate) fn record_rpc(&self, rpc: &'static str, code: &str) {
+        self.inner.rpcs_total.with_label_values(&[rpc, code]).inc();
+    }
+
+    /// Bump the auth-failures counter. `reason` should be a small
+    /// closed set: "missing_token", "invalid_token", "expired",
+    /// "unknown_kid", "missing_tenant", "unknown".
+    pub fn record_auth_failure(&self, reason: &'static str) {
+        self.inner
+            .auth_failures_total
+            .with_label_values(&[reason])
+            .inc();
+    }
+
+    /// Set the live backend-pool size. Call from the K8s watcher / on
+    /// pool reconfiguration.
+    pub fn set_backend_pool_size(&self, n: i64) {
+        self.inner.backend_pool_size.set(n);
+    }
+
+    /// Increment the active-streams gauge for the lifetime of `_guard`.
+    /// Use the returned [`StreamGuard`] for RAII-style accounting.
+    pub fn stream_guard(&self) -> StreamGuard {
+        self.inner.active_streams.inc();
+        StreamGuard {
+            metrics: self.clone(),
+        }
+    }
+
+    /// Read the live `scg_active_streams` count. Used by the
+    /// graceful-shutdown loop to decide whether the server can stop.
+    pub fn active_streams_value(&self) -> i64 {
+        self.inner.active_streams.get()
+    }
+}
+
+impl std::fmt::Debug for Metrics {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Metrics")
+            .field("registered_families", &self.inner.registry.gather().len())
+            .finish()
+    }
+}
+
+/// Per-RPC observation guard. Records the final gRPC code via
+/// [`RpcGuard::record`]; if the guard is dropped without `record`, the
+/// outcome is recorded as `Cancelled` so we don't lose the request from
+/// the totals.
+#[must_use = "RpcGuard records on Drop; capture it for the lifetime of the 
handler"]
+pub struct RpcGuard {
+    metrics: Metrics,
+    rpc: &'static str,
+    code: &'static str,
+    recorded: bool,
+    _timer: HistogramTimer,
+}
+
+impl RpcGuard {
+    /// Override the final gRPC code reported by this guard. Common
+    /// values: "OK", "Unauthenticated", "Unavailable", "Internal".
+    /// Calling `record` more than once is a no-op after the first.
+    pub fn record(&mut self, code: &'static str) {
+        if self.recorded {
+            return;
+        }
+        self.recorded = true;
+        self.code = code;
+    }
+}
+
+impl Drop for RpcGuard {
+    fn drop(&mut self) {
+        // If the handler returned early without calling `record`, we
+        // still want a count so the user can see the totals match
+        // request volume. Default code "Cancelled" stays.
+        self.metrics.record_rpc(self.rpc, self.code);
+        // _timer also drops here, recording the histogram observation.
+    }
+}
+
+/// RAII guard that decrements `scg_active_streams` on drop.
+#[must_use = "drop the guard at the end of the streaming RPC"]
+pub struct StreamGuard {
+    metrics: Metrics,
+}
+
+impl Drop for StreamGuard {
+    fn drop(&mut self) {
+        self.metrics.inner.active_streams.dec();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn read_counter(m: &Metrics, name: &str, labels: &[(&str, &str)]) -> u64 {
+        let mfs = m.registry().gather();
+        for mf in mfs {
+            if mf.name() != name {
+                continue;
+            }
+            for metric in mf.get_metric() {
+                let matches = labels.iter().all(|(k, v)| {
+                    metric
+                        .get_label()
+                        .iter()
+                        .any(|l| l.name() == *k && l.value() == *v)
+                });
+                if matches {
+                    return metric.get_counter().value() as u64;
+                }
+            }
+        }
+        0
+    }
+
+    fn read_gauge(m: &Metrics, name: &str) -> i64 {
+        let mfs = m.registry().gather();
+        for mf in mfs {
+            if mf.name() == name {
+                if let Some(metric) = mf.get_metric().first() {
+                    return metric.get_gauge().value() as i64;
+                }
+            }
+        }
+        0
+    }
+
+    #[test]
+    fn rpc_guard_records_ok_on_record() {
+        let m = Metrics::new().unwrap();
+        {
+            let mut g = m.rpc_guard("Config");
+            g.record("OK");
+        }
+        assert_eq!(
+            read_counter(&m, "scg_rpcs_total", &[("rpc", "Config"), ("code", 
"OK")]),
+            1
+        );
+    }
+
+    #[test]
+    fn rpc_guard_defaults_to_cancelled_on_early_drop() {
+        let m = Metrics::new().unwrap();
+        {
+            let _g = m.rpc_guard("ExecutePlan");
+            // simulate handler bailing without explicit record
+        }
+        assert_eq!(
+            read_counter(
+                &m,
+                "scg_rpcs_total",
+                &[("rpc", "ExecutePlan"), ("code", "Cancelled")]
+            ),
+            1
+        );
+    }
+
+    #[test]
+    fn auth_failure_increments() {
+        let m = Metrics::new().unwrap();
+        m.record_auth_failure("invalid_token");
+        m.record_auth_failure("invalid_token");
+        m.record_auth_failure("missing_token");
+        assert_eq!(
+            read_counter(
+                &m,
+                "scg_auth_failures_total",
+                &[("reason", "invalid_token")]
+            ),
+            2
+        );
+        assert_eq!(
+            read_counter(
+                &m,
+                "scg_auth_failures_total",
+                &[("reason", "missing_token")]
+            ),
+            1
+        );
+    }
+
+    #[test]
+    fn pool_size_gauge_is_settable() {
+        let m = Metrics::new().unwrap();
+        m.set_backend_pool_size(5);
+        assert_eq!(read_gauge(&m, "scg_backend_pool_size"), 5);
+        m.set_backend_pool_size(0);
+        assert_eq!(read_gauge(&m, "scg_backend_pool_size"), 0);
+    }
+
+    #[test]
+    fn stream_guard_increments_then_decrements() {
+        let m = Metrics::new().unwrap();
+        let g1 = m.stream_guard();
+        let g2 = m.stream_guard();
+        assert_eq!(read_gauge(&m, "scg_active_streams"), 2);
+        drop(g1);
+        assert_eq!(read_gauge(&m, "scg_active_streams"), 1);
+        drop(g2);
+        assert_eq!(read_gauge(&m, "scg_active_streams"), 0);
+    }
+}
diff --git a/crates/observability/src/tracing.rs 
b/crates/observability/src/tracing.rs
new file mode 100644
index 0000000..e94c0e2
--- /dev/null
+++ b/crates/observability/src/tracing.rs
@@ -0,0 +1,340 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Distributed tracing — OpenTelemetry exporter + W3C traceparent
+//! propagation across the gateway → backend hop.
+//!
+//! Two responsibilities:
+//!
+//! * `init_tracing(cfg)` — install the global `tracing_subscriber` with a
+//!   `tracing-opentelemetry` layer that forwards spans to an OTLP/gRPC
+//!   exporter. Returns a [`TracingHandle`] whose `shutdown()` flushes
+//!   spans before the process exits.
+//! * `extract_parent` / `inject_traceparent` — translate W3C traceparent
+//!   metadata between tonic `MetadataMap`s and OpenTelemetry `Context`s,
+//!   so a request's parent trace from the inbound side becomes the
+//!   parent of every outbound RPC the gateway makes.
+//!
+//! Tracing is disabled by default; passing a `TracingConfig` with
+//! `endpoint == None` returns a no-op handle and installs only the
+//! existing JSON formatter (so logs keep working unchanged).
+
+use std::time::Duration;
+
+use opentelemetry::global;
+use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator};
+use opentelemetry::trace::TracerProvider as _;
+use opentelemetry::{Context, KeyValue};
+use opentelemetry_otlp::{SpanExporter, WithExportConfig};
+use opentelemetry_sdk::propagation::TraceContextPropagator;
+use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
+use opentelemetry_sdk::Resource;
+use opentelemetry_semantic_conventions::resource as semres;
+use tonic::metadata::MetadataMap;
+use tracing_subscriber::layer::SubscriberExt;
+use tracing_subscriber::util::SubscriberInitExt;
+use tracing_subscriber::EnvFilter;
+
+/// Tracer name reported on every span emitted by the gateway. The
+/// service name (an OTel `Resource` attribute) is set separately via
+/// `TracingConfig::service_name`.
+pub const TRACER_NAME: &str = "scg.gateway";
+
+/// W3C trace-context header. Lower-case because gRPC metadata keys are
+/// case-insensitive and stored lower-cased.
+pub const TRACEPARENT_HEADER: &str = "traceparent";
+pub const TRACESTATE_HEADER: &str = "tracestate";
+
+/// Runtime tracing configuration. Constructed by the gateway from its
+/// YAML config, or by tests directly.
+#[derive(Debug, Clone)]
+pub struct TracingConfig {
+    /// Logical service name reported as `service.name` on every span.
+    pub service_name: String,
+    /// Service version reported as `service.version`.
+    pub service_version: String,
+    /// OTLP/gRPC collector endpoint, e.g. `http://otel-collector:4317`.
+    /// `None` disables span export — only the JSON log formatter is
+    /// installed.
+    pub endpoint: Option<String>,
+    /// `TraceIdRatioBased` ratio in `[0.0, 1.0]`. Wrapped in
+    /// `ParentBased` so a sampled parent always wins.
+    pub sample_ratio: f64,
+    /// Per-batch export deadline for the OTLP exporter.
+    pub export_timeout: Duration,
+}
+
+impl Default for TracingConfig {
+    fn default() -> Self {
+        Self {
+            service_name: "spark-connect-gateway".into(),
+            service_version: env!("CARGO_PKG_VERSION").into(),
+            endpoint: None,
+            sample_ratio: 1.0,
+            export_timeout: Duration::from_secs(10),
+        }
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum TracingError {
+    #[error("building OTLP exporter: {0}")]
+    Exporter(#[from] opentelemetry_sdk::error::OTelSdkError),
+    #[error("setting tracing subscriber: {0}")]
+    SetSubscriber(#[from] tracing_subscriber::util::TryInitError),
+    #[error("OTLP exporter init: {0}")]
+    OtlpInit(String),
+}
+
+/// RAII handle returned by [`init_tracing`]. When the gateway shuts
+/// down, call [`TracingHandle::shutdown`] to flush in-flight spans
+/// before the process exits.
+pub struct TracingHandle {
+    provider: Option<SdkTracerProvider>,
+}
+
+impl TracingHandle {
+    /// A handle that does nothing on shutdown. Used for the
+    /// `endpoint == None` case and in tests where the caller installs
+    /// its own provider.
+    pub fn noop() -> Self {
+        Self { provider: None }
+    }
+
+    /// Force-flush and shut down the OTLP exporter. Safe to call
+    /// multiple times; subsequent calls are no-ops.
+    pub fn shutdown(&mut self) {
+        if let Some(p) = self.provider.take() {
+            // `shutdown` returns Result; we log on error but never
+            // propagate — at this point the process is exiting.
+            if let Err(e) = p.shutdown() {
+                tracing::warn!(error = %e, "OTel tracer provider shutdown 
failed");
+            }
+        }
+    }
+}
+
+impl Drop for TracingHandle {
+    fn drop(&mut self) {
+        self.shutdown();
+    }
+}
+
+/// Install the global tracing subscriber:
+///
+/// * Always: a `tracing_subscriber` JSON-formatter layer driven by
+///   `RUST_LOG` (defaults to `info`).
+/// * If `cfg.endpoint.is_some()`: also a `tracing-opentelemetry` layer
+///   that exports spans to the configured OTLP/gRPC endpoint, plus a
+///   global `TraceContextPropagator` so W3C traceparent extraction /
+///   injection works.
+///
+/// Returns a [`TracingHandle`] whose `shutdown` must be called before
+/// the process exits, otherwise the final batch of spans may be lost.
+pub fn init_tracing(cfg: TracingConfig) -> Result<TracingHandle, TracingError> 
{
+    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| 
EnvFilter::new("info"));
+    let fmt_layer = tracing_subscriber::fmt::layer().json().with_target(false);
+
+    let Some(endpoint) = cfg.endpoint.clone() else {
+        // Logs only — keep the existing JSON formatter behaviour.
+        tracing_subscriber::registry()
+            .with(env_filter)
+            .with(fmt_layer)
+            .try_init()?;
+        return Ok(TracingHandle::noop());
+    };
+
+    let exporter = SpanExporter::builder()
+        .with_tonic()
+        .with_endpoint(endpoint)
+        .with_timeout(cfg.export_timeout)
+        .build()
+        .map_err(|e| TracingError::OtlpInit(e.to_string()))?;
+
+    let resource = Resource::builder()
+        .with_attribute(KeyValue::new(
+            semres::SERVICE_NAME,
+            cfg.service_name.clone(),
+        ))
+        .with_attribute(KeyValue::new(
+            semres::SERVICE_VERSION,
+            cfg.service_version.clone(),
+        ))
+        .build();
+
+    // Wrap the configured ratio in ParentBased so a remote sampled
+    // decision always wins; fresh local roots fall through to the
+    // ratio sampler.
+    //
+    // KNOWN LIMITATION: when the inbound request carries a W3C
+    // `traceparent` header, the OTel SDK's interaction with
+    // `tracing-opentelemetry::set_parent` does not propagate the
+    // inbound trace_id onto the gateway's span (the parent_cx is set
+    // via `Context::with_remote_span_context`, which is invisible to
+    // `Context::has_active_span()` and to several internal codepaths
+    // in `tracer::build_with_context`). The resulting gateway span
+    // is dropped before reaching the OTLP exporter — fmt-side logs
+    // still record it (so JSON logs + correlation IDs work), but the
+    // distributed trace cannot link gateway → backend hops on those
+    // RPCs. Spans for RPCs without an inbound traceparent (i.e. the
+    // gateway is the trace root) export normally. Tracking upstream
+    // for resolution; switch back to a richer sampler when fixed.
+    let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(
+        cfg.sample_ratio.clamp(0.0, 1.0),
+    )));
+
+    let provider = SdkTracerProvider::builder()
+        .with_batch_exporter(exporter)
+        .with_resource(resource)
+        .with_sampler(sampler)
+        .build();
+
+    // Install the W3C traceparent propagator globally so extract /
+    // inject helpers below pick it up without re-creating an instance.
+    global::set_text_map_propagator(TraceContextPropagator::new());
+
+    let tracer = provider.tracer(TRACER_NAME);
+    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
+
+    tracing_subscriber::registry()
+        .with(env_filter)
+        .with(fmt_layer)
+        .with(otel_layer)
+        .try_init()?;
+
+    Ok(TracingHandle {
+        provider: Some(provider),
+    })
+}
+
+/// Tonic [`MetadataMap`] adapter for OpenTelemetry's `Extractor` /
+/// `Injector` traits. Both use lower-case keys.
+struct MetadataCarrier<'a>(&'a MetadataMap);
+
+impl<'a> Extractor for MetadataCarrier<'a> {
+    fn get(&self, key: &str) -> Option<&str> {
+        self.0.get(key).and_then(|v| v.to_str().ok())
+    }
+
+    fn keys(&self) -> Vec<&str> {
+        self.0
+            .keys()
+            .map(|k| match k {
+                tonic::metadata::KeyRef::Ascii(k) => k.as_str(),
+                tonic::metadata::KeyRef::Binary(k) => k.as_str(),
+            })
+            .collect()
+    }
+}
+
+struct MetadataInjector<'a>(&'a mut MetadataMap);
+
+impl<'a> Injector for MetadataInjector<'a> {
+    fn set(&mut self, key: &str, value: String) {
+        if let (Ok(k), Ok(v)) = (
+            
tonic::metadata::MetadataKey::<tonic::metadata::Ascii>::from_bytes(key.as_bytes()),
+            tonic::metadata::MetadataValue::try_from(value),
+        ) {
+            self.0.insert(k, v);
+        }
+    }
+}
+
+/// Extract a W3C trace-context parent from inbound gRPC metadata.
+/// Returns the empty `Context` when no traceparent is present (the
+/// resulting span will be a fresh trace root).
+pub fn extract_parent(metadata: &MetadataMap) -> Context {
+    let propagator = TraceContextPropagator::new();
+    propagator.extract(&MetadataCarrier(metadata))
+}
+
+/// Inject the OpenTelemetry context held by `cx` into outbound gRPC
+/// metadata as a W3C `traceparent` (and `tracestate` when present).
+pub fn inject_context(cx: &Context, metadata: &mut MetadataMap) {
+    let propagator = TraceContextPropagator::new();
+    propagator.inject_context(cx, &mut MetadataInjector(metadata));
+}
+
+/// Test-only helper: install a global `tracing` subscriber that
+/// forwards spans to the supplied [`SdkTracerProvider`], plus the W3C
+/// trace-context propagator so [`extract_parent`] / [`inject_context`]
+/// work. Returns silently if a global subscriber is already installed
+/// (every test in a binary shares the same one).
+///
+/// Pair with `opentelemetry_sdk::trace::InMemorySpanExporter` for
+/// round-trip assertions; see `crates/proxy/tests/tracing_integration.rs`.
+#[cfg(feature = "testing")]
+pub fn install_test_subscriber(provider: 
&opentelemetry_sdk::trace::SdkTracerProvider) {
+    use std::sync::Once;
+    static ONCE: Once = Once::new();
+    ONCE.call_once(|| {
+        global::set_text_map_propagator(TraceContextPropagator::new());
+        let tracer = provider.tracer(TRACER_NAME);
+        let layer = tracing_opentelemetry::layer().with_tracer(tracer);
+        let _ = tracing_subscriber::registry().with(layer).try_init();
+    });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use opentelemetry::trace::TraceContextExt;
+
+    #[test]
+    fn extract_with_no_traceparent_yields_invalid_span_context() {
+        let md = MetadataMap::new();
+        let cx = extract_parent(&md);
+        assert!(!cx.span().span_context().is_valid());
+    }
+
+    #[test]
+    fn inject_then_extract_round_trips_traceparent() {
+        // Build a Context whose active span has a known SpanContext.
+        use opentelemetry::trace::{SpanContext, SpanId, TraceFlags, TraceId, 
TraceState};
+        let span_ctx = SpanContext::new(
+            TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap(),
+            SpanId::from_hex("b7ad6b7169203331").unwrap(),
+            TraceFlags::SAMPLED,
+            true, // remote
+            TraceState::default(),
+        );
+        let cx = Context::new().with_remote_span_context(span_ctx.clone());
+
+        let mut md = MetadataMap::new();
+        inject_context(&cx, &mut md);
+
+        let tp = md
+            .get(TRACEPARENT_HEADER)
+            .expect("traceparent injected")
+            .to_str()
+            .unwrap();
+        // W3C format: 00-<traceid>-<spanid>-<flags>
+        assert!(tp.starts_with("00-0af7651916cd43dd8448eb211c80319c-"));
+
+        let extracted = extract_parent(&md);
+        let extracted_span = extracted.span();
+        let extracted_sc = extracted_span.span_context();
+        assert!(extracted_sc.is_valid());
+        assert_eq!(extracted_sc.trace_id(), span_ctx.trace_id());
+        assert_eq!(extracted_sc.span_id(), span_ctx.span_id());
+    }
+
+    #[test]
+    fn noop_handle_shutdown_is_idempotent() {
+        let mut h = TracingHandle::noop();
+        h.shutdown();
+        h.shutdown(); // must not panic
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to